@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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/settings-file.js
ADDED
|
@@ -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
|
+
};
|