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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/WORKSPACES.md ADDED
@@ -0,0 +1,69 @@
1
+ # Workspace folders: kinds, types and their settings
2
+
3
+ Every folder listed in `settings.yml`'s `workspaces:` is a workspace. What its page looks like and
4
+ what its rows can do come from two ideas that answer different questions:
5
+
6
+ - **kind** — how the page is laid out. `projects` (a table of project folders) or `files` (a list
7
+ of markdown/media items). Presentation, set in the folder's `presentation:` block.
8
+ - **type** — what the projects ARE, and therefore which actions their rows offer. Policy, set with
9
+ the `type:` key, editable from the Settings page.
10
+
11
+ A control appears only when **both** layers say yes: the folder must ship the script
12
+ (`cmd-tools-backup-*.sh`, `cmd-tools-upsun.sh`, …) and the type must want the action. Copying a
13
+ stray `cmd` file into a folder no longer grows its interface.
14
+
15
+ ## The types
16
+
17
+ | Type | For | Project-row actions |
18
+ | --- | --- | --- |
19
+ | `sites` | Built Drupal sites (dev, test, demos, sandboxes, projects, products, profiles, site-templates, rag, designs) | Build, DDEV menu (+Launch), Upsun menu, Backup, Remove, Graph, RAG, Tests, Checks, Users |
20
+ | `code` | Packages and repositories (modules, themes, libraries, recipes, components, forked, graphs) | Build, Backup, Remove, Graph, RAG, Checks |
21
+ | `files` | Documents and drafts (docs, worklogs, specs) | none — the page is an item list |
22
+ | `media` | Images, recordings and sounds (images, videos, audio) | none — the page is an item list |
23
+ | `ai-items` | Claude Code items (agents, skills, prompts) | none — these pages carry their own install/fetch/propose surface |
24
+
25
+ Defaults live in `core/workspace-app/workspace-types.js`; an unset `type:` resolves to the shipped
26
+ default for that folder, so nothing changes until somebody chooses differently. A brand-new
27
+ workspace folder defaults by its kind: `projects` → `sites`, `files` → `files`.
28
+
29
+ ## A workspace's settings file
30
+
31
+ `core/config/workspace.<name>.settings.yml`, all keys optional beyond `doc.name`:
32
+
33
+ ```yaml
34
+ doc:
35
+ name: dev # the folder under the workspace root
36
+ # path: /elsewhere/dev # override the location entirely
37
+
38
+ # What this folder's projects are, and which actions their rows offer.
39
+ type: sites # sites | code | files | media | ai-items
40
+
41
+ # What the COMPOSER menu may do on a folder with a composer.json.
42
+ composer: full # full = install, update, scripts… (dev, test, demos, sandboxes, projects)
43
+ # manifest (the default) = only add to composer.json, never install
44
+
45
+ presentation: # the card and rail — icon, words, layout
46
+ icon: tabler:code
47
+ label: Dev
48
+ subtitle: Development Projects
49
+ noun: project # what one item is called
50
+ plural: projects
51
+ kind: projects # projects | files — the page layout
52
+ also_files: false # true = project table AND file list (designs)
53
+ ```
54
+
55
+ Everything is read live (2s memo): edit the file or use the Settings page, reload, done.
56
+
57
+ ## Adding a new workspace folder
58
+
59
+ 1. Add its name to `workspaces:` in `settings.yml` (or drag-create from the dashboard).
60
+ 2. Create `core/config/workspace.<name>.settings.yml` with `doc.name` and pick a `type:`.
61
+ 3. Drop the `cmd-*.sh` scripts it should serve — copy them from a folder of the same type; each
62
+ one self-locates the tooling, so they work wherever they land.
63
+ 4. Optional: a `presentation:` block for the icon and words, an `AGENTS.md` for sessions.
64
+
65
+ ## Extending a workspace
66
+
67
+ Per-workspace features beyond the type's action set are plugins — `settings.yml plugins:` decides
68
+ which load and which workspaces they apply to, and a plugin can add rail buttons, per-project-row
69
+ buttons, fragments and actions without touching core code. See `PLUGINS.md`.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The AI-items actions: everything a row or the rail can do to an agent, a
3
+ * skill or a prompt — sync, fetch, add from any repo, update, install, save,
4
+ * delete, propose, clone, generate. One module per area of the dashboard;
5
+ * server.js merges the maps and dispatches. The shared primitives live in
6
+ * ../ai-items, which the rendering imports too, so a button and its badge can
7
+ * never disagree.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const { esc } = require('../html');
14
+ const { VIDEO_RE, DOC_ARTIFACT_RE } = require('../format');
15
+ const { ROOT, loadWorkspaces, workspaceDir, isValidWorkspace } = require('../workspaces');
16
+ const filesBrowser = require('../files-browser');
17
+ const { screenAction } = require('../jobs');
18
+ const { NAME_RE, ITEM_NAME_RE } = require('../validate');
19
+ const {
20
+ INSTALL_TARGETS, NO_CLAUDE_SYNC, AI_ITEMS_REPO, AI_ITEMS_REPO_PATHS, AI_ITEMS_PROJECT_PATHS,
21
+ itemFile, invalidateRepoCaches,
22
+ } = require('../ai-items');
23
+
24
+ async function save_item({ form, res, send, workspace, pathname }) {
25
+ const meta = loadWorkspaces()[workspace];
26
+ if (meta.kind !== 'files') return send('<div class="msg error">Not an editable workspace.</div>', 400);
27
+ const name = String(form.name || '');
28
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">A name starts with a letter or digit and holds letters, digits, dots, dashes and underscores.</div>', 400);
29
+ const content = String(form.content || '').replace(/\r\n/g, '\n');
30
+ if (content.length > 500000) return send('<div class="msg error">Content too large.</div>', 400);
31
+ const file = itemFile(workspace, name);
32
+ fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ fs.writeFileSync(file, content);
34
+ res.setHeader('HX-Trigger', 'refresh-projects');
35
+ return send(`<div class="msg assistant">✅ Saved <strong>${esc(name)}</strong> (${esc(path.relative(ROOT, file))}).</div>`);
36
+ }
37
+
38
+ async function sync_items({ form, res, send, workspace, pathname }) {
39
+ const key = workspace;
40
+ const dir = workspaceDir(key);
41
+ const script = 'cmd-tools-ai-items.sh';
42
+ if (!AI_ITEMS_REPO_PATHS[key] || !fs.existsSync(path.join(dir, script))) {
43
+ return send('<div class="msg error">No sync script in this workspace.</div>', 400);
44
+ }
45
+ // Only the directions the script understands. Reading ~/.claude keeps
46
+ // the script's own name filter, so client and third-party items stay out of
47
+ // a folder that may end up in a shared repository.
48
+ const source = ['claude', 'install', 'check', 'update'].includes(form.source) ? form.source : 'repo';
49
+ if (NO_CLAUDE_SYNC.has(key) && ['install', 'check', 'update', 'claude'].includes(source)) {
50
+ return send('<div class="msg error">Prompts are launch templates for a new screen, not slash commands — pull from the shared repo instead.</div>', 400);
51
+ }
52
+ const flags = source === 'install' ? ['--install']
53
+ : source === 'claude' ? ['--pull', '--source', 'claude']
54
+ : source === 'check' ? ['--check']
55
+ : source === 'update' ? ['--update']
56
+ : ['--pull'];
57
+ invalidateRepoCaches(key);
58
+ return screenAction(send, { workspace: key, project: source, projectDir: dir, verb: 'sync',
59
+ cmdLine: `bash ${script} ${flags.join(' ')}`, label: `${source} ${key}` });
60
+ }
61
+
62
+ async function fetch_item({ form, res, send, workspace, pathname }) {
63
+ const key = workspace;
64
+ const name = String(form.name || '');
65
+ if (!AI_ITEMS_REPO_PATHS[key]) return send('<div class="msg error">Not a fetchable workspace.</div>', 400);
66
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
67
+ const dir = workspaceDir(key);
68
+ const script = 'cmd-tools-ai-items.sh';
69
+ if (!fs.existsSync(path.join(dir, script))) {
70
+ return send('<div class="msg error">This workspace has no cmd-tools-ai-items.sh.</div>', 400);
71
+ }
72
+ const toClaude = pathname === '/actions/fetch-item';
73
+ if (toClaude && NO_CLAUDE_SYNC.has(key)) {
74
+ return send('<div class="msg error">Prompts stay in this workspace folder — use “Copy here to edit” instead.</div>', 400);
75
+ }
76
+ const flag = toClaude ? '--fetch' : '--pull';
77
+ invalidateRepoCaches(key);
78
+ return screenAction(send, { workspace: key, project: name, projectDir: dir,
79
+ verb: toClaude ? 'fetch' : 'copy',
80
+ cmdLine: `bash ${script} ${JSON.stringify(name)} ${flag}`,
81
+ label: toClaude ? `fetch ${name} into ~/.claude` : `copy ${name} into ${key}/` });
82
+ }
83
+
84
+ async function add_item({ form, res, send, workspace, pathname }) {
85
+ const key = workspace;
86
+ if (!AI_ITEMS_REPO_PATHS[key] || NO_CLAUDE_SYNC.has(key)) return send('<div class="msg error">Not an installable workspace.</div>', 400);
87
+ const dir = workspaceDir(key);
88
+ const script = 'cmd-tools-ai-items.sh';
89
+ if (!fs.existsSync(path.join(dir, script))) {
90
+ return send('<div class="msg error">This workspace has no cmd-tools-ai-items.sh.</div>', 400);
91
+ }
92
+ // The script re-validates the source; this only keeps junk out of the job line.
93
+ const source = String(form.source || '').trim();
94
+ if (!/^(https:\/\/github\.com\/[\w./@-]+|[\w.-]+\/[\w.-]+(@[\w./-]+)?)$/.test(source) || source.includes('..')) {
95
+ return send('<div class="msg error">Use owner/repo, owner/repo@branch, or a github.com URL.</div>', 400);
96
+ }
97
+ const name = String(form.name || '').trim();
98
+ if (name && !ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
99
+ const toWorkspace = String(form.toWorkspace || '').trim();
100
+ const toProject = String(form.toProject || '').trim();
101
+ const args = [script, ...(name ? [name] : []), '--add', source];
102
+ let where = '~/.claude';
103
+ if (toWorkspace) {
104
+ if (!isValidWorkspace(toWorkspace)) return send('<div class="msg error">Unknown target workspace.</div>', 400);
105
+ if (!toProject || !NAME_RE.test(toProject)) return send('<div class="msg error">Name the project to install into.</div>', 400);
106
+ if (!fs.existsSync(path.join(workspaceDir(toWorkspace), toProject))) {
107
+ return send(`<div class="msg error">No ${esc(toWorkspace)}/${esc(toProject)}.</div>`, 404);
108
+ }
109
+ args.push('--to', `${toWorkspace}/${toProject}`);
110
+ where = `${toWorkspace}/${toProject}`;
111
+ }
112
+ invalidateRepoCaches(key);
113
+ return screenAction(send, { workspace: key, project: name || `all-${key}`, projectDir: dir, verb: 'add',
114
+ cmdLine: `bash ${args.map((a) => JSON.stringify(a)).join(' ')}`,
115
+ label: `add ${name || `all ${key}`} → ${where}` });
116
+ }
117
+
118
+ async function update_one_item({ form, res, send, workspace, pathname }) {
119
+ const key = workspace;
120
+ const name = String(form.name || '');
121
+ if (!AI_ITEMS_REPO_PATHS[key]) return send('<div class="msg error">Not a shared-items workspace.</div>', 400);
122
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
123
+ const dir = workspaceDir(key);
124
+ const script = 'cmd-tools-ai-items.sh';
125
+ if (!fs.existsSync(path.join(dir, script))) {
126
+ return send('<div class="msg error">This workspace has no cmd-tools-ai-items.sh.</div>', 400);
127
+ }
128
+ // Take the repo's version of this one item into THIS FOLDER only. These folders are
129
+ // the staging area where items get worked on; reaching ~/.claude or a project is a
130
+ // separate, explicit Install.
131
+ const shellLine = `bash ${script} ${JSON.stringify(name)} --pull`;
132
+ invalidateRepoCaches(key);
133
+ return screenAction(send, { workspace: key, project: name, projectDir: dir, verb: 'update',
134
+ cmdLine: shellLine, label: `update ${name}` });
135
+ }
136
+
137
+ async function install_item({ form, res, send, workspace, pathname }) {
138
+ const meta = loadWorkspaces()[workspace];
139
+ if (!INSTALL_TARGETS[workspace] || meta.kind !== 'files') return send('<div class="msg error">This workspace has no install target.</div>', 400);
140
+ if (NO_CLAUDE_SYNC.has(workspace)) return send('<div class="msg error">Prompts run a new screen session — they do not install into ~/.claude or a project.</div>', 400);
141
+ const name = String(form.name || '');
142
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
143
+ const src = itemFile(workspace, name);
144
+ if (!fs.existsSync(src)) return send('<div class="msg error">Item not found.</div>', 404);
145
+
146
+ // Global by default; a chosen workspace + project puts it in that project's own .claude/
147
+ // instead, reaching only sessions started under it — same mechanism cmd-tools-ai-items.sh
148
+ // --to uses, so a --to install and this button never disagree about where things land.
149
+ const toWorkspace = String(form.toWorkspace || '').trim();
150
+ const toProject = String(form.toProject || '').trim();
151
+ let target = INSTALL_TARGETS[workspace];
152
+ let where = '~/.claude';
153
+ if (toWorkspace) {
154
+ if (!isValidWorkspace(toWorkspace)) return send('<div class="msg error">Unknown target workspace.</div>', 400);
155
+ if (!toProject || !NAME_RE.test(toProject)) return send('<div class="msg error">Name the project to install into.</div>', 400);
156
+ const projectDir = path.join(workspaceDir(toWorkspace), toProject);
157
+ if (!fs.existsSync(projectDir)) return send(`<div class="msg error">No ${esc(toWorkspace)}/${esc(toProject)}.</div>`, 404);
158
+ target = path.join(projectDir, AI_ITEMS_PROJECT_PATHS[workspace]);
159
+ where = `${toWorkspace}/${toProject}/${AI_ITEMS_PROJECT_PATHS[workspace]}`;
160
+ }
161
+
162
+ fs.mkdirSync(target, { recursive: true });
163
+ let dest;
164
+ if (workspace === 'ai-skills') {
165
+ dest = path.join(target, name, 'SKILL.md');
166
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
167
+ } else {
168
+ dest = path.join(target, `${name}.md`);
169
+ }
170
+ fs.copyFileSync(src, dest);
171
+ const usage = workspace === 'ai-prompts' && !toWorkspace ? ` — available in Claude Code as <code>/${esc(name)}</code>` : '';
172
+ const reach = toWorkspace ? `sessions started under ${esc(toWorkspace)}/${esc(toProject)}` : 'Claude Code sessions';
173
+ return send(`<div class="msg assistant">✅ Installed <strong>${esc(name)}</strong> to <code>${esc(where)}</code>${usage}. Restart ${reach} to pick it up.</div>`);
174
+ }
175
+
176
+ async function delete_item({ form, res, send, workspace, pathname }) {
177
+ const meta = loadWorkspaces()[workspace];
178
+ if (meta.kind !== 'files') return send('<div class="msg error">Not an editable workspace.</div>', 400);
179
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
180
+ // `names` deletes every format of one document or recording, which is what a grouped
181
+ // row offers; `name` stays for a single markdown item or skill.
182
+ const targets = String(form.names || form.name || '').split(',').map((n) => n.trim()).filter(Boolean);
183
+ if (!targets.length) return send('<div class="msg error">Nothing named to delete.</div>', 400);
184
+ for (const t of targets) {
185
+ if (!/^[a-zA-Z0-9_.-]+$/.test(t) || t.includes('..')) return send('<div class="msg error">Invalid name.</div>', 400);
186
+ }
187
+ // Which folder the row was in. A name is still a plain name — the subfolder is separate and
188
+ // resolved by the same check the listing uses (segments, then realpath), so a delete can reach
189
+ // into a folder you are browsing and nowhere else.
190
+ const where = filesBrowser.safeSub(workspace, form.dir || '');
191
+ if (!where.ok) return send('<div class="msg error">No such folder.</div>', 400);
192
+ const base = where.abs;
193
+ const inSub = !!where.rel;
194
+ const removed = [];
195
+ for (const name of targets) {
196
+ // Match on what the workspace actually lists. This used to test /\.(pdf|html)$/
197
+ // only, so once docs listed Office files and videos listed recordings, deleting
198
+ // either fell through to the markdown branch, looked for "<name>.mp4.md", found
199
+ // nothing and reported "(nothing found)" while leaving the file in place.
200
+ if (inSub || DOC_ARTIFACT_RE.test(name) || VIDEO_RE.test(name)) {
201
+ // Inside a folder every row is a file, whatever its extension: the browsable workspaces list
202
+ // what is there rather than a curated set, so the delete has to cover the same ground or a
203
+ // row offers a button that reports "(nothing found)".
204
+ const f = path.join(base, name);
205
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) { fs.unlinkSync(f); removed.push(where.rel ? `${where.rel}/${name}` : name); }
206
+ } else if (workspace === 'ai-skills') {
207
+ const d = path.join(meta.dir, name);
208
+ if (fs.existsSync(path.join(d, 'SKILL.md'))) { fs.rmSync(d, { recursive: true }); removed.push(name); }
209
+ } else {
210
+ const f = itemFile(workspace, name);
211
+ if (fs.existsSync(f)) { fs.unlinkSync(f); removed.push(`${name}.md`); }
212
+ }
213
+ }
214
+ res.setHeader('HX-Trigger', 'refresh-projects');
215
+ return send(`<div class="msg assistant">🗑️ Deleted: ${esc(removed.join(', ') || '(nothing found)')}</div>`);
216
+ }
217
+
218
+ async function propose_item({ form, res, send, workspace, pathname }) {
219
+ const meta = loadWorkspaces()[workspace];
220
+ if (!AI_ITEMS_REPO_PATHS[workspace] || meta.kind !== 'files') return send('<div class="msg error">Proposing is for agents, skills, and prompts.</div>', 400);
221
+ const name = String(form.name || '');
222
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
223
+ const summary = String(form.summary || '').slice(0, 2000).trim();
224
+ const confirmed = String(form.confirm || '') === 'yes';
225
+ if (confirmed && !summary) return send('<div class="msg error">Say what it is for — it goes into the issue and the PR.</div>', 400);
226
+ const script = 'cmd-tools-ai-items.sh';
227
+ if (!fs.existsSync(path.join(meta.dir, script))) {
228
+ return send('<div class="msg error">This workspace has no cmd-tools-ai-items.sh.</div>', 400);
229
+ }
230
+ const args = [script, name, '--propose', '--summary', summary || '(no summary given)'];
231
+ if (confirmed) args.push('--confirm');
232
+ return screenAction(send, { workspace, project: name, projectDir: meta.dir,
233
+ verb: confirmed ? 'propose' : 'propose-dry-run',
234
+ cmdLine: `bash ${args.map((a) => JSON.stringify(a)).join(' ')}`,
235
+ label: confirmed ? `propose ${name} upstream` : `what proposing ${name} would do` });
236
+ }
237
+
238
+ async function clone_item({ form, res, send, workspace, pathname }) {
239
+ const meta = loadWorkspaces()[workspace];
240
+ if (!INSTALL_TARGETS[workspace] || meta.kind !== 'files') return send('<div class="msg error">Cloning is for agents, skills, and prompts.</div>', 400);
241
+ const name = String(form.name || '');
242
+ const cloneTo = String(form.cloneTo || '');
243
+ const purpose = String(form.purpose || '').slice(0, 2000).trim();
244
+ if (!NAME_RE.test(name) || !NAME_RE.test(cloneTo)) return send('<div class="msg error">Invalid name.</div>', 400);
245
+ if (name === cloneTo) return send('<div class="msg error">Give the copy a different name.</div>', 400);
246
+
247
+ // The CLI owns the copy, the identity rewrite and the AI pass, so the button
248
+ // and `bash cmd-tools-ai-items.sh` cannot drift apart. A purpose means a
249
+ // claude run, which is slow — hence a job with the AI timeout, not an
250
+ // inline response.
251
+ const args = [`${name}`, '--clone-to', cloneTo];
252
+ if (purpose) args.push('--purpose', purpose);
253
+ const shellLine = `bash cmd-tools-ai-items.sh ${args.map((a) => JSON.stringify(a)).join(' ')}`;
254
+ res.setHeader('HX-Trigger', 'refresh-projects');
255
+ return screenAction(send, { workspace, project: cloneTo, projectDir: meta.dir, verb: 'clone',
256
+ cmdLine: shellLine, label: `clone ${name} to ${cloneTo}` });
257
+ }
258
+
259
+ async function generate_item({ form, res, send, workspace, pathname }) {
260
+ const target = INSTALL_TARGETS[workspace];
261
+ const meta = loadWorkspaces()[workspace];
262
+ if (!target || meta.kind !== 'files') return send('<div class="msg error">AI generation is for agents, skills, and prompts.</div>', 400);
263
+ const name = String(form.name || '');
264
+ const description = String(form.description || '').slice(0, 2000);
265
+ if (!ITEM_NAME_RE.test(name)) return send('<div class="msg error">Invalid name.</div>', 400);
266
+ if (!description.trim()) return send('<div class="msg error">Describe what it should do.</div>', 400);
267
+
268
+ const SPECS = {
269
+ agents: `a Claude Code CLI subagent definition markdown file. It MUST start with YAML frontmatter delimited by --- lines containing: name: ${name}, a one-paragraph description: telling Claude when to invoke this agent (starting "Use this agent to"), and a tools: list (only the tools it truly needs from Bash, Read, Write, Edit, Glob, Grep, WebFetch). After the frontmatter: a # ${name} heading, a role statement, and concrete ## Instructions the agent follows. Study ${ROOT} with your tools first if the description references this workspace.`,
270
+ skills: `a Claude Code CLI skill (SKILL.md) markdown file. It MUST start with YAML frontmatter delimited by --- lines containing: name: ${name} and a one-line description: saying what the skill does and when to use it. After the frontmatter: a # ${name} heading and precise step-by-step ## Instructions. Study ${ROOT} with your tools first if the description references this workspace.`,
271
+ prompts: `a reusable prompt file kept in this workspace's prompts folder (nothing installs into ~/.claude). Plain markdown, no frontmatter: just the complete, well-structured prompt text a user would paste or run repeatedly. Make it specific and actionable.`,
272
+ };
273
+ const file = itemFile(workspace, name);
274
+ fs.mkdirSync(path.dirname(file), { recursive: true });
275
+ const genPrompt = `Write ${SPECS[workspace]}\n\nWhat it should do: ${description}\n\nOutput ONLY the raw file content — no surrounding commentary, no code fences.`;
276
+ const shellLine = `set -e; echo "Generating ${meta.noun} '${name}' with AI…"; claude -p ${JSON.stringify(genPrompt)} --allowedTools Read Glob Grep --disallowedTools Write Edit Bash --no-session-persistence > ${JSON.stringify(file)}; echo "Written: ${path.relative(ROOT, file)}"; head -20 ${JSON.stringify(file)}`;
277
+ return screenAction(send, { workspace, project: name, projectDir: meta.dir, verb: 'generate',
278
+ cmdLine: shellLine, label: `generate the ${meta.noun} ${name}` });
279
+ }
280
+
281
+ module.exports = {
282
+ '/actions/save-item': save_item,
283
+ '/actions/sync-items': sync_items,
284
+ '/actions/fetch-item': fetch_item,
285
+ '/actions/pull-item': fetch_item,
286
+ '/actions/add-item': add_item,
287
+ '/actions/update-one-item': update_one_item,
288
+ '/actions/install-item': install_item,
289
+ '/actions/delete-item': delete_item,
290
+ '/actions/propose-item': propose_item,
291
+ '/actions/clone-item': clone_item,
292
+ '/actions/generate-item': generate_item,
293
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The audio actions: generate a voice from text with Piper or ElevenLabs.
3
+ *
4
+ * The form is `audio-voice.js`; the work is `audio/cmd-tools-voice.sh`, run on the host in a screen
5
+ * session like every other long action. Anything that spends credits needs `confirm=yes`, which only
6
+ * the confirm dialog's button sends, and the command refuses to spend without its own `--confirm`.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('../html');
13
+ const { workspaceDir } = require('../workspaces');
14
+ const { startInScreen, screenStartedHtml } = require('../jobs');
15
+ const voice = require('../audio-voice');
16
+
17
+ async function generate_voice({ form, res, send, workspace }) {
18
+ if (workspace !== 'audio') return send('<div class="msg error">Voices are generated in the audio workspace.</div>', 400);
19
+ const found = await voice.loadEngines();
20
+ const p = voice.plan(form, found.engines);
21
+ if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
22
+ if (p.engine === 'elevenlabs' && form.confirm !== 'yes') {
23
+ return send('<div class="msg error">ElevenLabs spends credits, so it needs your confirmation.</div>', 400);
24
+ }
25
+ // A script chosen from docs/ is read where it is; pasted text goes through a private temporary file.
26
+ const textFile = p.scriptPath ? null : voice.writeText(p.kind === 'video' ? p.script : p.text);
27
+ const opts = { workspace, project: path.basename(p.file, `.${p.ext}`), projectDir: workspaceDir('audio'),
28
+ verb: 'voice', cmdLine: voice.commandLine(p, textFile, true), label: `${p.engine} ${p.kind === 'video' ? 'voiceover' : 'voice'} ${p.file}` };
29
+ const started = await startInScreen(opts);
30
+ // The command removes the text itself once it has run; when nothing started, nothing will.
31
+ if (!started.ok) {
32
+ if (textFile) fs.rmSync(path.dirname(textFile), { recursive: true, force: true });
33
+ return send(screenStartedHtml(started, opts.label, opts.project), started.already ? 409 : 500);
34
+ }
35
+ // The modal stays open and says it is working; it asks again until the file is there.
36
+ return send(voice.progressHtml({ kind: p.kind, file: p.file, session: started.session, t0: Date.now(), engine: p.engine }, null));
37
+ }
38
+
39
+ /** The plan for a video voiceover: nothing runs, and nothing is spent. */
40
+ async function voiceover_plan({ form, send, workspace }) {
41
+ if (workspace !== 'audio') return send('<div class="msg error">Voices are generated in the audio workspace.</div>', 400);
42
+ const found = await voice.loadEngines();
43
+ const p = voice.plan({ ...form, kind: 'video' }, found.engines);
44
+ if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
45
+ return send(voice.planHtml(p, found));
46
+ }
47
+
48
+ module.exports = { '/actions/generate-voice': generate_voice, '/actions/voiceover-plan': voiceover_plan };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Writing the order of the builders in Build a new project.
3
+ *
4
+ * One form carries the whole arrangement, and every write carries the file's mtime and is refused with
5
+ * 409 when it changed since — the same rule as every other writer here, and it matters more than usual
6
+ * on this one: two people arranging the same list would otherwise silently keep whichever saved last.
7
+ *
8
+ * **Only what was moved is written.** The form posts every row, but a list that recorded all of them
9
+ * would freeze the order of commands nobody has an opinion about: one added next month would then land
10
+ * at the bottom rather than in its shipped place, and the file would assert decisions nobody made. So
11
+ * what is written is the SHORTEST list that reproduces what you see — see `minimalOrder`.
12
+ */
13
+
14
+ const order = require('../build-order');
15
+ const page = require('../build-order-page');
16
+ const { loadWorkspaces } = require('../workspaces');
17
+
18
+ const ok = (msg) => `<div class="msg assistant">✅ ${msg}</div>`;
19
+ const bad = (msg) => `<div class="msg error">${msg}</div>`;
20
+
21
+ const asArray = (v) => (v === undefined ? [] : (Array.isArray(v) ? v : [v]));
22
+
23
+ /** 'global', or a workspace that really exists; anything else is refused rather than written. */
24
+ function scopeOf(form) {
25
+ const want = String(form.scope || 'global');
26
+ if (want === 'global') return 'global';
27
+ return loadWorkspaces()[want] ? want : null;
28
+ }
29
+
30
+ function editor(send, form, message, status = 200) {
31
+ const scope = scopeOf(form) || 'global';
32
+ return send(page.editorHtml(scope, message), status);
33
+ }
34
+
35
+ async function build_order_save({ form, send }) {
36
+ const scope = scopeOf(form);
37
+ if (!scope) return send(bad('No such workspace.'), 400);
38
+
39
+ // One form, two lists. A kind that was not posted is left ALONE rather than emptied: a page that
40
+ // showed no tools must not be able to erase the tool order somebody set on another tab.
41
+ const posted = {
42
+ builders: [...new Set(asArray(form.builder).map(String).filter(order.isBuilder))],
43
+ tools: [...new Set(asArray(form.tool).map(String).filter(order.isTool))],
44
+ };
45
+ if (!posted.builders.length && !posted.tools.length) {
46
+ return editor(send, form, bad('Nothing to save: no commands were posted.'), 400);
47
+ }
48
+
49
+ const { raw, mtime } = order.rawOrder();
50
+ if (String(form.mtime || '') !== String(mtime)) {
51
+ return editor(send, form,
52
+ bad('The command order changed somewhere else while this was open — nothing was written. This is how it is now.'), 409);
53
+ }
54
+
55
+ const kept = {};
56
+ let placed = 0;
57
+ for (const kind of order.KINDS) {
58
+ if (!posted[kind].length) continue; // not posted: withScope keeps what is on disk
59
+ kept[kind] = order.minimalOrder(posted[kind], scope, kind);
60
+ placed += kept[kind].length;
61
+ }
62
+ order.saveOrder(order.withScope(raw, scope, kept));
63
+ return editor(send, form, ok(placed
64
+ ? `Command order saved — ${placed} placed by this list, the rest left where they were. It applies on the next page you open.`
65
+ : 'Command order saved. Nothing here differs from the shipped order, so this scope names nothing — which is what makes a command added later still arrive in the right place.'));
66
+ }
67
+
68
+ async function build_order_reset({ form, send }) {
69
+ const scope = scopeOf(form);
70
+ if (!scope) return send(bad('No such workspace.'), 400);
71
+ const { raw } = order.rawOrder();
72
+ order.saveOrder(order.withScope(raw, scope, Object.fromEntries(order.KINDS.map((k) => [k, []]))));
73
+ return editor(send, form, ok('Back to the shipped order.'));
74
+ }
75
+
76
+ module.exports = {
77
+ '/actions/build-order-save': build_order_save,
78
+ '/actions/build-order-reset': build_order_reset,
79
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Running the checks a project already declares, from the dashboard.
3
+ *
4
+ * Two handlers. `/actions/checks-plan` answers instantly and starts nothing — it is the same
5
+ * `--list` the command prints, so you can see what a package covers before deciding to run it.
6
+ * `/actions/checks` starts the real thing as a job, because a full set on a large package is
7
+ * minutes of work and belongs in a toast rather than in a request.
8
+ *
9
+ * Neither ever passes a writing flag. The command has one (`--fix`); the dashboard does not send it,
10
+ * because a button that rewrites source files on a click is not a button anybody should press by
11
+ * accident.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const { esc } = require('../html');
18
+ const { workspaceDir } = require('../workspaces');
19
+ const { jobFragment, runningJobKey, startJob, listProjects , startInScreen, screenStartedHtml } = require('../jobs');
20
+ const { NAME_RE } = require('../validate');
21
+ const { checksDeclaredOf } = require('../checks');
22
+
23
+ const SCRIPT = 'cmd-tools-checks.sh';
24
+
25
+ // The guards every handler here repeats: a real workspace folder that ships the command, a project
26
+ // name that is a name, and a project that is actually in that folder.
27
+ function target(form, workspace) {
28
+ const dir = workspaceDir(workspace);
29
+ const projectName = String(form.projectName || '');
30
+ if (!fs.existsSync(path.join(dir, SCRIPT))) return { error: 'This workspace has no checks command.' };
31
+ if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) return { error: 'Unknown project.' };
32
+ return { dir, projectName };
33
+ }
34
+
35
+ // The plan, rendered as the table the command prints. No job: this is filesystem work.
36
+ async function checks_plan({ form, send, workspace }) {
37
+ const t = target(form, workspace);
38
+ if (t.error) return send(`<div class="msg error">${esc(t.error)}</div>`, 400);
39
+
40
+ const { resolvePlan } = require(path.join(require('../workspaces').ROOT, 'core', 'scripts', 'libs', 'checks_plan'));
41
+ let plan;
42
+ try {
43
+ plan = resolvePlan(path.join(t.dir, t.projectName));
44
+ } catch (e) {
45
+ return send(`<div class="msg error">Could not read that package: ${esc(e.message)}</div>`, 500);
46
+ }
47
+
48
+ const symbol = { ready: '✅', masked: '⛔', refused: '🚫', skipped: '⏭', 'deps-missing': '⏭', 'unresolved-runner': '⏭', none: '➖' };
49
+ const rows = plan.rows.map((r) => `
50
+ <tr class="checks-row is-${esc(r.status)}">
51
+ <td>${symbol[r.status] || '➖'}</td>
52
+ <td><code>${esc(r.id)}</code></td>
53
+ <td>${r.script ? `<code>${esc(r.script)}</code>` : '<span class="uk-text-meta">—</span>'}</td>
54
+ <td class="uk-text-meta">${esc(r.status === 'ready' ? r.command : r.reason)}</td>
55
+ </tr>`).join('');
56
+
57
+ return send(`
58
+ <div class="checks-plan">
59
+ <p class="uk-text-meta uk-margin-remove-bottom">
60
+ ${plan.hasPackageJson
61
+ ? `${esc(plan.runner || 'no package manager')} · ${esc(plan.runnerReason)} · dependencies ${plan.deps.installed ? 'installed' : `<strong>not installed</strong> (${esc(plan.deps.evidence)})`}`
62
+ : 'This project has no package.json — only the checks a DDEV add-on provides can apply.'}
63
+ </p>
64
+ <table class="uk-table uk-table-small uk-table-divider checks-table"><tbody>${rows}</tbody></table>
65
+ <p class="uk-text-meta">Every check this project declares, and the script behind it. Nothing has been run.</p>
66
+ </div>`);
67
+ }
68
+
69
+ // The real run, as a job.
70
+ async function checks_run({ form, send, workspace }) {
71
+ const t = target(form, workspace);
72
+ if (t.error) return send(`<div class="msg error">${esc(t.error)}</div>`, 400);
73
+
74
+ const declared = checksDeclaredOf(t.dir, t.projectName);
75
+ if (!declared.declared) {
76
+ return send('<div class="msg error">This project declares no check this command can run — open the plan to see why.</div>', 409);
77
+ }
78
+
79
+ const jobKey = `checks:${workspace}/${t.projectName}`;
80
+ if (runningJobKey(jobKey)) {
81
+ return send('<div class="msg error">The checks are already running for this project.</div>', 409);
82
+ }
83
+
84
+ // --only rides through when the row asked for one check; otherwise the whole declared set runs.
85
+ const only = String(form.only || '').trim();
86
+ const args = [SCRIPT, t.projectName, ...(only && /^[a-z,]+$/.test(only) ? ['--only', only] : [])];
87
+ const started = await startInScreen({ workspace, project: t.projectName, projectDir: t.dir, verb: 'checks',
88
+ cmdLine: `bash ${args.map((a) => JSON.stringify(a)).join(' ')}`, label: 'checks' });
89
+ return send(screenStartedHtml(started, 'checks', t.projectName), started.ok ? 200 : (started.already ? 409 : 500));
90
+ }
91
+
92
+ module.exports = {
93
+ '/actions/checks': checks_run,
94
+ '/actions/checks-plan': checks_plan,
95
+ };