acrylblends 1.0.10 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +124 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +58 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/main.ts +1 -6
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/main.ts +1 -6
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
package/dist/index.js
CHANGED
|
@@ -1,13 +1,1270 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
|
-
import {
|
|
8
|
+
import { connect, createServer } from "node:net";
|
|
8
9
|
import { createInterface } from "node:readline/promises";
|
|
9
|
-
import { createServer } from "node:net";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
+
//#region ../../frame/packages.json
|
|
12
|
+
var scope = "@acrylblends";
|
|
13
|
+
var dshVersion = "0.2.0-rc.2";
|
|
14
|
+
var packages = {
|
|
15
|
+
"acryl-ui": {
|
|
16
|
+
"name": "ui",
|
|
17
|
+
"version": "0.3.0"
|
|
18
|
+
},
|
|
19
|
+
"dsh-client-ui-brand-acryl": {
|
|
20
|
+
"name": "brand-client",
|
|
21
|
+
"version": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"acryl-app-shell": {
|
|
24
|
+
"name": "app-shell",
|
|
25
|
+
"version": "0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"acryl-workspace": {
|
|
28
|
+
"name": "workspace",
|
|
29
|
+
"version": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"acryl-settings": {
|
|
32
|
+
"name": "settings",
|
|
33
|
+
"version": "0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"acryl-sign-in": {
|
|
36
|
+
"name": "sign-in",
|
|
37
|
+
"version": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"acryl-control": {
|
|
40
|
+
"name": "control",
|
|
41
|
+
"version": "0.2.5"
|
|
42
|
+
},
|
|
43
|
+
"acryl-diagnostics": {
|
|
44
|
+
"name": "diagnostics",
|
|
45
|
+
"version": "0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"acryl-plugin-admin": {
|
|
48
|
+
"name": "plugin-admin",
|
|
49
|
+
"version": "0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"acryl-support": {
|
|
52
|
+
"name": "support",
|
|
53
|
+
"version": "0.1.0"
|
|
54
|
+
},
|
|
55
|
+
"acryl-loopback-http": {
|
|
56
|
+
"name": "loopback-http",
|
|
57
|
+
"version": "0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"acryl-web": {
|
|
60
|
+
"name": "web",
|
|
61
|
+
"version": "0.1.0"
|
|
62
|
+
},
|
|
63
|
+
"acryl-system-prompt": {
|
|
64
|
+
"name": "system-prompt",
|
|
65
|
+
"version": "0.1.0"
|
|
66
|
+
},
|
|
67
|
+
"acryl-app-context": {
|
|
68
|
+
"name": "app-context",
|
|
69
|
+
"version": "0.1.0"
|
|
70
|
+
},
|
|
71
|
+
"acryl-versions": {
|
|
72
|
+
"name": "versions",
|
|
73
|
+
"version": "0.1.0"
|
|
74
|
+
},
|
|
75
|
+
"acryl-blends-frame": {
|
|
76
|
+
"name": "blends-frame",
|
|
77
|
+
"version": "0.1.0"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/ide-profile.ts
|
|
82
|
+
/**
|
|
83
|
+
* The host an app runs on inside the ACRYL frame: the app's own node server and loader composition, with DSH riding as the chat/session driver
|
|
84
|
+
* (`docs/plans/ide-host-inversion.md`). The overlay (`cordis.patch.yml`) is the app's own layer: the frame's rows, the app's rows and the switches.
|
|
85
|
+
* `scripts/ide-host.mjs` is the launcher: it composes the driver rows from DSH's base and web bundles with this app's layer and boots them on the
|
|
86
|
+
* framework's node server. No DSH profile, no `dsh` launcher, no `dsh plugin add`. Generation is deterministic and idempotent: asking twice writes
|
|
87
|
+
* the same bytes and the second call reports nothing changed, so a template or an upgrade can call it freely.
|
|
88
|
+
*/
|
|
89
|
+
/** Where an app keeps its data, for the plugins that inject `appInstance` (the framework's settings service does): the slice 0 row, kept in the app. */
|
|
90
|
+
const APP_INSTANCE_ROW = `// Generated by acrylblends: the app's own folder family, for the plugins that inject appInstance.
|
|
91
|
+
import { join } from 'node:path'
|
|
92
|
+
|
|
93
|
+
export const name = 'app-instance'
|
|
94
|
+
|
|
95
|
+
export function apply(ctx) {
|
|
96
|
+
ctx.effect(() => {
|
|
97
|
+
const home = process.env.ACRYLBLENDS_HOME ?? join(process.cwd(), '.acrylblends')
|
|
98
|
+
const instance = ctx.reflect.provide('appInstance', {
|
|
99
|
+
root: process.cwd(),
|
|
100
|
+
home,
|
|
101
|
+
// The engine home ACRYL's plugins read (support serves \`dshHome/logs\`); in this host the engine and the app share one home.
|
|
102
|
+
dshHome: home,
|
|
103
|
+
})
|
|
104
|
+
return () => {
|
|
105
|
+
void instance()
|
|
106
|
+
}
|
|
107
|
+
}, 'app-instance')
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
/** The packages the frame mounts, in row order: the router pair, the host services, the frame's client plugins. */
|
|
111
|
+
const FRAME_PACKAGES = [
|
|
112
|
+
{
|
|
113
|
+
name: "acrylblends/cordis/router",
|
|
114
|
+
spec: "",
|
|
115
|
+
row: "acryl-router",
|
|
116
|
+
installed: true
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "acrylblends/cordis/router-http",
|
|
120
|
+
spec: "",
|
|
121
|
+
row: "acryl-router-http",
|
|
122
|
+
installed: true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "acryl-web",
|
|
126
|
+
spec: ""
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "acryl-settings",
|
|
130
|
+
spec: ""
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "acryl-app-shell",
|
|
134
|
+
spec: ""
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "acryl-versions",
|
|
138
|
+
spec: ""
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "acryl-blends-frame",
|
|
142
|
+
spec: ""
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "acryl-plugin-admin",
|
|
146
|
+
spec: ""
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "acryl-system-prompt",
|
|
150
|
+
spec: ""
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "acryl-app-context",
|
|
154
|
+
spec: ""
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "acryl-support",
|
|
158
|
+
spec: ""
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "acryl-sign-in",
|
|
162
|
+
spec: ""
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "dsh-client-ui-brand-acryl",
|
|
166
|
+
spec: "",
|
|
167
|
+
row: "ui-brand-acryl",
|
|
168
|
+
disables: ["ui-brand-official"]
|
|
169
|
+
}
|
|
170
|
+
];
|
|
171
|
+
/**
|
|
172
|
+
* Where every template's frame row mounts the app (`FRAME_MOUNT_PREFIX` in the template's own source, `/app`). The overlay tells the frame's row, so
|
|
173
|
+
* the frame shows the app's page in its centre; `init-frame` asserts the templates still agree with this constant.
|
|
174
|
+
*/
|
|
175
|
+
const FRAME_PAGE_PREFIX = "/app";
|
|
176
|
+
/**
|
|
177
|
+
* The driver rows the chat and session need, with what they hard-inject, and nothing else of DSH's base or web bundles
|
|
178
|
+
* (`docs/plans/ide-host-inversion.md`, section 4). The composition check proves each one activates; a missing one is a loud failure, never a silent skip.
|
|
179
|
+
*/
|
|
180
|
+
const DRIVER_ROWS = [
|
|
181
|
+
"connection",
|
|
182
|
+
"typert",
|
|
183
|
+
"typert-loader",
|
|
184
|
+
"typert-gateway",
|
|
185
|
+
"session",
|
|
186
|
+
"session-persistence-jsonl",
|
|
187
|
+
"session-query-sqlite",
|
|
188
|
+
"session-projection",
|
|
189
|
+
"session-title",
|
|
190
|
+
"storage",
|
|
191
|
+
"storage-json",
|
|
192
|
+
"storage-domain",
|
|
193
|
+
"agent",
|
|
194
|
+
"agent-loop",
|
|
195
|
+
"agent-default-model",
|
|
196
|
+
"llm",
|
|
197
|
+
"llm-retry",
|
|
198
|
+
"llm-deepseek",
|
|
199
|
+
"llm-deepseek-account",
|
|
200
|
+
"llm-pi-ai",
|
|
201
|
+
"deepseek-account",
|
|
202
|
+
"credentials",
|
|
203
|
+
"authorization",
|
|
204
|
+
"settings",
|
|
205
|
+
"config-editor",
|
|
206
|
+
"attachment-local",
|
|
207
|
+
"file-reference-local",
|
|
208
|
+
"session-controller",
|
|
209
|
+
"settings-controller",
|
|
210
|
+
"api-remotes",
|
|
211
|
+
"workspace",
|
|
212
|
+
"workspace-controller",
|
|
213
|
+
"tools",
|
|
214
|
+
"system-prompt",
|
|
215
|
+
"skill",
|
|
216
|
+
"file-upload",
|
|
217
|
+
"fs-sandbox",
|
|
218
|
+
"sandbox-policy",
|
|
219
|
+
"commands",
|
|
220
|
+
"fs-observation-policy",
|
|
221
|
+
"sandbox",
|
|
222
|
+
"bash-sandbox",
|
|
223
|
+
"pwsh-sandbox",
|
|
224
|
+
"approval",
|
|
225
|
+
"permission",
|
|
226
|
+
"shell-env",
|
|
227
|
+
"jobs",
|
|
228
|
+
"subprocess",
|
|
229
|
+
"workspace-changes",
|
|
230
|
+
"message-feedback",
|
|
231
|
+
"session-stats",
|
|
232
|
+
"session-turn-outline",
|
|
233
|
+
"job-controller",
|
|
234
|
+
"workspace-files",
|
|
235
|
+
"open-in-app",
|
|
236
|
+
"session-log-download",
|
|
237
|
+
"session-reference",
|
|
238
|
+
"token-meter",
|
|
239
|
+
"user-questions"
|
|
240
|
+
];
|
|
241
|
+
/**
|
|
242
|
+
* The chat's agent preset. DSH's web bundle gives every session a preset (`standard`: every tool DSH has, subagents, workflows, skills and plan mode, each
|
|
243
|
+
* needing host rows of its own), and a session without a loadable preset cannot start. The frame offers the agent what builds an app, and nothing more:
|
|
244
|
+
* the shell, the file tools (read, write, edit, search) and background jobs, with the persona and the app folder's instructions. It is the registry's
|
|
245
|
+
* default, so every chat in the frame uses it. A tool added here needs the host rows it stands on in `DRIVER_ROWS`.
|
|
246
|
+
*/
|
|
247
|
+
const AGENT_PRESET_ID = "acryl-blends";
|
|
248
|
+
/**
|
|
249
|
+
* The plugins the frame's preset takes from DSH's own `standard` preset, as DSH declares them (config and prompt included), so they follow every DSH
|
|
250
|
+
* bump instead of being restated here: the app's skills (`skill-filesystem` finds `.agents/skills/` in the app folder, the skills `acrylblends` writes
|
|
251
|
+
* there with every app, and `tool-skill` lets the agent load one), plan mode and its `/plan` (`planning`, which ui-plan's plan card and Plan tab show),
|
|
252
|
+
* the To-dos list the composer's dock shows (`tool-todo`), the questions the agent can ask (`tool-ask-user`), and context compaction (`compaction`),
|
|
253
|
+
* whose meter is the footer's context %.
|
|
254
|
+
*/
|
|
255
|
+
const DSH_STANDARD_PLUGINS = [
|
|
256
|
+
"skill-filesystem",
|
|
257
|
+
"tool-skill",
|
|
258
|
+
"planning",
|
|
259
|
+
"compaction",
|
|
260
|
+
"tool-ask-user",
|
|
261
|
+
"tool-todo"
|
|
262
|
+
];
|
|
263
|
+
const AGENT_PRESET_ROWS = [{
|
|
264
|
+
id: "agent-preset-registry",
|
|
265
|
+
name: "@deepseek-ai/dsh-agent-preset-registry",
|
|
266
|
+
config: { default: AGENT_PRESET_ID }
|
|
267
|
+
}, {
|
|
268
|
+
id: `preset-${AGENT_PRESET_ID}`,
|
|
269
|
+
name: "@deepseek-ai/dsh-agent-preset",
|
|
270
|
+
config: {
|
|
271
|
+
id: AGENT_PRESET_ID,
|
|
272
|
+
order: 1,
|
|
273
|
+
plugins: [
|
|
274
|
+
{
|
|
275
|
+
id: "persona",
|
|
276
|
+
name: "@deepseek-ai/dsh-persona",
|
|
277
|
+
config: {
|
|
278
|
+
prefix: "You are a coding agent powered by the {{model}} model.",
|
|
279
|
+
suffix: "Your working directory is {{cwd}}."
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "agent-instructions",
|
|
284
|
+
name: "@deepseek-ai/dsh-agent-instructions",
|
|
285
|
+
config: { maxBytes: 65536 }
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "tool-bash",
|
|
289
|
+
name: "@deepseek-ai/dsh-tool-bash",
|
|
290
|
+
disabled: { __jsExpr: "process.platform === 'win32'" }
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "tool-pwsh",
|
|
294
|
+
name: "@deepseek-ai/dsh-tool-pwsh",
|
|
295
|
+
disabled: { __jsExpr: "process.platform !== 'win32'" }
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: "tool-fs",
|
|
299
|
+
name: "@deepseek-ai/dsh-tool-fs"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: "tool-fs-search",
|
|
303
|
+
name: "@deepseek-ai/dsh-tool-fs-search",
|
|
304
|
+
config: { sampleOverCapGlobResults: false }
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "tool-jobs",
|
|
308
|
+
name: "@deepseek-ai/dsh-tool-jobs"
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
}
|
|
312
|
+
}];
|
|
313
|
+
const CLIENT_ROWS = [
|
|
314
|
+
{
|
|
315
|
+
id: "client-modules",
|
|
316
|
+
name: "@deepseek-ai/dsh-client-modules"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: "client-locale",
|
|
320
|
+
name: "@deepseek-ai/dsh-client-locale"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "client-shortcuts",
|
|
324
|
+
name: "@deepseek-ai/dsh-client-shortcuts"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "ui-renderer",
|
|
328
|
+
name: "@deepseek-ai/dsh-client-ui-renderer"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: "ui-theme",
|
|
332
|
+
name: "@deepseek-ai/dsh-client-ui-theme",
|
|
333
|
+
config: { preference: "dark" }
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "ui-settings",
|
|
337
|
+
name: "@deepseek-ai/dsh-client-ui-settings"
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "ui-settings-general",
|
|
341
|
+
name: "@deepseek-ai/dsh-client-ui-settings-general"
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "ui-settings-models",
|
|
345
|
+
name: "@deepseek-ai/dsh-client-ui-settings-models"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "ui-session",
|
|
349
|
+
name: "@deepseek-ai/dsh-client-ui-session"
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: "ui-sidebar-right",
|
|
353
|
+
name: "@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "ui-sidebar-documentpreview",
|
|
357
|
+
name: "@deepseek-ai/dsh-client-ui-sidebar-documentpreview"
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "ui-commands",
|
|
361
|
+
name: "@deepseek-ai/dsh-client-ui-commands"
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: "ui-workspace",
|
|
365
|
+
name: "@deepseek-ai/dsh-client-ui-workspace"
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "ui-conversation",
|
|
369
|
+
name: "@deepseek-ai/dsh-client-ui-conversation"
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: "ui-chat",
|
|
373
|
+
name: "@deepseek-ai/dsh-client-ui-chat"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: "ui-tool",
|
|
377
|
+
name: "@deepseek-ai/dsh-client-ui-tool"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
id: "ui-approval",
|
|
381
|
+
name: "@deepseek-ai/dsh-client-ui-approval"
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: "ui-attachment",
|
|
385
|
+
name: "@deepseek-ai/dsh-client-ui-attachment"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: "ui-shortcuts",
|
|
389
|
+
name: "@deepseek-ai/dsh-client-ui-shortcuts"
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
id: "ui-workflow-run",
|
|
393
|
+
name: "@deepseek-ai/dsh-client-ui-workflow-run"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: "ui-skill",
|
|
397
|
+
name: "@deepseek-ai/dsh-client-ui-skill"
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: "ui-subagent",
|
|
401
|
+
name: "@deepseek-ai/dsh-client-ui-subagent"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: "ui-reference",
|
|
405
|
+
name: "@deepseek-ai/dsh-client-ui-reference"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "ui-jobs",
|
|
409
|
+
name: "@deepseek-ai/dsh-client-ui-jobs"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
id: "ui-goal",
|
|
413
|
+
name: "@deepseek-ai/dsh-client-ui-goal"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
id: "ui-message-feedback",
|
|
417
|
+
name: "@deepseek-ai/dsh-client-ui-message-feedback"
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: "ui-model-selection",
|
|
421
|
+
name: "@deepseek-ai/dsh-client-ui-model-selection"
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: "ui-permission",
|
|
425
|
+
name: "@deepseek-ai/dsh-client-ui-permission-presets"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: "ui-agent-preset",
|
|
429
|
+
name: "@deepseek-ai/dsh-client-ui-agent-preset"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: "ui-settings-session-log",
|
|
433
|
+
name: "@deepseek-ai/dsh-client-ui-settings-session-log"
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: "ui-user-questions",
|
|
437
|
+
name: "@deepseek-ai/dsh-client-ui-user-questions"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "ui-trajectory",
|
|
441
|
+
name: "@deepseek-ai/dsh-client-ui-trajectory"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: "ui-deliverables",
|
|
445
|
+
name: "@deepseek-ai/dsh-client-ui-deliverables"
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: "ui-plan",
|
|
449
|
+
name: "@deepseek-ai/dsh-client-ui-plan"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: "ui-open-in-app",
|
|
453
|
+
name: "@deepseek-ai/dsh-client-ui-open-in-app"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "client-resources",
|
|
457
|
+
name: "@deepseek-ai/dsh-client-resources"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: "ui-input-trigger",
|
|
461
|
+
name: "@deepseek-ai/dsh-client-ui-input-trigger"
|
|
462
|
+
}
|
|
463
|
+
];
|
|
464
|
+
/** The array literal a host script writes for its rows (`const clientRows = [...];`, JSON at two spaces). */
|
|
465
|
+
const CLIENT_ROWS_BLOCK = /const clientRows = (\[[\s\S]*?\n\])/u;
|
|
466
|
+
/** The client rows a host script names, or undefined when its array cannot be read. */
|
|
467
|
+
function parseHostClientRows(hostText) {
|
|
468
|
+
const match = CLIENT_ROWS_BLOCK.exec(hostText);
|
|
469
|
+
if (match?.[1] === void 0) return void 0;
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(match[1]);
|
|
472
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
473
|
+
return parsed.filter((row) => row !== null && typeof row === "object" && typeof row.id === "string" && typeof row.name === "string");
|
|
474
|
+
} catch {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Add the client rows the current host script names and the app's host script does not, keeping every row the app has exactly as it is: a person's
|
|
480
|
+
* own row, or one whose config they changed. The app's rows keep their order; the missing current rows follow; the rest of the host script is the
|
|
481
|
+
* current one. An app without a host script (a new app) or with an unreadable array gets the current script as it is.
|
|
482
|
+
* @param currentHost - the host script the CLI generates now.
|
|
483
|
+
* @param appHost - the app's existing host script, when it has one.
|
|
484
|
+
* @returns the text to write and the row names it added.
|
|
485
|
+
*/
|
|
486
|
+
function mergeHostClientRows(currentHost, appHost) {
|
|
487
|
+
if (appHost === void 0) return {
|
|
488
|
+
text: currentHost,
|
|
489
|
+
added: []
|
|
490
|
+
};
|
|
491
|
+
const current = parseHostClientRows(currentHost);
|
|
492
|
+
const app = parseHostClientRows(appHost);
|
|
493
|
+
if (current === void 0 || app === void 0) return {
|
|
494
|
+
text: currentHost,
|
|
495
|
+
added: []
|
|
496
|
+
};
|
|
497
|
+
const names = new Set(app.map((row) => row.name));
|
|
498
|
+
const missing = current.filter((row) => !names.has(row.name));
|
|
499
|
+
const block = CLIENT_ROWS_BLOCK.exec(currentHost);
|
|
500
|
+
if (block === null) return {
|
|
501
|
+
text: currentHost,
|
|
502
|
+
added: []
|
|
503
|
+
};
|
|
504
|
+
const merged = [...app, ...missing];
|
|
505
|
+
return {
|
|
506
|
+
text: currentHost.replace(block[0], `const clientRows = ${JSON.stringify(merged, null, 2)}`),
|
|
507
|
+
added: missing.map((row) => row.name)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* `scripts/ide-host.mjs`: the app's launcher. It boots the framework's node server and the webServer port the driver rows register on, then the frame
|
|
512
|
+
* layer, and mounts DSH's driver rows from the base and web bundles (only the ids in `DRIVER_ROWS`) as ordinary rows of this composition. `--check`
|
|
513
|
+
* audits the composition (every row active) and disposes it, for the gate.
|
|
514
|
+
*/
|
|
515
|
+
function hostScriptOf(profile, frameModule) {
|
|
516
|
+
return [
|
|
517
|
+
"// Generated by acrylblends: boots this app inside the ACRYL frame on the app's own node server. Regenerate instead of editing.",
|
|
518
|
+
"import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'",
|
|
519
|
+
"import { chmodSync } from 'node:fs'",
|
|
520
|
+
"import { spawnSync } from 'node:child_process'",
|
|
521
|
+
"import { randomBytes, timingSafeEqual } from 'node:crypto'",
|
|
522
|
+
"import { createRequire } from 'node:module'",
|
|
523
|
+
"import { dirname, isAbsolute, join, resolve } from 'node:path'",
|
|
524
|
+
"import { fileURLToPath, pathToFileURL } from 'node:url'",
|
|
525
|
+
"import { DEFAULT_SCHEMA, dump as dumpYaml, Type, load as parseYaml } from 'js-yaml'",
|
|
526
|
+
"import { Acryl } from 'acrylblends'",
|
|
527
|
+
"",
|
|
528
|
+
"const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..')",
|
|
529
|
+
`const profileName = '${profile}'`,
|
|
530
|
+
"const home = process.env.ACRYLBLENDS_HOME ?? join(appRoot, '.acrylblends')",
|
|
531
|
+
"// DSH's rows keep their files in this app's own home: set before any driver row is imported.",
|
|
532
|
+
"process.env.DSH_HOME = home",
|
|
533
|
+
"// Development only: the app's code can be loaded again in this host (a version gone back to, a file written by the chat, an agent or an editor). Every file",
|
|
534
|
+
"// of the app's src/ then resolves under a generation the frame's Host raises, so nothing but the app's own rows restarts. Before anything of the app loads;",
|
|
535
|
+
"// a frame package that predates the hook leaves the code as it started.",
|
|
536
|
+
"try {",
|
|
537
|
+
` const { installAppGeneration } = await import('${frameModule}/app-generation')`,
|
|
538
|
+
" installAppGeneration({ appRoot })",
|
|
539
|
+
"} catch (error) {",
|
|
540
|
+
" if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' && error?.code !== 'ERR_MODULE_NOT_FOUND') throw error",
|
|
541
|
+
"}",
|
|
542
|
+
"// Before any DSH module is imported: its home resolution reads the environment at import time.",
|
|
543
|
+
"const { boot } = await import('@deepseek-ai/dsh-app-boot')",
|
|
544
|
+
"// The frame listens where the app's own server would: the folder's stable port (or the home's, when ACRYLBLENDS_HOME pins one). A restart then",
|
|
545
|
+
"// keeps the page's origin, its auth cookie and its tabs; ACRYLBLENDS_PORT still decides, and the gates pass 0 for any free port.",
|
|
546
|
+
"const { selectInstance } = await import(new URL('../plugins/instance/index.ts', import.meta.url).href)",
|
|
547
|
+
"const port = String(selectInstance({ projectRoot: appRoot, env: process.env }).port.start)",
|
|
548
|
+
"const check = process.argv.includes('--check')",
|
|
549
|
+
"const require = createRequire(import.meta.url)",
|
|
550
|
+
"/** The `!!js` tag the loader evaluates: js-yaml needs the same type the Cordis include plugin declares. */",
|
|
551
|
+
"const JsExpr = new Type('tag:yaml.org,2002:js', {",
|
|
552
|
+
" kind: 'scalar',",
|
|
553
|
+
" construct: (data) => ({ __jsExpr: data }),",
|
|
554
|
+
" represent: (data) => data.__jsExpr,",
|
|
555
|
+
"})",
|
|
556
|
+
"const load = (text) => parseYaml(text, { schema: DEFAULT_SCHEMA.extend([JsExpr]) })",
|
|
557
|
+
"",
|
|
558
|
+
"/** An insert names its module beside the patch file that named it (the loader rule, and the rule DSH applies when it reads a profile again). The",
|
|
559
|
+
" * composition is read as the profile bundle, from its own directory, so a row written relative would turn into a bundle-relative path the first time",
|
|
560
|
+
" * DSH reconciles the profile (any Settings write) and the app rows would stop loading. Anchoring each layer at the directory it was written for",
|
|
561
|
+
" * keeps one row meaning one file: boot, a Settings write and the frame reload then import the same absolute URL. */",
|
|
562
|
+
"function anchorRows(patches, baseDir) {",
|
|
563
|
+
" const visit = (row) => {",
|
|
564
|
+
" if (typeof row.name === 'string' && (isAbsolute(row.name) || row.name.startsWith('./') || row.name.startsWith('../')))",
|
|
565
|
+
" row.name = pathToFileURL(resolve(baseDir, row.name)).href",
|
|
566
|
+
" if (row.group && Array.isArray(row.config)) row.config.forEach(visit)",
|
|
567
|
+
" }",
|
|
568
|
+
" for (const patch of patches) patch.insert?.forEach(visit)",
|
|
569
|
+
" return patches",
|
|
570
|
+
"}",
|
|
571
|
+
"",
|
|
572
|
+
"/** The driver rows above are selected from the bundles by id; nothing else of DSH is mounted. */",
|
|
573
|
+
`const DRIVER_ROWS = ${JSON.stringify(DRIVER_ROWS, null, 2).replaceAll("\n", "\n")}`,
|
|
574
|
+
"",
|
|
575
|
+
"/** A bundle's patch files, in application order, as the package declares them, anchored beside that package (a relative name in a bundle is its own file). */",
|
|
576
|
+
"function bundlePatches(packageName) {",
|
|
577
|
+
" const dir = dirname(require.resolve(packageName + '/package.json'))",
|
|
578
|
+
" const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))",
|
|
579
|
+
" const files = manifest.dsh?.bundle?.patch",
|
|
580
|
+
" const list = typeof files === 'string' ? [files] : Array.isArray(files) ? files : []",
|
|
581
|
+
" return anchorRows(list.flatMap((file) => load(readFileSync(join(dir, file), 'utf8')) ?? []), dir)",
|
|
582
|
+
"}",
|
|
583
|
+
"",
|
|
584
|
+
"/** Keep only the driver rows: insert lists filtered entry by entry, id-targeted patches only when they hit a driver row. */",
|
|
585
|
+
"function onlyDriverRows(patches) {",
|
|
586
|
+
" const kept = []",
|
|
587
|
+
" for (const patch of patches) {",
|
|
588
|
+
" if (Array.isArray(patch.insert)) {",
|
|
589
|
+
" const insert = patch.insert.filter((entry) => DRIVER_ROWS.includes(entry.id))",
|
|
590
|
+
" if (insert.length > 0) kept.push({ ...patch, insert })",
|
|
591
|
+
" } else if (typeof patch.id === 'string' && DRIVER_ROWS.includes(patch.id)) kept.push(patch)",
|
|
592
|
+
" }",
|
|
593
|
+
" return kept",
|
|
594
|
+
"}",
|
|
595
|
+
"",
|
|
596
|
+
"const frameLayer = anchorRows(load(readFileSync(join(appRoot, 'cordis.patch.yml'), 'utf8')) ?? [], appRoot)",
|
|
597
|
+
"/** Our host rows first: the framework's node server, then the webServer port the driver rows register on (they are the app's, not DSH's). */",
|
|
598
|
+
`const clientRows = ${JSON.stringify(CLIENT_ROWS, null, 2).replaceAll("\\n", "\\n")}`,
|
|
599
|
+
`const agentRows = ${JSON.stringify(AGENT_PRESET_ROWS, null, 2)}`,
|
|
600
|
+
`const DSH_STANDARD_PLUGINS = ${JSON.stringify(DSH_STANDARD_PLUGINS)}`,
|
|
601
|
+
"/** One plugin row of DSH's `standard` preset, exactly as DSH's web bundle declares it; a missing one fails the boot loudly. */",
|
|
602
|
+
"function dshStandardPlugin(id) {",
|
|
603
|
+
" for (const patch of bundlePatches('@deepseek-ai/dsh-web-app'))",
|
|
604
|
+
" for (const entry of patch.insert ?? [])",
|
|
605
|
+
" if (entry.config?.id === 'standard') {",
|
|
606
|
+
" const found = entry.config.plugins?.find((plugin) => plugin.id === id)",
|
|
607
|
+
" if (found !== undefined) return found",
|
|
608
|
+
" }",
|
|
609
|
+
" throw new Error(`DSH's standard preset has no ${id} row`)",
|
|
610
|
+
"}",
|
|
611
|
+
`agentRows.find((row) => row.id === 'preset-${AGENT_PRESET_ID}').config.plugins.push(...DSH_STANDARD_PLUGINS.map(dshStandardPlugin))`,
|
|
612
|
+
"const hostLayer = anchorRows([{",
|
|
613
|
+
" insert: [",
|
|
614
|
+
" { id: 'node-server', name: './plugins/acryl-node-server/src/index.ts', config: { hostname: '127.0.0.1', port: Number(port), scan: Number(port) === 0 ? 0 : 20 } },",
|
|
615
|
+
" { id: 'web-server', name: './plugins/acryl-web-server/src/index.ts' },",
|
|
616
|
+
" { id: 'page', name: './plugins/acryl-page/src/index.ts' },",
|
|
617
|
+
" ...clientRows,",
|
|
618
|
+
" ...agentRows,",
|
|
619
|
+
" ],",
|
|
620
|
+
"}], appRoot)",
|
|
621
|
+
"// The composition: the driver rows selected from the bundles, the host rows and the app's frame layer, in the order they mount.",
|
|
622
|
+
"const composition = [",
|
|
623
|
+
" ...onlyDriverRows(bundlePatches('@deepseek-ai/dsh-base')),",
|
|
624
|
+
" ...onlyDriverRows(bundlePatches('@deepseek-ai/dsh-web-app')),",
|
|
625
|
+
" ...hostLayer,",
|
|
626
|
+
" ...frameLayer,",
|
|
627
|
+
"]",
|
|
628
|
+
"",
|
|
629
|
+
"// The app's DSH profile, which DSH's Settings, config editor and plugin manager read and write (docs/plans/ide-host-inversion.md, section 20):",
|
|
630
|
+
"// - package.json: the profile manifest (no DSH bundles; the composition is ours), created once when the app has none.",
|
|
631
|
+
"// - cordis.patch.yml: the composition, regenerated on every boot from the layers above, so an upgrade or a framework bump never leaves a stale copy.",
|
|
632
|
+
"// - settings.patch.yml: the profile patch document ProfileContext.patchPath points at. Only the config editor writes it; the host creates it empty",
|
|
633
|
+
"// when missing and never rewrites it, so the Settings edits of the app survive a boot, an upgrade and a framework bump.",
|
|
634
|
+
"mkdirSync(home, { recursive: true })",
|
|
635
|
+
"const manifestPath = join(home, 'package.json')",
|
|
636
|
+
"// The composition is a profile bundle: bundle layers are applied before the profile patch document, so a Settings edit always wins over a row the",
|
|
637
|
+
"// composition also configures. DSH resolves it from the profile directory like any installed bundle, and the launcher rewrites it on every boot.",
|
|
638
|
+
"const bundleDir = join(home, 'node_modules', 'acrylblends-frame')",
|
|
639
|
+
"mkdirSync(bundleDir, { recursive: true })",
|
|
640
|
+
"writeFileSync(join(bundleDir, 'package.json'), `${JSON.stringify({ name: 'acrylblends-frame', version: '0.0.0', private: true, dsh: { bundle: { patch: 'cordis.patch.yml' } } }, null, 2)}\\n`)",
|
|
641
|
+
"const compositionHeader =",
|
|
642
|
+
" \"# Generated by acrylblends: the host composition of this app, a profile bundle. Rewritten on every boot from the launcher layers; do not edit. Settings live in settings.patch.yml.\\n\"",
|
|
643
|
+
"writeFileSync(join(bundleDir, 'cordis.patch.yml'), compositionHeader + dumpYaml(composition, { schema: DEFAULT_SCHEMA.extend([JsExpr]), noRefs: true }))",
|
|
644
|
+
"writeFileSync(manifestPath, `${JSON.stringify({ name: profileName, private: true, dependencies: { 'acrylblends-frame': '0.0.0' }, dsh: { profile: { bundles: ['acrylblends-frame'] } } }, null, 2)}\\n`)",
|
|
645
|
+
"const settingsPath = join(home, 'settings.patch.yml')",
|
|
646
|
+
"if (!existsSync(settingsPath)) writeFileSync(settingsPath, '[]\\n')",
|
|
647
|
+
"",
|
|
648
|
+
"// Boot reads the same list readProfilePatches will: the composition bundle first, then the settings document, which is also the order DSH uses for",
|
|
649
|
+
"// bundles and the profile patch, so the first reconcile is a no-op recomposition and every later one differs only by the edited patch.",
|
|
650
|
+
"const compositionLayer = load(readFileSync(join(bundleDir, 'cordis.patch.yml'), 'utf8')) ?? []",
|
|
651
|
+
"const settingsLayer = anchorRows(load(readFileSync(settingsPath, 'utf8')) ?? [], home)",
|
|
652
|
+
"/** The profile context DSH's settings row, config editor and plugin manager read. */",
|
|
653
|
+
"const profileContext = {",
|
|
654
|
+
" name: 'web',",
|
|
655
|
+
" dir: home,",
|
|
656
|
+
" patchPath: settingsPath,",
|
|
657
|
+
" installAnchor: join(appRoot, 'package.json'),",
|
|
658
|
+
" cwd: appRoot,",
|
|
659
|
+
" home,",
|
|
660
|
+
" startedBundles: [],",
|
|
661
|
+
" overlays: [],",
|
|
662
|
+
" telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,",
|
|
663
|
+
"}",
|
|
664
|
+
"const layers = [compositionLayer, settingsLayer]",
|
|
665
|
+
"let ctx",
|
|
666
|
+
"try {",
|
|
667
|
+
" ctx = await boot('" + profile + "', join(appRoot, 'cordis.yml'), layers.flat(), (hostContext) => {",
|
|
668
|
+
" hostContext.provide('profileContext', profileContext)",
|
|
669
|
+
" }, pathToFileURL(join(appRoot, 'package.json')).href)",
|
|
670
|
+
"} catch (error) {",
|
|
671
|
+
" // A row that fails before it has a plugin (a module that is not one, an import that throws) is reported as 'failed to import' with no cause; the boot's",
|
|
672
|
+
" // StartupError carries the original failures. Print each one, by row id and module, then fail: a silent half-composed host is never an option.",
|
|
673
|
+
" const entries = Array.isArray(error?.entries) ? error.entries : []",
|
|
674
|
+
" const failures = entries.filter(({ outcome }) => outcome?.kind === \"failed\")",
|
|
675
|
+
" console.error(`acrylblends host: startup failed (${String(failures.length)} row(s))`)",
|
|
676
|
+
" for (const { entry, outcome } of failures) {",
|
|
677
|
+
" const cause = outcome.error",
|
|
678
|
+
" const detail = cause instanceof Error ? cause.stack ?? cause.message : String(cause)",
|
|
679
|
+
" console.error(`- ${entry.id} (${entry.options?.name ?? \"?\"}): ${detail}`)",
|
|
680
|
+
" }",
|
|
681
|
+
" const cause = error?.cause",
|
|
682
|
+
" if (failures.length === 0 && cause !== undefined) console.error(cause instanceof Error ? cause.stack ?? cause.message : String(cause))",
|
|
683
|
+
" process.exit(1)",
|
|
684
|
+
"}",
|
|
685
|
+
"const server = ctx.get('nodeServer')",
|
|
686
|
+
"const connection = ctx.get('connection')",
|
|
687
|
+
"// The frame listens on a stable port; its launch token is made stable the same way: the address of the previous run is read back and the connection",
|
|
688
|
+
"// is given that token, so a restart keeps the same URL and an open page - even one whose cookie was lost - comes back by reloading it. The record is",
|
|
689
|
+
"// the launcher own bookkeeping (the installed framework may predate `acrylblends open`), so it is read and written here: owner-only (0600, and a",
|
|
690
|
+
"// restricted ACL on Windows), a temp file renamed over the old one. The token lives in the connection instance field DSH reads on every check;",
|
|
691
|
+
"// nothing in the pinned package is edited, and a connection without that field keeps the token the process minted.",
|
|
692
|
+
"const frameRecordPath = join(home, 'frame-url.json')",
|
|
693
|
+
"function readFrameRecord() {",
|
|
694
|
+
" try {",
|
|
695
|
+
" return JSON.parse(readFileSync(frameRecordPath, \"utf8\"))",
|
|
696
|
+
" } catch {",
|
|
697
|
+
" return undefined",
|
|
698
|
+
" }",
|
|
699
|
+
"}",
|
|
700
|
+
"function restrictFrameRecord(path) {",
|
|
701
|
+
" chmodSync(path, 0o600)",
|
|
702
|
+
" if (process.platform !== \"win32\" || !process.env.USERNAME) return",
|
|
703
|
+
" spawnSync(\"icacls\", [path, \"/inheritance:r\", \"/grant:r\", `${process.env.USERNAME}:F`], { stdio: \"ignore\", windowsHide: true })",
|
|
704
|
+
"}",
|
|
705
|
+
"function writeFrameRecord(url) {",
|
|
706
|
+
" let token = \"\"",
|
|
707
|
+
" try {",
|
|
708
|
+
" token = new URL(url).searchParams.get(\"token\") ?? \"\"",
|
|
709
|
+
" } catch {}",
|
|
710
|
+
" mkdirSync(home, { recursive: true })",
|
|
711
|
+
" const temporary = `${frameRecordPath}.${String(process.pid)}.tmp`",
|
|
712
|
+
" writeFileSync(temporary, JSON.stringify({ version: 1, url, token, pid: process.pid, writtenAt: new Date().toISOString() }, null, 2) + \"\\n\", { mode: 0o600 })",
|
|
713
|
+
" restrictFrameRecord(temporary)",
|
|
714
|
+
" renameSync(temporary, frameRecordPath)",
|
|
715
|
+
" restrictFrameRecord(frameRecordPath)",
|
|
716
|
+
"}",
|
|
717
|
+
"const connectionAuth = connection !== undefined && typeof connection === \"object\" ? connection.browserAuth : undefined",
|
|
718
|
+
"const previousAddress = readFrameRecord()",
|
|
719
|
+
"const reusedToken = connectionAuth !== undefined && typeof connectionAuth.launchToken === \"string\" && previousAddress !== undefined && typeof previousAddress.token === \"string\" && previousAddress.token !== \"\"",
|
|
720
|
+
"if (reusedToken) connectionAuth.launchToken = previousAddress.token",
|
|
721
|
+
"// The chat's remote RPC is token-authenticated: the printed URL carries the launch token, and the page exchange turns it into a cookie.",
|
|
722
|
+
"const url = server === undefined ? undefined : connection === undefined ? server.url : connection.authenticatedUrl(server.url)",
|
|
723
|
+
"console.log(`acrylblends host: ${url ?? 'no node server'}`)",
|
|
724
|
+
"// `acrylblends open` prints the record back; a stopped host leaves it behind on purpose, so the command can say the frame is not running instead of",
|
|
725
|
+
"// pretending the app never ran one.",
|
|
726
|
+
"if (url !== undefined) {",
|
|
727
|
+
" try {",
|
|
728
|
+
" writeFrameRecord(url)",
|
|
729
|
+
" } catch (error) {",
|
|
730
|
+
" console.error(`acrylblends host: could not write the frame address: ${String(error)}`)",
|
|
731
|
+
" }",
|
|
732
|
+
" // `acrylblends open --new` rotates the token in the running host: the stored token presents itself, the connection gets a new one, the new address is",
|
|
733
|
+
" // written and answered; every other request is refused.",
|
|
734
|
+
" const frameRouter = ctx.get(\"router\")",
|
|
735
|
+
" const frameRouterHttp = ctx.get(\"routerHttp\")",
|
|
736
|
+
" if (frameRouter !== undefined && frameRouterHttp !== undefined && connectionAuth !== undefined && typeof connectionAuth.launchToken === \"string\") {",
|
|
737
|
+
" const sameToken = (actual, expected) => {",
|
|
738
|
+
" const a = Buffer.from(String(actual), \"utf8\")",
|
|
739
|
+
" const b = Buffer.from(String(expected), \"utf8\")",
|
|
740
|
+
" return a.byteLength === b.byteLength && timingSafeEqual(a, b)",
|
|
741
|
+
" }",
|
|
742
|
+
" const rotate = () => {",
|
|
743
|
+
" connectionAuth.launchToken = randomBytes(32).toString(\"base64url\")",
|
|
744
|
+
" const next = connection.authenticatedUrl(server.url)",
|
|
745
|
+
" writeFrameRecord(next)",
|
|
746
|
+
" return next",
|
|
747
|
+
" }",
|
|
748
|
+
" frameRouter.route(\"/api/acryl-frame\", new Acryl().post(\"/token/new\", (c) => {",
|
|
749
|
+
" const presented = c.req.header(\"x-frame-token\") ?? \"\"",
|
|
750
|
+
" if (!sameToken(presented, connectionAuth.launchToken)) return c.text(\"forbidden\", 403)",
|
|
751
|
+
" return c.json({ url: rotate() })",
|
|
752
|
+
" }))",
|
|
753
|
+
" frameRouterHttp.mount(\"/api/acryl-frame\")",
|
|
754
|
+
" }",
|
|
755
|
+
"}",
|
|
756
|
+
"if (previousAddress !== undefined && !reusedToken && url !== undefined) {",
|
|
757
|
+
" console.warn(\"acrylblends host: the stored frame address could not be reused on this boot; a new one was minted\")",
|
|
758
|
+
"}",
|
|
759
|
+
"if (check) {",
|
|
760
|
+
" const loader = ctx.get('loader')",
|
|
761
|
+
" const rows = [...loader.entries()]",
|
|
762
|
+
" const stalled = []",
|
|
763
|
+
" for (const row of rows) {",
|
|
764
|
+
" const state = row.fiber?.state",
|
|
765
|
+
" if (row.disabled) continue",
|
|
766
|
+
" if (state !== 2 && state !== 4) stalled.push(`${row.id} (${row.options.name}): ${state === undefined ? 'no fiber' : String(state)}`)",
|
|
767
|
+
" }",
|
|
768
|
+
" const webRows = rows.filter((row) => /(^|\\/)(host-webserver|web-app|web-frontend)$/u.test(row.options.name))",
|
|
769
|
+
" if (stalled.length > 0 || webRows.length > 0) {",
|
|
770
|
+
" console.error('acrylblends host: composition check failed')",
|
|
771
|
+
" for (const line of stalled) console.error(`- not active: ${line}`)",
|
|
772
|
+
" for (const row of webRows) console.error(`- a DSH web row is mounted: ${row.id} (${row.options.name})`)",
|
|
773
|
+
" process.exitCode = 1",
|
|
774
|
+
" } else {",
|
|
775
|
+
" console.log(`acrylblends host: every row active (${String(rows.length)} rows), no DSH web row mounted`)",
|
|
776
|
+
" }",
|
|
777
|
+
" // The Cordis way: a provider removed puts its consumer PENDING, restoring it activates it again; a reload adds nothing twice.",
|
|
778
|
+
" // The rows live under the include entry (the launcher mounts one config file), so an id is found by its last segment.",
|
|
779
|
+
" const findRow = (id) => rows.find((row) => row.id === id || row.id.endsWith(`:${id}`))",
|
|
780
|
+
" const stateOf = (id) => findRow(id)?.fiber?.state",
|
|
781
|
+
" await loader.update(findRow('credentials').id, { disabled: true })",
|
|
782
|
+
" await loader.await()",
|
|
783
|
+
" const pendingAfterRemoval = stateOf('connection') === 0",
|
|
784
|
+
" await loader.update(findRow('credentials').id, { disabled: false })",
|
|
785
|
+
" await loader.await()",
|
|
786
|
+
" const activeAfterRestore = stateOf('connection') === 2",
|
|
787
|
+
" for (let round = 0; round < 2; round += 1) {",
|
|
788
|
+
" await loader.update(findRow('session-title').id, { disabled: true })",
|
|
789
|
+
" await loader.update(findRow('session-title').id, { disabled: false })",
|
|
790
|
+
" await loader.await()",
|
|
791
|
+
" }",
|
|
792
|
+
" const reloaded = stateOf('session-title') === 2",
|
|
793
|
+
" if (!pendingAfterRemoval || !activeAfterRestore || !reloaded) {",
|
|
794
|
+
" console.error(`acrylblends host: lifecycle check failed (pending ${String(pendingAfterRemoval)}, restored ${String(activeAfterRestore)}, reloaded ${String(reloaded)})`)",
|
|
795
|
+
" process.exitCode = 1",
|
|
796
|
+
" } else {",
|
|
797
|
+
" console.log('acrylblends host: PENDING on provider removal, active on restore, two reloads clean')",
|
|
798
|
+
" }",
|
|
799
|
+
" await ctx.fiber.dispose()",
|
|
800
|
+
"} else {",
|
|
801
|
+
" for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { void ctx.fiber.dispose() })",
|
|
802
|
+
"}",
|
|
803
|
+
""
|
|
804
|
+
].join("\n");
|
|
805
|
+
}
|
|
806
|
+
function frameModuleOf(packages) {
|
|
807
|
+
return packages.find((entry) => (entry.row ?? entry.name) === "acryl-blends-frame")?.name ?? "acryl-blends-frame";
|
|
808
|
+
}
|
|
809
|
+
/** `acrylblends-<app>`, safe for a DSH profile name: lowercase, dashes, nothing else. */
|
|
810
|
+
function profileNameFor(appName) {
|
|
811
|
+
const slug = appName.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "");
|
|
812
|
+
return `acrylblends-${slug === "" ? "app" : slug}`;
|
|
813
|
+
}
|
|
814
|
+
/** A package name as a YAML scalar: a scoped name starts with `@`, which YAML reserves at the start of a scalar, so it is quoted. */
|
|
815
|
+
const yamlName = (name) => name.startsWith("@") ? `'${name}'` : name;
|
|
816
|
+
function overlayOf(packages, door = false, appRows = []) {
|
|
817
|
+
const rows = packages;
|
|
818
|
+
const disabled = new Set(packages.flatMap((entry) => entry.disables ?? []));
|
|
819
|
+
const lines = [
|
|
820
|
+
"# Generated by acrylblends: the DSH overlay that mounts the ACRYL frame. Regenerate instead of editing.",
|
|
821
|
+
"- insert:",
|
|
822
|
+
" - id: app-instance",
|
|
823
|
+
" name: ./app/runtime.mjs",
|
|
824
|
+
...door ? [
|
|
825
|
+
" - id: acryl-door",
|
|
826
|
+
" name: ./plugins/acryl-door/src/index.ts",
|
|
827
|
+
" config:",
|
|
828
|
+
" hostIndex: false",
|
|
829
|
+
` appBase: ${FRAME_PAGE_PREFIX}`
|
|
830
|
+
] : [],
|
|
831
|
+
...appRows.flatMap((row) => [
|
|
832
|
+
` - id: ${row.id}`,
|
|
833
|
+
` name: ${row.name}`,
|
|
834
|
+
...row.isolate === void 0 ? [] : [` isolate: ${JSON.stringify(row.isolate)}`]
|
|
835
|
+
])
|
|
836
|
+
];
|
|
837
|
+
for (const entry of rows) {
|
|
838
|
+
lines.push(` - id: ${entry.row ?? entry.name}`, ` name: ${yamlName(entry.name)}`);
|
|
839
|
+
if ((entry.row ?? entry.name) === "acryl-blends-frame") lines.push(" config:", ` appPage: ${FRAME_PAGE_PREFIX}`);
|
|
840
|
+
}
|
|
841
|
+
lines.push("- id: connection", " inject: []", " config:", " trustedHosts: ['127.0.0.1']");
|
|
842
|
+
lines.push("- id: workspace-controller", " config:", " documentsDirectory: !!js process.env.ACRYLBLENDS_HOME ?? process.cwd() + '/.acrylblends'");
|
|
843
|
+
for (const id of [...disabled].sort()) lines.push(`- id: ${id}`, " disabled: true");
|
|
844
|
+
return `${lines.join("\n")}\n`;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The files a fresh app's profile needs, without touching the disk: the overlay text and the app's `package.json` with the DSH pin, the framework, the
|
|
848
|
+
* frame packages and a `dev` script on the app's own profile. An existing `package.json` keeps its keys; only the generated fields are set.
|
|
849
|
+
*/
|
|
850
|
+
function ideProfilePlan(input, existing) {
|
|
851
|
+
const profile = profileNameFor(input.appName);
|
|
852
|
+
const packages = (input.packages ?? FRAME_PACKAGES).map((entry) => ({
|
|
853
|
+
...entry,
|
|
854
|
+
spec: entry.spec === "" ? input.spec?.(entry.row ?? entry.name) ?? "" : entry.spec
|
|
855
|
+
}));
|
|
856
|
+
const dependencies = {
|
|
857
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
858
|
+
"@deepseek-ai/cordis-plugin-loader": "~1.0.6",
|
|
859
|
+
"@deepseek-ai/dsh-app-boot": input.dshVersion,
|
|
860
|
+
"@deepseek-ai/dsh-base": input.dshVersion,
|
|
861
|
+
"@deepseek-ai/dsh-web-app": input.dshVersion,
|
|
862
|
+
acrylblends: input.frameworkVersion,
|
|
863
|
+
"js-yaml": "^4.2.0",
|
|
864
|
+
...input.dependencies
|
|
865
|
+
};
|
|
866
|
+
for (const entry of packages) if (entry.installed !== true && entry.spec !== "") dependencies[entry.name] = entry.spec;
|
|
867
|
+
const manifest = existing === void 0 ? {
|
|
868
|
+
name: input.appName,
|
|
869
|
+
private: true,
|
|
870
|
+
type: "module"
|
|
871
|
+
} : JSON.parse(existing);
|
|
872
|
+
return {
|
|
873
|
+
profile,
|
|
874
|
+
overlay: overlayOf(packages, input.door === true, input.rows ?? []),
|
|
875
|
+
packageJson: `${JSON.stringify(mergeManifest(manifest, dependencies), null, 2)}\n`,
|
|
876
|
+
rootConfig: "[]\n",
|
|
877
|
+
hostScript: hostScriptOf(profile, frameModuleOf(packages))
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/** The app's manifest with the generated fields set: dependencies merged (generated wins), the dev script on its own profile, keys in a stable order. */
|
|
881
|
+
function mergeManifest(manifest, dependencies) {
|
|
882
|
+
const sorted = Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
883
|
+
const scripts = {
|
|
884
|
+
...manifest.scripts,
|
|
885
|
+
dev: "node --import ./plugins/acryl-node-server/src/register.ts scripts/ide-host.mjs"
|
|
886
|
+
};
|
|
887
|
+
return {
|
|
888
|
+
...manifest,
|
|
889
|
+
dependencies: {
|
|
890
|
+
...manifest.dependencies,
|
|
891
|
+
...sorted
|
|
892
|
+
},
|
|
893
|
+
scripts
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* The first line of every file this module generates. `acrylblends upgrade` uses it to tell a file it may rewrite from one the app wrote (a hand-made
|
|
898
|
+
* `cordis.patch.yml` for DSH, say): a file without the marker is refused, never overwritten.
|
|
899
|
+
*/
|
|
900
|
+
const GENERATED_MARKER = /^(?:#|\/\/) Generated by acrylblends:/u;
|
|
901
|
+
/** The generated files, in the order this module writes them, with the plan field each one's text comes from. */
|
|
902
|
+
const GENERATED_FILES = [
|
|
903
|
+
{
|
|
904
|
+
file: "cordis.patch.yml",
|
|
905
|
+
of: (plan) => plan.overlay
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
file: "cordis.yml",
|
|
909
|
+
of: (plan) => plan.rootConfig
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
file: "scripts/ide-host.mjs",
|
|
913
|
+
of: (plan) => plan.hostScript
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
file: "app/runtime.mjs",
|
|
917
|
+
of: () => APP_INSTANCE_ROW
|
|
918
|
+
}
|
|
919
|
+
];
|
|
920
|
+
/** The door is app source, not a registry package: the row exists when the app carries it, and nothing fails when it does not. */
|
|
921
|
+
function withDoor(root, input) {
|
|
922
|
+
return {
|
|
923
|
+
...input,
|
|
924
|
+
door: existsSync(join(root, "plugins", "acryl-door", "src", "index.ts"))
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Plan writing the profile into `root`, which may already hold an app: a file this module generated (its first line carries the marker) is an `update`, a
|
|
929
|
+
* file the app wrote is a `conflict` and must be refused by the caller, a missing file is an `add`. The bytes are exactly what `applyIdeProfile` writes.
|
|
930
|
+
*/
|
|
931
|
+
function planIdeProfileWrite(root, input) {
|
|
932
|
+
const manifestFile = join(root, "package.json");
|
|
933
|
+
const existing = existsSync(manifestFile) ? readFileSync(manifestFile, "utf8") : void 0;
|
|
934
|
+
const plan = ideProfilePlan(withDoor(root, input), existing);
|
|
935
|
+
const hostPath = join(root, "scripts/ide-host.mjs");
|
|
936
|
+
const appHost = existsSync(hostPath) ? readFileSync(hostPath, "utf8") : void 0;
|
|
937
|
+
let clientRowsAdded = [];
|
|
938
|
+
return {
|
|
939
|
+
plan,
|
|
940
|
+
files: GENERATED_FILES.map(({ file, of }) => {
|
|
941
|
+
let text = of(plan);
|
|
942
|
+
if (file === "scripts/ide-host.mjs") {
|
|
943
|
+
const merged = mergeHostClientRows(text, appHost);
|
|
944
|
+
text = merged.text;
|
|
945
|
+
clientRowsAdded = merged.added;
|
|
946
|
+
}
|
|
947
|
+
const path = join(root, file);
|
|
948
|
+
if (!existsSync(path)) return {
|
|
949
|
+
file,
|
|
950
|
+
text,
|
|
951
|
+
state: "add"
|
|
952
|
+
};
|
|
953
|
+
const before = readFileSync(path, "utf8");
|
|
954
|
+
if (before === text) return {
|
|
955
|
+
file,
|
|
956
|
+
text,
|
|
957
|
+
state: "same"
|
|
958
|
+
};
|
|
959
|
+
const firstLine = before.split("\n", 1)[0] ?? "";
|
|
960
|
+
return {
|
|
961
|
+
file,
|
|
962
|
+
text,
|
|
963
|
+
state: GENERATED_MARKER.test(firstLine) ? "update" : "conflict"
|
|
964
|
+
};
|
|
965
|
+
}),
|
|
966
|
+
manifestChanged: existing !== plan.packageJson,
|
|
967
|
+
clientRowsAdded
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/** Write a plan made by {@link planIdeProfileWrite}: only the files whose bytes differ, and `package.json` only when the merge changes it. */
|
|
971
|
+
function writeIdeProfilePlan(root, planned) {
|
|
972
|
+
const written = [];
|
|
973
|
+
const unchanged = [];
|
|
974
|
+
for (const file of planned.files) {
|
|
975
|
+
if (file.state === "same") {
|
|
976
|
+
unchanged.push(file.file);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const path = join(root, file.file);
|
|
980
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
981
|
+
writeFileSync(path, file.text);
|
|
982
|
+
written.push(file.file);
|
|
983
|
+
}
|
|
984
|
+
if (planned.manifestChanged) {
|
|
985
|
+
writeFileSync(join(root, "package.json"), planned.plan.packageJson);
|
|
986
|
+
written.push("package.json");
|
|
987
|
+
} else unchanged.push("package.json");
|
|
988
|
+
return {
|
|
989
|
+
written,
|
|
990
|
+
unchanged
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Write the profile into `root`: `cordis.patch.yml` and the app's `package.json`. Idempotent: a file whose bytes are already the generated ones is not
|
|
995
|
+
* touched, so a second run reports it under `unchanged` and the git working tree stays clean.
|
|
996
|
+
*/
|
|
997
|
+
function applyIdeProfile(root, input) {
|
|
998
|
+
mkdirSync(root, { recursive: true });
|
|
999
|
+
return writeIdeProfilePlan(root, planIdeProfileWrite(root, input));
|
|
1000
|
+
}
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/version.ts
|
|
1003
|
+
/** The version of this package. */
|
|
1004
|
+
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
1005
|
+
/** The DSH release the frame's packages pin as peers, so an app's DSH pin is the same one. */
|
|
1006
|
+
const FRAME_DSH_VERSION = dshVersion;
|
|
1007
|
+
/** Every package the frame publishes, in the file's order. */
|
|
1008
|
+
const FRAME_RELEASE = Object.entries(packages).map(([source, entry]) => ({
|
|
1009
|
+
source,
|
|
1010
|
+
name: `${scope}/${entry.name}`,
|
|
1011
|
+
version: entry.version
|
|
1012
|
+
}));
|
|
1013
|
+
/** `ACRYLBLENDS_FRAME_PACKAGES=none`: the app runs on the framework alone, whatever the default would be. */
|
|
1014
|
+
const FRAMEWORK_ONLY = "framework-only";
|
|
1015
|
+
/**
|
|
1016
|
+
* The frame's specs from npm: every row package by its published name at a caret over its released version, the DSH pin the packages are built against,
|
|
1017
|
+
* and this CLI's own release as the framework. A row package the file does not name is an error (the CLI tests catch it), never a silent skip.
|
|
1018
|
+
*/
|
|
1019
|
+
function publishedFrameProfile() {
|
|
1020
|
+
const bySource = new Map(FRAME_RELEASE.map((entry) => [entry.source, entry]));
|
|
1021
|
+
const packages = {};
|
|
1022
|
+
const names = {};
|
|
1023
|
+
for (const entry of FRAME_PACKAGES) {
|
|
1024
|
+
if (entry.installed === true) continue;
|
|
1025
|
+
const published = bySource.get(entry.name);
|
|
1026
|
+
if (published === void 0) throw new Error(`frame/packages.json does not name the frame row ${entry.name}`);
|
|
1027
|
+
packages[entry.name] = `^${published.version}`;
|
|
1028
|
+
names[entry.name] = published.name;
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
dshVersion: FRAME_DSH_VERSION,
|
|
1032
|
+
frameworkVersion: `^${VERSION}`,
|
|
1033
|
+
packages,
|
|
1034
|
+
names
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/** `ACRYLBLENDS_FRAME_PACKAGES`: specs (a checkout's build), {@link FRAMEWORK_ONLY} for `none`, undefined when unset or malformed. */
|
|
1038
|
+
function frameProfileFromEnv() {
|
|
1039
|
+
const raw = process.env.ACRYLBLENDS_FRAME_PACKAGES;
|
|
1040
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
1041
|
+
if (raw.trim() === "none") return FRAMEWORK_ONLY;
|
|
1042
|
+
try {
|
|
1043
|
+
const parsed = JSON.parse(raw);
|
|
1044
|
+
if (typeof parsed.dshVersion !== "string" || typeof parsed.frameworkVersion !== "string") return void 0;
|
|
1045
|
+
const packages = {};
|
|
1046
|
+
for (const [key, value] of Object.entries(parsed.packages ?? {})) if (typeof value === "string") packages[key] = value;
|
|
1047
|
+
return {
|
|
1048
|
+
dshVersion: parsed.dshVersion,
|
|
1049
|
+
frameworkVersion: parsed.frameworkVersion,
|
|
1050
|
+
packages
|
|
1051
|
+
};
|
|
1052
|
+
} catch {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** The specs a new app's frame installs: the environment's, else npm's for a desktop app; undefined leaves the app on the framework alone. */
|
|
1057
|
+
function frameProfileForInit(desktop) {
|
|
1058
|
+
const fromEnv = frameProfileFromEnv();
|
|
1059
|
+
if (fromEnv === "framework-only") return void 0;
|
|
1060
|
+
return fromEnv ?? (desktop ? publishedFrameProfile() : void 0);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* The specs for an app that may or may not be framed already: the environment first, then the app's own `package.json` (a framed app carries the DSH
|
|
1064
|
+
* pin, the framework and the frame packages, by their source or published names), then npm's for a desktop app (one with `plugins/desktop`).
|
|
1065
|
+
* Undefined when none applies: the caller then leaves the app on the framework alone and says why.
|
|
1066
|
+
*/
|
|
1067
|
+
function frameProfileForApp(root) {
|
|
1068
|
+
const fromEnv = frameProfileFromEnv();
|
|
1069
|
+
if (fromEnv === "framework-only") return void 0;
|
|
1070
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
1071
|
+
const fromApp = frameProfileOfManifest(root);
|
|
1072
|
+
if (fromApp !== void 0) return fromApp;
|
|
1073
|
+
return existsSync(join(root, "plugins", "desktop")) ? publishedFrameProfile() : void 0;
|
|
1074
|
+
}
|
|
1075
|
+
function frameProfileOfManifest(root) {
|
|
1076
|
+
const manifestFile = join(root, "package.json");
|
|
1077
|
+
if (!existsSync(manifestFile)) return void 0;
|
|
1078
|
+
let manifest;
|
|
1079
|
+
try {
|
|
1080
|
+
manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
1081
|
+
} catch {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const dependencies = manifest.dependencies ?? {};
|
|
1085
|
+
const dshVersion = dependencies["@deepseek-ai/dsh-base"];
|
|
1086
|
+
const frameworkVersion = dependencies.acrylblends;
|
|
1087
|
+
if (typeof dshVersion !== "string" || typeof frameworkVersion !== "string") return void 0;
|
|
1088
|
+
const publishedOf = new Map(FRAME_RELEASE.map((entry) => [entry.source, entry.name]));
|
|
1089
|
+
const packages = {};
|
|
1090
|
+
const names = {};
|
|
1091
|
+
for (const entry of FRAME_PACKAGES) {
|
|
1092
|
+
const source = dependencies[entry.name];
|
|
1093
|
+
const publishedName = publishedOf.get(entry.name);
|
|
1094
|
+
const published = publishedName === void 0 ? void 0 : dependencies[publishedName];
|
|
1095
|
+
if (typeof source === "string") packages[entry.name] = source;
|
|
1096
|
+
else if (typeof published === "string" && publishedName !== void 0) {
|
|
1097
|
+
packages[entry.name] = published;
|
|
1098
|
+
names[entry.name] = publishedName;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (Object.keys(packages).length === 0) return void 0;
|
|
1102
|
+
return {
|
|
1103
|
+
dshVersion,
|
|
1104
|
+
frameworkVersion,
|
|
1105
|
+
packages,
|
|
1106
|
+
names
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The profile input for an app from its specs: every frame row named by the module it installs as, its Loader row id kept as the source name (so a checkout
|
|
1111
|
+
* app and a published one have the same rows, and an upgrade between them renames nothing), and the specs as dependencies under the same module names.
|
|
1112
|
+
*/
|
|
1113
|
+
function frameProfileInput(specs, appName, rows) {
|
|
1114
|
+
const moduleOf = (source) => specs.names?.[source] ?? source;
|
|
1115
|
+
const packages = FRAME_PACKAGES.map((entry) => entry.installed === true ? entry : {
|
|
1116
|
+
...entry,
|
|
1117
|
+
name: moduleOf(entry.name),
|
|
1118
|
+
row: entry.row ?? entry.name,
|
|
1119
|
+
spec: specs.packages[entry.name] ?? ""
|
|
1120
|
+
});
|
|
1121
|
+
return {
|
|
1122
|
+
appName,
|
|
1123
|
+
dshVersion: specs.dshVersion,
|
|
1124
|
+
frameworkVersion: specs.frameworkVersion,
|
|
1125
|
+
packages,
|
|
1126
|
+
dependencies: Object.fromEntries(Object.entries(specs.packages).map(([source, spec]) => [moduleOf(source), spec])),
|
|
1127
|
+
...rows === void 0 ? {} : { rows }
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
//#endregion
|
|
1131
|
+
//#region src/frame-rows.ts
|
|
1132
|
+
/**
|
|
1133
|
+
* The rows the frame's layer needs for an app's own code, and the generated composition row that runs the app's plugins beside the host.
|
|
1134
|
+
*
|
|
1135
|
+
* A template with `src/frame.ts` gets the app's routes row. A template whose `src/app.ts` exports `composeInto` also gets `frame/app-plugins.mjs`, one row
|
|
1136
|
+
* that composes the app exactly as its own server does (same rows, order and config; `host: true` leaves the host's infrastructure rows out). The services
|
|
1137
|
+
* the app's own rows provide are read from their sources, never a hand list, and isolated through the Loader's own entry option, so the host's DSH rows keep
|
|
1138
|
+
* their copies of the same names.
|
|
1139
|
+
*
|
|
1140
|
+
* `init` writes these for a fresh app and `upgrade` writes them for an app made before the frame: one home for the row ids, the isolate rule and the
|
|
1141
|
+
* generated file.
|
|
1142
|
+
*/
|
|
1143
|
+
/** Rows the host mounts itself: an app's manifest may name them, but the generated layer does not. */
|
|
1144
|
+
const HOST_PROVIDED_IDS = /* @__PURE__ */ new Set([
|
|
1145
|
+
"instance",
|
|
1146
|
+
"acryl-router",
|
|
1147
|
+
"acryl-node-server",
|
|
1148
|
+
"acryl-web-server",
|
|
1149
|
+
"acryl-page",
|
|
1150
|
+
"manifest",
|
|
1151
|
+
"save",
|
|
1152
|
+
"acryl-door"
|
|
1153
|
+
]);
|
|
1154
|
+
/** The generated `frame/app-plugins.mjs`, by the row that mounts it. */
|
|
1155
|
+
const APP_PLUGINS_FILE = "frame/app-plugins.mjs";
|
|
1156
|
+
/**
|
|
1157
|
+
* The generated row source: the app's plugins composed beside the host, with the services they provide isolated (the Loader entry option). Every
|
|
1158
|
+
* route they mount lands under the frame's app prefix (`prefix`): a plugin's `ctx.router.route('/board')` answers `/app/board` in the canvas and
|
|
1159
|
+
* `/board` on the app's own server, so a plugin never writes the prefix. `@param prefix` is the canvas prefix, {@link FRAME_PAGE_PREFIX}.
|
|
1160
|
+
*/
|
|
1161
|
+
function appPluginsRow(prefix) {
|
|
1162
|
+
return [
|
|
1163
|
+
"// Generated by acrylblends: the app's own plugins, composed as the app's server composes them (src/app.ts composeInto). The layer row isolates the",
|
|
1164
|
+
"// services this app provides (the Loader entry option), so the host's driver rows keep their own. Every route these rows mount lands under the",
|
|
1165
|
+
`// frame's app prefix (${prefix}): a plugin's ctx.router.route('/board') answers /${prefix.replace(/^\/+/u, "")}/board in the canvas and /board on the app's own server.`,
|
|
1166
|
+
"import { composeInto } from '../src/app.ts'",
|
|
1167
|
+
"",
|
|
1168
|
+
"export const name = 'app-plugins'",
|
|
1169
|
+
"export const inject = ['appInstance', 'router']",
|
|
1170
|
+
"",
|
|
1171
|
+
"/** The path a route lands at under the prefix: \"/\" is the prefix itself, a path already under it stays as it is. */",
|
|
1172
|
+
"function prefixedAppRoute(prefix, path) {",
|
|
1173
|
+
" const base = prefix.replace(/\\/+$/, '')",
|
|
1174
|
+
" if (base === '') return path",
|
|
1175
|
+
" if (path === '' || path === '/') return base",
|
|
1176
|
+
" return path === base || path.startsWith(base + '/') ? path : base + (path.startsWith('/') ? '' : '/') + path",
|
|
1177
|
+
"}",
|
|
1178
|
+
"",
|
|
1179
|
+
"/** The router as the app's rows see it: mounts land under the prefix, every other member is the host router's. */",
|
|
1180
|
+
"function appRouter(router, prefix) {",
|
|
1181
|
+
" const route = (path, app) => router.route(prefixedAppRoute(prefix, path), app)",
|
|
1182
|
+
" return new Proxy(router, {",
|
|
1183
|
+
" get(target, property) {",
|
|
1184
|
+
" if (property === 'route') return route",
|
|
1185
|
+
" const value = Reflect.get(target, property, target)",
|
|
1186
|
+
" return typeof value === 'function' ? value.bind(target) : value",
|
|
1187
|
+
" },",
|
|
1188
|
+
" })",
|
|
1189
|
+
"}",
|
|
1190
|
+
"",
|
|
1191
|
+
"export async function apply(ctx, config) {",
|
|
1192
|
+
` const prefix = typeof config?.appPrefix === 'string' ? config.appPrefix : ${JSON.stringify(prefix)}`,
|
|
1193
|
+
" // The rows read ctx.router as the app's own router: the same paths as on their own server, under the frame's prefix.",
|
|
1194
|
+
" const appScope = ctx.extend({ router: appRouter(ctx.router, prefix) })",
|
|
1195
|
+
" const fibers = await composeInto(appScope, ctx.appInstance, { host: true })",
|
|
1196
|
+
" ctx.effect(() => () => { for (const fiber of [...fibers].reverse()) void fiber.dispose() }, 'app-plugins: dispose')",
|
|
1197
|
+
"}",
|
|
1198
|
+
""
|
|
1199
|
+
].join("\n");
|
|
1200
|
+
}
|
|
1201
|
+
/** Whether an app's composition root is the one `composeInto` makes: the row can call it only then. */
|
|
1202
|
+
function carriesComposeInto(appTs) {
|
|
1203
|
+
return appTs.includes("export async function composeInto");
|
|
1204
|
+
}
|
|
1205
|
+
/** The ids of the app's own rows (blend.yaml), without the ones the host provides. */
|
|
1206
|
+
function ownRowIds(appDir) {
|
|
1207
|
+
const blend = join(appDir, "blend.yaml");
|
|
1208
|
+
if (!existsSync(blend)) return [];
|
|
1209
|
+
const ids = [];
|
|
1210
|
+
for (const match of readFileSync(blend, "utf8").matchAll(/^\s*- id:\s*(\S+)\s*$/gmu)) if (!HOST_PROVIDED_IDS.has(match[1])) ids.push(match[1]);
|
|
1211
|
+
return ids;
|
|
1212
|
+
}
|
|
1213
|
+
/** The service names the app's own plugins provide, read from their sources. */
|
|
1214
|
+
function providedServices(appDir) {
|
|
1215
|
+
const provided = /* @__PURE__ */ new Set();
|
|
1216
|
+
const walk = (folder) => {
|
|
1217
|
+
if (!existsSync(folder)) return;
|
|
1218
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
1219
|
+
const path = join(folder, entry.name);
|
|
1220
|
+
if (entry.isDirectory()) {
|
|
1221
|
+
walk(path);
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (!/\.(ts|tsx|mjs)$/u.test(entry.name)) continue;
|
|
1225
|
+
const text = readFileSync(path, "utf8");
|
|
1226
|
+
for (const match of text.matchAll(/super\(\s*ctx\s*,\s*['"]([\w.-]+)['"]/gu)) provided.add(match[1]);
|
|
1227
|
+
for (const match of text.matchAll(/reflect\.provide\(\s*['"]([\w.-]+)['"]/gu)) provided.add(match[1]);
|
|
1228
|
+
for (const match of text.matchAll(/ctx\.provide\(\s*['"]([\w.-]+)['"]/gu)) provided.add(match[1]);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
for (const row of ownRowIds(appDir)) walk(join(appDir, "plugins", row));
|
|
1232
|
+
return Object.fromEntries([...provided].sort().map((name) => [name, true]));
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The frame rows for an app: its routes row when it carries `src/frame.ts`, and the composition row when its `src/app.ts` exports `composeInto`.
|
|
1236
|
+
* `appTs` and `hasFrame` let a caller plan against the files it is about to write (an upgrade replacing the scaffold) rather than the ones on disk.
|
|
1237
|
+
*/
|
|
1238
|
+
function appFrameRows(appDir, options = {}) {
|
|
1239
|
+
if (options.hasFrame !== true && !existsSync(join(appDir, "src", "frame.ts"))) return {
|
|
1240
|
+
rows: [],
|
|
1241
|
+
appPlugins: void 0
|
|
1242
|
+
};
|
|
1243
|
+
const appTs = options.appTs ?? readAppTs(appDir);
|
|
1244
|
+
const composes = appTs !== void 0 && carriesComposeInto(appTs);
|
|
1245
|
+
const rows = [];
|
|
1246
|
+
if (composes) {
|
|
1247
|
+
const isolate = providedServices(appDir);
|
|
1248
|
+
rows.push({
|
|
1249
|
+
id: "app-plugins",
|
|
1250
|
+
name: `./${APP_PLUGINS_FILE}`,
|
|
1251
|
+
...Object.keys(isolate).length === 0 ? {} : { isolate }
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
rows.push({
|
|
1255
|
+
id: "app-routes",
|
|
1256
|
+
name: "./src/frame.ts"
|
|
1257
|
+
});
|
|
1258
|
+
return {
|
|
1259
|
+
rows,
|
|
1260
|
+
appPlugins: composes ? appPluginsRow(FRAME_PAGE_PREFIX) : void 0
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function readAppTs(appDir) {
|
|
1264
|
+
const file = join(appDir, "src", "app.ts");
|
|
1265
|
+
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1266
|
+
}
|
|
1267
|
+
//#endregion
|
|
11
1268
|
//#region src/templates.ts
|
|
12
1269
|
/** The templates, all Cordis apps: a web app with a page and a client component (nodejs), a notes app and a chat app on the db port (notes, chat), a blank app on the router, a todo list on the router and state, and the blank app with the offline agent. */
|
|
13
1270
|
const TEMPLATES = [
|
|
@@ -73,10 +1330,6 @@ function frameworkDocsRoot() {
|
|
|
73
1330
|
}
|
|
74
1331
|
}
|
|
75
1332
|
//#endregion
|
|
76
|
-
//#region src/version.ts
|
|
77
|
-
/** The version of this package. */
|
|
78
|
-
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
79
|
-
//#endregion
|
|
80
1333
|
//#region src/lock.ts
|
|
81
1334
|
/**
|
|
82
1335
|
* What an app knows about the library plugins it carries: `plugins/library.lock.json` records, for every library plugin copied into the app, the version it came from and a hash of each file
|
|
@@ -409,7 +1662,10 @@ function readTemplatePlugins(templateDir) {
|
|
|
409
1662
|
const { plugins } = JSON.parse(readFileSync(file, "utf8"));
|
|
410
1663
|
return plugins ?? [];
|
|
411
1664
|
}
|
|
412
|
-
/**
|
|
1665
|
+
/**
|
|
1666
|
+
* Add run-time dependencies to an app's package.json, keeping what is there and sorting the keys. Returns the names added. A package the app lists as a development
|
|
1667
|
+
* dependency too is taken out of that list: npm lets the development entry win, so `npm install --omit=dev` would leave a run-time dependency out.
|
|
1668
|
+
*/
|
|
413
1669
|
function mergeDependencies(appDir, dependencies) {
|
|
414
1670
|
const file = join(appDir, "package.json");
|
|
415
1671
|
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -420,6 +1676,7 @@ function mergeDependencies(appDir, dependencies) {
|
|
|
420
1676
|
...current
|
|
421
1677
|
};
|
|
422
1678
|
manifest.dependencies = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
1679
|
+
if (manifest.devDependencies !== void 0) manifest.devDependencies = Object.fromEntries(Object.entries(manifest.devDependencies).filter(([name]) => !(name in merged)));
|
|
423
1680
|
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
424
1681
|
return added;
|
|
425
1682
|
}
|
|
@@ -450,20 +1707,22 @@ function mergeOpener(appDir, opener) {
|
|
|
450
1707
|
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
451
1708
|
}
|
|
452
1709
|
/**
|
|
453
|
-
* Add a plugin's development dependencies and scripts to an app's package.json: nothing the app already has is replaced
|
|
454
|
-
* and scripts added.
|
|
1710
|
+
* Add a plugin's development dependencies and scripts to an app's package.json: nothing the app already has is replaced, and a package the app already needs at run time
|
|
1711
|
+
* (`dependencies`) is not listed again as a development one. Returns the names of the development dependencies and scripts added.
|
|
455
1712
|
*/
|
|
456
1713
|
function mergeTooling(appDir, tooling) {
|
|
457
1714
|
const file = join(appDir, "package.json");
|
|
458
1715
|
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
459
1716
|
const dev = manifest.devDependencies ?? {};
|
|
1717
|
+
const runtime = manifest.dependencies ?? {};
|
|
460
1718
|
const scripts = manifest.scripts ?? {};
|
|
461
|
-
const
|
|
1719
|
+
const wanted = Object.entries(tooling.devDependencies ?? {}).filter(([name]) => !(name in runtime));
|
|
1720
|
+
const addedDev = wanted.map(([name]) => name).filter((name) => !(name in dev));
|
|
462
1721
|
const addedScripts = Object.keys(tooling.scripts ?? {}).filter((name) => !(name in scripts));
|
|
463
1722
|
manifest.devDependencies = Object.fromEntries(Object.entries({
|
|
464
|
-
...
|
|
1723
|
+
...Object.fromEntries(wanted),
|
|
465
1724
|
...dev
|
|
466
|
-
}).sort(([a], [b]) => a.localeCompare(b)));
|
|
1725
|
+
}).filter(([name]) => !(name in runtime)).sort(([a], [b]) => a.localeCompare(b)));
|
|
467
1726
|
manifest.scripts = {
|
|
468
1727
|
...tooling.scripts,
|
|
469
1728
|
...scripts
|
|
@@ -510,22 +1769,34 @@ function appendGitignore(appDir, lines) {
|
|
|
510
1769
|
if (missing.length > 0) writeFileSync(file, `${current}${current.endsWith("\n") || current === "" ? "" : "\n"}${missing.join("\n")}\n`);
|
|
511
1770
|
return missing;
|
|
512
1771
|
}
|
|
513
|
-
/**
|
|
1772
|
+
/**
|
|
1773
|
+
* The generated app's guide for a coding agent: the shared core, then the template's own part. `CLAUDE.md` is only an import of it. The skills
|
|
1774
|
+
* every app ships (`shared/skills/<name>/SKILL.md`) go to `.claude/skills/` (Claude Code) and `.agents/skills/` (the other agents), the same text in both.
|
|
1775
|
+
*/
|
|
514
1776
|
function writeAgentFiles(sharedDir, templateDir, target, name) {
|
|
515
1777
|
const core = readFileSync(join(sharedDir, "AGENTS.core.md"), "utf8");
|
|
516
1778
|
const own = existsSync(join(templateDir, "AGENTS.md")) ? readFileSync(join(templateDir, "AGENTS.md"), "utf8") : "";
|
|
517
1779
|
const text = `${core}${readFileSync(join(sharedDir, "DEFAULTS.md"), "utf8")}${own}`.replaceAll("__APP_NAME__", name);
|
|
518
1780
|
writeFileSync(join(target, "AGENTS.md"), text);
|
|
519
1781
|
writeFileSync(join(target, "CLAUDE.md"), text);
|
|
520
|
-
const skill = readFileSync(join(sharedDir, "SKILL.md"), "utf8").replaceAll("__APP_NAME__", name);
|
|
521
1782
|
const written = ["AGENTS.md", "CLAUDE.md"];
|
|
522
|
-
for (const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
1783
|
+
for (const skill of sharedSkills(sharedDir)) {
|
|
1784
|
+
const skillText = readFileSync(join(sharedDir, "skills", skill, "SKILL.md"), "utf8").replaceAll("__APP_NAME__", name);
|
|
1785
|
+
for (const scope of [".claude/skills", ".agents/skills"]) {
|
|
1786
|
+
const folder = `${scope}/${skill}`;
|
|
1787
|
+
mkdirSync(join(target, folder), { recursive: true });
|
|
1788
|
+
writeFileSync(join(target, folder, "SKILL.md"), skillText);
|
|
1789
|
+
written.push(`${folder}/SKILL.md`);
|
|
1790
|
+
}
|
|
526
1791
|
}
|
|
527
1792
|
return written;
|
|
528
1793
|
}
|
|
1794
|
+
/** The skills every generated app carries: the folder name of each under `shared/skills/`. */
|
|
1795
|
+
function sharedSkills(sharedDir) {
|
|
1796
|
+
const root = join(sharedDir, "skills");
|
|
1797
|
+
if (!existsSync(root)) return [];
|
|
1798
|
+
return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "SKILL.md"))).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
|
|
1799
|
+
}
|
|
529
1800
|
/**
|
|
530
1801
|
* Copy the framework's docs (a generated copy of ACRYL's `docs/framework`, already checked against its digest) into `<app>/docs/framework/`,
|
|
531
1802
|
* so a coding agent working in the app reads the API from the folder it is in. The generated-copy notes and the digest record stay behind.
|
|
@@ -558,6 +1829,33 @@ function copyFrameworkDocs(source, target) {
|
|
|
558
1829
|
var UsageError = class extends Error {
|
|
559
1830
|
name = "UsageError";
|
|
560
1831
|
};
|
|
1832
|
+
/**
|
|
1833
|
+
* A new app is a git repository from the start: its `.gitignore` is written with the scaffold, `npm run save` commits it, and the frame's Files and
|
|
1834
|
+
* Changes views work on a git worktree (the app's own folder; without one they answer "file request failed"). A folder that already is a repository, or
|
|
1835
|
+
* a machine without git, is left alone.
|
|
1836
|
+
*/
|
|
1837
|
+
function startRepository(directory) {
|
|
1838
|
+
if (existsSync(join(directory, ".git"))) return;
|
|
1839
|
+
const run = (args) => spawnSync("git", args, {
|
|
1840
|
+
cwd: directory,
|
|
1841
|
+
stdio: "ignore",
|
|
1842
|
+
windowsHide: true
|
|
1843
|
+
}).status === 0;
|
|
1844
|
+
if (!run(["init", "-q"])) return;
|
|
1845
|
+
if (!run(["add", "-A"])) return;
|
|
1846
|
+
run([
|
|
1847
|
+
...run(["config", "user.email"]) ? [] : [
|
|
1848
|
+
"-c",
|
|
1849
|
+
"user.name=acrylblends",
|
|
1850
|
+
"-c",
|
|
1851
|
+
"user.email=scaffold@acrylblends.invalid"
|
|
1852
|
+
],
|
|
1853
|
+
"commit",
|
|
1854
|
+
"-q",
|
|
1855
|
+
"-m",
|
|
1856
|
+
"Initial commit from acrylblends"
|
|
1857
|
+
]);
|
|
1858
|
+
}
|
|
561
1859
|
/** Parse `init [dir] [--template t] [--agent|--no-agent] [--name n] [--install] [-y]`. `--agent` means `--template agent`, `--no-agent` means `--template blank`. */
|
|
562
1860
|
function parseInitOptions(argv) {
|
|
563
1861
|
let parsed;
|
|
@@ -639,9 +1937,26 @@ async function init(options, io) {
|
|
|
639
1937
|
mergeTooling(directory, plugin);
|
|
640
1938
|
}
|
|
641
1939
|
addNodePreflight(directory);
|
|
1940
|
+
const frame = appFrameRows(directory);
|
|
1941
|
+
if (frame.appPlugins !== void 0) {
|
|
1942
|
+
mkdirSync(join(directory, "frame"), { recursive: true });
|
|
1943
|
+
writeFileSync(join(directory, APP_PLUGINS_FILE), frame.appPlugins);
|
|
1944
|
+
}
|
|
1945
|
+
const frameRow = frame.rows;
|
|
1946
|
+
if (frameRow.length > 0) {
|
|
1947
|
+
const specs = frameProfileForInit(options.desktop);
|
|
1948
|
+
if (specs === void 0) {
|
|
1949
|
+
const why = options.desktop ? "ACRYLBLENDS_FRAME_PACKAGES is none" : "init --desktop puts it inside the ACRYL frame";
|
|
1950
|
+
io.stdout.write(`note: this app runs on the framework alone; ${why}\n`);
|
|
1951
|
+
} else {
|
|
1952
|
+
const wrote = applyIdeProfile(directory, frameProfileInput(specs, name, frameRow));
|
|
1953
|
+
io.stdout.write(`frame: wrote ${wrote.written.join(", ") || "nothing (already right)"}\n`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
642
1956
|
const desktopPath = options.desktop ? readTemplateDesktopPath(templateDir(template)) : void 0;
|
|
643
1957
|
if (desktopPath !== void 0) writeDesktopPath(directory, desktopPath);
|
|
644
1958
|
io.stdout.write(`created ${name} (${template}) in ${directory}: ${files.length} files, plugins ${own.map((plugin) => plugin.name).join(", ")}${forDesktop.length > 0 ? `; the desktop shell adds ${forDesktop.map((plugin) => plugin.name).join(", ")}, which it needs` : ""}\n`);
|
|
1959
|
+
startRepository(directory);
|
|
645
1960
|
if (install && await io.install(directory) !== 0) {
|
|
646
1961
|
io.stderr.write("error: npm install failed; run it yourself in the new directory\n");
|
|
647
1962
|
return 1;
|
|
@@ -673,9 +1988,17 @@ const MOUNTED = [
|
|
|
673
1988
|
local: "driverAcp",
|
|
674
1989
|
needs: ["agent-panel"],
|
|
675
1990
|
after: "agent-panel"
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
name: "feedback",
|
|
1994
|
+
local: "feedback",
|
|
1995
|
+
needs: ["acryl-router"]
|
|
676
1996
|
}
|
|
677
1997
|
];
|
|
678
|
-
|
|
1998
|
+
/** A row, in either line ending: a file edited on Windows (or checked out with core.autocrlf) is still a Blend. */
|
|
1999
|
+
const ROW = /^( *)- id: ?(\S+)\r?\n\1 {2}name: ?(\S+)\r?\n/gmu;
|
|
2000
|
+
/** The line ending the text already uses, so an edit keeps the file's own style. */
|
|
2001
|
+
const eolOf$1 = (text) => text.includes("\r\n") ? "\r\n" : "\n";
|
|
679
2002
|
/** The `name:` of every row of the app's `blend.yaml`, or `undefined` when it has none. */
|
|
680
2003
|
function rowNames(appDir) {
|
|
681
2004
|
const file = join(appDir, "blend.yaml");
|
|
@@ -696,10 +2019,11 @@ function mount(appDir, plugin) {
|
|
|
696
2019
|
let text = readFileSync(manifestFile, "utf8");
|
|
697
2020
|
if ([...text.matchAll(ROW)].some((match) => match[3] === plugin.name)) done.push("blend.yaml (already has the row)");
|
|
698
2021
|
else {
|
|
699
|
-
const
|
|
2022
|
+
const eol = eolOf$1(text);
|
|
2023
|
+
const found = (plugin.after ? new RegExp(`^( *)- id: ?\\S+\\r?\\n\\1 {2}name: ?${plugin.after}\\r?\\n(?:\\1 {2}config:\\r?\\n(?:\\1 {4}.*\\r?\\n|\\1 {2}\\s*\\r?\\n)*)?`, "mu") : /^( *)- id: ?routes\r?\n/mu).exec(text);
|
|
700
2024
|
if (found) {
|
|
701
2025
|
const indent = found[1];
|
|
702
|
-
const row = `${indent}- id: ${plugin.row ?? plugin.name}
|
|
2026
|
+
const row = `${indent}- id: ${plugin.row ?? plugin.name}${eol}${indent} name: ${plugin.name}${eol}`;
|
|
703
2027
|
const at = plugin.after ? found.index + found[0].length : found.index;
|
|
704
2028
|
text = text.slice(0, at) + row + text.slice(at);
|
|
705
2029
|
writeFileSync(manifestFile, text);
|
|
@@ -713,13 +2037,14 @@ function mount(appDir, plugin) {
|
|
|
713
2037
|
const entry = `'${plugin.name}': ${plugin.local}`;
|
|
714
2038
|
if (text.includes(entry)) done.push("src/app.ts (already registered)");
|
|
715
2039
|
else {
|
|
716
|
-
const
|
|
717
|
-
const
|
|
2040
|
+
const eol = eolOf$1(text);
|
|
2041
|
+
const last = [...text.matchAll(/^import .* from '\.\.\/plugins\/[^']+'\r?\n/gmu)].at(-1);
|
|
2042
|
+
const table = /^export const registry = \{(\r?\n)/mu.exec(text);
|
|
718
2043
|
if (last && table) {
|
|
719
2044
|
const file = plugin.entry ?? "index.ts";
|
|
720
|
-
const line = `import * as ${plugin.local} from '../plugins/${plugin.name}/${file}'
|
|
2045
|
+
const line = `import * as ${plugin.local} from '../plugins/${plugin.name}/${file}'${eol}`;
|
|
721
2046
|
const at = table.index + table[0].length;
|
|
722
|
-
text = text.slice(0, at) + (table[1] === "\n" ? ` ${entry}
|
|
2047
|
+
text = text.slice(0, at) + (table[1] === "\n" || table[1] === "\r\n" ? ` ${entry},${eol}` : `${entry}, `) + text.slice(at);
|
|
723
2048
|
const lastEnd = last.index + last[0].length;
|
|
724
2049
|
text = text.slice(0, lastEnd) + line + text.slice(lastEnd);
|
|
725
2050
|
writeFileSync(appFile, text);
|
|
@@ -1127,6 +2452,216 @@ function checkDeps(options, io) {
|
|
|
1127
2452
|
return 1;
|
|
1128
2453
|
}
|
|
1129
2454
|
//#endregion
|
|
2455
|
+
//#region src/frame-url.ts
|
|
2456
|
+
/**
|
|
2457
|
+
* The running frame's address, kept where the app's own data lives.
|
|
2458
|
+
*
|
|
2459
|
+
* The generated launcher prints the frame's URL with the launch token in it, and that token is the only way into a frame whose browser session ended.
|
|
2460
|
+
* So the address is written where the app keeps its data - `.acrylblends/`, which every scaffold's `.gitignore` already covers - at mint time,
|
|
2461
|
+
* owner-only (0600 here; a restricted ACL on Windows), and atomically (a temp file in the same folder renamed over it, so a reader never sees half a
|
|
2462
|
+
* record). On every later boot the launcher reuses the stored token instead of minting a new one, so the address - and an open page - survives a
|
|
2463
|
+
* restart; `acrylblends open` prints it, and `acrylblends open --new` rotates it (the running host replaces the token in place through
|
|
2464
|
+
* `/api/acryl-frame/token/new`), invalidating the old one immediately.
|
|
2465
|
+
*
|
|
2466
|
+
* DSH's connection package mints its launch token as a random value with no configuration seam (BrowserAuth's constructor calls
|
|
2467
|
+
* `processLaunchToken`, cached per process owner). The one seam it offers is the instance field the class keeps: `browserAuth.launchToken`, which
|
|
2468
|
+
* `authorizeIndex` and `authenticatedUrl` read on every use. The launcher sets that field from the stored record (guarded: a DSH package whose shape
|
|
2469
|
+
* lacks it simply keeps its fresh token), and sets it again on a rotation. Nothing inside the pinned package is edited.
|
|
2470
|
+
*/
|
|
2471
|
+
/** The record's file name inside the app home. */
|
|
2472
|
+
const FRAME_URL_FILE = "frame-url.json";
|
|
2473
|
+
/** Where a running frame rotates its token: a route the launcher mounts on the frame's own router. */
|
|
2474
|
+
const FRAME_TOKEN_PATH = "/api/acryl-frame/token/new";
|
|
2475
|
+
/** The app home the frame uses: `ACRYLBLENDS_HOME` when set, else `<app>/.acrylblends` (the launcher's own rule). */
|
|
2476
|
+
function frameHome(appRoot, env = process.env) {
|
|
2477
|
+
const pinned = env.ACRYLBLENDS_HOME?.trim();
|
|
2478
|
+
return pinned === void 0 || pinned === "" ? join(resolve(appRoot), ".acrylblends") : resolve(pinned);
|
|
2479
|
+
}
|
|
2480
|
+
function frameUrlPath(home) {
|
|
2481
|
+
return join(home, FRAME_URL_FILE);
|
|
2482
|
+
}
|
|
2483
|
+
/** Windows has no mode bits; the equivalent is an ACL with inheritance removed and only this account allowed. Best effort: a failure leaves the
|
|
2484
|
+
* inherited ACL, which is still the user's folder on a normal install. */
|
|
2485
|
+
function restrictToOwner(path) {
|
|
2486
|
+
chmodSync(path, 384);
|
|
2487
|
+
if (process.platform !== "win32") return;
|
|
2488
|
+
const user = process.env.USERNAME;
|
|
2489
|
+
if (user === void 0 || user === "") return;
|
|
2490
|
+
spawnSync("icacls", [
|
|
2491
|
+
path,
|
|
2492
|
+
"/inheritance:r",
|
|
2493
|
+
"/grant:r",
|
|
2494
|
+
`${user}:F`
|
|
2495
|
+
], {
|
|
2496
|
+
stdio: "ignore",
|
|
2497
|
+
windowsHide: true
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
/** The token a frame address carries, if it carries one. */
|
|
2501
|
+
function tokenOfFrameUrl(url) {
|
|
2502
|
+
try {
|
|
2503
|
+
const token = new URL(url).searchParams.get("token");
|
|
2504
|
+
return token === null || token === "" ? void 0 : token;
|
|
2505
|
+
} catch {
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
/** Write the frame's address for `home`, owner-only and atomically. Throws when the folder or file cannot be written; the caller decides whether
|
|
2510
|
+
* that is fatal (the launcher prints why and keeps serving the app). */
|
|
2511
|
+
function writeFrameUrl(home, url, pid = process.pid) {
|
|
2512
|
+
mkdirSync(home, { recursive: true });
|
|
2513
|
+
const record = {
|
|
2514
|
+
version: 1,
|
|
2515
|
+
url,
|
|
2516
|
+
token: tokenOfFrameUrl(url) ?? "",
|
|
2517
|
+
pid,
|
|
2518
|
+
writtenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2519
|
+
};
|
|
2520
|
+
const target = frameUrlPath(home);
|
|
2521
|
+
const temporary = join(home, `${FRAME_URL_FILE}.${String(pid)}.tmp`);
|
|
2522
|
+
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 384 });
|
|
2523
|
+
restrictToOwner(temporary);
|
|
2524
|
+
renameSync(temporary, target);
|
|
2525
|
+
restrictToOwner(target);
|
|
2526
|
+
}
|
|
2527
|
+
/** Remove the record, but only the one `pid` wrote: a host that is stopping must not erase the address of a newer one that already started. */
|
|
2528
|
+
function removeFrameUrl(home, pid = process.pid) {
|
|
2529
|
+
const record = readFrameUrl(home);
|
|
2530
|
+
if (record === void 0 || record.pid !== pid) return;
|
|
2531
|
+
rmSync(frameUrlPath(home), { force: true });
|
|
2532
|
+
}
|
|
2533
|
+
/** The record, or undefined when there is none, it is not ours, or it is malformed. */
|
|
2534
|
+
function readFrameUrl(home) {
|
|
2535
|
+
const path = frameUrlPath(home);
|
|
2536
|
+
if (!existsSync(path)) return void 0;
|
|
2537
|
+
let parsed;
|
|
2538
|
+
try {
|
|
2539
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2540
|
+
} catch {
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
2544
|
+
const record = parsed;
|
|
2545
|
+
if (record.version !== 1) return void 0;
|
|
2546
|
+
if (typeof record.url !== "string" || record.url === "") return void 0;
|
|
2547
|
+
if (typeof record.pid !== "number" || !Number.isInteger(record.pid)) return void 0;
|
|
2548
|
+
if (typeof record.writtenAt !== "string") return void 0;
|
|
2549
|
+
const token = typeof record.token === "string" ? record.token : tokenOfFrameUrl(record.url) ?? "";
|
|
2550
|
+
return {
|
|
2551
|
+
version: 1,
|
|
2552
|
+
url: record.url,
|
|
2553
|
+
token,
|
|
2554
|
+
pid: record.pid,
|
|
2555
|
+
writtenAt: record.writtenAt
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
/** Whether something accepts a TCP connection at the record's address (the host process is alive and listening). */
|
|
2559
|
+
function frameIsListening(url, timeoutMs = 500) {
|
|
2560
|
+
let target;
|
|
2561
|
+
try {
|
|
2562
|
+
target = new URL(url);
|
|
2563
|
+
} catch {
|
|
2564
|
+
return Promise.resolve(false);
|
|
2565
|
+
}
|
|
2566
|
+
const port = Number(target.port === "" ? target.protocol === "https:" ? 443 : 80 : target.port);
|
|
2567
|
+
return new Promise((done) => {
|
|
2568
|
+
const socket = connect({
|
|
2569
|
+
host: target.hostname,
|
|
2570
|
+
port
|
|
2571
|
+
});
|
|
2572
|
+
const finish = (listening) => {
|
|
2573
|
+
socket.destroy();
|
|
2574
|
+
done(listening);
|
|
2575
|
+
};
|
|
2576
|
+
socket.setTimeout(timeoutMs);
|
|
2577
|
+
socket.once("connect", () => finish(true));
|
|
2578
|
+
socket.once("timeout", () => finish(false));
|
|
2579
|
+
socket.once("error", () => finish(false));
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
/** Read the record and say whether the frame it names is still listening. */
|
|
2583
|
+
async function lookUpFrameUrl(appRoot, env = process.env) {
|
|
2584
|
+
const home = frameHome(appRoot, env);
|
|
2585
|
+
const record = readFrameUrl(home);
|
|
2586
|
+
if (record === void 0) return {
|
|
2587
|
+
kind: "none",
|
|
2588
|
+
home
|
|
2589
|
+
};
|
|
2590
|
+
return await frameIsListening(record.url) ? {
|
|
2591
|
+
kind: "running",
|
|
2592
|
+
record
|
|
2593
|
+
} : {
|
|
2594
|
+
kind: "stopped",
|
|
2595
|
+
record
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
/** Ask the running host to rotate its token, presenting the current one. Returns the new address, or a reason it failed. */
|
|
2599
|
+
async function rotateFrameToken(record) {
|
|
2600
|
+
let origin;
|
|
2601
|
+
try {
|
|
2602
|
+
origin = new URL(record.url).origin;
|
|
2603
|
+
} catch {
|
|
2604
|
+
return { reason: "the stored address is not a URL" };
|
|
2605
|
+
}
|
|
2606
|
+
let response;
|
|
2607
|
+
try {
|
|
2608
|
+
response = await fetch(`${origin}${FRAME_TOKEN_PATH}`, {
|
|
2609
|
+
method: "POST",
|
|
2610
|
+
headers: {
|
|
2611
|
+
"x-frame-token": record.token,
|
|
2612
|
+
accept: "application/json"
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
} catch (cause) {
|
|
2616
|
+
return { reason: cause instanceof Error ? cause.message : String(cause) };
|
|
2617
|
+
}
|
|
2618
|
+
if (!response.ok) return { reason: `the host answered ${String(response.status)}` };
|
|
2619
|
+
const body = await response.json().catch(() => void 0);
|
|
2620
|
+
if (typeof body?.url !== "string" || body.url === "") return { reason: "the host answered without an address" };
|
|
2621
|
+
return { url: body.url };
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* `acrylblends open [dir] [--new]`: print the address of the frame running the app in this folder, so a page whose session ended (or the person, after
|
|
2625
|
+
* a restart) can open it again without restarting the host. Only a listening host's address is printed, and only the address goes to stdout, for copy
|
|
2626
|
+
* and paste. `--new` rotates the token in the running host first: the old address stops working at once and the new one is printed; with no host
|
|
2627
|
+
* running, the stored address is simply forgotten and the next start mints a fresh one.
|
|
2628
|
+
*/
|
|
2629
|
+
async function openFrame(args, io) {
|
|
2630
|
+
const fresh = args.includes("--new");
|
|
2631
|
+
const rest = args.filter((arg) => arg !== "--new");
|
|
2632
|
+
if (rest.length > 1) {
|
|
2633
|
+
io.stderr.write("usage: acrylblends open [dir] [--new]\n");
|
|
2634
|
+
return 2;
|
|
2635
|
+
}
|
|
2636
|
+
const directory = resolve(io.cwd, rest[0] ?? ".");
|
|
2637
|
+
const found = await lookUpFrameUrl(directory);
|
|
2638
|
+
if (found.kind === "none") {
|
|
2639
|
+
io.stderr.write(`no frame has run in ${directory} yet; start it with npm run dev\n`);
|
|
2640
|
+
return 1;
|
|
2641
|
+
}
|
|
2642
|
+
if (!fresh) {
|
|
2643
|
+
if (found.kind === "running") {
|
|
2644
|
+
io.stdout.write(`${found.record.url}\n`);
|
|
2645
|
+
return 0;
|
|
2646
|
+
}
|
|
2647
|
+
io.stderr.write(`the frame of ${directory} is not running (its last address was minted ${found.record.writtenAt}); run npm run dev, then this command again\n`);
|
|
2648
|
+
return 1;
|
|
2649
|
+
}
|
|
2650
|
+
if (found.kind === "stopped") {
|
|
2651
|
+
rmSync(frameUrlPath(frameHome(directory)), { force: true });
|
|
2652
|
+
io.stderr.write(`no frame is running in ${directory}; its stored address was forgotten and the next npm run dev mints a new one\n`);
|
|
2653
|
+
return 0;
|
|
2654
|
+
}
|
|
2655
|
+
const rotated = await rotateFrameToken(found.record);
|
|
2656
|
+
if ("reason" in rotated) {
|
|
2657
|
+
io.stderr.write(`the running frame did not rotate its address (${rotated.reason}); run npm run dev to start it with a fresh one\n`);
|
|
2658
|
+
return 1;
|
|
2659
|
+
}
|
|
2660
|
+
writeFrameUrl(frameHome(directory), rotated.url);
|
|
2661
|
+
io.stdout.write(`${rotated.url}\n`);
|
|
2662
|
+
return 0;
|
|
2663
|
+
}
|
|
2664
|
+
//#endregion
|
|
1130
2665
|
//#region src/spawn.ts
|
|
1131
2666
|
/**
|
|
1132
2667
|
* How to start a package manager without the shell option (Node warns about passing args to a shell, DEP0190, and a `.cmd` shim cannot be spawned directly).
|
|
@@ -1530,6 +3065,115 @@ async function runPreflight(io, probes = machineProbes()) {
|
|
|
1530
3065
|
return checks.some((check) => check.status === "FIX") ? 1 : 0;
|
|
1531
3066
|
}
|
|
1532
3067
|
//#endregion
|
|
3068
|
+
//#region src/app-registration.ts
|
|
3069
|
+
/**
|
|
3070
|
+
* The two `src/app.ts` edits `acrylblends add` makes for a plugin it mounts (`mount.ts`): an import line after the last plugin import, and a
|
|
3071
|
+
* registry entry after `export const registry = {`. An app's own copy of these lines is not the owner's change; the upgrade recognises them,
|
|
3072
|
+
* keeps them on the new file it writes, and reports only the owner's other lines.
|
|
3073
|
+
*/
|
|
3074
|
+
const eolOf = (text) => text.includes("\r\n") ? "\r\n" : "\n";
|
|
3075
|
+
/** Every registration line at the start of `text`, the line ending kept; a line is one registration. */
|
|
3076
|
+
function registrations(text, mounted) {
|
|
3077
|
+
const found = [];
|
|
3078
|
+
for (const raw of text.split("\n")) {
|
|
3079
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
3080
|
+
const importMatch = /^import \* as (\w+) from '\.\.\/plugins\/([^/]+)\/([^']+)'$/u.exec(line);
|
|
3081
|
+
if (importMatch !== null) {
|
|
3082
|
+
const plugin = mounted.find((candidate) => candidate.name === importMatch[2] && candidate.local === importMatch[1]);
|
|
3083
|
+
if (plugin !== void 0) found.push({
|
|
3084
|
+
plugin,
|
|
3085
|
+
kind: "import",
|
|
3086
|
+
line
|
|
3087
|
+
});
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
3090
|
+
const entryMatch = /^ {2}'([^']+)': (\w+),$/u.exec(line);
|
|
3091
|
+
if (entryMatch !== null) {
|
|
3092
|
+
const plugin = mounted.find((candidate) => candidate.name === entryMatch[1] && candidate.local === entryMatch[2]);
|
|
3093
|
+
if (plugin !== void 0) found.push({
|
|
3094
|
+
plugin,
|
|
3095
|
+
kind: "registry",
|
|
3096
|
+
line
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
return found;
|
|
3101
|
+
}
|
|
3102
|
+
/** The text without the framework's registration lines, and those lines in file order. A line the released template already has is the
|
|
3103
|
+
* template's own (agent-panel's import ships with the agent template), never the add command's edit. */
|
|
3104
|
+
function stripRegistrations(text, mounted = MOUNTED, template) {
|
|
3105
|
+
const inTemplate = new Set((template ?? "").split("\n").map((raw) => raw.endsWith("\r") ? raw.slice(0, -1) : raw));
|
|
3106
|
+
const edits = registrations(text, mounted).filter((edit) => !inTemplate.has(edit.line));
|
|
3107
|
+
if (edits.length === 0) return {
|
|
3108
|
+
text,
|
|
3109
|
+
edits
|
|
3110
|
+
};
|
|
3111
|
+
const wanted = new Set(edits.map((edit) => edit.line));
|
|
3112
|
+
return {
|
|
3113
|
+
text: text.split("\n").filter((raw) => {
|
|
3114
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
3115
|
+
return !wanted.has(line);
|
|
3116
|
+
}).join("\n"),
|
|
3117
|
+
edits
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Put the framework's registration lines back into a template, at the same places `mount` inserts them: every import after the last
|
|
3122
|
+
* `../plugins/` import, every registry entry after the `export const registry = {` line. Lines already present are left alone.
|
|
3123
|
+
*/
|
|
3124
|
+
function withRegistrations(text, edits) {
|
|
3125
|
+
const eol = eolOf(text);
|
|
3126
|
+
let next = text;
|
|
3127
|
+
const imports = edits.filter((edit) => edit.kind === "import" && !next.split("\n").some((raw) => (raw.endsWith("\r") ? raw.slice(0, -1) : raw) === edit.line));
|
|
3128
|
+
if (imports.length > 0) {
|
|
3129
|
+
const last = [...next.matchAll(/^import .* from '\.\.\/plugins\/[^']+'\r?\n/gmu)].at(-1);
|
|
3130
|
+
if (last !== void 0) {
|
|
3131
|
+
const at = last.index + last[0].length;
|
|
3132
|
+
next = next.slice(0, at) + imports.map((edit) => `${edit.line}${eol}`).join("") + next.slice(at);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const entries = edits.filter((edit) => edit.kind === "registry" && !next.split("\n").some((raw) => (raw.endsWith("\r") ? raw.slice(0, -1) : raw) === edit.line));
|
|
3136
|
+
if (entries.length > 0) {
|
|
3137
|
+
const table = /^export const registry = \{(\r?\n)/mu.exec(next);
|
|
3138
|
+
if (table !== null) {
|
|
3139
|
+
const at = table.index + table[0].length;
|
|
3140
|
+
next = next.slice(0, at) + entries.map((edit) => `${edit.line}${eol}`).join("") + next.slice(at);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
return next;
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* The app's lines that the expected file does not have, in order: the owner's real changes. A line-level longest common subsequence, cut to
|
|
3147
|
+
* `limit` lines, so a change reads as the lines it is, not as a whole file.
|
|
3148
|
+
*/
|
|
3149
|
+
function ownerChangeLines(actual, expected, limit = 5) {
|
|
3150
|
+
const lineOf = (raw) => raw === void 0 ? "" : raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
3151
|
+
const left = actual.split("\n");
|
|
3152
|
+
const right = expected.split("\n");
|
|
3153
|
+
const lengths = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
|
|
3154
|
+
for (let i = left.length - 1; i >= 0; i -= 1) for (let j = right.length - 1; j >= 0; j -= 1) lengths[i][j] = lineOf(left[i]) === lineOf(right[j]) ? (lengths[i + 1][j + 1] ?? 0) + 1 : Math.max(lengths[i + 1][j] ?? 0, lengths[i][j + 1] ?? 0);
|
|
3155
|
+
const changed = [];
|
|
3156
|
+
let i = 0;
|
|
3157
|
+
let j = 0;
|
|
3158
|
+
while (i < left.length && changed.length < limit) {
|
|
3159
|
+
if (j < right.length && lineOf(left[i]) === lineOf(right[j])) {
|
|
3160
|
+
i += 1;
|
|
3161
|
+
j += 1;
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
3164
|
+
if (j < right.length && (lengths[i + 1][j] ?? 0) < (lengths[i][j + 1] ?? 0)) {
|
|
3165
|
+
j += 1;
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
changed.push({
|
|
3169
|
+
line: i + 1,
|
|
3170
|
+
text: lineOf(left[i])
|
|
3171
|
+
});
|
|
3172
|
+
i += 1;
|
|
3173
|
+
}
|
|
3174
|
+
return changed;
|
|
3175
|
+
}
|
|
3176
|
+
//#endregion
|
|
1533
3177
|
//#region src/diff.ts
|
|
1534
3178
|
function ops(a, b) {
|
|
1535
3179
|
if (a.length * b.length > 6e6) return void 0;
|
|
@@ -1619,6 +3263,159 @@ function unifiedDiff(name, from, to, context = 3) {
|
|
|
1619
3263
|
return `${out.join("\n")}\n`;
|
|
1620
3264
|
}
|
|
1621
3265
|
//#endregion
|
|
3266
|
+
//#region src/legacy.ts
|
|
3267
|
+
/**
|
|
3268
|
+
* The scaffolds published acrylblends releases shipped, as `acrylblends upgrade` needs them: the template's route module and, where the composition root
|
|
3269
|
+
* exports `composeInto` today, `src/app.ts`. Fixtures are verbatim from each release (deduplicated by content hash under `legacy/<template>/<hash>/`, built by
|
|
3270
|
+
* `scripts/build-legacy-layouts.mjs` from the release tags). An app's file matches when it equals the released bytes with the app name substituted, or the
|
|
3271
|
+
* current template's file (already upgraded); anything else is the app's own and is never replaced.
|
|
3272
|
+
*/
|
|
3273
|
+
/** The app name init substituted for `__APP_NAME__` in the template's files. */
|
|
3274
|
+
const PLACEHOLDER = "__APP_NAME__";
|
|
3275
|
+
/** The frame row module's path inside an app: a template with it is framed by the host. */
|
|
3276
|
+
const FRAME_FILE = "src/frame.ts";
|
|
3277
|
+
/** The `legacy/` fixtures: beside `dist/` in the build and `src/` in a checkout, found by walking up like the templates. */
|
|
3278
|
+
function legacyRoot() {
|
|
3279
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
3280
|
+
for (;;) {
|
|
3281
|
+
const candidate = join(dir, "legacy");
|
|
3282
|
+
if (existsSync(join(candidate, "layouts.json"))) return candidate;
|
|
3283
|
+
const parent = dirname(dir);
|
|
3284
|
+
if (parent === dir) throw new Error("acrylblends cannot find its legacy layout fixtures");
|
|
3285
|
+
dir = parent;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
/** Whether a template carries the frame row (only the framed templates can be upgraded onto it). `dir` overrides the template folder for tests. */
|
|
3289
|
+
function carriesFrame(template, dir = templateDir(template)) {
|
|
3290
|
+
return existsSync(join(dir, FRAME_FILE));
|
|
3291
|
+
}
|
|
3292
|
+
/** A template's file as a scaffolded app of `name` has it, or undefined when the current template has no such file. */
|
|
3293
|
+
function templateText(template, file, name) {
|
|
3294
|
+
const path = join(templateDir(template), file);
|
|
3295
|
+
return existsSync(path) ? readFileSync(path, "utf8").replaceAll(PLACEHOLDER, name) : void 0;
|
|
3296
|
+
}
|
|
3297
|
+
function read(path) {
|
|
3298
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
3299
|
+
}
|
|
3300
|
+
function walk(dir, base, into) {
|
|
3301
|
+
for (const entry of readdirSync(dir)) {
|
|
3302
|
+
const path = join(dir, entry);
|
|
3303
|
+
if (statSync(path).isDirectory()) walk(path, `${base}${entry}/`, into);
|
|
3304
|
+
else into.set(`${base}${entry}`, readFileSync(path, "utf8"));
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
/** Every distinct scaffold the releases shipped for a template, sorted by hash. Empty when no release shipped this template. */
|
|
3308
|
+
function legacyVariants(template) {
|
|
3309
|
+
const dir = join(legacyRoot(), template);
|
|
3310
|
+
if (!existsSync(dir)) return [];
|
|
3311
|
+
return readdirSync(dir).filter((entry) => statSync(join(dir, entry)).isDirectory()).sort().map((id) => {
|
|
3312
|
+
const files = /* @__PURE__ */ new Map();
|
|
3313
|
+
walk(join(dir, id), "", files);
|
|
3314
|
+
return {
|
|
3315
|
+
id,
|
|
3316
|
+
files
|
|
3317
|
+
};
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
/** The app's name as `init` wrote it into the scaffold (package.json, then the folder). */
|
|
3321
|
+
function appNameOf(appDir) {
|
|
3322
|
+
try {
|
|
3323
|
+
const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8"));
|
|
3324
|
+
if (typeof manifest.name === "string" && manifest.name !== "") return manifest.name;
|
|
3325
|
+
} catch {}
|
|
3326
|
+
return basename(appDir);
|
|
3327
|
+
}
|
|
3328
|
+
/** The scaffold files the releases recorded for a template (the union across variants): what a fixture app starts from. */
|
|
3329
|
+
function legacyScaffold(template) {
|
|
3330
|
+
const paths = /* @__PURE__ */ new Set();
|
|
3331
|
+
for (const variant of legacyVariants(template)) for (const file of variant.files.keys()) paths.add(file);
|
|
3332
|
+
return [...paths].sort();
|
|
3333
|
+
}
|
|
3334
|
+
/**
|
|
3335
|
+
* The app's entry module when a release before 0.7.8 wrote it: those releases shipped `src/main.ts` importing `./plugins/...` where the app keeps its
|
|
3336
|
+
* plugins one folder up (`../plugins/...` since 0.7.8), so the app cannot start as scaffolded and the frame layout cannot be applied on top of it.
|
|
3337
|
+
*/
|
|
3338
|
+
function brokenEntry(appDir) {
|
|
3339
|
+
const text = read(join(appDir, "src", "main.ts"));
|
|
3340
|
+
return text !== void 0 && /from '\.\/plugins\//u.test(text) ? "src/main.ts" : void 0;
|
|
3341
|
+
}
|
|
3342
|
+
/** The releases the fixtures cover, oldest first, from the index the build script wrote (for the CLI's message). */
|
|
3343
|
+
function releasedVersions() {
|
|
3344
|
+
try {
|
|
3345
|
+
return (JSON.parse(readFileSync(join(legacyRoot(), "layouts.json"), "utf8")).releases ?? []).map((release) => release.version).filter((version) => typeof version === "string");
|
|
3346
|
+
} catch {
|
|
3347
|
+
return [];
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
/** The release range the fixtures cover, for a sentence: `0.3.0 through 1.1.0`, or `the published releases` when the index cannot be read. */
|
|
3351
|
+
function releasedRange() {
|
|
3352
|
+
const versions = releasedVersions();
|
|
3353
|
+
const first = versions[0];
|
|
3354
|
+
const last = versions.at(-1);
|
|
3355
|
+
if (first === void 0 || last === void 0) return "the published releases";
|
|
3356
|
+
return first === last ? first : `${first} through ${last}`;
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Which template made this app: the template whose released (or current) scaffold files the app matches most. Undefined when none matches: the app's
|
|
3360
|
+
* scaffold was edited, or a release that shipped no template made it, and the upgrade leaves it alone.
|
|
3361
|
+
*/
|
|
3362
|
+
function identifyTemplate(appDir, name) {
|
|
3363
|
+
let best;
|
|
3364
|
+
for (const template of TEMPLATES) {
|
|
3365
|
+
const variants = legacyVariants(template);
|
|
3366
|
+
if (variants.length === 0) continue;
|
|
3367
|
+
let score = 0;
|
|
3368
|
+
for (const variant of variants) {
|
|
3369
|
+
let matches = 0;
|
|
3370
|
+
for (const [file, text] of variant.files) if (read(join(appDir, file)) === text.replaceAll(PLACEHOLDER, name)) matches += 1;
|
|
3371
|
+
score = Math.max(score, matches);
|
|
3372
|
+
}
|
|
3373
|
+
let current = 0;
|
|
3374
|
+
for (const file of legacyScaffold(template)) {
|
|
3375
|
+
const text = templateText(template, file, name);
|
|
3376
|
+
if (text !== void 0 && read(join(appDir, file)) === text) current += 1;
|
|
3377
|
+
}
|
|
3378
|
+
score = Math.max(score, current);
|
|
3379
|
+
if (score > 0 && (best === void 0 || score > best.score)) best = {
|
|
3380
|
+
template,
|
|
3381
|
+
score
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
return best?.template;
|
|
3385
|
+
}
|
|
3386
|
+
/**
|
|
3387
|
+
* What the frame layout would do to the app's scaffold of `template`: `same` when the app already has the current template's file, `update` when it has a
|
|
3388
|
+
* released one, `add` when it is missing, `conflict` when the app edited it (the caller refuses without `--force`).
|
|
3389
|
+
*/
|
|
3390
|
+
function readLegacy(appDir, template, name) {
|
|
3391
|
+
const variants = legacyVariants(template);
|
|
3392
|
+
const files = [];
|
|
3393
|
+
const frame = read(join(appDir, FRAME_FILE));
|
|
3394
|
+
if (carriesFrame(template)) {
|
|
3395
|
+
const text = templateText(template, FRAME_FILE, name);
|
|
3396
|
+
if (text !== void 0) files.push({
|
|
3397
|
+
file: FRAME_FILE,
|
|
3398
|
+
text,
|
|
3399
|
+
state: frame === void 0 ? "add" : frame === text ? "same" : "conflict"
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
for (const file of legacyScaffold(template)) {
|
|
3403
|
+
const text = templateText(template, file, name);
|
|
3404
|
+
if (text === void 0) continue;
|
|
3405
|
+
const app = read(join(appDir, file));
|
|
3406
|
+
const released = variants.map((variant) => variant.files.get(file)).filter((value) => value !== void 0).map((value) => value.replaceAll(PLACEHOLDER, name));
|
|
3407
|
+
files.push({
|
|
3408
|
+
file,
|
|
3409
|
+
text,
|
|
3410
|
+
state: app === void 0 ? "add" : app === text ? "same" : released.includes(app) ? "update" : "conflict"
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
return {
|
|
3414
|
+
template,
|
|
3415
|
+
files
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
//#endregion
|
|
1622
3419
|
//#region src/upgrade.ts
|
|
1623
3420
|
/** Parse `upgrade [plugin...] [--dir <app>] [--dry-run] [--force] [--diff]`. */
|
|
1624
3421
|
function parseUpgradeOptions(argv) {
|
|
@@ -1656,6 +3453,128 @@ function carriedCopies(dir) {
|
|
|
1656
3453
|
const listFile = join(dir, LIBRARY_LIST);
|
|
1657
3454
|
return (existsSync(listFile) ? JSON.parse(readFileSync(listFile, "utf8")).plugins ?? [] : readdirSync(dir).filter((name) => statSync(join(dir, name)).isDirectory())).filter((name) => existsSync(join(dir, name)));
|
|
1658
3455
|
}
|
|
3456
|
+
/** The plugins the frame's host layer mounts by path: any framed app carries them, and an app made before the frame gets them here. */
|
|
3457
|
+
const HOST_PLUGINS = ["acryl-web-server", "acryl-page"];
|
|
3458
|
+
/** The profile script the DSH-profile layout used; the host launcher replaced it, so an app upgraded from that layout drops it. */
|
|
3459
|
+
const OLD_PROFILE_SCRIPT = "scripts/ide-profile.mjs";
|
|
3460
|
+
/**
|
|
3461
|
+
* Scaffold files the app owns from its first edit: the release's copy is only the starting point, so an edited (or removed) one is never written, refused
|
|
3462
|
+
* over or backed up. A copy the app never edited is still the release's and is updated like any other scaffold file.
|
|
3463
|
+
*/
|
|
3464
|
+
const KEPT_BY_THE_APP = /* @__PURE__ */ new Set(["src/routes.ts"]);
|
|
3465
|
+
/** What the frame row of every template imports from `src/routes.ts`: an app-owned copy without it cannot be shown in the frame. */
|
|
3466
|
+
const FRAME_PREFIX_EXPORT = /\bexport const FRAME_MOUNT_PREFIX\b/u;
|
|
3467
|
+
/** The state of one generated file that already exists: our marker makes it ours to update, anything else is the app's. */
|
|
3468
|
+
function generatedState(existing, text) {
|
|
3469
|
+
if (existing === void 0) return "add";
|
|
3470
|
+
if (existing === text) return "same";
|
|
3471
|
+
return GENERATED_MARKER.test(existing.split("\n", 1)[0] ?? "") ? "update" : "conflict";
|
|
3472
|
+
}
|
|
3473
|
+
/**
|
|
3474
|
+
* Plan the frame half of an upgrade: an app made by the previous published release gets the frame-hosted layout (`scripts/ide-host.mjs`, the overlay with
|
|
3475
|
+
* the app's rows, `frame/app-plugins.mjs` with its entry isolate, the mount config in its composition root, the host plugins), and the template's own files
|
|
3476
|
+
* are replaced only where the app still has the released ones. An app whose scaffold was edited is refused by the caller unless it passes --force; an app
|
|
3477
|
+
* of a template without a frame row yet, or one whose scaffold is not this release's, stays standalone and is told so.
|
|
3478
|
+
*/
|
|
3479
|
+
function planFrameStep(appDir, library, templateDirFor = templateDir) {
|
|
3480
|
+
const name = appNameOf(appDir);
|
|
3481
|
+
const broken = brokenEntry(appDir);
|
|
3482
|
+
if (broken !== void 0) return {
|
|
3483
|
+
kind: "broken-scaffold",
|
|
3484
|
+
file: broken
|
|
3485
|
+
};
|
|
3486
|
+
const template = identifyTemplate(appDir, name);
|
|
3487
|
+
if (template === void 0) return { kind: "unidentified" };
|
|
3488
|
+
if (!carriesFrame(template, templateDirFor(template))) return {
|
|
3489
|
+
kind: "no-frame-template",
|
|
3490
|
+
template
|
|
3491
|
+
};
|
|
3492
|
+
const specs = frameProfileForApp(appDir);
|
|
3493
|
+
if (specs === void 0) return {
|
|
3494
|
+
kind: "unavailable",
|
|
3495
|
+
template
|
|
3496
|
+
};
|
|
3497
|
+
const released = readLegacy(appDir, template, name);
|
|
3498
|
+
const ownedByTheApp = (file) => KEPT_BY_THE_APP.has(file.file) && (file.state === "conflict" || file.state === "add");
|
|
3499
|
+
const kept = released.files.filter(ownedByTheApp).map((file) => {
|
|
3500
|
+
const path = join(appDir, file.file);
|
|
3501
|
+
return {
|
|
3502
|
+
file: file.file,
|
|
3503
|
+
mountable: existsSync(path) && FRAME_PREFIX_EXPORT.test(readText(path))
|
|
3504
|
+
};
|
|
3505
|
+
});
|
|
3506
|
+
const files = released.files.filter((file) => !ownedByTheApp(file)).map((file) => ({
|
|
3507
|
+
file: file.file,
|
|
3508
|
+
text: file.text,
|
|
3509
|
+
state: file.state
|
|
3510
|
+
}));
|
|
3511
|
+
const appIndex = files.findIndex((file) => file.file === "src/app.ts");
|
|
3512
|
+
const appFile = appIndex < 0 ? void 0 : files[appIndex];
|
|
3513
|
+
let conflictLines;
|
|
3514
|
+
if (appFile !== void 0 && appFile.state === "conflict") {
|
|
3515
|
+
const appTsPath = join(appDir, "src", "app.ts");
|
|
3516
|
+
const current = existsSync(appTsPath) ? readText(appTsPath) : void 0;
|
|
3517
|
+
if (current !== void 0) {
|
|
3518
|
+
const stripped = stripRegistrations(current, void 0, appFile.text);
|
|
3519
|
+
const target = withRegistrations(appFile.text, stripped.edits);
|
|
3520
|
+
if (stripped.text === appFile.text) files[appIndex] = {
|
|
3521
|
+
file: "src/app.ts",
|
|
3522
|
+
text: target,
|
|
3523
|
+
state: target === current ? "same" : "update"
|
|
3524
|
+
};
|
|
3525
|
+
else conflictLines = { "src/app.ts": ownerChangeLines(current, target) };
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
const appTs = files.find((file) => file.file === "src/app.ts")?.text ?? (existsSync(join(appDir, "src", "app.ts")) ? readText(join(appDir, "src", "app.ts")) : void 0);
|
|
3529
|
+
const rows = appFrameRows(appDir, {
|
|
3530
|
+
...appTs === void 0 ? {} : { appTs },
|
|
3531
|
+
hasFrame: true
|
|
3532
|
+
});
|
|
3533
|
+
if (rows.appPlugins !== void 0) {
|
|
3534
|
+
const path = join(appDir, APP_PLUGINS_FILE);
|
|
3535
|
+
files.push({
|
|
3536
|
+
file: APP_PLUGINS_FILE,
|
|
3537
|
+
text: rows.appPlugins,
|
|
3538
|
+
state: generatedState(existsSync(path) ? readText(path) : void 0, rows.appPlugins)
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
const hostPlugins = HOST_PLUGINS.filter((plugin) => library.has(plugin) && !existsSync(join(appDir, "plugins", plugin)));
|
|
3542
|
+
const oldScript = join(appDir, OLD_PROFILE_SCRIPT);
|
|
3543
|
+
const removeProfileScript = existsSync(oldScript) && GENERATED_MARKER.test(readText(oldScript).split("\n", 1)[0] ?? "");
|
|
3544
|
+
const input = frameProfileInput(specs, name, rows.rows);
|
|
3545
|
+
const profile = planIdeProfileWrite(appDir, input);
|
|
3546
|
+
const conflicts = [...files.filter((file) => file.state === "conflict").map((file) => file.file), ...profile.files.filter((file) => file.state === "conflict").map((file) => file.file)];
|
|
3547
|
+
const changed = files.some((file) => file.state !== "same") || profile.manifestChanged || profile.files.some((file) => file.state !== "same") || hostPlugins.length > 0 || removeProfileScript;
|
|
3548
|
+
return {
|
|
3549
|
+
kind: "planned",
|
|
3550
|
+
template,
|
|
3551
|
+
input,
|
|
3552
|
+
profile,
|
|
3553
|
+
files,
|
|
3554
|
+
kept,
|
|
3555
|
+
hostPlugins,
|
|
3556
|
+
removeProfileScript,
|
|
3557
|
+
conflicts,
|
|
3558
|
+
...conflictLines === void 0 ? {} : { conflictLines },
|
|
3559
|
+
changed
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
/** The `frame` line `upgrade` prints: every file it changes, or why it leaves the app alone. */
|
|
3563
|
+
function frameReport(frame) {
|
|
3564
|
+
if (frame.kind === "broken-scaffold") return `${frame.file} imports './plugins/...', as acrylblends 0.3.0 through 0.7.7 wrote it (the plugins are one folder up; 0.7.8 fixed the paths), so the app cannot start: change those imports to '../plugins/...' and run again`;
|
|
3565
|
+
if (frame.kind === "unidentified") return `not the scaffold of any published acrylblends release (${releasedRange()}), so it was edited or another version made it: left on the framework alone`;
|
|
3566
|
+
if (frame.kind === "no-frame-template") return `the ${frame.template} template has no src/frame.ts yet: left standalone`;
|
|
3567
|
+
if (frame.kind === "unavailable") return "left on the framework alone: not a desktop app (`acrylblends add desktop` makes it one), or ACRYLBLENDS_FRAME_PACKAGES is none";
|
|
3568
|
+
const parts = [];
|
|
3569
|
+
for (const file of frame.files) if (file.state !== "same") parts.push(`${file.file} ${file.state === "update" ? "updated" : "added"}`);
|
|
3570
|
+
for (const file of frame.profile.files) if (file.state !== "same") parts.push(`${file.file} ${file.state === "update" ? "updated" : "added"}`);
|
|
3571
|
+
if (frame.profile.clientRowsAdded.length > 0) parts.push(`scripts/ide-host.mjs client rows added (${frame.profile.clientRowsAdded.join(", ")})`);
|
|
3572
|
+
for (const plugin of frame.hostPlugins) parts.push(`plugins/${plugin} added`);
|
|
3573
|
+
if (frame.removeProfileScript) parts.push(`${OLD_PROFILE_SCRIPT} removed`);
|
|
3574
|
+
if (frame.profile.manifestChanged) parts.push("package.json merged (dev runs scripts/ide-host.mjs)");
|
|
3575
|
+
for (const { file, mountable } of frame.kept) if (!mountable) parts.push(`${file} kept as the app's (edited): it does not export FRAME_MOUNT_PREFIX, so the frame cannot show the app until it does (a fresh app's ${file} shows how)`);
|
|
3576
|
+
return parts.length === 0 ? "up to date" : parts.join(", ");
|
|
3577
|
+
}
|
|
1659
3578
|
/**
|
|
1660
3579
|
* `acrylblends upgrade`: bring the library plugins an app carries (the router, the server, the door, `extensions`, ...) up to the version of this package. Each file is compared with what
|
|
1661
3580
|
* the app was given: an untouched file is replaced, a file the app edited stops the upgrade (it is shown as a diff and nothing changes) unless `--force`, which saves the edited version
|
|
@@ -1680,8 +3599,16 @@ async function upgrade(options, io) {
|
|
|
1680
3599
|
return 1;
|
|
1681
3600
|
}
|
|
1682
3601
|
const selected = (options.names.length > 0 ? [...options.names] : carried).filter((name) => library.has(name));
|
|
1683
|
-
|
|
1684
|
-
|
|
3602
|
+
const frame = planFrameStep(appDir, library);
|
|
3603
|
+
if (frame.kind === "planned" && frame.conflicts.length > 0 && !options.force) {
|
|
3604
|
+
const own = frame.conflictLines?.["src/app.ts"] ?? [];
|
|
3605
|
+
const detail = own.length === 0 ? "" : `\n src/app.ts keeps the framework's plugin registrations; the app's own lines are ${own.map((line) => `line ${String(line.line)} (${JSON.stringify(line.text)})`).join(", ")}`;
|
|
3606
|
+
io.stderr.write(`error: ${frame.conflicts.join(", ")} in ${appDir} ${frame.conflicts.length === 1 ? "is" : "are"} not the previous release's scaffold and acrylblends did not write ${frame.conflicts.length === 1 ? "it" : "them"}; nothing was changed.${detail} Move the app's version aside (or merge it into the template's) and run again, or run again with --force (the app's versions are saved under .acrylblends/upgrade-backup/ first)\n`);
|
|
3607
|
+
return 1;
|
|
3608
|
+
}
|
|
3609
|
+
const frameChanged = frame.kind === "planned" && frame.changed;
|
|
3610
|
+
if (selected.length === 0 && !frameChanged) {
|
|
3611
|
+
io.stdout.write(frame.kind === "planned" ? "nothing to upgrade: the library plugins and the frame are up to date\n" : `nothing to upgrade: the app carries no library plugins\n ${"frame".padEnd(22)} ${frameReport(frame)}\n`);
|
|
1685
3612
|
return 0;
|
|
1686
3613
|
}
|
|
1687
3614
|
const scratch = join(appDir, ".acrylblends", `upgrade-${process.pid}-${Date.now()}`);
|
|
@@ -1753,6 +3680,7 @@ async function upgrade(options, io) {
|
|
|
1753
3680
|
io.stdout.write(` ${label} ${parts.join(", ")}\n`);
|
|
1754
3681
|
}
|
|
1755
3682
|
for (const name of unknown) io.stdout.write(` ${name.padEnd(22)} not in this library: left alone\n`);
|
|
3683
|
+
io.stdout.write(` ${"frame".padEnd(22)} ${frameReport(frame)}\n`);
|
|
1756
3684
|
for (const plan of plans) for (const [file, state] of plan.files) {
|
|
1757
3685
|
if (!(state === "conflict" || options.diff && (state === "update" || state === "add" || state === "remove"))) continue;
|
|
1758
3686
|
const name = `plugins/${plan.plugin.name}/${file}`;
|
|
@@ -1765,10 +3693,10 @@ async function upgrade(options, io) {
|
|
|
1765
3693
|
return 1;
|
|
1766
3694
|
}
|
|
1767
3695
|
if (options.dryRun) {
|
|
1768
|
-
io.stdout.write(`\n${changes === 0 ? "everything is up to date" : "dry run: nothing was changed"}\n`);
|
|
3696
|
+
io.stdout.write(`\n${changes === 0 && !frameChanged ? "everything is up to date" : "dry run: nothing was changed"}\n`);
|
|
1769
3697
|
return 0;
|
|
1770
3698
|
}
|
|
1771
|
-
if (changes === 0) {
|
|
3699
|
+
if (changes === 0 && !frameChanged) {
|
|
1772
3700
|
io.stdout.write("\neverything is up to date\n");
|
|
1773
3701
|
return 0;
|
|
1774
3702
|
}
|
|
@@ -1807,8 +3735,36 @@ async function upgrade(options, io) {
|
|
|
1807
3735
|
manifest.dependencies.acrylblends = `^${VERSION}`;
|
|
1808
3736
|
writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1809
3737
|
}
|
|
1810
|
-
|
|
1811
|
-
if (
|
|
3738
|
+
let frameBackups = 0;
|
|
3739
|
+
if (frame.kind === "planned" && frameChanged) {
|
|
3740
|
+
for (const file of [...frame.files, ...frame.profile.files]) {
|
|
3741
|
+
if (file.state !== "conflict") continue;
|
|
3742
|
+
mkdirSync(dirname(join(backup, file.file)), { recursive: true });
|
|
3743
|
+
cpSync(join(appDir, file.file), join(backup, file.file));
|
|
3744
|
+
frameBackups += 1;
|
|
3745
|
+
}
|
|
3746
|
+
for (const plugin of frame.hostPlugins) {
|
|
3747
|
+
const known = library.get(plugin);
|
|
3748
|
+
if (known === void 0) continue;
|
|
3749
|
+
copyPlugin(known, appDir);
|
|
3750
|
+
if (known.imports) mergeImports(appDir, known.imports, "plugins");
|
|
3751
|
+
addedDependencies.push(...mergeDependencies(appDir, known.dependencies));
|
|
3752
|
+
addedDependencies.push(...mergeTooling(appDir, known).devDependencies);
|
|
3753
|
+
appendGitignore(appDir, known.gitignore);
|
|
3754
|
+
recordLibraryCopy(folder.dir, plugin);
|
|
3755
|
+
}
|
|
3756
|
+
for (const file of frame.files) {
|
|
3757
|
+
if (file.state === "same") continue;
|
|
3758
|
+
const target = join(appDir, file.file);
|
|
3759
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
3760
|
+
writeFileSync(target, file.text);
|
|
3761
|
+
}
|
|
3762
|
+
writeIdeProfilePlan(appDir, planIdeProfileWrite(appDir, frame.input));
|
|
3763
|
+
if (frame.removeProfileScript) rmSync(join(appDir, OLD_PROFILE_SCRIPT), { force: true });
|
|
3764
|
+
}
|
|
3765
|
+
const upgradedPlugins = plans.filter((p) => [...p.files.values()].some((s) => s !== "same" && s !== "extra") || p.isNew).map((p) => p.plugin.name);
|
|
3766
|
+
io.stdout.write(`\nupgraded ${[...upgradedPlugins, ...frameChanged ? ["the frame"] : []].join(", ")}\n`);
|
|
3767
|
+
if (conflicts + frameBackups > 0) io.stdout.write(`the ${conflicts + frameBackups} edited file(s) replaced by --force are saved in ${posix$1(relative(appDir, backup))}/\n`);
|
|
1812
3768
|
io.stdout.write(`acrylblends is now ^${VERSION} in package.json${addedDependencies.length > 0 ? `; new dependencies: ${[...new Set(addedDependencies)].join(", ")}` : ""}: run npm install, then npm run check and npm test\n`);
|
|
1813
3769
|
return 0;
|
|
1814
3770
|
} finally {
|
|
@@ -1831,6 +3787,8 @@ Usage:
|
|
|
1831
3787
|
Bring the library plugins an app carries (the router, the server, the door, ...) up to this
|
|
1832
3788
|
version: untouched files are replaced, an edited file stops it (diff shown) unless --force
|
|
1833
3789
|
acrylblends plugins List the plugin library
|
|
3790
|
+
acrylblends open [dir] Print the address of the frame running this app (the URL npm run dev printed),
|
|
3791
|
+
for a page that has to be opened again after its session ended
|
|
1834
3792
|
acrylblends preflight Check this machine for the workshop: Node, npm, git, a coding agent, a free port
|
|
1835
3793
|
(and on Windows PowerShell and long paths); one line per check, PASS or the fix
|
|
1836
3794
|
acrylblends build-client [root] [out] Compile the client modules once for production (src/client to dist/client)
|
|
@@ -1873,6 +3831,7 @@ async function runCli(argv, io = processIo()) {
|
|
|
1873
3831
|
if (command === "kv") return await kvImport(parseKv(args), io);
|
|
1874
3832
|
if (command === "check-deps") return checkDeps(parseCheckDeps(args), io);
|
|
1875
3833
|
if (command === "plugins") return listPlugins(io);
|
|
3834
|
+
if (command === "open") return await openFrame(args, io);
|
|
1876
3835
|
if (command === "preflight") return await runPreflight(io);
|
|
1877
3836
|
throw new UsageError(`unknown command "${command}"`);
|
|
1878
3837
|
} catch (error) {
|
|
@@ -1895,6 +3854,6 @@ function parseKv(args) {
|
|
|
1895
3854
|
return parseKvOptions(args);
|
|
1896
3855
|
}
|
|
1897
3856
|
//#endregion
|
|
1898
|
-
export { HELP, TEMPLATES, TEMPLATE_ALIASES, TEMPLATE_SUMMARY, TargetNotEmptyError, UsageError, VERSION, add, assertTargetFree, closure, copyPlugin, copyTemplate, init, isOmitted, isTemplate, isValidName, libraryRoot, listPlugins, mergeDependencies, nameFrom, parseAddOptions, parseInitOptions, parseUpgradeOptions, readLibrary, readTemplatePlugins, resolveTemplate, runCli, templateDir, templatesRoot, upgrade };
|
|
3857
|
+
export { FRAME_URL_FILE, HELP, TEMPLATES, TEMPLATE_ALIASES, TEMPLATE_SUMMARY, TargetNotEmptyError, UsageError, VERSION, add, assertTargetFree, closure, copyPlugin, copyTemplate, frameHome, frameUrlPath, init, isOmitted, isTemplate, isValidName, libraryRoot, listPlugins, lookUpFrameUrl, mergeDependencies, mergeTooling, nameFrom, openFrame, parseAddOptions, parseInitOptions, parseUpgradeOptions, readFrameUrl, readLibrary, readTemplatePlugins, removeFrameUrl, resolveTemplate, runCli, templateDir, templatesRoot, tokenOfFrameUrl, upgrade, writeFrameUrl };
|
|
1899
3858
|
|
|
1900
3859
|
//# sourceMappingURL=index.js.map
|