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

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 (311) 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/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Commands → Buttons (rows management), and running the actions it lets someone add.
3
+ *
4
+ * Every change answers with the editor re-rendered, on the tab it came from. Each form carries the file's
5
+ * mtime from when it was drawn, and a write refuses with 409 when the file changed since — another tab,
6
+ * or a hand edit — rather than silently writing over it. An action change made from the Rows tab carries
7
+ * the rest of that form, so a drag not yet saved is not lost by adding, editing or deleting an action.
8
+ *
9
+ * An action is run by id only. Its steps come from the file, never from the request; the only things a
10
+ * request carries into a run are option values, each checked against its option and single-quoted.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { esc } = require('../html');
17
+ const { workspaceDir } = require('../workspaces');
18
+ const { screenStartedHtml, screenSessionName } = require('../jobs');
19
+ const { NAME_RE } = require('../validate');
20
+ const { lingerTail } = require('../linger');
21
+ const { hasOwnGit } = require('../git-info');
22
+ const { startUnlessRunning, terminalLink, isDdevProject, hasComposerJson, SAFE_PATH_RE } = require('./composer');
23
+ const { hasPackageJson } = require('./node');
24
+ const rowLayout = require('../row-layout');
25
+ const catalog = require('../commands');
26
+
27
+ const paneOf = (form) => (['0', '1', '2'].includes(String(form.pane)) ? Number(form.pane) : 0);
28
+ // The card someone was working in stays open when the editor comes back.
29
+ const editor = (send, form, message, code = 200) => send(rowLayout.rowActionsEditorHtml(message, paneOf(form),
30
+ { openButton: String(form.open_button || '') }), code);
31
+ const ok = (text) => `<div class="msg assistant">✅ ${text}</div>`;
32
+ const bad = (text) => `<div class="msg error">${text}</div>`;
33
+
34
+ /** Write, or answer with why not. True when it was written. */
35
+ function write(send, form, fn) {
36
+ try {
37
+ fn();
38
+ return true;
39
+ } catch (err) {
40
+ if (err && err.code === 'CHANGED') {
41
+ editor(send, form, bad('Row actions changed since this page was loaded (another tab, or a hand edit), so nothing was saved. This is the current state.'), 409);
42
+ } else {
43
+ editor(send, form, bad(`Could not save: ${esc(String(err && err.message || err))}`), 500);
44
+ }
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /** The layout with whatever the Rows form carried applied to it (unchanged when the request had no form). */
50
+ function arranged(form) {
51
+ const layout = rowLayout.loadRowLayoutFresh();
52
+ return rowLayout.arrangementFromForm(layout, form);
53
+ }
54
+
55
+ // One step a line. A line `use: <command id>` runs that command of the catalog; any other line is a command line.
56
+ const stepsFrom = (text) => String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
57
+ .map((l) => { const m = /^use:\s*(\S+)$/.exec(l); return m ? { use: m[1] } : l; });
58
+
59
+ async function arrange_save({ form, send }) {
60
+ const next = rowLayout.normalizeLayout(arranged(form));
61
+ if (!write(send, form, () => rowLayout.saveRowLayout(next, form.mtime))) return undefined;
62
+ return editor(send, form, ok('Rows saved. Reload a workspace page to see them.'));
63
+ }
64
+
65
+ async function rows_op({ form, send }) {
66
+ const doc = arranged(form);
67
+ const i = Number(form.index);
68
+ const op = String(form.op || '');
69
+ if (op === 'add') {
70
+ if (doc.rows.length >= rowLayout.MAX_ROWS) return editor(send, form, bad(`Up to ${rowLayout.MAX_ROWS} rows.`), 400);
71
+ doc.rows.push([]);
72
+ } else if (!Number.isInteger(i) || i < 0 || i >= doc.rows.length) {
73
+ return editor(send, form, bad('No such row.'), 400);
74
+ } else if (op === 'remove') {
75
+ if (doc.rows.length === 1) return editor(send, form, bad('A project row keeps at least one row.'), 400);
76
+ const [gone] = doc.rows.splice(i, 1);
77
+ doc.rows[Math.max(0, i - 1)].push(...gone);
78
+ } else if (op === 'up' || op === 'down') {
79
+ const j = op === 'up' ? i - 1 : i + 1;
80
+ if (j < 0 || j >= doc.rows.length) return editor(send, form, bad('That row cannot move further.'), 400);
81
+ [doc.rows[i], doc.rows[j]] = [doc.rows[j], doc.rows[i]];
82
+ } else {
83
+ return editor(send, form, bad('Unknown row operation.'), 400);
84
+ }
85
+ if (!write(send, form, () => rowLayout.saveRowLayout(rowLayout.normalizeLayout(doc), form.mtime))) return undefined;
86
+ const said = { add: 'Row added', remove: 'Row removed — its buttons moved to the row above', up: 'Row moved up', down: 'Row moved down' }[op];
87
+ return editor(send, form, ok(`${said}. Reload a workspace page to see it.`));
88
+ }
89
+
90
+ async function custom_add({ form, send }) {
91
+ const doc = arranged(form);
92
+ const key = form.for_button === '__new__' ? 'new' : String(form.for_button || '');
93
+ const field = (n) => (form[`add_${n}_${key}`] === undefined ? '' : String(form[`add_${n}_${key}`]));
94
+ // A command picked with nothing typed is the whole action: it takes that command's label, group and node filter.
95
+ const typed = stepsFrom(field('steps'));
96
+ const use = field('use').trim();
97
+ const alone = use && !typed.length ? catalog.findCommand(use) : null;
98
+ const label = field('label').trim() || (alone ? alone.label : '');
99
+ const taken = new Set(doc.custom.map((c) => c.id));
100
+ const base = rowLayout.slug(label);
101
+ let id = base;
102
+ for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`;
103
+ const entry = rowLayout.cleanCustom({
104
+ id, label,
105
+ button: key === 'new' ? field('button') : key,
106
+ group: field('group').trim() || (alone ? alone.group : 'Custom'),
107
+ when: alone && (!field('when') || field('when') === 'any') ? alone.when : (field('when') || 'any'),
108
+ steps: use ? [...typed, { use }] : typed,
109
+ keepgoing: field('keepgoing') === '1',
110
+ confirm: field('confirm') === '1',
111
+ open: field('open'),
112
+ });
113
+ const why = rowLayout.customRefusal(entry) || rowLayout.useRefusal(entry);
114
+ if (why) return editor(send, form, bad(`Not added: ${esc(why)}.`), 400);
115
+ doc.custom.push(rowLayout.customToDoc(entry));
116
+ if (!write(send, form, () => rowLayout.saveRowLayout(rowLayout.normalizeLayout(doc), form.mtime))) return undefined;
117
+ return editor(send, form, ok(`Added ${esc(entry.label)} to ${esc(rowLayout.buttonLabel(entry.button))}. Reload a workspace page to see it.`));
118
+ }
119
+
120
+ async function custom_save({ form, send }) {
121
+ const doc = arranged(form);
122
+ const id = String(form.id || '');
123
+ const at = doc.custom.findIndex((c) => c.id === id);
124
+ if (at < 0) return editor(send, form, bad('No such action — it may have been deleted.'), 404);
125
+ const old = rowLayout.cleanCustom(doc.custom[at]);
126
+ const field = (n) => (form[`edit_${n}_${id}`] === undefined ? '' : String(form[`edit_${n}_${id}`]));
127
+ // A step whose text did not change keeps its on_error; options are kept whole (Definition edits those).
128
+ const steps = stepsFrom(field('steps')).map((step, i) => {
129
+ const was = old.steps[i];
130
+ const text = typeof step === 'string' ? step : `use: ${step.use}`;
131
+ if (!was || !was.onError || rowLayout.stepText(was) !== text) return step;
132
+ return typeof step === 'string' ? { command: step, on_error: was.onError } : { use: step.use, on_error: was.onError };
133
+ });
134
+ const entry = rowLayout.cleanCustom({
135
+ ...rowLayout.customToDoc(old),
136
+ command: undefined,
137
+ label: field('label').trim(),
138
+ group: field('group').trim() || 'Custom',
139
+ when: field('when').trim().split(/\s+/).filter(Boolean),
140
+ steps,
141
+ keepgoing: field('keepgoing') === '1',
142
+ confirm: field('confirm') === '1',
143
+ open: field('open'),
144
+ });
145
+ const why = rowLayout.customRefusal(entry) || rowLayout.useRefusal(entry);
146
+ if (why) return editor(send, form, bad(`Not saved: ${esc(why)}.`), 400);
147
+ doc.custom[at] = rowLayout.customToDoc(entry);
148
+ if (!write(send, form, () => rowLayout.saveRowLayout(rowLayout.normalizeLayout(doc), form.mtime))) return undefined;
149
+ return editor(send, form, ok(`Saved ${esc(entry.label)}. Reload a workspace page to see it.`));
150
+ }
151
+
152
+ async function custom_remove({ form, send }) {
153
+ const doc = arranged(form);
154
+ const id = String(form.id || '');
155
+ if (!doc.custom.some((c) => c.id === id)) return editor(send, form, bad('No such action — only actions you added can be deleted.'), 404);
156
+ doc.custom = doc.custom.filter((c) => c.id !== id);
157
+ if (!write(send, form, () => rowLayout.saveRowLayout(rowLayout.normalizeLayout(doc), form.mtime))) return undefined;
158
+ return editor(send, form, ok('Action deleted.'));
159
+ }
160
+
161
+ async function layout_reset({ form, send }) {
162
+ if (!write(send, form, () => rowLayout.resetRowLayout(form.mtime))) return undefined;
163
+ return editor(send, form, ok('Back to the defaults.'));
164
+ }
165
+
166
+ function reportHtml(report) {
167
+ return `${report.errors.map((e) => bad(esc(e))).join('')}${report.notes.length
168
+ ? `<div class="msg assistant">${report.errors.length ? '' : '✅ '}${report.notes.map(esc).join(' ')}</div>` : ''}`;
169
+ }
170
+
171
+ async function definition_check({ form, send }) {
172
+ const report = rowLayout.checkDefinition(form.definition, String(form.mode || 'replace'), rowLayout.loadRowLayoutFresh());
173
+ if (!report.errors.length) report.notes.unshift('It loads cleanly.');
174
+ return send(reportHtml(report), report.errors.length ? 400 : 200);
175
+ }
176
+
177
+ async function definition_save({ form, send }) {
178
+ const report = rowLayout.checkDefinition(form.definition, String(form.mode || 'replace'), rowLayout.loadRowLayoutFresh());
179
+ if (report.errors.length) return editor(send, { ...form, pane: '2' }, `${bad('Nothing was saved:')}${reportHtml(report)}`, 400);
180
+ if (!write(send, { ...form, pane: '2' }, () => rowLayout.saveRowLayout(report.next, form.mtime))) return undefined;
181
+ return editor(send, { ...form, pane: '2' }, ok(`Loaded. ${report.notes.map(esc).join(' ')}`));
182
+ }
183
+
184
+ /** The action and the folder it would run in, or an answer saying why not. */
185
+ function actionTarget({ form, send, workspace }) {
186
+ const projectName = String(form.projectName || '');
187
+ if (!NAME_RE.test(projectName)) { send(bad('Invalid project name.'), 400); return { done: true }; }
188
+ const entry = rowLayout.loadRowLayout().custom.find((c) => c.id === String(form.id || ''));
189
+ if (!entry) { send(bad('No such action — it may have been deleted in Commands → Buttons.'), 404); return { done: true }; }
190
+ const why = rowLayout.customRefusal(entry);
191
+ if (why) { send(bad(`Not running ${esc(entry.label)}: ${esc(why)}.`), 400); return { done: true }; }
192
+ const projectDir = path.join(workspaceDir(workspace), projectName);
193
+ let isDir = false;
194
+ try { isDir = fs.statSync(projectDir).isDirectory(); } catch (_) { /* not there */ }
195
+ if (!isDir) { send(bad(`No folder called ${esc(projectName)} here.`), 400); return { done: true }; }
196
+ if (!SAFE_PATH_RE.test(projectDir)) { send(bad('Not running it: the folder path has characters a shell line cannot carry safely.'), 400); return { done: true }; }
197
+ const ctx = { key: workspace, isDdev: isDdevProject(projectDir), hasComposer: hasComposerJson(projectDir), hasNode: hasPackageJson(projectDir), hasGit: hasOwnGit(projectDir) };
198
+ if (!rowLayout.whenMatches(entry.when, ctx)) { send(bad(`${esc(entry.label)} is only for ${esc(rowLayout.describeWhen(entry.when))}.`), 400); return { done: true }; }
199
+ const used = rowLayout.useRefusal(entry);
200
+ if (used) { send(bad(`Not running ${esc(entry.label)}: ${esc(used)}.`), 400); return { done: true }; }
201
+ // A command a step uses keeps its own node filter: the action's filter does not widen it.
202
+ for (const { cmd } of rowLayout.usedCommands(entry)) {
203
+ if (!rowLayout.whenMatches(cmd.when, ctx)) {
204
+ send(bad(`${esc(entry.label)} uses ${esc(cmd.label)}, which is only for ${esc(rowLayout.describeWhen(cmd.when))}.`), 400);
205
+ return { done: true };
206
+ }
207
+ }
208
+ return { entry, projectName, projectDir };
209
+ }
210
+
211
+ async function custom_options(args) {
212
+ const t = actionTarget(args);
213
+ if (t.done) return undefined;
214
+ return args.send(rowLayout.actionOptionsFormHtml(t.entry, { workspace: args.workspace, project: t.projectName }));
215
+ }
216
+
217
+ async function custom_run(args) {
218
+ const { form, send, workspace } = args;
219
+ const t = actionTarget(args);
220
+ if (t.done) return undefined;
221
+ const { entry, projectName, projectDir } = t;
222
+ const options = rowLayout.effectiveOptions(entry);
223
+ if (options.length && form.submitted !== '1') {
224
+ return send(bad(`${esc(entry.label)} asks for ${options.length === 1 ? 'an option' : 'options'} — run it from its menu, which opens the form.`), 400);
225
+ }
226
+ const { values, error } = rowLayout.resolveOptions({ ...entry, options }, form);
227
+ if (error) return send(bad(`Not running ${esc(entry.label)}: ${esc(error)}.`), 400);
228
+ const expanded = catalog.expandSteps(entry, { workspace, project: projectName, projectDir });
229
+ if (expanded.error) return send(bad(`Not running ${esc(entry.label)}: ${esc(expanded.error)}.`), 400);
230
+
231
+ const verb = `custom-${entry.id}`;
232
+ // The last run of this action here is about to have its log truncated: write down how it ended first.
233
+ rowLayout.settleRun(screenSessionName(projectName, verb, workspace));
234
+ const started = await startUnlessRunning({ workspace, project: projectName, projectDir, verb,
235
+ cmdLine: `${rowLayout.actionCommandLine(expanded.entry, values)}; ${lingerTail(entry.label)}`, label: entry.label });
236
+ if (started.stillRunning) {
237
+ return send(bad(`<code>${esc(entry.label)}</code> is still running on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open it')}. Left alone.`), 409);
238
+ }
239
+ if (!started.ok) return send(screenStartedHtml(started, entry.label, projectName), started.already ? 409 : 500);
240
+ rowLayout.recordRun({ at: new Date().toISOString(), id: entry.id, label: entry.label, workspace, project: projectName, session: started.session });
241
+
242
+ if (entry.open === 'screen') {
243
+ // Its terminal, at once, in the dashboard's one modal — the same terminal the SCREENS menu opens.
244
+ // Nothing visible lands in the page: this only fetches the terminal into the modal and shows it.
245
+ return send(`<div hidden hx-get="/fragments/screen-term/${encodeURIComponent(started.session)}" hx-trigger="load" hx-target="#editor-modal-body" hx-swap="innerHTML"
246
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"></div>`);
247
+ }
248
+ return send(`<div class="msg assistant flash-now"><code>${esc(entry.label)}</code> on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open the output')}</div>`);
249
+ }
250
+
251
+ module.exports = {
252
+ '/actions/row-arrange-save': arrange_save,
253
+ '/actions/row-rows': rows_op,
254
+ '/actions/row-custom-add': custom_add,
255
+ '/actions/row-custom-save': custom_save,
256
+ '/actions/row-custom-remove': custom_remove,
257
+ '/actions/row-layout-reset': layout_reset,
258
+ '/actions/row-definition-check': definition_check,
259
+ '/actions/row-definition-save': definition_save,
260
+ '/actions/row-custom-options': custom_options,
261
+ '/actions/row-custom-run': custom_run,
262
+ };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The screen-session actions: watch a session's log live, or stop it. The
3
+ * domain lives in ../screens, shared with the modal and the Screens page.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const { esc } = require('../html');
10
+ const { run, startJob, stopJob, jobFragment, runningJobKey, hostRunnerUp, hostRunnerRequest } = require('../jobs');
11
+ const { screenLogFor, screensGridHtml, screenTargetDir, defaultSessionName } = require('../screens');
12
+ const claudeSessions = require('../claude-sessions');
13
+ const claudeArgs = require('../claude-args');
14
+ const { ROOT } = require('../workspaces');
15
+
16
+ // A new session: named by the user, started detached in a directory the
17
+ // server resolves from a target token (never a path from the browser), with
18
+ // its output teed to the workspace's own log store.
19
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
20
+
21
+ /** The prompt of a session, kept beside its log with this user's permissions only. */
22
+ function writePromptFile(session, text) {
23
+ const dir = path.join(ROOT, '.screens', 'prompts');
24
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ const file = path.join(dir, `${session}.txt`);
26
+ fs.writeFileSync(file, text, { mode: 0o600 });
27
+ return file;
28
+ }
29
+
30
+ async function screen_new({ form, send, res }) {
31
+ // An empty name is not a mistake: it means "call it after where it runs", which is what the form
32
+ // pre-fills anyway. Refusing it made the one field nobody wants to think about the one that fails.
33
+ const session = String(form.session || '').trim() || defaultSessionName(form.target);
34
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session) || session.length > 60) {
35
+ return send('<div class="msg error">A session name starts with a lowercase letter or digit and holds only letters, digits, - _ and . (60 max).</div>', 400);
36
+ }
37
+ const dir = screenTargetDir(form.target);
38
+ if (!dir) return send('<div class="msg error">Unknown place to run in.</div>', 400);
39
+ // The name says where it runs, exactly like the sessions the row's commands
40
+ // start: <workspace>--<project>--<name>. A target of the workspace root or a
41
+ // folder contributes only what it has.
42
+ const target = String(form.target || '');
43
+ const pm = target.match(/^p:([a-z0-9_-]+):(.+)$/);
44
+ const wm = target.match(/^ws:([a-z0-9_-]+)$/);
45
+ const prefix = pm ? `${pm[1]}--${pm[2]}--` : wm ? `${wm[1]}--` : '';
46
+ // The form pre-fills the name with where it runs, so a name that already says it is not said twice:
47
+ // `dev--ha1x00b1` must not become `dev--ha1x00b1--dev--ha1x00b1`.
48
+ const said = prefix && (`${session}--` === prefix || session.startsWith(prefix));
49
+ const fullSession = `${said ? '' : prefix}${session}`.toLowerCase()
50
+ .replace(/[^a-z0-9._-]+/g, '-').replace(/-{3,}/g, '--').slice(0, 60);
51
+ const logDir = path.join(ROOT, '.screens');
52
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch (_) { /* exists */ }
53
+ const logFile = path.join(logDir, `${fullSession}.log`);
54
+ const command = String(form.command || '').trim();
55
+ const kind = String(form.kind || (command ? 'command' : 'shell'));
56
+ let inner;
57
+ if (kind === 'claude') {
58
+ // Claude Code interactively, in the real PTY screen provides. The field is
59
+ // the prompt it opens with; a prompt template brings its own flags along,
60
+ // so the session runs the same command that prompt file documents
61
+ // (typically --permission-mode bypassPermissions).
62
+ // The flags are the CLI's own, read from `claude --help` (claude-args.js) rather than typed into a
63
+ // free-text box: every one that arrives has to be an option that help printed, and every value has
64
+ // to fit the shape that option documented. A value that does not is REFUSED by name — a flag that
65
+ // silently did nothing is worse than one that says why.
66
+ const { options, error } = await claudeArgs.claudeOptions();
67
+ if (error) {
68
+ return send(`<div class="msg error">The Claude Code options could not be read on this machine: ${esc(error.slice(0, 200))}</div>`, 503);
69
+ }
70
+ const { words, problems } = claudeArgs.flagsFromForm(options, form);
71
+ if (problems.length) {
72
+ return send(`<div class="msg error">${problems.map((x) => esc(x)).join('<br>')}</div>`, 400);
73
+ }
74
+ const flags = words.join(' ');
75
+ // Resuming an existing conversation, by id: `claude --resume <id>` from the same directory. The id
76
+ // is checked against the uuid shape before it reaches a shell line, and only a session that really
77
+ // exists for THIS directory is accepted — an id from another project would resume nothing and say
78
+ // so far from here.
79
+ // The checkbox is what turns it on; the select only says which one. Without the checkbox a select
80
+ // that was left on its default would resume a session nobody asked to resume.
81
+ const wanted = form.resume_on ? String(form.resume || '').trim() : '';
82
+ const resumable = wanted && claudeSessions.isSessionId(wanted)
83
+ && claudeSessions.sessionsFor(dir, { limit: 200 }).some((x) => x.id === wanted)
84
+ ? wanted : '';
85
+ if (wanted && !resumable) {
86
+ return send(`<div class="msg error">No Claude session <code>${esc(wanted.slice(0, 40))}</code> in <code>${esc(dir)}</code> to resume.</div>`, 400);
87
+ }
88
+ const resumeFlag = resumable ? `--resume ${resumable}` : '';
89
+ // The opening prompt is free text, so it reaches claude through a private file and `$(cat …)`, never as
90
+ // a double-quoted word in the line: there `$(…)` and backticks in it would be RUN by the shell.
91
+ const promptFile = command ? writePromptFile(fullSession, command) : '';
92
+ inner = (command
93
+ ? `exec claude ${resumeFlag} ${flags} "$(cat ${shq(promptFile)})"`
94
+ : `exec claude ${resumeFlag} ${flags}`).replace(/ {2,}/g, ' ').trim();
95
+ } else if (kind === 'shell' || !command) {
96
+ inner = 'exec bash -l';
97
+ } else {
98
+ // Braces group the WHOLE command before the pipe: without them only the
99
+ // last segment of `a; b` is logged.
100
+ inner = `{ ${command} ; } 2>&1 | tee -a ${JSON.stringify(logFile)}`;
101
+ }
102
+ // The session knows where it was started without being told: the workspace, the project and the folder
103
+ // are in its environment, so a shell, a command or the agent never has to ask which workspace this is.
104
+ const sq = shq;
105
+ const known = { WEBSHIP_WORKSPACE: pm ? pm[1] : wm ? wm[1] : '', WEBSHIP_PROJECT: pm ? pm[2] : '', WEBSHIP_WORKSPACE_DIR: dir };
106
+ const vars = Object.entries(known).map(([k, v]) => `${k}=${sq(v)}`).join(' ');
107
+ // The runner keeps a command that starts with `exec ` interactive and pipes anything else through tee,
108
+ // which turns claude into --print and a shell into a pipe, so the variables go INSIDE the exec.
109
+ inner = /^exec /.test(inner) ? `exec env ${vars} ${inner.slice(5)}` : `export ${vars}; ${inner}`;
110
+ // Sessions run on the machine, not in this container — so `screen -r` in the
111
+ // user's own terminal reaches them, and a browser an agent opens is a window
112
+ // on their desktop.
113
+ if (!(await hostRunnerUp())) return send(runnerDownHtml(), 503);
114
+ const answer = await hostRunnerRequest('start', { session: fullSession, dir, owner: dir, cmd: inner });
115
+ if (!answer) return send(runnerDownHtml(), 503);
116
+ if (answer.already) return send(`<div class="msg error">A session called <strong>${esc(fullSession)}</strong> is already running.</div>`, 409);
117
+ if (!answer.ok) return send(`<div class="msg error">Could not start it: <pre>${esc(String(answer.error || '').slice(-250))}</pre></div>`, 500);
118
+
119
+ // Started for a work item (the form's `work_key`): the plugins note the run and say so on the item
120
+ // if that was asked for. The dashboard itself keeps no record of what a key means.
121
+ const workKey = String(form.work_key || '').toUpperCase();
122
+ let posted = '';
123
+ if (/^[A-Z][A-Z0-9]+-[0-9]+$/.test(workKey)) {
124
+ const wm = String(form.target || '').match(/^p:([a-z0-9_-]+):(.+)$/);
125
+ posted = await require('../plugin-loader').pluginScreenStarted({ session: fullSession, workKey,
126
+ workspace: wm ? wm[1] : '', project: wm ? wm[2] : '', dir, form });
127
+ }
128
+ // A session that exists now should show up now: the badge in the topbar, the row's SCREENS menu and
129
+ // the screens page all listen for this, so none of them waits out its own poll to admit that the
130
+ // thing you just started is running.
131
+ try { res.setHeader('HX-Trigger', JSON.stringify({ 'screens-changed': { session: fullSession } })); } catch (_) { /* headers already sent */ }
132
+ return send(posted + await screensGridHtml());
133
+ }
134
+
135
+ // The rest of the screen(1) verbs the page offers, each one command:
136
+ // detach screen -S <s> -X detach — drop every other client
137
+ // interrupt screen -S <s> -X stuff ^C — interrupt what runs inside
138
+ // wipe screen -wipe — forget dead sessions
139
+ // The dashboard's container has no screen sessions of its own any more, so a
140
+ // screen(1) verb is asked of the machine — falling back to the container only
141
+ // for the sessions started before the move.
142
+ async function onHostScreen(session, args) {
143
+ const live = await require('../screens').listScreens();
144
+ const row = live.find((s) => s.session === session);
145
+ // Stopping goes through the host runner when the runner owns the session: it started the process
146
+ // group and is the one that can take the whole thing down.
147
+ if (row && row.where === 'host' && args.includes('quit')) {
148
+ const answer = await hostRunnerRequest('stop', { session });
149
+ if (answer && answer.ok) return { ok: true, stdout: '', stderr: '' };
150
+ }
151
+ // Everything else is screen's own, and screen IN HERE can reach it: `/run/screen` is shared with
152
+ // this container, which is the same reason `screen -x` attaches at all.
153
+ //
154
+ // This used to refuse every verb but quit for any session marked `host` — which is every session,
155
+ // since that is what listScreens labels them — and answer with "run it in your terminal" instead. So
156
+ // the terminal window's Ctrl-C and Detach buttons did nothing but print a command to go and type,
157
+ // for a session this process could drive all along. Verified from the container: `screen -S <s> -X
158
+ // stuff` is accepted.
159
+ const out = await run('screen', args, process.env.HOME, { timeoutMs: 10 * 1000 });
160
+ if (out.ok) return out;
161
+ // Only when it really fails is there something for a person to do, and then the command is the one
162
+ // that was actually attempted rather than a reconstruction of it.
163
+ const verb = args.slice(3).join(' ');
164
+ return { ok: false,
165
+ stdout: out.stdout,
166
+ stderr: `${String(out.stderr || out.stdout || '').trim()}\nIf this keeps failing, run it in your terminal: screen -S ${session} -X ${verb}` };
167
+ }
168
+
169
+ function runnerDownHtml() {
170
+ return '<div class="msg error">Sessions run on your machine, not in this container, and its runner is not up. '
171
+ + 'Start it once with: <code>bash ~/workspace/core/workspace-app/scripts/screen-host.sh --start</code></div>';
172
+ }
173
+
174
+ // The modes a program turns on and does not always turn off again.
175
+ //
176
+ // A TUI asks the terminal to report the mouse (1000/1002/1003, SGR 1006/1015), to bracket pastes
177
+ // (2004) and for its own screen (1049). screen remembers the window's state and REPLAYS it to every
178
+ // display that attaches afterwards — so when the program dies without putting them back, the next
179
+ // terminal to attach has mouse reporting on with nothing consuming it, and the wheel writes
180
+ // coordinates like `<35;80;10M` into the shell instead of scrolling. It looks like a broken terminal
181
+ // and it is a mode nobody can see.
182
+ //
183
+ // There is no screen command that writes to a window's terminal: `-X echo` goes to the status line,
184
+ // and `-X stuff` goes to the program's INPUT. So the reset is what a person would type — a printf of
185
+ // the disable sequences — which is why this asks first and says so: it types a line into the session,
186
+ // and that is only harmless at a shell prompt.
187
+ const MODE_RESETS = ['?1000l', '?1001l', '?1002l', '?1003l', '?1005l', '?1006l', '?1015l', '?2004l']
188
+ .map((m) => `\\\\033[${m}`).join('');
189
+
190
+ async function screen_verb({ form, send, pathname }) {
191
+ const session = String(form.session || '');
192
+ if (pathname === '/actions/screen-wipe') {
193
+ await run('screen', ['-wipe'], process.env.HOME, { timeoutMs: 10 * 1000 });
194
+ return send(await screensGridHtml());
195
+ }
196
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
197
+ if (pathname === '/actions/screen-reset-modes') {
198
+ if (form.confirm !== 'yes') return send('<div class="msg error">Resetting types a command into the session, so it asks first.</div>', 400);
199
+ const out = await onHostScreen(session, ['-S', session, '-X', 'stuff', `printf "${MODE_RESETS}"\n`]);
200
+ return send(out.ok
201
+ ? `<div class="msg assistant">Cleared the mouse and paste modes on <strong>${esc(session)}</strong>.
202
+ Every terminal attached to it, here and in a real terminal, scrolls normally again.
203
+ <p class="uk-text-meta">It typed <code>printf</code> at the session's prompt — if a full-screen program was running
204
+ rather than a shell, that line went to the program instead and nothing was reset.</p></div>`
205
+ : `<div class="msg error">Could not reach ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, out.ok ? 200 : 500);
206
+ }
207
+ const args = pathname === '/actions/screen-detach'
208
+ ? ['-S', session, '-X', 'detach']
209
+ : ['-S', session, '-X', 'stuff', '\x03'];
210
+ const out = await onHostScreen(session, args);
211
+ const what = pathname === '/actions/screen-detach' ? 'Detached every other client from' : 'Sent Ctrl-C to';
212
+ return send(out.ok
213
+ ? `<div class="msg assistant">${what} <strong>${esc(session)}</strong>.</div>`
214
+ : `<div class="msg error">Could not reach ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, out.ok ? 200 : 500);
215
+ }
216
+
217
+ async function screen_watch_or_stop({ form, send, pathname, res }) {
218
+ const session = String(form.session || '');
219
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
220
+ if (pathname === '/actions/screen-stop') {
221
+ if (form.confirm !== 'yes') return send('<div class="msg error">Stopping requires confirmation.</div>', 400);
222
+ const out = await onHostScreen(session, ['-S', session, '-X', 'quit']);
223
+ if (!out.ok) {
224
+ // A dead socket has no process to quit — `screen -wipe` is what clears it.
225
+ await run('screen', ['-wipe'], process.env.HOME, { timeoutMs: 10 * 1000 });
226
+ const still = (await require('../screens').listScreens()).some((s) => s.session === session);
227
+ if (still) return send(`<div class="msg error">Could not stop ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, 500);
228
+ }
229
+ // Answer with the refreshed manager so the killed row disappears at once — and say which session
230
+ // went, so a terminal open on it closes itself. A pane left open on a dead session is a terminal
231
+ // you can still type into, and nothing on the other end.
232
+ try { res.setHeader('HX-Trigger', JSON.stringify({ 'screen-killed': { session }, 'screens-changed': { session } })); } catch (_) { /* headers already sent */ }
233
+ return send(await screensGridHtml());
234
+ }
235
+ const log = screenLogFor(session);
236
+ if (!log) return send('<div class="msg error">No known log for this session — reattach in a terminal instead.</div>', 404);
237
+ const shellLine = [
238
+ `tail -n +1 -F ${JSON.stringify(log)} 2>/dev/null & TP=$!`,
239
+ `while screen -ls 2>/dev/null | grep -q "[.]${session}[[:space:]]"; do sleep 3; done`,
240
+ `sleep 1; kill $TP 2>/dev/null`,
241
+ `echo; echo "--- the session ended; full log: ${path.basename(log)} ---"`,
242
+ ].join('; ');
243
+ // Keyed, so clicking Watch twice re-shows the running tail instead of
244
+ // stacking another one against the same log.
245
+ const key = `watch:${session}`;
246
+ const existing = runningJobKey(key);
247
+ if (existing) return send(jobFragment(existing).html);
248
+ const id = startJob(`📺 Watch <strong>${esc(session)}</strong>`, 'bash', ['-c', shellLine], process.env.HOME,
249
+ { timeoutMs: 12 * 60 * 60 * 1000, inline: true, key });
250
+ return send(jobFragment(id).html);
251
+ }
252
+
253
+ async function job_stop({ form, send }) {
254
+ const id = String(form.id || '');
255
+ if (!/^[0-9]+-[a-z0-9]+$/.test(id)) return send('<div class="msg error">Not a job id.</div>', 400);
256
+ stopJob(id);
257
+ return send(jobFragment(id).html);
258
+ }
259
+
260
+ module.exports = {
261
+ '/actions/job-stop': job_stop,
262
+ '/actions/screen-new': screen_new,
263
+ '/actions/screen-detach': screen_verb,
264
+ '/actions/screen-reset-modes': screen_verb,
265
+ '/actions/screen-interrupt': screen_verb,
266
+ '/actions/screen-wipe': screen_verb,
267
+ '/actions/screen-watch': screen_watch_or_stop,
268
+ '/actions/screen-stop': screen_watch_or_stop,
269
+ onHostScreen,
270
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Setting a project's testing environment up, running its suite, and deleting what a run left.
3
+ *
4
+ * One module per area of the dashboard, each exporting the handlers it owns keyed by the path that
5
+ * reaches them. server.js merges the maps and dispatches; nothing here knows about routing, and
6
+ * adding an action is adding an entry rather than another branch in a 1,250-line function.
7
+ *
8
+ * Every handler takes the same context: the parsed form, the response, a `send` that writes an HTML
9
+ * fragment, and the workspace the request named.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const { esc } = require('../html');
16
+ const { humanSize, humanTime } = require('../format');
17
+ const { ROOT, loadWorkspaces, workspaceDir } = require('../workspaces');
18
+ const {
19
+ run, jobFragment, runningJobKey, startJob, ddevProjectName, listProjects,
20
+ startInScreen, screenStartedHtml,
21
+ } = require('../jobs');
22
+ const { graphStoreDir } = require('../graphs');
23
+ const {
24
+ milvusProjectName, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
25
+ ragCollectionName, milvusPost, ragCollections, ragInvalidate,
26
+ } = require('../rag');
27
+ const {
28
+ findTestReports, testReportFor, invalidateTestReports, testMediaHtml, testingStackOf,
29
+ } = require('../test-reports');
30
+ const { NAME_RE, SCRIPT_RE } = require('../validate');
31
+ const { commandBlock } = require('../command-block');
32
+ const { wsUrl } = require('../urls');
33
+ const { findGraphifyScript, findRagifyScript } = require('../workspaces');
34
+ const { DDEV_ACTIONS } = require('../ddev');
35
+
36
+ // Delete one screenshot/recording, or all of a report's.
37
+ //
38
+ // Evidence from a finished run, and the next run writes its own — so it is deletable, unlike
39
+ // anything else in a project. Three things keep it safe:
40
+ // * no path ever comes from the request. The name is looked up in the report's OWN file list,
41
+ // the same rule the viewer uses, so a crafted name resolves to nothing.
42
+ // * every resolved file is realpath-checked to be inside the project directory.
43
+ // * a screenshot takes its DOM dump with it: the dump is written per failed step and is
44
+ // meaningless once the picture is gone.
45
+ async function test_media_delete({ form, res, send, workspace, pathname }) {
46
+ const dir = workspaceDir(workspace);
47
+ const projectName = String(form.projectName || '');
48
+ const kind = String(form.kind || '') === 'videos' ? 'videos' : 'shots';
49
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
50
+ if (!NAME_RE.test(projectName) || !fs.existsSync(path.join(dir, projectName))) {
51
+ return send('<div class="msg error">Unknown project.</div>', 400);
52
+ }
53
+ const t = testReportFor(dir, projectName);
54
+ if (!t) return send('<div class="msg error">No report for this project.</div>', 404);
55
+
56
+ const listed = kind === 'videos' ? t.report.videos : t.report.shots;
57
+ const wanted = String(form.name || '');
58
+ const picked = form.all === 'yes' ? listed : listed.filter((f) => f.name === wanted);
59
+ if (!picked.length) return send('<div class="msg error">That file is not in the report.</div>', 404);
60
+
61
+ // A screenshot's DOM dump shares its stem.
62
+ const stem = (n) => n.replace(/\.(png|jpe?g|html?)$/i, '');
63
+ const doms = kind === 'shots'
64
+ ? t.report.doms.filter((d) => picked.some((f) => stem(d.name) === stem(f.name)))
65
+ : [];
66
+
67
+ const root = fs.realpathSync(path.join(dir, projectName));
68
+ let removed = 0;
69
+ let bytes = 0;
70
+ for (const f of [...picked, ...doms]) {
71
+ const full = path.join(dir, projectName, f.file);
72
+ let real;
73
+ try {
74
+ real = fs.realpathSync(full);
75
+ if (!real.startsWith(root + path.sep)) continue; // outside the project: not ours to delete
76
+ } catch (_) { continue; } // already gone
77
+ try {
78
+ bytes += fs.statSync(real).size;
79
+ fs.unlinkSync(real);
80
+ removed += 1;
81
+ } catch (_) { /* raced with something else */ }
82
+ }
83
+ invalidateTestReports();
84
+ res.setHeader('HX-Trigger', 'refresh-projects');
85
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
86
+ return res.end(`<div class="msg assistant test-media-flash">🗑️ Deleted ${removed} file${removed === 1 ? '' : 's'} (${esc(humanSize(bytes))}).</div>`
87
+ + testMediaHtml(workspace, projectName, kind, 1));
88
+ }
89
+
90
+ // Set up a project's testing environment, and run its suite. Both are the workspace command,
91
+ // run as a job — they take minutes (an add-on install pulls browsers; a Webship suite is
92
+ // hundreds of scenarios) and both must not overlap on one project: two configures race on the
93
+ // same scaffold, and two runs write the same report.
94
+ async function testing_configure({ form, res, send, workspace, pathname }) {
95
+ const dir = workspaceDir(workspace);
96
+ const projectName = String(form.projectName || '');
97
+ const script = 'cmd-tools-testing.sh';
98
+ const isRun = pathname.endsWith('-run');
99
+ if (!fs.existsSync(path.join(dir, script))) return send('<div class="msg error">No testing script in this workspace.</div>', 400);
100
+ if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
101
+ return send('<div class="msg error">Unknown project.</div>', 400);
102
+ }
103
+ if (isRun && testingStackOf(dir, projectName) === 'none') {
104
+ return send('<div class="msg error">No testing stack in this project yet — set the environment up first.</div>', 409);
105
+ }
106
+ const jobKey = `testing:${workspace}/${projectName}`;
107
+ if (runningJobKey(jobKey)) {
108
+ return send('<div class="msg error">A testing job is already running for this project.</div>', 409);
109
+ }
110
+ const args = isRun ? [script, projectName, '--run'] : [script, projectName];
111
+ const started = await startInScreen({ workspace, project: projectName, projectDir: dir, ownerDir: path.join(dir, projectName),
112
+ verb: isRun ? 'tests' : 'tests-setup', cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(projectName)}${isRun ? ' --run' : ''}`,
113
+ label: isRun ? 'run the tests' : 'set up testing' });
114
+ return send(screenStartedHtml(started, isRun ? 'run the tests' : 'set up testing', projectName), started.ok ? 200 : (started.already ? 409 : 500));
115
+ }
116
+
117
+ module.exports = {
118
+ '/actions/test-media-delete': test_media_delete,
119
+ '/actions/testing-configure': testing_configure,
120
+ '/actions/testing-run': testing_configure,
121
+ };