@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

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 (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/jobs.js ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Running a command, and the registry of the ones still running.
3
+ *
4
+ * Every long action in the dashboard — a build, an index, a test suite — is a job: spawned
5
+ * detached, streamed into a buffer, and polled by the page as a fragment. The registry is in
6
+ * memory on purpose, which is why restarting the app while a job runs loses the job.
7
+ *
8
+ * Split out of server.js: nothing here knows about routing, and the actions that start jobs can
9
+ * import the runner without importing the server.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawn } = require('child_process');
15
+
16
+ const { esc } = require('./html');
17
+
18
+ /* ---------------- process helpers ---------------- */
19
+
20
+ function run(cmd, args, cwd, { timeoutMs = 15 * 60 * 1000, env } = {}) {
21
+ return new Promise((resolve) => {
22
+ // stdin must be closed ('ignore') — ddev wraps commands in `docker exec -i`,
23
+ // which hangs indefinitely on an open-but-silent stdin pipe.
24
+ const child = spawn(cmd, args, { cwd, env: env || jobEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
25
+ let stdout = '';
26
+ let stderr = '';
27
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
28
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
29
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
30
+ child.on('close', (code) => { clearTimeout(timer); resolve({ ok: code === 0, code, stdout, stderr }); });
31
+ child.on('error', (err) => { clearTimeout(timer); resolve({ ok: false, code: -1, stdout, stderr: String(err.message) }); });
32
+ });
33
+ }
34
+
35
+ // A project's real DDEV name (from its .ddev/config.yaml), or null if it
36
+ // isn't a DDEV project. The name usually equals the folder name (the build
37
+ // scripts pass --project-name=<folder>), but not necessarily.
38
+ // The DDEV project name for a directory, or null when it is not a DDEV project.
39
+ //
40
+ // `name:` is OPTIONAL in config.yaml — DDEV omits it when the project name is
41
+ // just the directory name, which is the common case for anything created with
42
+ // `ddev config` in place. Returning null there treated a real DDEV project as an
43
+ // ordinary folder, so it got no status badge and no Start/Stop/Launch at all: on
44
+ // this machine that silently hid the controls for 5 of the 13 DDEV projects in
45
+ // test/. The presence of .ddev/config.yaml is what makes it a project; `name:` is
46
+ // only an override.
47
+ function ddevProjectName(projectDir) {
48
+ const cfgPath = path.join(projectDir, '.ddev', 'config.yaml');
49
+ let cfg;
50
+ try {
51
+ cfg = fs.readFileSync(cfgPath, 'utf8');
52
+ } catch (_) {
53
+ return null;
54
+ }
55
+ const m = cfg.match(/^name:\s*["']?([^"'\s]+)/m);
56
+ if (m) return m[1];
57
+ // DDEV's own default: the directory name, with the characters it disallows in a
58
+ // project name replaced the way it does.
59
+ return path.basename(projectDir).replace(/[^a-zA-Z0-9._-]/g, '-');
60
+ }
61
+
62
+ /* Background jobs with live terminal output: long actions run detached while
63
+ * the page polls /fragments/job/<id> every second, streaming the command's
64
+ * real stdout/stderr into a terminal box until it exits. */
65
+ const jobs = new Map();
66
+ let jobSeq = 0;
67
+
68
+ // Is a job with this key still running? graphify runs must not overlap.
69
+ // The id of the running job under this key, or null. Every existing caller
70
+ // treats the answer as a boolean, and an id is truthy — but a caller that wants
71
+ // to SHOW the running job (Watch) needs the id itself.
72
+ function runningJobKey(key) {
73
+ for (const [id, job] of jobs.entries()) if (job.key === key && !job.done) return id;
74
+ return null;
75
+ }
76
+
77
+ // Nothing started from the dashboard has anyone to answer a prompt: stdin is closed (it has
78
+ // to be — `docker exec -i` hangs on an open one), so a question just stalls the job until its
79
+ // timeout. DDEV asks one after an upgrade: "May we send anonymous DDEV usage statistics?".
80
+ // Applies to the whole subtree, so a cmd-*.sh that calls ddev is covered too.
81
+ function jobEnv() {
82
+ return { ...process.env, DDEV_NONINTERACTIVE: 'true' };
83
+ }
84
+
85
+ // Wrap a command so it runs detached in a `screen` session that outlives this
86
+ // process, while a job can follow it live: the command's output goes to logFile,
87
+ // the returned shell line starts the session, tails the log, waits for the
88
+ // session to end, then prints the tail. One place owns this pattern — the exact
89
+ // quoting and the waiter were each wrong once (a bare `&` after a && chain
90
+ // backgrounds the whole chain; the session-poll must dot-escape the name).
91
+ // The screen configuration every session this dashboard starts runs under.
92
+ //
93
+ // There was none, so each session took screen's defaults: 100 lines of scrollback (one screen of a
94
+ // build log), no alt-screen (so a TUI's every repaint lands in the scrollback), and Ctrl-S as flow
95
+ // control (which looks exactly like a hung session). It lives beside the app rather than in
96
+ // ~/.screenrc, because it belongs to the dashboard's sessions and must not change a machine's own.
97
+ const SCREENRC = require('path').join(__dirname, 'screenrc');
98
+ const screenCmd = () => `screen -c ${JSON.stringify(SCREENRC)}`;
99
+
100
+ function screenWrap({ session, inner, logFile }) {
101
+ if (!/^[a-z0-9][a-z0-9_.-]*$/.test(session)) throw new Error(`bad screen session name: ${session}`);
102
+ return [
103
+ `: > ${JSON.stringify(logFile)}`,
104
+ `${screenCmd()} -dmS ${session} bash -c ${JSON.stringify(`{ ${inner} ; } 2>&1 | tee -a ${logFile}`)}`,
105
+ `sleep 1`,
106
+ `echo "Running in screen session ${session} — it survives dashboard restarts; reattach with: screen -r ${session}"`,
107
+ `( tail -F ${JSON.stringify(logFile)} 2>/dev/null & echo $! > /tmp/.tp-${session} )`,
108
+ `while screen -ls 2>/dev/null | grep -q "[.]${session}[[:space:]]"; do sleep 3; done`,
109
+ `sleep 1`,
110
+ `kill $(cat /tmp/.tp-${session}) 2>/dev/null; rm -f /tmp/.tp-${session}`,
111
+ `echo`, `echo "--- last lines of the log ---"`,
112
+ `tail -n 8 ${JSON.stringify(logFile)}`,
113
+ ].join('; ');
114
+ }
115
+
116
+ function startJob(title, cmd, args, cwd, { timeoutMs = 15 * 60 * 1000, echoLine = '', key = null, phases = null, inline = false, env = null } = {}) {
117
+ const id = `${++jobSeq}-${Math.random().toString(36).slice(2, 8)}`;
118
+ // `phases` — ordered output markers; the fragment counts which have appeared and renders an
119
+ // honest progress bar. `inline` — the toast stays where it was swapped in (a modal) instead
120
+ // of being adopted into the corner stack.
121
+ const job = { title, key, phases, inline, buf: echoLine ? `$ ${echoLine}\n\n` : '', done: false, ok: null };
122
+ jobs.set(id, job);
123
+ // stdin 'ignore' — see run(): docker exec -i hangs on an open stdin pipe.
124
+ // detached: killing the bash wrapper leaves ddev/composer/graphify children
125
+ // running, so signal the whole process group on timeout.
126
+ const child = spawn(cmd, args, { cwd, env: env ? { ...env, DDEV_NONINTERACTIVE: 'true' } : jobEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
127
+ job.child = child;
128
+ const append = (d) => { job.buf = (job.buf + d.toString()).slice(-30000); };
129
+ child.stdout.on('data', append);
130
+ child.stderr.on('data', append);
131
+ const timer = setTimeout(() => {
132
+ append(`\n⏱️ timed out after ${Math.round(timeoutMs / 60000)}m — stopping\n`);
133
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (_) { child.kill('SIGTERM'); }
134
+ setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { /* gone */ } }, 10000);
135
+ }, timeoutMs);
136
+ // Forget the job half an hour after it FINISHES, not half an hour after it starts.
137
+ //
138
+ // Started from the start, the timer fired while the work was still going: a Webship suite is
139
+ // 275 scenarios and takes about forty minutes, so at minute thirty the toast turned into
140
+ // "Job not found (expired)" and the live output stopped — while the run carried on invisibly,
141
+ // with nothing left in the interface to say whether it passed. Anything long enough to want a
142
+ // progress toast is long enough to outlive a timer started at launch.
143
+ const forget = () => setTimeout(() => jobs.delete(id), 30 * 60 * 1000);
144
+ child.on('close', (code) => { clearTimeout(timer); job.done = true; job.ok = code === 0; forget(); });
145
+ child.on('error', (err) => { clearTimeout(timer); job.buf += `\n${err.message}`; job.done = true; job.ok = false; forget(); });
146
+ return id;
147
+ }
148
+
149
+ // Stop a running job for real: the whole process group, then the toast says so.
150
+ function stopJob(id) {
151
+ const job = jobs.get(id);
152
+ if (!job || job.done || !job.child) return false;
153
+ job.buf += '\n🛑 stopped by the user\n';
154
+ job.stopped = true;
155
+ try { process.kill(-job.child.pid, 'SIGTERM'); } catch (_) { try { job.child.kill('SIGTERM'); } catch (_2) { /* gone */ } }
156
+ setTimeout(() => { try { process.kill(-job.child.pid, 'SIGKILL'); } catch (_) { /* gone */ } }, 5000);
157
+ return true;
158
+ }
159
+
160
+ function jobFragment(id) {
161
+ const job = jobs.get(id);
162
+ if (!job) return { html: '<div class="msg error">Job not found (expired).</div>', done: true };
163
+ const text = job.buf.replace(/\x1b\[[0-9;]*[mK]/g, '');
164
+ const status = job.done ? (job.stopped ? '🛑 stopped' : job.ok ? '✅ finished' : '❌ failed') : '<span uk-spinner="ratio: .5"></span> running…';
165
+ const poll = job.done ? '' : ` hx-get="/fragments/job/${id}" hx-trigger="every 1s" hx-swap="outerHTML"`;
166
+ // A job is a toast: it outlives the page area it was launched from, so a build survives
167
+ // navigating elsewhere, and it can be collapsed while it runs. The element polls ITSELF and
168
+ // replaces itself, which is what lets ui.js move it into the toast stack once and leave it
169
+ // there — every later poll lands wherever the element currently is.
170
+ const state = job.done ? (job.ok ? 'is-done' : 'is-failed') : 'is-running';
171
+ // The honest progress bar: phases are output markers, so the bar can only say what the
172
+ // output already proves — k of n phases reached, and the name of the current one.
173
+ let progress = '';
174
+ if (Array.isArray(job.phases) && job.phases.length) {
175
+ let reached = 0;
176
+ for (const marker of job.phases) { if (text.includes(marker)) reached += 1; }
177
+ const total = job.phases.length;
178
+ const value = job.done && job.ok ? total : reached;
179
+ const current = job.done ? (job.ok ? 'Done' : 'Failed') : (job.phases[Math.min(reached, total - 1)] || '');
180
+ progress = `
181
+ <div class="job-progress">
182
+ <progress class="uk-progress" value="${value}" max="${total}"></progress>
183
+ <span class="uk-text-meta">${esc(current)} (${value}/${total})</span>
184
+ </div>`;
185
+ }
186
+ const html = `
187
+ <div class="msg ${job.done && !job.ok ? 'error' : 'assistant'} terminal job-toast ${state}${job.inline ? ' job-inline' : ''}" id="job-${id}"${poll}>
188
+ <div class="job-toast-head">
189
+ <span class="job-toast-title">${job.title}</span>
190
+ <span class="job-toast-status">${status}</span>
191
+ ${job.done ? '' : `<button type="button" class="job-toast-btn" title="Stop this job — the process is killed"
192
+ hx-post="/actions/job-stop" hx-vals='{"id":"${id}"}' hx-target="#job-${id}" hx-swap="outerHTML"
193
+ hx-confirm="Stop this job? The running process is killed."><span uk-icon="icon: ban; ratio: .7"></span></button>`}
194
+ ${job.inline ? '' : `<button type="button" class="job-toast-btn job-toast-collapse" title="Collapse or expand this job"
195
+ aria-expanded="true"><span uk-icon="icon: chevron-up; ratio: .7"></span></button>
196
+ <button type="button" class="job-toast-btn job-toast-close" title="Dismiss${job.done ? '' : ' (the job keeps running)'}"
197
+ aria-label="Dismiss"><span uk-icon="icon: close; ratio: .7"></span></button>`}
198
+ </div>
199
+ ${progress}
200
+ <pre class="terminal-pre">${esc(text) || '…'}</pre>
201
+ ${job.inline && job.done ? `<p class="job-done-note">${job.ok ? '✅ Done — the process finished. You can close this window.' : '❌ The process failed — the output above says where.'}</p>` : ''}
202
+ </div>`;
203
+ return { html, done: job.done };
204
+ }
205
+
206
+ function listProjects(dir) {
207
+ try {
208
+ return fs.readdirSync(dir, { withFileTypes: true })
209
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
210
+ .map((e) => e.name);
211
+ } catch (_) {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ // The same list with a modification time, so the sorts that file lists already offer work
217
+ // here too. The project directory's own mtime is what changes when it is rebuilt.
218
+ function listProjectsDetailed(dir) {
219
+ return listProjects(dir).map((name) => {
220
+ let mtime = new Date(0);
221
+ try { mtime = fs.statSync(path.join(dir, name)).mtime; } catch (_) { /* vanished mid-read */ }
222
+ return { name, mtime };
223
+ });
224
+ }
225
+
226
+
227
+ // Every project command runs the same way: detached in a screen session named
228
+ // after the project, logged where the Screens page can find it, and answered
229
+ // with a pointer rather than a toast that follows the user around. The session
230
+ // name always carries the folder, so `screen -ls` and the row's SCREENS group
231
+ // both read as "what is happening to THIS project".
232
+
233
+ // ------------------------------------------------------- the host runner --
234
+ // Sessions belong on the machine, not in this container: that is where the
235
+ // display, the browser an agent drives and the user's own ~/.claude live. The
236
+ // runner on the host does the starting and attaching; this is how the dashboard
237
+ // asks it. When it is not running, everything falls back to the container, so
238
+ // the dashboard keeps working either way.
239
+ function hostRunner() {
240
+ const { ROOT } = require('./workspaces');
241
+ try {
242
+ const store = path.join(ROOT, '.screens');
243
+ const token = fs.readFileSync(path.join(store, '.runner-token'), 'utf8').trim();
244
+ const port = Number(fs.readFileSync(path.join(store, '.runner-port'), 'utf8').trim());
245
+ if (!token || !port) return null;
246
+ return { token, port, base: `http://host.docker.internal:${port}` };
247
+ } catch (_) { return null; }
248
+ }
249
+
250
+ async function hostRunnerCall(pathname, { method = 'GET', body = null } = {}) {
251
+ const r = hostRunner();
252
+ if (!r) return null;
253
+ try {
254
+ const res = await fetch(`${r.base}${pathname}`, {
255
+ method,
256
+ headers: { 'X-Runner-Token': r.token, ...(body ? { 'Content-Type': 'application/json' } : {}) },
257
+ body: body ? JSON.stringify(body) : undefined,
258
+ signal: AbortSignal.timeout(15000),
259
+ });
260
+ return { status: res.status, body: await res.json().catch(() => ({})) };
261
+ } catch (_) {
262
+ return null; // not reachable: the caller falls back to the container
263
+ }
264
+ }
265
+
266
+ /** Is the host runner alive? Its published file says so by being fresh: it is
267
+ * rewritten every two seconds, so anything older than fifteen means nobody is
268
+ * home. This is a file read, not a socket — the container cannot always reach
269
+ * the host over the network, but it always shares this folder. */
270
+ function hostSessionsFile() {
271
+ const { ROOT } = require('./workspaces');
272
+ try {
273
+ const raw = fs.readFileSync(path.join(ROOT, '.screens', 'host-sessions.json'), 'utf8');
274
+ const data = JSON.parse(raw);
275
+ if (!data || typeof data.at !== 'number') return null;
276
+ return (Date.now() - data.at) < 15000 ? data : null;
277
+ } catch (_) { return null; }
278
+ }
279
+
280
+ async function hostRunnerUp() {
281
+ return !!hostSessionsFile();
282
+ }
283
+
284
+ /** Ask the runner to do something, through the folder both sides can see. */
285
+ async function hostRunnerRequest(action, payload, { timeoutMs = 30000 } = {}) {
286
+ const { ROOT } = require('./workspaces');
287
+ const store = path.join(ROOT, '.screens');
288
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
289
+ try {
290
+ fs.mkdirSync(path.join(store, 'queue'), { recursive: true });
291
+ fs.mkdirSync(path.join(store, 'results'), { recursive: true });
292
+ // Written beside, then renamed: the runner must never read half a request.
293
+ const tmp = path.join(store, 'queue', `.${id}.tmp`);
294
+ fs.writeFileSync(tmp, JSON.stringify({ id, action, ...payload }));
295
+ fs.renameSync(tmp, path.join(store, 'queue', `${id}.json`));
296
+ } catch (_) { return null; }
297
+ const resultFile = path.join(store, 'results', `${id}.json`);
298
+ const until = Date.now() + timeoutMs;
299
+ while (Date.now() < until) {
300
+ try {
301
+ const body = JSON.parse(fs.readFileSync(resultFile, 'utf8'));
302
+ fs.unlink(resultFile, () => {});
303
+ return body;
304
+ } catch (_) { /* not answered yet */ }
305
+ await new Promise((r) => { setTimeout(r, 150); });
306
+ }
307
+ return null;
308
+ }
309
+
310
+ // A session name is workspace--project--verb, at most 60 characters. It used to be cut at 60, so on a long
311
+ // project name different commands became the same session — `composer update` and `composer update --dry-run`,
312
+ // two actions whose ids start alike — and one was refused as "still running", or replaced the other's output.
313
+ // A name that fits is exactly what it always was. One that does not keeps the workspace in front and the verb
314
+ // whole at the end (the screens page reads a Jira key from the end of a name), and shortens the project,
315
+ // adding a hash of the full name so it stays unique.
316
+ function screenSessionName(project, verb, workspace) {
317
+ const clean = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-{3,}/g, '--').replace(/^-+|-+$/g, '');
318
+ const name = clean([workspace, project, verb].filter(Boolean).join('--'));
319
+ if (name.length <= 60) return name;
320
+ const hash = require('crypto').createHash('sha1').update(name).digest('hex').slice(0, 6);
321
+ const ws = clean(workspace);
322
+ const vb = clean(verb);
323
+ const room = 60 - (ws ? ws.length + 2 : 0) - (vb ? vb.length + 2 : 0) - 7;
324
+ if (room >= 4) {
325
+ const pr = clean(project).slice(0, room).replace(/[-.]+$/, '');
326
+ return [ws, `${pr}-${hash}`, vb].filter(Boolean).join('--');
327
+ }
328
+ // A verb too long to keep whole: the front of the name, and the hash.
329
+ return `${name.slice(0, 53).replace(/[-.]+$/, '')}-${hash}`;
330
+ }
331
+
332
+ async function startInScreen({ project, projectDir, ownerDir, verb, cmdLine, label, workspace }) {
333
+ const { ROOT } = require('./workspaces');
334
+ const session = screenSessionName(project, verb, workspace);
335
+ const logDir = path.join(ROOT, '.screens');
336
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch (_) { /* exists */ }
337
+ const logFile = path.join(logDir, `${session}.log`);
338
+ // The row's SCREENS group reads this to know which sessions are its own. It
339
+ // is the PROJECT the work belongs to, which is not always where the command
340
+ // runs: a workspace script like cmd-tools-backup.sh runs from the workspace
341
+ // folder and takes the project as an argument.
342
+ try { fs.writeFileSync(path.join(logDir, `${session}.cwd`), `${ownerDir || projectDir}\n`); } catch (_) { /* best effort */ }
343
+
344
+ // Sessions belong on the machine: that is where the display, the browser an
345
+ // agent drives, and the user's own terminal are — `screen -r <name>` should
346
+ // just work, without `ddev ssh` first.
347
+ if (!(await hostRunnerUp())) {
348
+ return { ok: false, session, needsRunner: true };
349
+ }
350
+ const answer = await hostRunnerRequest('start',
351
+ { session, dir: projectDir, owner: ownerDir || projectDir, cmd: cmdLine });
352
+ if (!answer) return { ok: false, session, needsRunner: true };
353
+ if (answer.already) return { ok: false, session, already: true, where: 'host' };
354
+ if (answer.ok) return { ok: true, session, label, where: 'host' };
355
+ return { ok: false, session, why: answer.error || 'the host runner refused it', where: 'host' };
356
+ }
357
+
358
+ // Everything below stays for the sessions that already exist in the container:
359
+ // they are listed and attachable until they end, but nothing new joins them.
360
+ async function startInContainerScreen({ project, projectDir, ownerDir, verb, cmdLine, label, workspace }) {
361
+ const { ROOT } = require('./workspaces');
362
+ const session = screenSessionName(project, verb, workspace);
363
+ const logDir = path.join(ROOT, '.screens');
364
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch (_) { /* exists */ }
365
+ const logFile = path.join(logDir, `${session}.log`);
366
+ try { fs.writeFileSync(path.join(logDir, `${session}.cwd`), `${ownerDir || projectDir}\n`); } catch (_) { /* best effort */ }
367
+ const live = await run('screen', ['-ls'], process.env.HOME, { timeoutMs: 10 * 1000 });
368
+ if (new RegExp(`\\.${session}\\s`).test(String(live.stdout || ''))) {
369
+ return { ok: false, session, already: true };
370
+ }
371
+ // Braces group the whole line before the pipe, so every part is logged.
372
+ const inner = `{ ${cmdLine} ; } 2>&1 | tee -a ${JSON.stringify(logFile)}`;
373
+ const out = await run('bash', ['-c',
374
+ `: > ${JSON.stringify(logFile)} && ${screenCmd()} -dmS ${session} bash -lc ${JSON.stringify(inner)}`],
375
+ projectDir, { timeoutMs: 30 * 1000 });
376
+ if (!out.ok) return { ok: false, session, why: String(out.stderr || out.stdout).slice(-200) };
377
+ return { ok: true, session, logFile, label };
378
+ }
379
+
380
+ /** The one shape every action uses: start the command in a screen session on
381
+ * the machine and answer with where to watch it. Nothing the dashboard runs
382
+ * is a toast any more — it is a session on the Screens page. */
383
+ async function screenAction(send, opts) {
384
+ const started = await startInScreen(opts);
385
+ return send(screenStartedHtml(started, opts.label, opts.project || opts.workspace),
386
+ started.ok ? 200 : (started.already ? 409 : 500));
387
+ }
388
+
389
+ /** The standard answer: what started, where to watch it.
390
+ *
391
+ * `flash-now` marks these as a RESULT rather than content. It matters because 37 of the
392
+ * buttons that start a session post into #editor-modal-body — Remove, Clone, Restore and
393
+ * the item forms all open a modal, and the response replaces that form. ui.js otherwise
394
+ * leaves anything inside the modal alone, which is right for an editor and wrong here:
395
+ * the modal stayed open holding a sentence about a session that had already started. */
396
+ function screenStartedHtml(result, label, project) {
397
+ const { esc } = require('./html');
398
+ if (result.needsRunner) {
399
+ return `<div class="msg error flash-now">Sessions run on your machine, not in the dashboard's container — and its runner is not up, so <code>${esc(label)}</code> was not started.
400
+ <br>Start it once with:
401
+ <br><code>bash ${esc(require('./format').tildePath(require('./workspaces').ROOT))}/core/workspace-app/scripts/screen-host.sh --start</code>
402
+ <br><span class="uk-text-meta">It needs <code>screen</code> on this machine (<code>bash core/scripts/install.sh</code> installs it). After that <code>screen -r &lt;session&gt;</code> works straight from your own terminal.</span></div>`;
403
+ }
404
+ if (result.already) {
405
+ return `<div class="msg error flash-now"><code>${esc(label)}</code> is already running on <strong>${esc(project)}</strong> — session <code>${esc(result.session)}</code>, on <a href="/screens">Screens</a>.</div>`;
406
+ }
407
+ if (!result.ok) {
408
+ return `<div class="msg error flash-now">Could not start <code>${esc(label)}</code>: ${esc(result.why || 'unknown error')}</div>`;
409
+ }
410
+ // A toast is read in a glance, so it says what started, where, and where to look —
411
+ // and stops. The session id is what `screen -r` needs, and the Screens page prints
412
+ // it beside a live terminal; repeating it here is what turned one sentence into a
413
+ // paragraph, and a paragraph into something that reads as an error.
414
+ // `data-reload-on-close` is why the row for a project you just built appears without
415
+ // anyone pressing anything: the session is detached, so the page that started it has
416
+ // no idea the list changed. Only on the success path — an error toast reloading the
417
+ // page would take the message away before it could be acted on.
418
+ return `<div class="msg assistant flash-now" data-reload-on-close="1"><code>${esc(label)}</code> on <strong>${esc(project)}</strong> — <a href="/screens">watch on Screens</a></div>`;
419
+ }
420
+
421
+ module.exports = {
422
+ SCREENRC, screenCmd,
423
+ screenWrap,
424
+ run, jobs, runningJobKey, startJob, stopJob, jobFragment,
425
+ startInScreen, startInContainerScreen, screenStartedHtml, screenSessionName, screenAction,
426
+ hostRunner, hostRunnerCall, hostRunnerUp, hostRunnerRequest, hostSessionsFile,
427
+ // Not job machinery, but they travelled inside the same span of server.js and every
428
+ // caller of them also calls the runner. They move properly in the next split.
429
+ ddevProjectName, listProjects, listProjectsDetailed,
430
+ };
package/linger.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A session that lingers: a command that finishes in under a second would be gone before anyone
3
+ * opened its terminal, so the session stays open after the output, waiting for Enter, and closes by
4
+ * itself after 15 minutes. Shared by the GIT plugin, COMPOSER, NODE, the row actions and the commands
5
+ * center, so every lingering session ends the same way and the menus can read the mark back.
6
+ */
7
+
8
+ // How long a finished session stays open to be read.
9
+ const LINGER_SECONDS = 900;
10
+
11
+ // The last line a lingering session prints. The COMPOSER menu reads it back from the log to tell a
12
+ // session that is only waiting to be read from one whose command is still running.
13
+ const LINGER_MARK = 'Press Enter to close this session';
14
+
15
+ /** The end of a lingering session's line: say it finished, then wait for Enter or 15 minutes. Shared with COMPOSER. */
16
+ function lingerTail(label) {
17
+ return `echo; echo "${label} finished. ${LINGER_MARK}, or leave it: it closes by itself in 15 minutes."; `
18
+ + `timeout --foreground ${LINGER_SECONDS} bash -c 'while :; do read -r _ && exit 0; sleep 1; done' || true`;
19
+ }
20
+
21
+ module.exports = { LINGER_SECONDS, LINGER_MARK, lingerTail };