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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The settings files under core/config, read and written a LINE AT A TIME.
3
+ *
4
+ * Nothing here parses YAML and dumps it back. js-yaml would discard every comment, reflow the
5
+ * quoting and reorder nothing but look like it had — turning a one-key change into an unreviewable
6
+ * diff of the whole file. These files are hand-maintained and heavily commented (they are how the
7
+ * workspace is configured), so an edit here replaces the LINES it means to change and leaves the
8
+ * rest byte-for-byte.
9
+ *
10
+ * The other rule is about secrets: a secret's value is never sent to the browser, never echoed
11
+ * back in a response and never written to the audit log. `isSecretKey` decides that, and it judges
12
+ * the full dotted path — `account.pass` is the admin password a fresh install is given on this
13
+ * machine, which is the one thing that field is for, so it is not treated as a secret.
14
+ *
15
+ * Extracted from server.js so the assistant can name the settings files without importing the web
16
+ * server, and so the writers can be unit-tested against a real file rather than only through a
17
+ * form post.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const { CONFIG_DIR } = require('./workspaces');
24
+
25
+ // A theme's tokens are configuration like anything else here, so `theme.<name>.yml` is editable
26
+ // on the Settings screen rather than only by editing a stylesheet in the app's own directory.
27
+ const SETTINGS_FILE_RE = /^(settings\.yml|style\.yml|workspace\.[a-z0-9_-]+\.settings\.yml|plugin\.[a-z0-9_-]+\.settings\.yml|theme\.[a-z0-9_-]+\.yml)$/;
28
+
29
+ // Keys whose value must never reach the browser. Judged on the full path, because one
30
+ // "password" here is not a secret at all: `account.pass` is the admin password a fresh
31
+ // Drupal/Webship/Drupal CMS install is given on this machine. Masking it made the field
32
+ // unreadable for the one thing it is for — checking what you will log in with.
33
+ const NOT_SECRET_PATHS = new Set(['account.pass']);
34
+
35
+ function isSecretKey(key, dotted) {
36
+ if (dotted && NOT_SECRET_PATHS.has(dotted)) return false;
37
+ return /^(pass|password|passwd|token|api_key|apikey|secret|secret_key)$/i.test(key)
38
+ || /(^|_)(token|secret|api_key|apikey|password|passwd)$/i.test(key);
39
+ }
40
+
41
+ function listSettingsFiles() {
42
+ let files = [];
43
+ try {
44
+ files = fs.readdirSync(CONFIG_DIR).filter((f) => SETTINGS_FILE_RE.test(f));
45
+ } catch (_) {
46
+ return [];
47
+ }
48
+ // settings.yml first, then the per-workspace files alphabetically.
49
+ // settings.yml, then style.yml, then the workspaces, then the themes — the order somebody
50
+ // looks for them in, rather than the order the directory happens to list them.
51
+ const rank = (f) => (f === 'settings.yml' ? 0 : f === 'style.yml' ? 1 : f.startsWith('workspace.') ? 2 : f.startsWith('plugin.') ? 3 : 4);
52
+ return files.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
53
+ }
54
+
55
+ // Flatten a settings file into editable rows, straight from its lines so the
56
+ // order matches the file and nothing is invented.
57
+ //
58
+ // Scalars only. A list (settings.yml's `workspaces:`) is shown read-only: its
59
+ // order is the dashboard's card order and its membership registers a workspace,
60
+ // so it is not something to retype into a text field by accident.
61
+ function readSettingsRows(file) {
62
+ const full = path.join(CONFIG_DIR, file);
63
+ let text;
64
+ try {
65
+ text = fs.readFileSync(full, 'utf8');
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ return rowsFromText(text);
70
+ }
71
+
72
+ /**
73
+ * The same walk over text rather than a file, so the raw editor can give the text somebody TYPED the
74
+ * same dotted paths the file has. Matching a masked secret by its key alone was not enough: a file with
75
+ * `git.github.token`, `git.drupal.token` and `git.bitbucket.token` has three keys called `token`, and
76
+ * every one of them restored the first one's value — one service's token copied onto another.
77
+ */
78
+ function rowsFromText(text) {
79
+ const rows = [];
80
+ const stack = []; // [{ indent, key }]
81
+ let notes = []; // the comment lines straight above a key, which describe it
82
+ text.split('\n').forEach((line, i) => {
83
+ const c = line.match(/^\s*#\s?(.*)$/);
84
+ if (c) { notes.push(c[1]); return; }
85
+ const m = line.match(/^(\s*)([A-Za-z0-9_.-]+):\s*(.*)$/);
86
+ const comment = notes.join(' ').replace(/\s+/g, ' ').trim();
87
+ notes = [];
88
+ if (!m) return;
89
+ const [, indentStr, key, rawValue] = m;
90
+ const indent = indentStr.length;
91
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
92
+ const dotted = [...stack.map((x) => x.key), key].join('.');
93
+ const value = rawValue.replace(/\s+#.*$/, '').trim();
94
+ if (value === '') {
95
+ // A parent key, or a key whose list items follow. Recorded so children get
96
+ // their dotted path; only shown if it turns out to hold a list.
97
+ stack.push({ indent, key });
98
+ const next = text.split('\n')[i + 1] || '';
99
+ if (/^\s*-\s+/.test(next)) rows.push({ line: i, dotted, key, value: '', list: true });
100
+ return;
101
+ }
102
+ rows.push({ line: i, dotted, key, value, leaf: String(dotted).split('.').slice(-1)[0], secret: isSecretKey(key, dotted), comment });
103
+ });
104
+ return rows;
105
+ }
106
+
107
+ // Replace the value on one known line, leaving the rest of the file — comments,
108
+ // blank lines, quoting style, list blocks — exactly as it was.
109
+ function writeSettingsValues(file, updates) {
110
+ const full = path.join(CONFIG_DIR, file);
111
+ const lines = fs.readFileSync(full, 'utf8').split('\n');
112
+ let changed = 0;
113
+ for (const { line, key, value, bool } of updates) {
114
+ const cur = lines[line];
115
+ if (!cur) continue;
116
+ const m = cur.match(/^(\s*)([A-Za-z0-9_.-]+):(\s*)(.*)$/);
117
+ if (!m || m[2] !== key) continue; // the file moved under us — skip it
118
+ const [, indentStr, k, gap, rest] = m;
119
+ const comment = rest.match(/\s+#.*$/);
120
+ // Quote when the value could otherwise change type or break the parse.
121
+ // A value that already is a quoted scalar ("" from an emptied field, '…' from a form) is kept as it is.
122
+ const quoted = /^(".*"|'.*')$/.test(value);
123
+ const needsQuote = !bool && !quoted
124
+ && (/[:#]|^\s|\s$|^[@`!&*%|>{}\[\]?]|^-\s|^-$/.test(value) || (value !== '' && /^(y|n|yes|no|true|false|on|off|null|~)$/i.test(value)));
125
+ const out = needsQuote ? JSON.stringify(value) : value;
126
+ lines[line] = `${indentStr}${k}:${gap || ' '}${out}${comment ? comment[0] : ''}`;
127
+ changed += 1;
128
+ }
129
+ fs.writeFileSync(full, lines.join('\n'));
130
+ return changed;
131
+ }
132
+
133
+ // A list by its dotted path, so a nested one (auth.google.allowed_domains) is found as well as a top-level one.
134
+ function readListBlock(file, key) {
135
+ const full = path.join(CONFIG_DIR, file);
136
+ const text = fs.readFileSync(full, 'utf8');
137
+ const lines = text.split('\n');
138
+ const row = rowsFromText(text).find((r) => r.list && r.dotted === key);
139
+ if (!row) return null;
140
+ const start = row.line;
141
+ const items = [];
142
+ const raw = [];
143
+ let end = start;
144
+ for (let i = start + 1; i < lines.length; i += 1) {
145
+ const m = lines[i].match(/^\s*-\s+(.+?)\s*$/);
146
+ if (!m) break;
147
+ items.push(m[1].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, ''));
148
+ raw.push(lines[i]);
149
+ end = i;
150
+ }
151
+ const indent = (raw[0] || '').match(/^\s*/)[0] || `${lines[start].match(/^\s*/)[0]} `;
152
+ return { start, end, items, indent, raw };
153
+ }
154
+
155
+ // An entry that is kept keeps its own line, quotes and comment included; only new entries are written fresh.
156
+ function writeListBlock(file, key, items) {
157
+ const full = path.join(CONFIG_DIR, file);
158
+ const lines = fs.readFileSync(full, 'utf8').split('\n');
159
+ const block = readListBlock(file, key);
160
+ if (!block) throw new Error(`no ${key}: list in ${file}`);
161
+ const rendered = items.map((n) => {
162
+ const i = block.items.indexOf(n);
163
+ return i === -1 ? `${block.indent}- ${n}` : block.raw[i];
164
+ });
165
+ lines.splice(block.start + 1, block.end - block.start, ...rendered);
166
+ fs.writeFileSync(full, lines.join('\n'));
167
+ }
168
+
169
+ /**
170
+ * The icon and subtitle on a workspace's card, changed IN PLACE.
171
+ *
172
+ * This used to render the whole `presentation:` block from the two fields the form posts and
173
+ * splice it over whatever was there — which deleted every other key under it. That is not
174
+ * hypothetical: `presentation:` also carries `label` (the RAG workspace's card says RAG while its
175
+ * folder is `rag`), `noun`, `kind` and `also_files`, plus the comments explaining them. Choosing
176
+ * an icon on the RAG card silently renamed the workspace to "rag" and dropped its buildable
177
+ * behaviour.
178
+ *
179
+ * So: replace the `icon:` line and the `subtitle:` line, and touch nothing else. An empty value
180
+ * removes just that line. A missing key is inserted at the top of the block, which is where both
181
+ * already sit in every file that has them.
182
+ */
183
+ function writeWorkspacePresentation(file, icon, subtitle) {
184
+ const full = path.join(CONFIG_DIR, file);
185
+ const lines = fs.readFileSync(full, 'utf8').split('\n');
186
+ const start = lines.findIndex((l) => /^presentation:\s*$/.test(l));
187
+
188
+ // No block yet: write one, with only the fields that have a value.
189
+ if (start === -1) {
190
+ const rendered = [];
191
+ if (icon) rendered.push(` icon: ${icon}`);
192
+ if (subtitle) rendered.push(` subtitle: ${JSON.stringify(subtitle)}`);
193
+ if (!rendered.length) return;
194
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
195
+ fs.writeFileSync(full, `${[...lines, 'presentation:', ...rendered].join('\n')}\n`);
196
+ return;
197
+ }
198
+
199
+ // Where the block ends: the last line that is still indented under it.
200
+ let end = start;
201
+ for (let i = start + 1; i < lines.length; i += 1) {
202
+ if (!/^\s+\S/.test(lines[i]) && lines[i].trim() !== '') break;
203
+ if (lines[i].trim() !== '') end = i;
204
+ }
205
+
206
+ // One key, edited where it stands. Comment lines and every other key are never touched.
207
+ const setKey = (key, value) => {
208
+ const at = lines.findIndex((l, i) => i > start && i <= end && new RegExp(`^\\s+${key}:`).test(l));
209
+ if (!value) {
210
+ if (at !== -1) { lines.splice(at, 1); end -= 1; }
211
+ return;
212
+ }
213
+ const rendered = ` ${key}: ${key === 'subtitle' ? JSON.stringify(value) : value}`;
214
+ if (at === -1) { lines.splice(start + 1, 0, rendered); end += 1; }
215
+ else lines[at] = rendered;
216
+ };
217
+
218
+ setKey('icon', icon);
219
+ setKey('subtitle', subtitle);
220
+
221
+ // An empty block is not valid YAML: `presentation:` with nothing under it parses as null and
222
+ // the loader would read a workspace with no presentation at all. Drop the header too.
223
+ const stillHasKeys = lines.slice(start + 1, end + 1).some((l) => /^\s+[A-Za-z0-9_-]+:/.test(l));
224
+ if (!stillHasKeys) lines.splice(start, end - start + 1);
225
+
226
+ fs.writeFileSync(full, lines.join('\n'));
227
+ }
228
+
229
+
230
+ /* ------------------------------------------------------------------ *
231
+ * The file itself, for the raw editor
232
+ * ------------------------------------------------------------------ */
233
+
234
+ /**
235
+ * What a secret's value is replaced with on its way to the browser, and what a save reads as "keep it".
236
+ *
237
+ * The line-by-line editor renders a secret as an empty password field, and a blank submission keeps
238
+ * what is on disk. A raw editor cannot do that: the whole file goes to the browser, and the whole file
239
+ * comes back. So the value is swapped for this on the way out, and any value that is still exactly this
240
+ * on the way in is put back from disk — the same promise, expressed in a form a text editor can hold.
241
+ *
242
+ * Deliberately unlikely to be typed by accident, and deliberately visible: a row of dots would look
243
+ * like a value somebody had set.
244
+ */
245
+ const SECRET_MASK = '<<kept — the value on disk>>';
246
+
247
+ const SECRET_LINE = /^(\s*)([A-Za-z0-9_.-]+):[ \t]*(.*)$/;
248
+
249
+ /** The file as text, with every secret's value masked. Also says which keys were masked. */
250
+ function readSettingsText(file) {
251
+ const full = path.join(CONFIG_DIR, file);
252
+ let text;
253
+ try { text = fs.readFileSync(full, 'utf8'); } catch (_) { return null; }
254
+ const rows = readSettingsRows(file) || [];
255
+ // `line` from readSettingsRows is a ZERO-based index into the file, which is what writeSettingsValues
256
+ // indexes with too. Reading it as a line NUMBER masked the line after each secret — the next key's
257
+ // value — and left the secret itself in the clear, which is the exact failure this must never have.
258
+ const secretAtLine = new Map(rows.filter((r) => r.secret).map((r) => [r.line, r.dotted]));
259
+ const masked = [];
260
+ const lines = text.split('\n').map((line, i) => {
261
+ const dotted = secretAtLine.get(i);
262
+ if (!dotted) return line;
263
+ const m = line.match(SECRET_LINE);
264
+ if (!m || !m[3].trim()) return line;
265
+ masked.push(dotted);
266
+ return `${m[1]}${m[2]}: ${SECRET_MASK}`;
267
+ });
268
+ return { text: lines.join('\n'), masked };
269
+ }
270
+
271
+ /**
272
+ * Write the file from the raw editor, putting every still-masked secret back from disk.
273
+ *
274
+ * Refuses rather than writes when: the text is not YAML at all, or it is not a mapping, or it is empty.
275
+ * A settings file that does not parse takes the dashboard's whole configuration with it, and the person
276
+ * who broke it is not necessarily the person who finds out.
277
+ */
278
+ function writeSettingsText(file, text, { yaml }) {
279
+ const full = path.join(CONFIG_DIR, file);
280
+ const body = String(text == null ? '' : text).replace(/\r\n/g, '\n');
281
+ if (!body.trim()) return { error: 'An empty file would take every setting in it with it.' };
282
+ let parsed;
283
+ try {
284
+ parsed = yaml.load(body);
285
+ } catch (err) {
286
+ return { error: `That is not YAML: ${String(err.message || err).split('\n')[0]}` };
287
+ }
288
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
289
+ return { error: 'A settings file is a mapping of keys to values.' };
290
+ }
291
+
292
+ // Every secret still wearing its mask keeps the value on disk. A secret whose mask was replaced was
293
+ // deliberately retyped, and is taken literally — which is how a raw editor sets a new one.
294
+ const onDisk = new Map((readSettingsRows(file) || []).filter((r) => r.secret).map((r) => [r.dotted, r]));
295
+ let current = [];
296
+ try { current = fs.readFileSync(full, 'utf8').split('\n'); } catch (_) { current = []; }
297
+ // Where each line of the SUBMITTED text sits in the tree, so a masked value is matched by its whole
298
+ // path and not by its key. Three services can each have a `token`, and restoring by key gave all
299
+ // three the first one's value.
300
+ const submitted = new Map(rowsFromText(body).map((r) => [r.line, r.dotted]));
301
+ const kept = [];
302
+ const lines = body.split('\n').map((line, i) => {
303
+ const m = line.match(SECRET_LINE);
304
+ if (!m || m[3].trim() !== SECRET_MASK) return line;
305
+ const row = onDisk.get(submitted.get(i));
306
+ const was = row ? (current[row.line] || '').match(SECRET_LINE) : null;
307
+ // A masked value with nothing behind it — a key moved, or renamed — is written empty rather than
308
+ // guessed at, and the mask never reaches the file.
309
+ if (!was) return `${m[1]}${m[2]}:`;
310
+ kept.push(row.dotted);
311
+ return `${m[1]}${m[2]}: ${was[3]}`;
312
+ });
313
+
314
+ const out = lines.join('\n');
315
+ const tmp = `${full}.tmp-${process.pid}`;
316
+ fs.writeFileSync(tmp, out.endsWith('\n') ? out : `${out}\n`);
317
+ fs.renameSync(tmp, full);
318
+ return { kept };
319
+ }
320
+
321
+ /**
322
+ * Set one dotted key, creating whatever of its path the file does not have yet. A machine's settings.yml
323
+ * is never overwritten by an update (it holds the real secrets), so a block added in a later release is
324
+ * simply absent there; a feature that saves into it creates the lines on first use rather than failing.
325
+ * An existing key is rewritten in place, comment and all; a missing one is added directly under its
326
+ * parent, or at the end of the file for a new top-level block.
327
+ */
328
+ function upsertSettingsValue(file, dotted, value) {
329
+ const full = path.join(CONFIG_DIR, file);
330
+ const text = fs.readFileSync(full, 'utf8');
331
+ const row = rowsFromText(text).find((r) => r.dotted === dotted);
332
+ const out = value === '' ? '""' : value;
333
+ if (row) return writeSettingsValues(file, [{ line: row.line, key: row.key, value: out }]);
334
+
335
+ const parts = dotted.split('.');
336
+ const lines = text.split('\n');
337
+ let parentLine = -1;
338
+ let depth = 0;
339
+ // Walk down the parents that exist: each must sit one level in from the one above it.
340
+ for (; depth < parts.length - 1; depth += 1) {
341
+ const indent = depth * 2;
342
+ let found = -1;
343
+ for (let i = parentLine + 1; i < lines.length; i += 1) {
344
+ const m = lines[i].match(/^(\s*)(\S.*)$/);
345
+ if (!m || m[2].startsWith('#')) continue;
346
+ if (m[1].length < indent) break; // left the parent's block
347
+ if (m[1].length === indent && m[2].replace(/\s+#.*$/, '').trim() === `${parts[depth]}:`) { found = i; break; }
348
+ }
349
+ if (found < 0) break;
350
+ parentLine = found;
351
+ }
352
+ const add = [];
353
+ for (let d = depth; d < parts.length; d += 1) add.push(`${' '.repeat(d * 2)}${parts[d]}:${d === parts.length - 1 ? ` ${!/^(".*"|'.*')$/.test(out) && /[:#]|^\s|\s$|^[@`!&*%|>{}\[\]?]|^-\s|^-$/.test(out) ? JSON.stringify(out) : out}` : ''}`);
354
+ if (parentLine >= 0) lines.splice(parentLine + 1, 0, ...add);
355
+ else {
356
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
357
+ lines.push('', ...add, '');
358
+ }
359
+ fs.writeFileSync(full, lines.join('\n'));
360
+ return 1;
361
+ }
362
+
363
+ /**
364
+ * Set a dotted key to a LIST, in place: the existing items under it are replaced and the rest of the file is
365
+ * left alone; a key (or the path to it) the file lacks is created. Items are always quoted, since a bare *
366
+ * starts a YAML alias.
367
+ */
368
+ function upsertSettingsList(file, dotted, items) {
369
+ const full = path.join(CONFIG_DIR, file);
370
+ const lines = fs.readFileSync(full, 'utf8').split('\n');
371
+ const parts = dotted.split('.');
372
+ let parentLine = -1;
373
+ let depth = 0;
374
+ for (; depth < parts.length; depth += 1) {
375
+ const indent = depth * 2;
376
+ let found = -1;
377
+ for (let i = parentLine + 1; i < lines.length; i += 1) {
378
+ const m = lines[i].match(/^(\s*)(\S.*)$/);
379
+ if (!m || m[2].startsWith('#')) continue;
380
+ if (m[1].length < indent) break;
381
+ if (m[1].length === indent && m[2].replace(/\s+#.*$/, '').trim().replace(/^([^:]+):.*$/, '$1:') === `${parts[depth]}:`) { found = i; break; }
382
+ }
383
+ if (found < 0) break;
384
+ parentLine = found;
385
+ }
386
+ const quote = (v) => JSON.stringify(String(v));
387
+ if (depth === parts.length) {
388
+ // The key exists: drop its old items (the dash lines straight after it) and write the new ones.
389
+ const keyIndent = (lines[parentLine].match(/^\s*/) || [''])[0].length;
390
+ let end = parentLine + 1;
391
+ while (end < lines.length && (/^\s*-\s/.test(lines[end]) || /^\s*-$/.test(lines[end])) && (lines[end].match(/^\s*/) || [''])[0].length >= keyIndent) end += 1;
392
+ lines[parentLine] = lines[parentLine].replace(/:\s*(\[\])?\s*(#.*)?$/, ':$2').replace(/:$/, ':');
393
+ lines.splice(parentLine + 1, end - parentLine - 1, ...items.map((v) => `${' '.repeat(keyIndent + 2)}- ${quote(v)}`));
394
+ if (!items.length) lines[parentLine] = lines[parentLine].replace(/:\s*(#.*)?$/, ': []$1');
395
+ } else {
396
+ const add = [];
397
+ for (let d = depth; d < parts.length; d += 1) add.push(`${' '.repeat(d * 2)}${parts[d]}:${d === parts.length - 1 && !items.length ? ' []' : ''}`);
398
+ if (items.length) add.push(...items.map((v) => `${' '.repeat(parts.length * 2)}- ${quote(v)}`));
399
+ if (parentLine >= 0) lines.splice(parentLine + 1, 0, ...add);
400
+ else {
401
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
402
+ lines.push('', ...add, '');
403
+ }
404
+ }
405
+ fs.writeFileSync(full, lines.join('\n'));
406
+ }
407
+
408
+ module.exports = {
409
+ SETTINGS_FILE_RE,
410
+ isSecretKey,
411
+ listSettingsFiles,
412
+ readSettingsRows,
413
+ writeSettingsValues,
414
+ upsertSettingsValue,
415
+ upsertSettingsList,
416
+ readSettingsText,
417
+ writeSettingsText,
418
+ rowsFromText,
419
+ SECRET_MASK,
420
+ readListBlock,
421
+ writeListBlock,
422
+ writeWorkspacePresentation,
423
+ };