@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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Renaming and deleting an image in the images workspace. Images shown from other workspaces are
3
+ * read-only here, so both refuse any source but images/.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const { esc } = require('../html');
10
+ const { workspaceDir, isValidWorkspace } = require('../workspaces');
11
+ const images = require('../images');
12
+
13
+ function ownImage(form) {
14
+ if (!isValidWorkspace('images')) return null;
15
+ const where = images.safeIn(workspaceDir('images'), String(form.file || ''));
16
+ if (!where || !images.IMAGE_RE.test(where.abs)) return null;
17
+ try { if (!fs.statSync(where.abs).isFile()) return null; } catch (_) { return null; }
18
+ return where;
19
+ }
20
+
21
+ function refreshed(res) {
22
+ images.forgetScan();
23
+ try { res.setHeader('HX-Trigger', 'refresh-projects'); } catch (_) { /* headers already sent */ }
24
+ }
25
+
26
+ async function image_rename({ form, send, res }) {
27
+ const where = ownImage(form);
28
+ if (!where) return send('<div class="msg error">That image is not in the images workspace.</div>', 404);
29
+ const ext = path.extname(where.abs).toLowerCase();
30
+ let name = images.safeName(form.name);
31
+ if (!name) return send('<div class="msg error">Give it a name: letters, digits, dot, dash and underscore.</div>', 400);
32
+ // The extension is the file's own; a rename never changes what kind of image it is.
33
+ if (path.extname(name).toLowerCase() !== ext) name = `${name.replace(/\.[^.]*$/, '')}${ext}`;
34
+ const dest = path.join(path.dirname(where.abs), name);
35
+ if (dest === where.abs) return send('<div class="msg assistant">The name is unchanged.</div>');
36
+ if (fs.existsSync(dest)) return send(`<div class="msg error"><code>${esc(name)}</code> already exists in that folder.</div>`, 409);
37
+ fs.renameSync(where.abs, dest);
38
+ // A sidecar travels with its image.
39
+ if (fs.existsSync(`${where.abs}.json`)) fs.renameSync(`${where.abs}.json`, `${dest}.json`);
40
+ refreshed(res);
41
+ const rel = path.relative(fs.realpathSync(workspaceDir('images')), dest).split(path.sep).join('/');
42
+ return send(`<div class="msg assistant">Renamed to <strong>${esc(name)}</strong>.</div>${
43
+ require('../images-page').viewerHtml({ q: '', sort: 'newest', kind: '', scope: 'here' }, path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel), 'images', rel) || ''}`);
44
+ }
45
+
46
+ async function image_delete({ form, send, res }) {
47
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting needs confirmation.</div>', 400);
48
+ const where = ownImage(form);
49
+ if (!where) return send('<div class="msg error">That image is not in the images workspace.</div>', 404);
50
+ fs.unlinkSync(where.abs);
51
+ try { fs.unlinkSync(`${where.abs}.json`); } catch (_) { /* no sidecar */ }
52
+ refreshed(res);
53
+ return send(`<div class="msg assistant">Deleted <strong>${esc(where.rel)}</strong>.</div>`);
54
+ }
55
+
56
+ const viewerOf = (rel) => require('../images-page').viewerHtml({ q: '', sort: 'newest', kind: '', scope: 'here' },
57
+ path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel), 'images', rel) || '';
58
+
59
+ async function image_screenshot({ form, send, res }) {
60
+ const r = await require('../images-create').screenshot(form);
61
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
62
+ refreshed(res);
63
+ return send(`<div class="msg assistant">Saved <strong>${esc(r.rel)}</strong>.</div>${viewerOf(r.rel)}`);
64
+ }
65
+
66
+ async function image_edit({ form, send, res }) {
67
+ const r = await require('../images-create').edit(form);
68
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
69
+ refreshed(res);
70
+ return send(`<div class="msg assistant">Saved as <strong>${esc(r.name)}</strong>; the original is unchanged.</div>${viewerOf(r.rel)}`);
71
+ }
72
+
73
+ /** Why a provider cannot run here, as HTML, or '' when it can. */
74
+ function notReady(id) {
75
+ const prov = require('../images-providers').get(id);
76
+ if (!prov) return 'Unknown provider.';
77
+ const a = prov.available();
78
+ if (a.ok) return '';
79
+ return prov.keyName ? `Set <code>ai_providers.${esc(prov.keyName)}.api_key</code> in <code>core/config/settings.yml</code> first.` : `${esc(prov.label)}: ${esc(a.why)}.`;
80
+ }
81
+
82
+ /** A Claude Code session has started: where to watch it and where the result lands. */
83
+ function startedHtml(session, where) {
84
+ return `<div class="msg assistant">Claude Code is drawing it in the screen session <strong>${esc(session)}</strong>: <a href="/screens">watch it on Screens</a>. The SVG, its PNG and the records appear in <strong>${where}</strong> when it is done.</div>`;
85
+ }
86
+
87
+ // Without confirm=yes this only answers with the plan; the paid request needs the confirm dialog's Yes.
88
+ async function image_generate({ form, send, res }) {
89
+ const ai = require('../images-ai');
90
+ if (form.confirm !== 'yes') {
91
+ const p = ai.plan(form);
92
+ if (!p.ok) return send(`<div class="msg error">${esc(p.error)}</div>${ai.formHtml()}`, 400);
93
+ const why = notReady(p.provider);
94
+ if (why) return send(`<div class="msg error">${why}</div>`, 503);
95
+ return send(ai.planHtml(p));
96
+ }
97
+ const r = await ai.generate(form);
98
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
99
+ if (r.async) return send(startedHtml(r.session, 'images/generated/'));
100
+ refreshed(res);
101
+ const page = require('../images-page');
102
+ const thumbs = r.made.map((rel) => `<button type="button" class="img-thumb img-made-thumb"
103
+ hx-get="/fragments/images/view?src=images&file=${encodeURIComponent(rel)}&path=generated" hx-target="#editor-modal-body" hx-swap="innerHTML">
104
+ <img src="${esc(page.fileUrl('images', rel))}" alt="${esc(rel)}"></button>`).join('');
105
+ const missing = (r.undescribed || []).length
106
+ ? `<div class="msg error">The description was not written for ${r.undescribed.map((n) => `<code>${esc(n)}</code>`).join(', ')}; open it and use <strong>Describe it</strong>.</div>` : '';
107
+ return send(`<div class="msg assistant">Made ${r.made.length} image${r.made.length === 1 ? '' : 's'} in <strong>images/generated/</strong>, each with its prompt, a description and an alt text${
108
+ r.cost === null ? '' : `, for about $${r.cost.toFixed(4)} in all`}. Open one to see it full size.</div>${missing}<div class="img-made-grid">${thumbs}</div>`);
109
+ }
110
+
111
+ // Edit with AI: a plan first, and the paid request only with the confirm dialog's Yes.
112
+ async function image_ai_edit({ form, send, res }) {
113
+ const ai = require('../images-ai');
114
+ if (form.confirm !== 'yes') {
115
+ const p = ai.editPlan(form);
116
+ if (!p.ok) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
117
+ const why = notReady(p.provider);
118
+ if (why) return send(`<div class="msg error">${why}</div>`, 503);
119
+ return send(ai.editPlanHtml(p));
120
+ }
121
+ const r = await ai.editImage(form);
122
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
123
+ if (r.async) return send(startedHtml(r.session, `the source's folder as ${esc(r.name)}`));
124
+ refreshed(res);
125
+ return send(`<div class="msg assistant">Saved as <strong>images/${esc(r.rel)}</strong>; the source is unchanged${
126
+ r.cost === null ? '' : `. It cost about $${r.cost.toFixed(4)}`}.${r.described ? '' : ' The description was not written: use <strong>Describe it</strong>.'}</div>${viewerOf(r.rel)}`);
127
+ }
128
+
129
+ // Describe an image: a small paid call, so only with the confirm dialog's Yes.
130
+ async function image_describe({ form, send, res }) {
131
+ const r = await require('../images-ai').describeExisting(form);
132
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
133
+ refreshed(res);
134
+ return send(`<div class="msg assistant">Described, for about $${r.cost.toFixed(4)}.</div>${viewerOf(r.rel)}`);
135
+ }
136
+
137
+ module.exports = {
138
+ '/actions/image-generate': image_generate,
139
+ '/actions/image-ai-edit': image_ai_edit,
140
+ '/actions/image-describe': image_describe,
141
+ '/actions/image-screenshot': image_screenshot,
142
+ '/actions/image-edit': image_edit,
143
+ '/actions/image-rename': image_rename,
144
+ '/actions/image-delete': image_delete,
145
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Writing the rail's layout: what is hidden, in what order, and the entries someone added.
3
+ *
4
+ * One form carries the whole arrangement (`#nav-form`), so a drag that has not been saved is posted with
5
+ * anything else that is done meanwhile — adding an entry never loses a reorder. Every write carries the
6
+ * file's mtime and is refused with 409 when it changed since, the way every other writer here works.
7
+ */
8
+
9
+ const rail = require('../navigation');
10
+ const navPage = require('../navigation-page');
11
+ const cc = require('../commands');
12
+ const { startJob, jobFragment } = require('../jobs');
13
+
14
+ const ok = (msg) => `<div class="msg assistant">✅ ${msg}</div>`;
15
+ const bad = (msg) => `<div class="msg error">${msg}</div>`;
16
+
17
+ const asArray = (v) => (v === undefined ? [] : (Array.isArray(v) ? v : [v]));
18
+
19
+ /** The arrangement as posted: group order, and each entry under the group it was dropped in. */
20
+ function arranged(form) {
21
+ // Deduplicated, and the reserved lists are not sidebar groups: the group order is a set, and a stray
22
+ // repeat would make the same group render twice.
23
+ const groups = [...new Set(asArray(form.group).map(String).filter(rail.isId))]
24
+ .filter((g) => !rail.RESERVED.has(g));
25
+ const order = {};
26
+ const hidden = [];
27
+ const topbar = rail.emptyTopbar();
28
+ // How much of each entry is shown: `<id>:icon`, `<id>:text`, or `<id>:both`. Both is the default and
29
+ // is not written down, which is what keeps the file a record of what somebody changed — and posting
30
+ // it explicitly is what makes going back to both work.
31
+ const ships = shippedShow(scopeOf(form));
32
+ const show = {};
33
+ for (const raw of asArray(form.show).map(String)) {
34
+ const at = raw.lastIndexOf(':');
35
+ if (at < 0) continue;
36
+ const id = raw.slice(0, at);
37
+ const how = raw.slice(at + 1);
38
+ if (!rail.isId(id)) continue;
39
+ if (how === 'icon' || how === 'text') { show[id] = how; continue; }
40
+ // Both is what an entry is unless somebody says otherwise, so it is not written down — EXCEPT when
41
+ // it is overriding an entry the dashboard ships one way. Without that there would be no way to put
42
+ // the assistant's label back: leaving the key out would just let its own default win again.
43
+ if (how === 'both' && ships[id]) show[id] = 'both';
44
+ }
45
+ for (const raw of asArray(form.item).map(String)) {
46
+ const at = raw.indexOf(':');
47
+ if (at < 0) continue;
48
+ const group = raw.slice(0, at);
49
+ const id = raw.slice(at + 1);
50
+ if (!rail.isId(id)) continue;
51
+ if (group === 'hidden') { hidden.push(id); continue; }
52
+ const region = rail.regionOf(group);
53
+ if (region) { topbar[region].push(id); continue; }
54
+ if (!rail.isId(group)) continue;
55
+ (order[group] || (order[group] = [])).push(id);
56
+ }
57
+ return { groups, order, hidden, topbar, show };
58
+ }
59
+
60
+ /** How each entry SHIPS, from what the navigation has drawn — a default an explicit answer overrides. */
61
+ function shippedShow(scope) {
62
+ const out = {};
63
+ for (const g of Object.values(rail.seenFor(scope))) {
64
+ for (const [id, was] of Object.entries((g || {}).items || {})) {
65
+ const how = rail.seenShips(was);
66
+ if (how) out[id] = how;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function scopeOf(form) {
73
+ const scope = String(form.scope || 'global');
74
+ return scope === 'global' || rail.isId(scope) ? scope : 'global';
75
+ }
76
+
77
+ /** Save one scope, keeping the entries it already had — they are edited by their own actions. */
78
+ function write(form, change) {
79
+ const { raw, mtime } = rail.rawLayout();
80
+ if (String(form.mtime || '') !== String(mtime)) {
81
+ return { conflict: true };
82
+ }
83
+ const scope = scopeOf(form);
84
+ const current = rail.scopeLayout(raw, scope);
85
+ const next = change({ ...current });
86
+ const out = rail.withScope(raw, scope, next);
87
+ return { mtime: rail.saveLayout(out) };
88
+ }
89
+
90
+ const editor = (send, form, message, status = 200) =>
91
+ send(navPage.navEditorHtml(scopeOf(form), message), status);
92
+
93
+ async function nav_save({ form, send }) {
94
+ const { groups, order, hidden, topbar, show } = arranged(form);
95
+ const res = write(form, (current) => ({ ...current, groups, order, hidden, topbar, show }));
96
+ if (res.conflict) {
97
+ return editor(send, form, bad('The navigation changed somewhere else while this was open — nothing was written. This is how it is now.'), 409);
98
+ }
99
+ return editor(send, form, ok('Navigation saved. It applies on the next page you open.'));
100
+ }
101
+
102
+ async function nav_add({ form, send }) {
103
+ const label = String(form.new_label || '').trim().slice(0, 40);
104
+ if (!label) return editor(send, form, bad('An entry needs a label.'), 400);
105
+ const href = String(form.new_href || '').trim();
106
+ const use = String(form.new_use || '').trim();
107
+ if (!href && !use) return editor(send, form, bad('An entry needs somewhere to go, or a command to run.'), 400);
108
+ if (href && use) return editor(send, form, bad('An entry is a link or a command, not both.'), 400);
109
+ if (use && !cc.loadCatalog().commands.some((c) => c.id === use)) {
110
+ return editor(send, form, bad('No such command.'), 400);
111
+ }
112
+ // A link goes in an href attribute and is followed by a click: only http(s) and a path of this
113
+ // dashboard, so nothing can put javascript: or data: into the rail.
114
+ if (href && !/^(https?:\/\/|\/)[^\s"'<>]*$/i.test(href)) {
115
+ return editor(send, form, bad('A link is an https:// address or a path of this dashboard.'), 400);
116
+ }
117
+
118
+ const base = rail.slugId(label);
119
+ const { raw } = rail.rawLayout();
120
+ const taken = new Set(rail.scopeLayout(raw, scopeOf(form)).items.map((i) => i.id));
121
+ let id = base;
122
+ for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`.slice(0, 48);
123
+
124
+ const item = { id, label };
125
+ const iconValue = String(form.new_icon || '').trim();
126
+ if (iconValue) item.icon = iconValue.slice(0, 40);
127
+ const group = String(form.new_group || '').trim();
128
+ // A region of the top bar is a legal destination, and slugId leaves it alone (it is already a slug).
129
+ if (group) item.group = rail.slugId(group);
130
+ if (href) item.href = href; else item.use = use;
131
+ if (form.new_confirm) item.confirm = true;
132
+
133
+ const { groups, order, hidden, topbar, show } = arranged(form);
134
+ const res = write(form, (current) => ({ ...current, groups, order, hidden, topbar, show, items: [...current.items, item] }));
135
+ if (res.conflict) {
136
+ return editor(send, form, bad('The navigation changed somewhere else while this was open — nothing was written.'), 409);
137
+ }
138
+ return editor(send, form, ok(`Added <strong>${label}</strong> to the navigation.`));
139
+ }
140
+
141
+ async function nav_remove({ form, send }) {
142
+ const id = String(form.id || '');
143
+ const { groups, order, hidden, topbar, show } = arranged(form);
144
+ const res = write(form, (current) => ({
145
+ ...current,
146
+ groups,
147
+ show: Object.fromEntries(Object.entries(show).filter(([x]) => x !== id)),
148
+ order: Object.fromEntries(Object.entries(order).map(([g, ids]) => [g, ids.filter((x) => x !== id)])),
149
+ hidden: hidden.filter((x) => x !== id),
150
+ topbar: Object.fromEntries(Object.entries(topbar).map(([r, ids]) => [r, ids.filter((x) => x !== id)])),
151
+ items: current.items.filter((it) => it.id !== id),
152
+ }));
153
+ if (res.conflict) return editor(send, form, bad('The navigation changed somewhere else while this was open.'), 409);
154
+ return editor(send, form, ok('Removed.'));
155
+ }
156
+
157
+ async function nav_reset({ form, send }) {
158
+ const { raw, mtime } = rail.rawLayout();
159
+ if (String(form.mtime || '') !== String(mtime)) {
160
+ return editor(send, form, bad('The navigation changed somewhere else while this was open.'), 409);
161
+ }
162
+ rail.saveLayout(rail.withScope(raw, scopeOf(form), {}));
163
+ return editor(send, form, ok('Back to the navigation this scope ships with.'));
164
+ }
165
+
166
+ /**
167
+ * Run a command an entry carries. The same runner a row action uses, so there is one history and one
168
+ * screen session per run rather than a second way of doing it.
169
+ */
170
+ async function nav_run({ form, send }) {
171
+ const use = String(form.use || '');
172
+ const command = cc.loadCatalog().commands.find((c) => c.id === use);
173
+ if (!command) return send(bad('No such command.'), 400);
174
+ let line;
175
+ try {
176
+ line = cc.commandLine(command, { workspace: command.workspace });
177
+ } catch (err) {
178
+ return send(bad(`That command cannot run from the rail: ${err.message}`), 400);
179
+ }
180
+ const id = startJob(`rail:${use}`, line, { cwd: command.dir });
181
+ return send(jobFragment(id).html);
182
+ }
183
+
184
+ module.exports = {
185
+ '/actions/nav-save': nav_save,
186
+ '/actions/nav-add': nav_add,
187
+ '/actions/nav-remove': nav_remove,
188
+ '/actions/nav-reset': nav_reset,
189
+ '/actions/nav-run': nav_run,
190
+ };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The NODE menu on a project row: npm and yarn for any folder with a package.json.
3
+ *
4
+ * It follows the COMPOSER menu (actions/composer.js) and reuses its machinery: every command runs in
5
+ * a SCREEN SESSION that stays open to be read, the page shows only the toast at the top, and a second
6
+ * click replaces a session only once its command has finished — a run still going is left alone.
7
+ * Unlike COMPOSER there is no manifest mode: every workspace gets the whole menu.
8
+ *
9
+ * DDEV only, as with composer:
10
+ *
11
+ * - A DDEV project runs `ddev npm` / `ddev yarn`, in its own web container (the site projects all
12
+ * carry `corepack_enable: true`, so a pinned Yarn 4 is what runs).
13
+ * - Any other folder runs in the DASHBOARD's DDEV container: `ddev exec -d <folder> npm …`. When the
14
+ * project pins yarn in `packageManager`, it runs `corepack yarn`, so the pinned version runs rather
15
+ * than the container's Yarn 1.
16
+ *
17
+ * Which manager: the menu offers both, as a yarn and an npm tab, each saying whether its lock file is
18
+ * there. The tab that opens is the one the project uses — its `packageManager` pin, else its lock file.
19
+ * Yarn 1 (classic) and Yarn 2+ (berry) differ in several commands, so every entry carries three forms;
20
+ * which Yarn a folder has is read from the folder (`packageManager`, `.yarnrc.yml`), never from the request.
21
+ *
22
+ * The handler runs only what these tables name for that manager, a package that looks like an npm
23
+ * package name (never a git URL, a tarball or a file: path), and a script package.json declares.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const { esc } = require('../html');
30
+ const { workspaceDir } = require('../workspaces');
31
+ const { screenStartedHtml } = require('../jobs');
32
+ const { NAME_RE } = require('../validate');
33
+ const { lingerTail } = require('../linger');
34
+ const { startUnlessRunning, terminalLink, isDdevProject, dashboardDir, sq, SAFE_PATH_RE } = require('./composer');
35
+
36
+ // The order of the tabs. yarn first: every site project in this tree uses it.
37
+ const MANAGERS = ['yarn', 'npm'];
38
+ const NODE_GROUPS = ['Daily', 'Inspect', 'Maintenance'];
39
+
40
+ // Each command in its three forms: npm, Yarn 1 (yarn1) and Yarn 2+ (yarn). null = that manager has no
41
+ // such command, and the entry is not offered. In the order people reach for them.
42
+ const NODE_RUN_ACTIONS = {
43
+ '/actions/node-install': { group: 'Daily', npm: ['install'], yarn1: ['install'], yarn: ['install'] },
44
+ '/actions/node-ci': { group: 'Daily', npm: ['ci'], yarn1: ['install', '--frozen-lockfile'], yarn: ['install', '--immutable'],
45
+ needsLock: true, title: 'Install exactly what the lock file says, and fail if package.json disagrees with it' },
46
+ '/actions/node-outdated': { group: 'Daily', npm: ['outdated'], yarn1: ['outdated'], yarn: ['upgrade-interactive'] },
47
+ '/actions/node-audit': { group: 'Daily', npm: ['audit'], yarn1: ['audit'], yarn: ['npm', 'audit'] },
48
+ '/actions/node-list': { group: 'Inspect', npm: ['ls', '--depth=0'], yarn1: ['list', '--depth=0'], yarn: ['info', '--name-only'] },
49
+ '/actions/node-version': { group: 'Inspect', npm: ['--version'], yarn1: ['--version'], yarn: ['--version'] },
50
+ '/actions/node-update': { group: 'Maintenance', npm: ['update'], yarn1: ['upgrade'], yarn: null },
51
+ '/actions/node-dedupe': { group: 'Maintenance', npm: ['dedupe'], yarn1: null, yarn: ['dedupe'] },
52
+ '/actions/node-cache': { group: 'Maintenance', npm: ['cache', 'verify'], yarn1: ['cache', 'clean'], yarn: ['cache', 'clean'] },
53
+ };
54
+
55
+ // Take a package from the field. Only add accepts a version after the name.
56
+ const NODE_PACKAGE_ACTIONS = {
57
+ '/actions/node-add': { button: 'add', npm: ['install'], yarn1: ['add'], yarn: ['add'], version: true },
58
+ '/actions/node-upgrade': { button: 'upgrade', npm: ['update'], yarn1: ['upgrade'], yarn: ['up'] },
59
+ '/actions/node-remove': { button: 'remove', npm: ['uninstall'], yarn1: ['remove'], yarn: ['remove'] },
60
+ '/actions/node-add-dev': { button: 'add --dev', npm: ['install', '--save-dev'], yarn1: ['add', '--dev'], yarn: ['add', '--dev'], version: true },
61
+ '/actions/node-why': { button: 'why', npm: ['explain'], yarn1: ['why'], yarn: ['why'] },
62
+ };
63
+
64
+ const NODE_SCRIPT_ROUTE = '/actions/node-script';
65
+
66
+ // npm's own package-name shape, optionally scoped. A version is range characters only: no quote, $,
67
+ // backtick, backslash or colon, so a git URL, a tarball or a file: path can never pass.
68
+ const NPM_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
69
+ const NPM_VERSION_RE = /^[A-Za-z0-9.^~*|<>=_ -]{1,60}$/;
70
+ const SCRIPT_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_:.@/-]{0,80}$/;
71
+ // Run by npm and yarn themselves around install, publish and version — not something to run by hand.
72
+ const LIFECYCLE = new Set(['install', 'preinstall', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly',
73
+ 'prepack', 'postpack', 'publish', 'postpublish', 'preversion', 'version', 'postversion', 'dependencies',
74
+ 'preuninstall', 'uninstall', 'postuninstall']);
75
+
76
+ function hasPackageJson(dir) {
77
+ try { return fs.statSync(path.join(dir, 'package.json')).isFile(); } catch (_) { return false; }
78
+ }
79
+
80
+ /** What the folder says about itself: the manifest, the pin, the lock files, and which Yarn. */
81
+ function nodeInfo(dir) {
82
+ let pkg = null;
83
+ try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch (_) { /* valid: false */ }
84
+ if (!pkg || typeof pkg !== 'object') pkg = null;
85
+ const has = (f) => { try { return fs.existsSync(path.join(dir, f)); } catch (_) { return false; } };
86
+ const m = pkg && typeof pkg.packageManager === 'string' ? /^(npm|yarn|pnpm)@(\d+)[^+\s]*/.exec(pkg.packageManager) : null;
87
+ const pinned = m ? { name: m[1], major: Number(m[2]), spec: m[0] } : null;
88
+ const locks = { yarn: has('yarn.lock'), npm: has('package-lock.json') };
89
+ const yarnBerry = (pinned && pinned.name === 'yarn' && pinned.major >= 2) || has('.yarnrc.yml');
90
+ const preferred = (pinned && MANAGERS.includes(pinned.name)) ? pinned.name
91
+ : (locks.yarn ? 'yarn' : (locks.npm ? 'npm' : 'npm'));
92
+ return { valid: !!pkg, pkg, pinned, locks, yarnBerry, preferred, nodeModules: has('node_modules') };
93
+ }
94
+
95
+ /** The arguments an entry runs with for this manager in this folder, or null when it has none. */
96
+ function nodeArgsFor(action, manager, info) {
97
+ if (manager === 'npm') return action.npm || null;
98
+ return ((info && info.yarnBerry) ? action.yarn : action.yarn1) || null;
99
+ }
100
+
101
+ /** Why this package value must not run, or '' when it may. */
102
+ function nodePackageRefusal(value, { version = false } = {}) {
103
+ const v = String(value || '').trim();
104
+ if (!v) return 'type a package first, as name or @scope/name';
105
+ const at = v.indexOf('@', 1);
106
+ const name = at > 0 ? v.slice(0, at) : v;
107
+ if (!NPM_NAME_RE.test(name)) return 'that is not a package name (name or @scope/name, lower case)';
108
+ if (at < 0) return '';
109
+ if (!version) return 'a version only goes with add';
110
+ return NPM_VERSION_RE.test(v.slice(at + 1)) ? '' : 'that is not a version or range';
111
+ }
112
+
113
+ /**
114
+ * The folder's own scripts. Lifecycle hooks — install, prepare… and the pre/post twin of another
115
+ * script — run by themselves, so they are not offered; `prettier` or `postcss` are ordinary scripts.
116
+ */
117
+ function nodeScripts(pkg) {
118
+ const scripts = (pkg && typeof pkg.scripts === 'object' && pkg.scripts) || {};
119
+ const names = Object.keys(scripts);
120
+ const isHook = (n) => LIFECYCLE.has(n)
121
+ || names.some((o) => o !== n && (n === `pre${o}` || n === `post${o}`));
122
+ return names
123
+ .filter((n) => !isHook(n) && SCRIPT_NAME_RE.test(n))
124
+ .map((n) => ({ name: n, command: typeof scripts[n] === 'string' ? scripts[n] : '' }));
125
+ }
126
+
127
+ /**
128
+ * The line a node session runs. No `$` anywhere: the runner hands it to an outer `bash -lc "…"`.
129
+ * Paths are checked against SAFE_PATH_RE and single-quoted.
130
+ */
131
+ function nodeScreenCommand(label, manager, args, { isDdev, projectDir, pinned }) {
132
+ let line;
133
+ if (isDdev) {
134
+ line = `ddev ${manager} ${args.join(' ')}`;
135
+ } else {
136
+ // Corepack would stop to ask before downloading the pinned Yarn; `env` sets that off without a `$`.
137
+ const bin = (manager === 'yarn' && pinned && pinned.name === 'yarn')
138
+ ? 'env COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack yarn' : manager;
139
+ line = `cd ${sq(dashboardDir())} && ddev exec -d ${sq(projectDir)} ${bin} ${args.join(' ')}`;
140
+ }
141
+ return `${line}; ${lingerTail(label)}`;
142
+ }
143
+
144
+ async function node_run({ form, send, workspace, pathname }) {
145
+ const isScript = pathname === NODE_SCRIPT_ROUTE;
146
+ const act = NODE_RUN_ACTIONS[pathname] || NODE_PACKAGE_ACTIONS[pathname];
147
+ if (!act && !isScript) return send('<div class="msg error">Unknown node command.</div>', 404);
148
+ const projectName = String(form.projectName || '');
149
+ if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
150
+ const manager = String(form.manager || '');
151
+ if (!MANAGERS.includes(manager)) return send('<div class="msg error">Pick npm or yarn.</div>', 400);
152
+
153
+ // What the request carries is checked before anything on disk is looked at.
154
+ const isPackage = !!NODE_PACKAGE_ACTIONS[pathname];
155
+ if (isPackage) {
156
+ const why = nodePackageRefusal(form.package, act);
157
+ if (why) return send(`<div class="msg error">Not running ${esc(manager)} ${esc(act.button)}: ${esc(why)}.</div>`, 400);
158
+ }
159
+ const script = String(form.script || '');
160
+ if (isScript && !SCRIPT_NAME_RE.test(script)) return send('<div class="msg error">Invalid script name.</div>', 400);
161
+
162
+ const projectDir = path.join(workspaceDir(workspace), projectName);
163
+ if (!hasPackageJson(projectDir)) {
164
+ return send(`<div class="msg error">${esc(projectName)} has no package.json.</div>`, 400);
165
+ }
166
+ if (!SAFE_PATH_RE.test(projectDir)) {
167
+ return send('<div class="msg error">Not running it: the folder path has characters a shell line cannot carry safely.</div>', 400);
168
+ }
169
+ const info = nodeInfo(projectDir);
170
+
171
+ let args;
172
+ let verb;
173
+ if (isScript) {
174
+ if (!nodeScripts(info.pkg).some((s) => s.name === script)) {
175
+ return send(`<div class="msg error">${esc(projectName)}'s package.json has no script called ${esc(script)}.</div>`, 400);
176
+ }
177
+ args = ['run', script];
178
+ verb = `${manager}-run-${script.replace(/[^\w-]/g, '-')}`;
179
+ } else {
180
+ const base = nodeArgsFor(act, manager, info);
181
+ if (!base) return send(`<div class="msg error">${esc(manager)} has no such command in ${esc(projectName)}.</div>`, 400);
182
+ if (act.needsLock && !info.locks[manager]) {
183
+ return send(`<div class="msg error">${esc(projectName)} has no ${manager === 'npm' ? 'package-lock.json' : 'yarn.lock'} to install from.</div>`, 400);
184
+ }
185
+ args = [...base];
186
+ verb = `${manager}-${pathname.replace('/actions/node-', '')}`;
187
+ }
188
+ // The label goes into the session's closing echo: the manager and the command, never the typed package.
189
+ const label = `${manager} ${args.join(' ')}`;
190
+ if (isPackage) args.push(sq(String(form.package).trim()));
191
+
192
+ const isDdev = isDdevProject(projectDir);
193
+ if (!isDdev && !isDdevProject(dashboardDir())) {
194
+ return send('<div class="msg error">This folder is not a DDEV project, and the dashboard\'s own DDEV project was not found to run it in.</div>', 500);
195
+ }
196
+
197
+ const started = await startUnlessRunning({ workspace, project: projectName, projectDir, verb,
198
+ cmdLine: nodeScreenCommand(label, manager, args, { isDdev, projectDir, pinned: info.pinned }), label });
199
+ if (started.stillRunning) {
200
+ return send(`<div class="msg error"><code>${esc(label)}</code> is still running on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open it')}. Left alone.</div>`, 409);
201
+ }
202
+ if (!started.ok) return send(screenStartedHtml(started, label, projectName), started.already ? 409 : 500);
203
+ // These change node_modules or a lock file: reload as the toast leaves, so the caption is current.
204
+ const reload = !isScript && /^(install|ci|add|remove|uninstall|update|upgrade|up|dedupe)$/.test(args[0]);
205
+ return send(`<div class="msg assistant flash-now"${reload ? ' data-reload-on-close="1"' : ''}><code>${esc(label)}</code> on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open the output')}</div>`);
206
+ }
207
+
208
+ module.exports = {
209
+ MANAGERS,
210
+ NODE_GROUPS,
211
+ NODE_RUN_ACTIONS,
212
+ NODE_PACKAGE_ACTIONS,
213
+ NODE_SCRIPT_ROUTE,
214
+ hasPackageJson,
215
+ nodeInfo,
216
+ nodeArgsFor,
217
+ nodePackageRefusal,
218
+ nodeScripts,
219
+ nodeScreenCommand,
220
+ ...Object.fromEntries([...Object.keys(NODE_RUN_ACTIONS), ...Object.keys(NODE_PACKAGE_ACTIONS), NODE_SCRIPT_ROUTE]
221
+ .map((route) => [route, node_run])),
222
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Writing the note on a folder or a project.
3
+ *
4
+ * The note is a file INSIDE the folder it is about, so the only thing this has to be careful about is
5
+ * which folder: the workspace and the project name come from the browser, and a path built from them
6
+ * without checking would write `.workspace-note` anywhere on the machine that `..` can reach.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('../html');
13
+ const { isValidWorkspace, workspaceDir } = require('../workspaces');
14
+ const notes = require('../notes');
15
+
16
+ /**
17
+ * The folder a request names, or null.
18
+ *
19
+ * Three checks, and each one is the one that matters: the workspace has to be a workspace this
20
+ * dashboard knows, the project name has to be a plain name rather than a path, and the resolved
21
+ * directory has to still be inside the workspace — the same containment check the command editor makes
22
+ * before it writes a script.
23
+ */
24
+ function folderOf(form) {
25
+ const ws = String(form.workspace || '');
26
+ const project = String(form.projectName || '');
27
+ if (!isValidWorkspace(ws)) return null;
28
+ const root = workspaceDir(ws);
29
+ if (project && !/^[a-zA-Z0-9_-]+$/.test(project)) return null;
30
+ const dir = project ? path.join(root, project) : root;
31
+ const resolved = fs.realpathSync.native ? safeReal(dir) : dir;
32
+ if (!resolved || !(resolved === safeReal(root) || resolved.startsWith(`${safeReal(root)}${path.sep}`))) return null;
33
+ return fs.existsSync(resolved) ? resolved : null;
34
+ }
35
+
36
+ function safeReal(p) {
37
+ try { return fs.realpathSync(p); } catch (_) { return ''; }
38
+ }
39
+
40
+ async function note_save({ form, send, res }) {
41
+ const dir = folderOf(form);
42
+ if (!dir) return send('<div class="msg error">No such folder.</div>', 400);
43
+ const r = notes.writeNote(dir, { title: form.title, body: form.body });
44
+ if (!r.ok) return send(`<div class="msg error">The note could not be written: ${esc(r.error)}</div>`, 500);
45
+ const what = form.projectName ? `${form.workspace}/${form.projectName}` : `${form.workspace}/`;
46
+ // The rows carry the note as their tooltip and their heading, so the page behind this has to redraw
47
+ // — otherwise the note is saved and nothing on screen says so until the next navigation. Set the way
48
+ // every other action here asks for a refresh, on the response rather than through send().
49
+ try { res.setHeader('HX-Trigger', 'refresh-projects'); } catch (_) { /* headers already sent */ }
50
+ return send(`<div class="msg assistant">${r.title || r.body
51
+ ? `Noted on <strong>${esc(what)}</strong>${r.title ? ` — it is called <strong>${esc(r.title)}</strong> here now.` : '.'}`
52
+ : `The note on <strong>${esc(what)}</strong> was removed.`}</div>`);
53
+ }
54
+
55
+ module.exports = { '/actions/note-save': note_save, folderOf };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Plugins page's buttons: each one is core/scripts/plugins.sh with a verb, run as a job so the
3
+ * output streams to the page and the record of what happened is the same as on the command line.
4
+ */
5
+
6
+ const path = require('path');
7
+
8
+ const { esc } = require('../html');
9
+ const { ROOT } = require('../workspaces');
10
+ const { jobFragment, runningJobKey, startJob } = require('../jobs');
11
+ const { pluginByName } = require('../plugin-manifest');
12
+
13
+ const VERBS = new Set(['require', 'activate', 'install', 'configure', 'deactivate', 'uninstall']);
14
+ const NAME_RE = /^[a-z0-9-]+$/;
15
+ // An npm spec: a package name, github:owner/repo[#ref], a git+https url, a tarball or a path. Nothing a shell could read as more than one word.
16
+ const SPEC_RE = /^[A-Za-z0-9@:/._#+~-]+$/;
17
+
18
+ module.exports = {
19
+ '/actions/plugins': async ({ form, send }) => {
20
+ const verb = String(form.verb || '');
21
+ if (!VERBS.has(verb)) return send('<div class="msg error">Unknown plugin action.</div>', 400);
22
+ let arg = '';
23
+ if (verb === 'require') {
24
+ arg = String(form.spec || '').trim();
25
+ if (!SPEC_RE.test(arg)) return send('<div class="msg error">That is not a package spec: a name, github:owner/repo, a git url, a tarball or a path.</div>', 400);
26
+ } else {
27
+ arg = String(form.name || '');
28
+ if (!NAME_RE.test(arg) || !pluginByName(arg)) return send('<div class="msg error">Unknown plugin.</div>', 400);
29
+ }
30
+ const key = `plugins:${verb}:${arg}`;
31
+ if (runningJobKey(key)) return send('<div class="msg error">That is already running.</div>', 409);
32
+ const id = startJob(`🧩 Plugin <strong>${esc(verb)}</strong> ${esc(arg)}`, 'bash', ['core/scripts/plugins.sh', `--${verb}`, arg], ROOT,
33
+ { timeoutMs: 10 * 60 * 1000, echoLine: `bash core/scripts/plugins.sh --${verb} ${arg}`, key });
34
+ return send(jobFragment(id).html);
35
+ },
36
+ };