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.
Files changed (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. 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 { spawn, spawnSync } from "node:child_process";
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
- /** Add run-time dependencies to an app's package.json, keeping what is there and sorting the keys. Returns the names added. */
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. Returns the names of the development dependencies
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 addedDev = Object.keys(tooling.devDependencies ?? {}).filter((name) => !(name in dev));
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
- ...tooling.devDependencies,
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
- /** 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. */
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 folder of [".claude/skills/app-guide", ".agents/skills/app-guide"]) {
523
- mkdirSync(join(target, folder), { recursive: true });
524
- writeFileSync(join(target, folder, "SKILL.md"), skill);
525
- written.push(`${folder}/SKILL.md`);
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
- const ROW = /^( *)- id: ?(\S+)\n\1 {2}name: ?(\S+)\n/gmu;
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 found = (plugin.after ? new RegExp(`^( *)- id: ?\\S+\\n\\1 {2}name: ?${plugin.after}\\n(?:\\1 {2}config:\\n(?:\\1 {4}.*\\n|\\1 {2}\\s*\\n)*)?`, "mu") : /^( *)- id: ?routes\n/mu).exec(text);
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}\n${indent} name: ${plugin.name}\n`;
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 last = [...text.matchAll(/^import .* from '\.\.\/plugins\/[^']+'\n/gmu)].at(-1);
717
- const table = /^export const registry = \{(\s)/mu.exec(text);
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}'\n`;
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},\n` : `${entry}, `) + text.slice(at);
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
- if (selected.length === 0) {
1684
- io.stdout.write("nothing to upgrade: the app carries no library plugins\n");
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
- io.stdout.write(`\nupgraded ${plans.filter((p) => [...p.files.values()].some((s) => s !== "same" && s !== "extra") || p.isNew).map((p) => p.plugin.name).join(", ")}\n`);
1811
- if (conflicts > 0) io.stdout.write(`the ${conflicts} edited file(s) replaced by --force are saved in ${posix$1(relative(appDir, backup))}/\n`);
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