@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.
- 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/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/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/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/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 +466 -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 +1439 -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 +7923 -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 +553 -0
package/commands.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command catalog: every command the dashboard can run, kept as data rather than code.
|
|
3
|
+
*
|
|
4
|
+
* Three sources, one list:
|
|
5
|
+
*
|
|
6
|
+
* built in commands/defaults.yml, shipped with the dashboard — an update brings new ones
|
|
7
|
+
* yours core/config/commands.yml, written by the Commands page; one with a built-in's id is your version of it
|
|
8
|
+
* scripts every cmd-*.sh in the tree, listed as it is (read, never copied)
|
|
9
|
+
*
|
|
10
|
+
* A command is a Rundeck job cut down to one machine:
|
|
11
|
+
*
|
|
12
|
+
* id, label, group, description, tags
|
|
13
|
+
* on: workspace | project | folder | file what it runs on (Rundeck's node)
|
|
14
|
+
* when: [ddev, composer, "!workspace:products"] the node filter, the same as a row action's
|
|
15
|
+
* run: a shell line or
|
|
16
|
+
* script: <workspace>/cmd-….sh, with args a script of the tree, run from its own folder
|
|
17
|
+
* options: asked for before it runs, the same as a row action's
|
|
18
|
+
* confirm: asks in a dialog; open: toast | screen
|
|
19
|
+
*
|
|
20
|
+
* A line receives values only as {{workspace}} {{project}} {{folder}} {{file}} {{path}} and {{opt.name}}, each
|
|
21
|
+
* put in single quotes. No line and no value can hold $ or a backtick: the screen runner hands the whole line to
|
|
22
|
+
* an outer shell, which would expand them before it runs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const yaml = require('js-yaml');
|
|
28
|
+
|
|
29
|
+
const { CONFIG_DIR, ROOT, loadWorkspaces, isValidWorkspace, workspaceDir, builderLabel, commandTitle, commandDescription } = require('./workspaces');
|
|
30
|
+
const { NAME_RE, SCRIPT_RE } = require('./validate');
|
|
31
|
+
const rl = require('./row-layout');
|
|
32
|
+
|
|
33
|
+
const ON = {
|
|
34
|
+
workspace: 'a workspace folder',
|
|
35
|
+
project: 'a project',
|
|
36
|
+
folder: 'a folder',
|
|
37
|
+
file: 'a file',
|
|
38
|
+
};
|
|
39
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,44}$/;
|
|
40
|
+
const TAG_RE = /^[a-z0-9][a-z0-9-]{0,20}$/;
|
|
41
|
+
const SCRIPT_REF_RE = /^([a-z0-9_-]{1,40})\/(cmd-[a-zA-Z0-9_.-]+\.sh)$/;
|
|
42
|
+
const PLACEHOLDER_ANY_RE = /\{\{([^}]*)\}\}/g;
|
|
43
|
+
const OPT_PLACEHOLDER_RE = /^opt\.[a-z][a-z0-9_]{0,30}$/;
|
|
44
|
+
const VARS = ['workspace', 'project', 'folder', 'file', 'path'];
|
|
45
|
+
// Which targets give a value: a workspace folder has no project, and only a file has a file.
|
|
46
|
+
const VAR_NEEDS = { project: ['project', 'folder', 'file'], folder: ['folder', 'file'], file: ['file'] };
|
|
47
|
+
// A path inside a target: plain names joined by /, never . or .. — spaces are fine, the value is quoted.
|
|
48
|
+
const REL_PATH_RE = /^[A-Za-z0-9_.@+~ ,()-]{1,120}(?:\/[A-Za-z0-9_.@+~ ,()-]{1,120}){0,20}$/;
|
|
49
|
+
const SAFE_DIR_RE = /^[\w/.@+~-]+$/;
|
|
50
|
+
const MAX_COMMANDS = 500;
|
|
51
|
+
|
|
52
|
+
function defaultsFile() { return process.env.COMMANDS_DEFAULTS_FILE || path.join(__dirname, 'commands', 'defaults.yml'); }
|
|
53
|
+
function userFile() { return process.env.COMMANDS_FILE || path.join(CONFIG_DIR, 'commands.yml'); }
|
|
54
|
+
|
|
55
|
+
const str = (v) => (v === undefined || v === null || typeof v === 'object' ? '' : String(v)).trim();
|
|
56
|
+
|
|
57
|
+
/** A command from whatever a file or a form held. An unknown `on` is kept, so it is refused rather than changed. */
|
|
58
|
+
function cleanCommand(c, source = 'yours') {
|
|
59
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
60
|
+
const on = str(c.on);
|
|
61
|
+
return {
|
|
62
|
+
id: str(c.id),
|
|
63
|
+
label: str(c.label),
|
|
64
|
+
group: str(c.group) || 'General',
|
|
65
|
+
description: str(c.description),
|
|
66
|
+
on: on || 'project',
|
|
67
|
+
when: rl.whenList(c.when),
|
|
68
|
+
run: str(c.run),
|
|
69
|
+
script: str(c.script),
|
|
70
|
+
args: str(c.args),
|
|
71
|
+
options: rl.cleanCustom({ options: c.options }).options,
|
|
72
|
+
confirm: c.confirm === true,
|
|
73
|
+
open: c.open === 'screen' ? 'screen' : 'toast',
|
|
74
|
+
tags: (Array.isArray(c.tags) ? c.tags : []).map(str).filter(Boolean),
|
|
75
|
+
source,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a command runs, as one line: its run line, or the script with its arguments. */
|
|
80
|
+
function lineOf(e) {
|
|
81
|
+
return e.run || `bash ${e.script}${e.args ? ` ${e.args}` : ''}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Why this command must not be saved or run, or '' when it may. */
|
|
85
|
+
function commandRefusal(e) {
|
|
86
|
+
if (!e) return 'it is not a command (a mapping with id, label, and run or script)';
|
|
87
|
+
if (!ID_RE.test(e.id)) return 'the id is lower-case letters, digits and dashes (up to 45)';
|
|
88
|
+
if (!ON[e.on]) return `"on" is ${Object.keys(ON).join(', ')}`;
|
|
89
|
+
if (!!e.run === !!e.script) return 'a command has either run (a shell line) or script (a cmd-*.sh), and not both';
|
|
90
|
+
if (e.script && !SCRIPT_REF_RE.test(e.script)) return 'script is <workspace>/cmd-<name>.sh, like dev/cmd-tools-backup-dev.sh';
|
|
91
|
+
if (e.run && e.args) return 'args go with a script — put them in the run line instead';
|
|
92
|
+
if (e.tags.length > 10 || e.tags.some((t) => !TAG_RE.test(t))) return 'tags are up to 10 short lower-case names';
|
|
93
|
+
// Label, group, description, when, the line itself and the options are what a row action must be: the same code checks them.
|
|
94
|
+
const why = rl.customRefusal({ ...e, button: 'more', keepgoing: false, steps: [{ command: lineOf(e), onError: '' }] });
|
|
95
|
+
if (why) return why;
|
|
96
|
+
for (const m of lineOf(e).matchAll(PLACEHOLDER_ANY_RE)) {
|
|
97
|
+
const name = m[1];
|
|
98
|
+
if (name.startsWith('opt.')) {
|
|
99
|
+
if (!OPT_PLACEHOLDER_RE.test(name)) return `{{${name}}} is not an option name`;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!VARS.includes(name)) return `{{${name}}} is not a value a command can use: ${VARS.map((v) => `{{${v}}}`).join(' ')} or {{opt.name}}`;
|
|
103
|
+
if (VAR_NEEDS[name] && !VAR_NEEDS[name].includes(e.on)) return `{{${name}}} needs "on" to be ${VAR_NEEDS[name].join(' or ')}`;
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The YAML shape of a command: only what differs from the defaults. */
|
|
109
|
+
function commandToDoc(e) {
|
|
110
|
+
const doc = { id: e.id, label: e.label };
|
|
111
|
+
if (e.group && e.group !== 'General') doc.group = e.group;
|
|
112
|
+
if (e.description) doc.description = e.description;
|
|
113
|
+
doc.on = e.on;
|
|
114
|
+
if (!(e.when.length === 1 && e.when[0] === 'any')) doc.when = e.when;
|
|
115
|
+
if (e.run) {
|
|
116
|
+
doc.run = e.run;
|
|
117
|
+
} else {
|
|
118
|
+
doc.script = e.script;
|
|
119
|
+
if (e.args) doc.args = e.args;
|
|
120
|
+
}
|
|
121
|
+
if (e.options.length) {
|
|
122
|
+
doc.options = e.options.map((o) => {
|
|
123
|
+
const d = { name: o.name };
|
|
124
|
+
if (o.label) d.label = o.label;
|
|
125
|
+
if (o.value) d.value = o.value;
|
|
126
|
+
if (o.values.length) d.values = o.values;
|
|
127
|
+
if (o.enforced) d.enforced = true;
|
|
128
|
+
if (o.required) d.required = true;
|
|
129
|
+
if (o.regex) d.regex = o.regex;
|
|
130
|
+
return d;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (e.confirm) doc.confirm = true;
|
|
134
|
+
if (e.open === 'screen') doc.open = 'screen';
|
|
135
|
+
if (e.tags.length) doc.tags = e.tags;
|
|
136
|
+
return doc;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mtimeOf(file) {
|
|
140
|
+
try { return Math.floor(fs.statSync(file).mtimeMs); } catch (_) { return 0; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The commands a file holds, what was skipped and why, and whether the file could be read at all. */
|
|
144
|
+
function readList(file, source) {
|
|
145
|
+
const empty = { entries: [], problems: [], error: '' };
|
|
146
|
+
let text;
|
|
147
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (_) { return empty; }
|
|
148
|
+
let doc;
|
|
149
|
+
try { doc = yaml.load(text); } catch (err) { return { ...empty, error: `it is not valid YAML (${String(err.reason || err.message).slice(0, 120)})` }; }
|
|
150
|
+
if (!rl.withinBudget(doc)) return { ...empty, error: 'it is too large or too deeply nested to be a list of commands' };
|
|
151
|
+
if (doc === null || doc === undefined) return empty;
|
|
152
|
+
if (typeof doc !== 'object' || Array.isArray(doc)) return { ...empty, error: 'it is not a mapping with a commands: list' };
|
|
153
|
+
if (doc.commands === undefined || doc.commands === null) return empty;
|
|
154
|
+
if (!Array.isArray(doc.commands)) return { ...empty, error: '"commands" is not a list' };
|
|
155
|
+
const entries = [];
|
|
156
|
+
const problems = [];
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
for (const raw of doc.commands.slice(0, MAX_COMMANDS)) {
|
|
159
|
+
const e = cleanCommand(raw, source);
|
|
160
|
+
const why = commandRefusal(e) || (seen.has(e.id) ? 'the id is used twice' : '');
|
|
161
|
+
if (why) { problems.push(`${(e && e.id) || '(no id)'}: ${why}`); continue; }
|
|
162
|
+
seen.add(e.id);
|
|
163
|
+
entries.push(e);
|
|
164
|
+
}
|
|
165
|
+
if (doc.commands.length > MAX_COMMANDS) problems.push(`only the first ${MAX_COMMANDS} commands are read`);
|
|
166
|
+
return { entries, problems, error: '' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let cache = { key: '', value: null };
|
|
170
|
+
|
|
171
|
+
/** Built in and yours, merged by id: yours replaces a built-in one in its place, and a new one of yours comes after. */
|
|
172
|
+
function loadCatalog() {
|
|
173
|
+
const key = `${defaultsFile()}:${mtimeOf(defaultsFile())}|${userFile()}:${mtimeOf(userFile())}`;
|
|
174
|
+
if (cache.key === key && cache.value) return cache.value;
|
|
175
|
+
const builtin = readList(defaultsFile(), 'builtin');
|
|
176
|
+
const yours = readList(userFile(), 'yours');
|
|
177
|
+
const byId = new Map(builtin.entries.map((e) => [e.id, e]));
|
|
178
|
+
for (const e of yours.entries) byId.set(e.id, byId.has(e.id) ? { ...e, overrides: true } : e);
|
|
179
|
+
const value = {
|
|
180
|
+
commands: [...byId.values()],
|
|
181
|
+
builtin: builtin.entries,
|
|
182
|
+
yours: yours.entries,
|
|
183
|
+
problems: [...builtin.problems.map((p) => `built in — ${p}`), ...yours.problems],
|
|
184
|
+
error: yours.error,
|
|
185
|
+
builtinError: builtin.error,
|
|
186
|
+
mtime: mtimeOf(userFile()),
|
|
187
|
+
};
|
|
188
|
+
cache = { key, value };
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function invalidateCatalog() { cache = { key: '', value: null }; }
|
|
193
|
+
|
|
194
|
+
function findCommand(id) {
|
|
195
|
+
return loadCatalog().commands.find((c) => c.id === String(id || '')) || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const HEADER = '# Your commands for the Webship Workspace dashboard (see core/workspace-app/commands.js).\n'
|
|
199
|
+
+ '# Written by the Commands page; a hand edit works too. A command with a built-in id is your version of it.\n'
|
|
200
|
+
+ '# Delete this file to go back to the built-in commands only.\n';
|
|
201
|
+
|
|
202
|
+
function yourDocText(list) {
|
|
203
|
+
return `${HEADER}${yaml.dump({ commands: list.map(commandToDoc) }, { lineWidth: 140, noRefs: true })}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Write your commands, unless the file changed since the page that asks was drawn. No commands removes the file. */
|
|
207
|
+
function saveYourCommands(list, expectedMtime) {
|
|
208
|
+
const file = userFile();
|
|
209
|
+
if (expectedMtime !== undefined && expectedMtime !== null && String(expectedMtime) !== ''
|
|
210
|
+
&& String(mtimeOf(file)) !== String(expectedMtime)) {
|
|
211
|
+
const err = new Error('Your commands changed since this page was loaded.');
|
|
212
|
+
err.code = 'CHANGED';
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
if (!list.length) {
|
|
216
|
+
try { fs.unlinkSync(file); } catch (_) { /* already gone */ }
|
|
217
|
+
invalidateCatalog();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
221
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
222
|
+
fs.writeFileSync(tmp, yourDocText(list));
|
|
223
|
+
fs.renameSync(tmp, file);
|
|
224
|
+
invalidateCatalog();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function definitionText() {
|
|
228
|
+
try {
|
|
229
|
+
const text = fs.readFileSync(userFile(), 'utf8');
|
|
230
|
+
if (text.trim()) return text;
|
|
231
|
+
} catch (_) { /* no file yet */ }
|
|
232
|
+
return `${HEADER}commands: []\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** What loading this YAML as your commands would do, and the commands it would leave — or why not. */
|
|
236
|
+
function checkDefinition(text) {
|
|
237
|
+
const report = { errors: [], notes: [], next: null };
|
|
238
|
+
const raw = String(text === undefined || text === null ? '' : text);
|
|
239
|
+
if (!raw.trim()) {
|
|
240
|
+
report.errors.push('The definition is empty. To remove every command of yours, use Remove all of yours below.');
|
|
241
|
+
return report;
|
|
242
|
+
}
|
|
243
|
+
let doc;
|
|
244
|
+
try { doc = yaml.load(raw); } catch (err) {
|
|
245
|
+
report.errors.push(`It is not valid YAML: ${String(err.reason || err.message).slice(0, 200)}`);
|
|
246
|
+
return report;
|
|
247
|
+
}
|
|
248
|
+
if (!rl.withinBudget(doc)) {
|
|
249
|
+
report.errors.push('The definition is too large or too deeply nested to be a list of commands (YAML aliases can make a few lines stand for billions of values).');
|
|
250
|
+
return report;
|
|
251
|
+
}
|
|
252
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !Array.isArray(doc.commands)) {
|
|
253
|
+
report.errors.push('It needs a "commands:" list.');
|
|
254
|
+
return report;
|
|
255
|
+
}
|
|
256
|
+
if (doc.commands.length > MAX_COMMANDS) {
|
|
257
|
+
report.errors.push(`Up to ${MAX_COMMANDS} commands.`);
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
const next = [];
|
|
261
|
+
const ids = new Set();
|
|
262
|
+
doc.commands.forEach((item, i) => {
|
|
263
|
+
const e = cleanCommand(item, 'yours');
|
|
264
|
+
const why = commandRefusal(e) || (ids.has(e.id) ? 'the id is used twice' : '');
|
|
265
|
+
if (why) report.errors.push(`Command ${i + 1}${e && e.id ? ` (${e.id})` : ''}: ${why}.`);
|
|
266
|
+
else { ids.add(e.id); next.push(e); }
|
|
267
|
+
});
|
|
268
|
+
if (report.errors.length) return report;
|
|
269
|
+
|
|
270
|
+
const current = loadCatalog();
|
|
271
|
+
const before = new Map(current.yours.map((e) => [e.id, JSON.stringify(commandToDoc(e))]));
|
|
272
|
+
const builtinIds = new Set(current.builtin.map((e) => e.id));
|
|
273
|
+
const added = next.filter((e) => !before.has(e.id)).map((e) => e.id);
|
|
274
|
+
const changed = next.filter((e) => before.has(e.id) && before.get(e.id) !== JSON.stringify(commandToDoc(e))).map((e) => e.id);
|
|
275
|
+
const removed = [...before.keys()].filter((id) => !ids.has(id));
|
|
276
|
+
const overrides = next.filter((e) => builtinIds.has(e.id)).map((e) => e.id);
|
|
277
|
+
// A command of yours that a button's action uses cannot go: the action would be left with a step that runs nothing.
|
|
278
|
+
for (const id of removed.filter((x) => !builtinIds.has(x))) {
|
|
279
|
+
const by = usedBy(id);
|
|
280
|
+
if (by.length) report.errors.push(`It removes ${id}, which is used by ${describeUses(by)} — take it off the button first.`);
|
|
281
|
+
}
|
|
282
|
+
if (report.errors.length) return report;
|
|
283
|
+
report.notes.push(`${next.length} command${next.length === 1 ? '' : 's'} of yours.`);
|
|
284
|
+
if (added.length) report.notes.push(`Adds ${added.join(', ')}.`);
|
|
285
|
+
if (changed.length) report.notes.push(`Changes ${changed.join(', ')}.`);
|
|
286
|
+
if (removed.length) report.notes.push(`Removes ${removed.join(', ')}.`);
|
|
287
|
+
if (overrides.length) report.notes.push(`Your version of the built-in ${overrides.join(', ')}.`);
|
|
288
|
+
if (!added.length && !changed.length && !removed.length) report.notes.push('Nothing changes.');
|
|
289
|
+
report.next = next;
|
|
290
|
+
return report;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------- scripts
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Every cmd-*.sh in the tree, grouped by the workspace folder it lives in. commands/ is not a registered workspace
|
|
297
|
+
* — its cmd-tools-commands.sh still belongs in the list. What each one says about itself is read from the script,
|
|
298
|
+
* so it cannot fall out of step with an edit made by someone who did not know a list existed.
|
|
299
|
+
*/
|
|
300
|
+
function listScripts() {
|
|
301
|
+
const all = loadWorkspaces();
|
|
302
|
+
const groups = [];
|
|
303
|
+
for (const key of [...Object.keys(all), 'commands']) {
|
|
304
|
+
const dir = all[key] ? all[key].dir : path.join(ROOT, key);
|
|
305
|
+
const label = all[key] ? all[key].label : key.charAt(0).toUpperCase() + key.slice(1);
|
|
306
|
+
let files = [];
|
|
307
|
+
try {
|
|
308
|
+
// Builders first in their order, then the tools in theirs — Settings → Command order, over the
|
|
309
|
+
// shipped default. Alphabetical used to drop `cmd-tools-backup-dev.sh` between two Webship
|
|
310
|
+
// builders, which reads as a mistake before it reads as an order. Lazy require: build-order.js
|
|
311
|
+
// reads CONFIG_DIR from workspaces.js, and this module is already in that neighbourhood.
|
|
312
|
+
files = require('./build-order').sortCommands(
|
|
313
|
+
fs.readdirSync(dir).filter((f) => SCRIPT_RE.test(f)), key);
|
|
314
|
+
} catch (_) { continue; }
|
|
315
|
+
if (!files.length) continue;
|
|
316
|
+
groups.push({
|
|
317
|
+
key,
|
|
318
|
+
label,
|
|
319
|
+
dir,
|
|
320
|
+
items: files.map((f) => ({
|
|
321
|
+
file: f,
|
|
322
|
+
label: builderLabel(dir, f),
|
|
323
|
+
title: commandTitle(dir, f),
|
|
324
|
+
desc: commandDescription(dir, f),
|
|
325
|
+
})),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return groups;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The scripts as catalog entries, so one list can show, search and filter all of them. */
|
|
332
|
+
function scriptEntries(groups) {
|
|
333
|
+
return groups.flatMap((g) => g.items.map((it) => ({
|
|
334
|
+
id: `${g.key}/${it.file}`,
|
|
335
|
+
source: 'script',
|
|
336
|
+
label: it.title || it.file,
|
|
337
|
+
file: it.file,
|
|
338
|
+
workspace: g.key,
|
|
339
|
+
group: `Scripts · ${g.label}`,
|
|
340
|
+
description: it.desc && it.desc !== it.title ? it.desc : '',
|
|
341
|
+
on: 'workspace',
|
|
342
|
+
when: ['any'],
|
|
343
|
+
run: '',
|
|
344
|
+
script: `${g.key}/${it.file}`,
|
|
345
|
+
args: '',
|
|
346
|
+
options: [],
|
|
347
|
+
confirm: false,
|
|
348
|
+
open: 'toast',
|
|
349
|
+
tags: [],
|
|
350
|
+
})));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function filterCommands(list, { q = '', source = 'all', on = 'all' } = {}) {
|
|
354
|
+
const needle = String(q || '').trim().toLowerCase();
|
|
355
|
+
return list.filter((e) => {
|
|
356
|
+
if (source !== 'all' && e.source !== source) return false;
|
|
357
|
+
if (on !== 'all' && e.on !== on) return false;
|
|
358
|
+
if (!needle) return true;
|
|
359
|
+
return [e.id, e.label, e.group, e.description, e.run, e.script, e.args, ...(e.tags || [])].join(' ').toLowerCase().includes(needle);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------- on buttons
|
|
364
|
+
|
|
365
|
+
/** The row actions whose steps use this command. */
|
|
366
|
+
function usedBy(id) {
|
|
367
|
+
return rl.loadRowLayout().custom.filter((c) => c.steps.some((s) => s.use === id));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function describeUses(actions) {
|
|
371
|
+
return actions.map((a) => `${rl.buttonLabel(a.button)} → ${a.group} → ${a.label}`).join(', ');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The line a `use:` step runs from a project row. A command that runs on a project or a folder runs in the project
|
|
376
|
+
* (its folder is the project itself); one that runs on a workspace folder, and a script, runs from its own folder.
|
|
377
|
+
* Its {{opt.name}} placeholders are left for the action, which asks for the options and fills them.
|
|
378
|
+
*/
|
|
379
|
+
function stepLine(cmd, { workspace, project, projectDir }, env = { workspaceDir, exists: fs.existsSync }) {
|
|
380
|
+
if (cmd.on === 'file') return { error: `${cmd.label} runs on a file, and a project row gives no file to run it on` };
|
|
381
|
+
const wsDir = env.workspaceDir(workspace);
|
|
382
|
+
const vars = {
|
|
383
|
+
workspace,
|
|
384
|
+
project: cmd.on === 'workspace' ? '' : project,
|
|
385
|
+
folder: cmd.on === 'folder' ? '.' : '',
|
|
386
|
+
file: '',
|
|
387
|
+
path: cmd.on === 'workspace' ? wsDir : projectDir,
|
|
388
|
+
};
|
|
389
|
+
const fillVars = (t) => t.replace(PLACEHOLDER_ANY_RE, (whole, name) => (VARS.includes(name) ? shq(vars[name]) : whole));
|
|
390
|
+
let body;
|
|
391
|
+
let dir = '';
|
|
392
|
+
if (cmd.run) {
|
|
393
|
+
body = fillVars(cmd.run);
|
|
394
|
+
if (cmd.on === 'workspace') dir = wsDir;
|
|
395
|
+
} else {
|
|
396
|
+
const file = scriptPath(cmd, env);
|
|
397
|
+
if (!file || !env.exists(file)) return { error: `${cmd.label} runs ${cmd.script}, which does not exist` };
|
|
398
|
+
body = `bash ${shq(file)}${cmd.args ? ` ${fillVars(cmd.args)}` : ''}`;
|
|
399
|
+
dir = path.dirname(file);
|
|
400
|
+
}
|
|
401
|
+
if (dir && !SAFE_DIR_RE.test(dir)) return { error: `${cmd.label} runs from a folder whose path a shell line cannot carry safely` };
|
|
402
|
+
return { command: dir ? `( cd ${shq(dir)} && ${body} )` : body };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** An action with each use: step replaced by that command's line, for the project it runs on — or why not. */
|
|
406
|
+
function expandSteps(entry, ctx, env) {
|
|
407
|
+
const steps = [];
|
|
408
|
+
for (const [i, s] of entry.steps.entries()) {
|
|
409
|
+
if (!s.use) { steps.push(s); continue; }
|
|
410
|
+
const cmd = findCommand(s.use);
|
|
411
|
+
if (!cmd) return { error: `step ${i + 1} uses ${s.use}, which is not a command any more` };
|
|
412
|
+
const line = stepLine(cmd, ctx, env);
|
|
413
|
+
if (line.error) return { error: `step ${i + 1}: ${line.error}` };
|
|
414
|
+
steps.push({ command: line.command, onError: s.onError, use: '' });
|
|
415
|
+
}
|
|
416
|
+
return { entry: { ...entry, steps } };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------- running
|
|
420
|
+
|
|
421
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch (_) { return false; } };
|
|
422
|
+
|
|
423
|
+
/** The folders a target's project can be picked from. */
|
|
424
|
+
function listFolders(dir) {
|
|
425
|
+
try {
|
|
426
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
427
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && NAME_RE.test(d.name))
|
|
428
|
+
.map((d) => d.name).sort().slice(0, 500);
|
|
429
|
+
} catch (_) { return []; }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Where a command runs and the values it gets, from what a run form picked — or why not.
|
|
434
|
+
*
|
|
435
|
+
* workspace the workspace folder
|
|
436
|
+
* project a project folder in it
|
|
437
|
+
* folder a folder inside the workspace, or inside a project when one is picked (empty: that folder itself)
|
|
438
|
+
* file a file there; the command runs from the workspace or project folder, and {{file}} is relative to it
|
|
439
|
+
*
|
|
440
|
+
* The path is resolved and must still be inside the workspace or project, so a link cannot lead out of it.
|
|
441
|
+
*/
|
|
442
|
+
function resolveTarget(e, form = {}, env = { isValidWorkspace, workspaceDir }) {
|
|
443
|
+
const ws = str(form.target_ws);
|
|
444
|
+
if (!env.isValidWorkspace(ws)) return { error: 'Pick a workspace.' };
|
|
445
|
+
const wsDir = env.workspaceDir(ws);
|
|
446
|
+
const project = e.on === 'workspace' ? '' : str(form.target_project);
|
|
447
|
+
let base = wsDir;
|
|
448
|
+
if (project) {
|
|
449
|
+
if (!NAME_RE.test(project)) return { error: 'That is not a project name.' };
|
|
450
|
+
base = path.join(wsDir, project);
|
|
451
|
+
if (!isDir(base)) return { error: `There is no ${project} folder in ${ws}.` };
|
|
452
|
+
} else if (e.on === 'project') {
|
|
453
|
+
return { error: 'Pick a project.' };
|
|
454
|
+
}
|
|
455
|
+
const rel = (e.on === 'folder' || e.on === 'file') ? str(form.target_path).replace(/^(\.\/)+/, '').replace(/\/+$/, '') : '';
|
|
456
|
+
let target = base;
|
|
457
|
+
if (e.on === 'file' && !rel) return { error: 'Name the file.' };
|
|
458
|
+
if (rel) {
|
|
459
|
+
if (!REL_PATH_RE.test(rel) || rel.split('/').some((s) => s === '.' || s === '..')) {
|
|
460
|
+
return { error: 'The path is a relative path inside the target: names with letters, digits, spaces and . _ - @ + ~ , ( ), joined by /.' };
|
|
461
|
+
}
|
|
462
|
+
let real;
|
|
463
|
+
let realBase;
|
|
464
|
+
try {
|
|
465
|
+
real = fs.realpathSync(path.join(base, rel));
|
|
466
|
+
realBase = fs.realpathSync(base);
|
|
467
|
+
} catch (_) { return { error: `There is nothing at ${rel}.` }; }
|
|
468
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) return { error: `${rel} leads outside ${project || ws}.` };
|
|
469
|
+
target = path.join(base, rel);
|
|
470
|
+
}
|
|
471
|
+
let st;
|
|
472
|
+
try { st = fs.statSync(target); } catch (_) { return { error: `There is nothing at ${rel || project || ws}.` }; }
|
|
473
|
+
if (e.on === 'folder' && !st.isDirectory()) return { error: `${rel} is not a folder.` };
|
|
474
|
+
if (e.on === 'file' && !st.isFile()) return { error: `${rel} is not a file.` };
|
|
475
|
+
|
|
476
|
+
const cwd = e.on === 'folder' ? target : base;
|
|
477
|
+
if (!SAFE_DIR_RE.test(cwd)) return { error: 'The folder path has characters a shell line cannot carry safely.' };
|
|
478
|
+
const vars = {
|
|
479
|
+
workspace: ws,
|
|
480
|
+
project,
|
|
481
|
+
folder: e.on === 'folder' ? (rel || '.') : (e.on === 'file' ? path.dirname(rel) : ''),
|
|
482
|
+
file: e.on === 'file' ? rel : '',
|
|
483
|
+
path: target,
|
|
484
|
+
};
|
|
485
|
+
if (Object.values(vars).some((v) => /[$`\r\n\0]/.test(v))) return { error: 'The target has a $, a backtick or a line break in its path.' };
|
|
486
|
+
const where = [project, rel].filter(Boolean).join('/');
|
|
487
|
+
return { ws, project, rel, base, cwd, target, vars, where, name: [ws, where].filter(Boolean).join('/') };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Names to offer while a path is typed: what is in the folder the typed part ends in (folders only, for a folder). */
|
|
491
|
+
function pathSuggestions(e, form = {}, env = { isValidWorkspace, workspaceDir }) {
|
|
492
|
+
const ws = str(form.target_ws);
|
|
493
|
+
if (!env.isValidWorkspace(ws)) return [];
|
|
494
|
+
const project = str(form.target_project);
|
|
495
|
+
let base = env.workspaceDir(ws);
|
|
496
|
+
if (project) {
|
|
497
|
+
if (!NAME_RE.test(project)) return [];
|
|
498
|
+
base = path.join(base, project);
|
|
499
|
+
}
|
|
500
|
+
const typed = str(form.target_path);
|
|
501
|
+
const dirPart = typed.includes('/') ? typed.slice(0, typed.lastIndexOf('/')) : '';
|
|
502
|
+
if (dirPart && (!REL_PATH_RE.test(dirPart) || dirPart.split('/').some((s) => s === '.' || s === '..'))) return [];
|
|
503
|
+
const dir = path.join(base, dirPart);
|
|
504
|
+
try {
|
|
505
|
+
const realBase = fs.realpathSync(base);
|
|
506
|
+
const real = fs.realpathSync(dir);
|
|
507
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) return [];
|
|
508
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
509
|
+
.filter((d) => !d.name.startsWith('.') && REL_PATH_RE.test(d.name) && (e.on !== 'folder' || d.isDirectory()))
|
|
510
|
+
.map((d) => `${dirPart ? `${dirPart}/` : ''}${d.name}${d.isDirectory() ? '/' : ''}`)
|
|
511
|
+
.sort().slice(0, 200);
|
|
512
|
+
} catch (_) { return []; }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const shq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
516
|
+
|
|
517
|
+
function fill(text, vars, values) {
|
|
518
|
+
return text.replace(PLACEHOLDER_ANY_RE, (whole, name) => {
|
|
519
|
+
if (name.startsWith('opt.')) return shq(values[name.slice(4)] === undefined ? '' : values[name.slice(4)]);
|
|
520
|
+
return VARS.includes(name) ? shq(vars[name] === undefined ? '' : vars[name]) : whole;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function scriptPath(e, env = { workspaceDir }) {
|
|
525
|
+
const m = SCRIPT_REF_RE.exec(e.script || '');
|
|
526
|
+
if (!m) return null;
|
|
527
|
+
return path.join(m[1] === 'commands' ? path.join(ROOT, 'commands') : env.workspaceDir(m[1]), m[2]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The line a run hands to its screen session, with the markers the Activity tab reads its result from. */
|
|
531
|
+
function commandLine(e, vars = {}, values = {}, env = { workspaceDir }) {
|
|
532
|
+
const body = e.run
|
|
533
|
+
? fill(e.run, vars, values)
|
|
534
|
+
: `bash ${shq(scriptPath(e, env))}${e.args ? ` ${fill(e.args, vars, values)}` : ''}`;
|
|
535
|
+
return `{ ${body} ; } && echo "ROW-ACTION succeeded" || echo "ROW-ACTION failed"`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
module.exports = {
|
|
539
|
+
ON,
|
|
540
|
+
VARS,
|
|
541
|
+
ID_RE,
|
|
542
|
+
SCRIPT_REF_RE,
|
|
543
|
+
MAX_COMMANDS,
|
|
544
|
+
defaultsFile,
|
|
545
|
+
userFile,
|
|
546
|
+
cleanCommand,
|
|
547
|
+
commandRefusal,
|
|
548
|
+
commandToDoc,
|
|
549
|
+
lineOf,
|
|
550
|
+
loadCatalog,
|
|
551
|
+
invalidateCatalog,
|
|
552
|
+
findCommand,
|
|
553
|
+
saveYourCommands,
|
|
554
|
+
definitionText,
|
|
555
|
+
checkDefinition,
|
|
556
|
+
listScripts,
|
|
557
|
+
scriptEntries,
|
|
558
|
+
filterCommands,
|
|
559
|
+
listFolders,
|
|
560
|
+
resolveTarget,
|
|
561
|
+
pathSuggestions,
|
|
562
|
+
scriptPath,
|
|
563
|
+
commandLine,
|
|
564
|
+
usedBy,
|
|
565
|
+
describeUses,
|
|
566
|
+
stepLine,
|
|
567
|
+
expandSteps,
|
|
568
|
+
};
|
package/cucumber.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webship-js (Playwright + Cucumber-js) against the running dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The steps come from the package rather than from a copy in this repository: webship-js is the
|
|
5
|
+
* workspace's own testing stack, so the dashboard is tested with the tool the dashboard scaffolds
|
|
6
|
+
* for everything else.
|
|
7
|
+
*
|
|
8
|
+
* It runs against the LIVE site, not a copy stood up for the occasion. The dashboard is a DDEV
|
|
9
|
+
* project with a job registry and real projects behind it, and a throwaway instance would be
|
|
10
|
+
* testing something other than the thing that serves.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const stepsFromPackage = 'node_modules/webship-js/tests/step-definitions/**/*.js';
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
default: {
|
|
17
|
+
// Longer than Playwright's own 30s, so a Playwright failure surfaces as its own message rather
|
|
18
|
+
// than as cucumber's "function timed out".
|
|
19
|
+
timeout: 45000,
|
|
20
|
+
require: [stepsFromPackage, 'tests/step-definitions/**/*.js'],
|
|
21
|
+
paths: ['tests/features/**/*.feature'],
|
|
22
|
+
format: ['progress', 'json:tests/reports/cucumber_report.json'],
|
|
23
|
+
worldParameters: {
|
|
24
|
+
launchUrl: process.env.LAUNCH_URL || 'https://workspace.ddev.site',
|
|
25
|
+
// DDEV signs its own certificates, and a browser in the container has no reason to trust one.
|
|
26
|
+
ignoreHTTPSErrors: true,
|
|
27
|
+
// webship-js pauses around every scenario by these amounts. Required, not optional: it reads
|
|
28
|
+
// them without a default and a missing block fails in its own Before hook, before any step
|
|
29
|
+
// has run — which reads as "13 scenarios failed" with nothing to point at.
|
|
30
|
+
minWaitTime: {
|
|
31
|
+
before_scenario: 0,
|
|
32
|
+
after_scenario: 0,
|
|
33
|
+
// The dashboard is HTMX: a click swaps a fragment in rather than loading a page, so a step
|
|
34
|
+
// that asserts on the result needs the swap to have landed.
|
|
35
|
+
before_step: 0,
|
|
36
|
+
after_step: 250,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
// One at a time: these run against ONE live dashboard with shared state, and two scenarios
|
|
40
|
+
// filtering the same list at once would read each other's results.
|
|
41
|
+
parallel: 0,
|
|
42
|
+
},
|
|
43
|
+
};
|