@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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing, updating and removing a project's DDEV add-ons.
|
|
3
|
+
*
|
|
4
|
+
* All three change what is in `.ddev/` and need a restart before they take effect, and all three can
|
|
5
|
+
* take a minute on a cold cache — so they run detached in the project's own screen session like the
|
|
6
|
+
* other long DDEV verbs, and the row's SCREENS group lists them while they work.
|
|
7
|
+
*
|
|
8
|
+
* The one thing this module exists to be careful about: `ddev add-on get` accepts a URL or a local
|
|
9
|
+
* path as readily as a repository, so it is a download-and-run from anywhere. A name that arrives
|
|
10
|
+
* from a browser is therefore checked against the REGISTRY LISTING — not merely against a pattern —
|
|
11
|
+
* and when the registry cannot be read nothing is installed at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { esc } = require('../html');
|
|
17
|
+
const { workspaceDir } = require('../workspaces');
|
|
18
|
+
const { ddevProjectName, startInScreen, screenStartedHtml } = require('../jobs');
|
|
19
|
+
const { NAME_RE } = require('../validate');
|
|
20
|
+
const addons = require('../ddev-addons');
|
|
21
|
+
|
|
22
|
+
// An installed add-on is removed and updated BY NAME (`adminer`), not by repository: that is the name
|
|
23
|
+
// DDEV recorded for it, and the only names accepted are the ones it recorded.
|
|
24
|
+
const INSTALLED_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
25
|
+
|
|
26
|
+
async function project(form, workspace) {
|
|
27
|
+
const projectName = String(form.projectName || '');
|
|
28
|
+
if (!NAME_RE.test(projectName)) return { error: 'Invalid project name.' };
|
|
29
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
30
|
+
if (!ddevProjectName(projectDir)) return { error: 'Not a DDEV project.' };
|
|
31
|
+
return { projectName, projectDir };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function addon_get({ form, send, workspace }) {
|
|
35
|
+
const p = await project(form, workspace);
|
|
36
|
+
if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
|
|
37
|
+
const repo = String(form.addon || '').trim();
|
|
38
|
+
// The registry is the allowlist. A repository nobody publishes is not installed from here, and a
|
|
39
|
+
// registry that could not be read means nothing is installed rather than anything being trusted.
|
|
40
|
+
const known = await addons.isKnownAddon(repo);
|
|
41
|
+
if (!known.ok) return send(`<div class="msg error">${esc(known.why)}</div>`, 400);
|
|
42
|
+
addons.invalidateRegistry();
|
|
43
|
+
const started = await startInScreen({
|
|
44
|
+
workspace,
|
|
45
|
+
project: p.projectName,
|
|
46
|
+
projectDir: p.projectDir,
|
|
47
|
+
verb: 'addon-get',
|
|
48
|
+
cmdLine: `ddev add-on get ${repo} && echo "Installed. Run ddev restart to enable it."`,
|
|
49
|
+
label: `ddev add-on get ${repo}`,
|
|
50
|
+
});
|
|
51
|
+
return send(screenStartedHtml(started, `ddev add-on get ${repo}`, p.projectName),
|
|
52
|
+
started.ok ? 200 : (started.already ? 409 : 500));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function addon_verb({ form, send, workspace, pathname }) {
|
|
56
|
+
const p = await project(form, workspace);
|
|
57
|
+
if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
|
|
58
|
+
const name = String(form.addon || '').trim();
|
|
59
|
+
if (!INSTALLED_NAME_RE.test(name)) return send('<div class="msg error">Invalid add-on name.</div>', 400);
|
|
60
|
+
// Only what DDEV has actually recorded in this project: removing or updating something that is not
|
|
61
|
+
// installed is an error worth giving now rather than in a screen session.
|
|
62
|
+
const installed = addons.installedAddons(p.projectDir);
|
|
63
|
+
const found = installed.find((a) => a.name === name);
|
|
64
|
+
if (!found) return send(`<div class="msg error"><strong>${esc(name)}</strong> is not installed in this project.</div>`, 404);
|
|
65
|
+
const removing = pathname === '/actions/ddev-addon-remove';
|
|
66
|
+
if (removing && form.confirm !== 'yes') {
|
|
67
|
+
return send('<div class="msg error">Removing an add-on takes its files out of the project — it needs confirmation.</div>', 400);
|
|
68
|
+
}
|
|
69
|
+
addons.invalidateRegistry();
|
|
70
|
+
const cmdLine = removing
|
|
71
|
+
? `ddev add-on remove ${name} && echo "Removed ${name}. Run ddev restart."`
|
|
72
|
+
: `ddev add-on update ${name} && echo "Updated ${name}. Run ddev restart."`;
|
|
73
|
+
const started = await startInScreen({
|
|
74
|
+
workspace,
|
|
75
|
+
project: p.projectName,
|
|
76
|
+
projectDir: p.projectDir,
|
|
77
|
+
verb: removing ? 'addon-remove' : 'addon-update',
|
|
78
|
+
cmdLine,
|
|
79
|
+
label: `ddev add-on ${removing ? 'remove' : 'update'} ${name}`,
|
|
80
|
+
});
|
|
81
|
+
return send(screenStartedHtml(started, `ddev add-on ${removing ? 'remove' : 'update'} ${name}`, p.projectName),
|
|
82
|
+
started.ok ? 200 : (started.already ? 409 : 500));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
'/actions/ddev-addon-get': addon_get,
|
|
87
|
+
'/actions/ddev-addon-remove': addon_verb,
|
|
88
|
+
'/actions/ddev-addon-update': addon_verb,
|
|
89
|
+
INSTALLED_NAME_RE,
|
|
90
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three things done to a project's captured mail: mark it read, delete one, empty the mailbox.
|
|
3
|
+
*
|
|
4
|
+
* Each answers with the PANEL rather than a toast, because every one of them changes what the panel
|
|
5
|
+
* says — a delete that leaves the list showing the message it deleted is a control you have to
|
|
6
|
+
* refresh to believe.
|
|
7
|
+
*
|
|
8
|
+
* The Mailpit URL is never taken from the request: it is read from the same DDEV listing the row's
|
|
9
|
+
* status comes from, so a request can only ever reach the mailbox of the project it named.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const { esc } = require('../html');
|
|
15
|
+
const { workspaceDir } = require('../workspaces');
|
|
16
|
+
const { ddevProjectName } = require('../jobs');
|
|
17
|
+
const { NAME_RE } = require('../validate');
|
|
18
|
+
const mail = require('../ddev-mail');
|
|
19
|
+
const { mailPanelHtml } = require('../ddev-mail-page');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The project a request names, and where its Mailpit is.
|
|
23
|
+
*
|
|
24
|
+
* `mailpitFor` is injected by server.js when it merges this map, because the status listing lives
|
|
25
|
+
* there — this module must not grow its own copy of it, and must not take a URL from a form.
|
|
26
|
+
*/
|
|
27
|
+
async function context(deps, form, workspace) {
|
|
28
|
+
const projectName = String(form.projectName || '');
|
|
29
|
+
if (!NAME_RE.test(projectName)) return { error: 'Invalid project name.' };
|
|
30
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
31
|
+
if (!ddevProjectName(projectDir)) return { error: 'Not a DDEV project.' };
|
|
32
|
+
const base = await deps.mailpitFor(workspace, projectName);
|
|
33
|
+
if (!base) return { error: 'This project has no Mailpit — it is probably not running.' };
|
|
34
|
+
return { projectName, base };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function make(deps) {
|
|
38
|
+
const panel = (workspace, projectName, base, q = '') =>
|
|
39
|
+
mailPanelHtml({ workspace, project: projectName, base, q });
|
|
40
|
+
|
|
41
|
+
async function mail_read({ form, send, workspace }) {
|
|
42
|
+
const c = await context(deps, form, workspace);
|
|
43
|
+
if (c.error) return send(`<div class="msg error">${esc(c.error)}</div>`, 400);
|
|
44
|
+
const r = await mail.markAllRead(c.base);
|
|
45
|
+
if (!r.ok) return send(`<div class="msg error">Mailpit did not answer: ${esc(r.error)}</div>`, 502);
|
|
46
|
+
return send(await panel(workspace, c.projectName, c.base));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function mail_delete({ form, send, workspace }) {
|
|
50
|
+
const c = await context(deps, form, workspace);
|
|
51
|
+
if (c.error) return send(`<div class="msg error">${esc(c.error)}</div>`, 400);
|
|
52
|
+
if (form.confirm !== 'yes') return send('<div class="msg error">Deleting a message needs confirmation.</div>', 400);
|
|
53
|
+
const id = String(form.id || '');
|
|
54
|
+
// The id comes from a listing this panel drew, and it goes into a request body — checked against
|
|
55
|
+
// the shape Mailpit issues rather than passed along.
|
|
56
|
+
if (!mail.MAIL_ID_RE.test(id)) return send('<div class="msg error">That is not a message id.</div>', 400);
|
|
57
|
+
const r = await mail.deleteOneMail(c.base, id);
|
|
58
|
+
if (!r.ok) return send(`<div class="msg error">Mailpit did not answer: ${esc(r.error)}</div>`, 502);
|
|
59
|
+
return send(await panel(workspace, c.projectName, c.base));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function mail_clear({ form, send, workspace }) {
|
|
63
|
+
const c = await context(deps, form, workspace);
|
|
64
|
+
if (c.error) return send(`<div class="msg error">${esc(c.error)}</div>`, 400);
|
|
65
|
+
// Emptying a mailbox is not undoable, and a mailbox under test is often the evidence somebody is
|
|
66
|
+
// in the middle of reading.
|
|
67
|
+
if (form.confirm !== 'yes') return send('<div class="msg error">Emptying the mailbox needs confirmation.</div>', 400);
|
|
68
|
+
const r = await mail.deleteAllMail(c.base);
|
|
69
|
+
if (!r.ok) return send(`<div class="msg error">Mailpit did not answer: ${esc(r.error)}</div>`, 502);
|
|
70
|
+
return send(await panel(workspace, c.projectName, c.base));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
'/actions/ddev-mail-read': mail_read,
|
|
75
|
+
'/actions/ddev-mail-delete': mail_delete,
|
|
76
|
+
'/actions/ddev-mail-clear': mail_clear,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { make, context };
|
package/actions/ddev.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DDEV verbs a project row offers.
|
|
3
|
+
*
|
|
4
|
+
* One module per area of the dashboard, each exporting the handlers it owns keyed by the path that
|
|
5
|
+
* reaches them. server.js merges the maps and dispatches; nothing here knows about routing, and
|
|
6
|
+
* adding an action is adding an entry rather than another branch in a 1,250-line function.
|
|
7
|
+
*
|
|
8
|
+
* Every handler takes the same context: the parsed form, the response, a `send` that writes an HTML
|
|
9
|
+
* fragment, and the workspace the request named.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { esc } = require('../html');
|
|
16
|
+
const { humanSize, humanTime } = require('../format');
|
|
17
|
+
const { ROOT, loadWorkspaces, workspaceDir } = require('../workspaces');
|
|
18
|
+
const {
|
|
19
|
+
run, jobFragment, runningJobKey, startJob, ddevProjectName, listProjects,
|
|
20
|
+
startInScreen, screenStartedHtml, screenAction,
|
|
21
|
+
} = require('../jobs');
|
|
22
|
+
const { graphStoreDir } = require('../graphs');
|
|
23
|
+
const {
|
|
24
|
+
milvusProjectName, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
|
|
25
|
+
ragCollectionName, milvusPost, ragCollections, ragInvalidate,
|
|
26
|
+
} = require('../rag');
|
|
27
|
+
const {
|
|
28
|
+
findTestReports, testReportFor, invalidateTestReports, testMediaHtml, testingStackOf,
|
|
29
|
+
} = require('../test-reports');
|
|
30
|
+
const { NAME_RE, SCRIPT_RE } = require('../validate');
|
|
31
|
+
const { commandBlock } = require('../command-block');
|
|
32
|
+
const { wsUrl } = require('../urls');
|
|
33
|
+
const { findGraphifyScript, findRagifyScript } = require('../workspaces');
|
|
34
|
+
const { DDEV_ACTIONS, projectDdevCommands, serialiseRouter, touchesRouter } = require('../ddev');
|
|
35
|
+
const snapshots = require('../ddev-snapshots');
|
|
36
|
+
const ddevImport = require('../ddev-import');
|
|
37
|
+
|
|
38
|
+
async function ddev_start({ form, res, send, workspace, pathname }) {
|
|
39
|
+
const projectName = String(form.projectName || '');
|
|
40
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
41
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
42
|
+
if (!ddevProjectName(projectDir)) return send('<div class="msg error">Not a DDEV project.</div>', 400);
|
|
43
|
+
const verb = pathname === '/actions/ddev-start' ? 'start' : 'stop';
|
|
44
|
+
// `ddev start` accepts -y (skip confirmation); `ddev stop` has no such flag.
|
|
45
|
+
const args = verb === 'start' ? ['start', '-y'] : ['stop'];
|
|
46
|
+
// Both verbs recreate the shared ddev-router, so they take the router lock: five
|
|
47
|
+
// concurrent starts is what left the machine with no router and every workspace URL
|
|
48
|
+
// dead. See serialiseRouter() in ../ddev.js.
|
|
49
|
+
const cmdLine = serialiseRouter(`ddev ${args.join(' ')}`);
|
|
50
|
+
const started = await startInScreen({ workspace, project: projectName, projectDir, verb, cmdLine, label: `ddev ${verb}` });
|
|
51
|
+
return send(screenStartedHtml(started, `ddev ${verb}`, projectName), started.ok ? 200 : (started.already ? 409 : 500));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Every remaining DDEV verb shares one handler, driven by the table in ddev.js: they differ only
|
|
55
|
+
// in the arguments, the timeout and whether the project has to be running.
|
|
56
|
+
async function ddev_verb({ form, res, send, workspace, pathname }) {
|
|
57
|
+
const act = DDEV_ACTIONS[pathname];
|
|
58
|
+
const projectName = String(form.projectName || '');
|
|
59
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
60
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
61
|
+
const ddevName = ddevProjectName(projectDir);
|
|
62
|
+
if (!ddevName) return send('<div class="msg error">Not a DDEV project.</div>', 400);
|
|
63
|
+
let args = act.args;
|
|
64
|
+
// A destructive command is never one click away, and the dashboard is not
|
|
65
|
+
// the only door — the guard lives here, not only in the markup.
|
|
66
|
+
if (act.destructive && form.confirm !== 'yes') {
|
|
67
|
+
return send(`<div class="msg error">${esc(act.label)} ${esc(act.confirmWhy || 'destroys data')} — it needs confirmation.</div>`, 400);
|
|
68
|
+
}
|
|
69
|
+
// A verb that takes a snapshot name: the name is checked against what is ON DISK, not merely
|
|
70
|
+
// against a pattern, so a restore or a delete cannot be asked for something that is not there —
|
|
71
|
+
// and the answer comes back now rather than from a screen session three seconds later.
|
|
72
|
+
if (act.named) {
|
|
73
|
+
const raw = String(form.snapshot || '').trim();
|
|
74
|
+
if (raw) {
|
|
75
|
+
if (!snapshots.SNAPSHOT_NAME_RE.test(raw)) {
|
|
76
|
+
return send('<div class="msg error">A snapshot name starts with a letter or digit and holds letters, digits, dots, dashes and underscores.</div>', 400);
|
|
77
|
+
}
|
|
78
|
+
if (pathname === '/actions/ddev-snapshot') {
|
|
79
|
+
args = [...args, '--name', raw];
|
|
80
|
+
} else {
|
|
81
|
+
const found = snapshots.findSnapshot(projectDir, raw);
|
|
82
|
+
if (!found) return send(`<div class="msg error">No snapshot called <strong>${esc(raw)}</strong> in this project.</div>`, 404);
|
|
83
|
+
if (pathname === '/actions/ddev-snapshot-restore') {
|
|
84
|
+
// The engine and version a snapshot was taken from are in its file name, and DDEV will
|
|
85
|
+
// refuse a restore across them. Saying so here names both versions; ddev's own refusal
|
|
86
|
+
// arrives inside a screen session somebody has to open to read.
|
|
87
|
+
const can = snapshots.restorable(found, snapshots.projectDbVersion(projectDir));
|
|
88
|
+
if (!can.ok) return send(`<div class="msg error">${esc(can.why)}</div>`, 409);
|
|
89
|
+
// `restore` takes the name as a POSITIONAL argument; `--name` belongs to taking one and
|
|
90
|
+
// to `--cleanup`. Given the flag, ddev prints its help and the log reads like a crash.
|
|
91
|
+
args = [...args, raw];
|
|
92
|
+
} else {
|
|
93
|
+
args = [...args, '--name', raw];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else if (pathname !== '/actions/ddev-snapshot') {
|
|
97
|
+
return send('<div class="msg error">Name the snapshot.</div>', 400);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (act.needsRunning) {
|
|
101
|
+
// Asked of the project itself rather than the dashboard's status map:
|
|
102
|
+
// that map lives in server.js, which requires this file — importing it
|
|
103
|
+
// back would be a cycle, and this is one cheap call on one project.
|
|
104
|
+
const desc = await run('ddev', ['describe', '--json-output'], projectDir, { timeoutMs: 60 * 1000 });
|
|
105
|
+
let status = '';
|
|
106
|
+
try { status = JSON.parse(desc.stdout).raw.status || ''; } catch (_) { status = ''; }
|
|
107
|
+
if (status !== 'running') {
|
|
108
|
+
return send(`<div class="msg error">${esc(projectName)} is not running — start it first.</div>`, 400);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (act.toBackups) {
|
|
112
|
+
// A dump belongs in the workspace's backups folder, never in the home directory or
|
|
113
|
+
// the project itself.
|
|
114
|
+
const outDir = path.join(loadWorkspaces()[workspace].backupsDir);
|
|
115
|
+
try { fs.mkdirSync(outDir, { recursive: true }); } catch (_) { /* reported by ddev */ }
|
|
116
|
+
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
117
|
+
args = [...args, '--gzip', '--file', path.join(outDir, `${projectName}--db--${stamp}.sql.gz`)];
|
|
118
|
+
}
|
|
119
|
+
// Before anything destructive: a dump into the workspace's backups folder.
|
|
120
|
+
// `sql:drop` leaves nothing behind, so this is the only copy there will be.
|
|
121
|
+
// && not ; — nothing is dropped unless the dump actually succeeded.
|
|
122
|
+
let line = `ddev ${args.join(' ')}`;
|
|
123
|
+
if (act.backupFirst) {
|
|
124
|
+
const outDir = path.join(loadWorkspaces()[workspace].backupsDir);
|
|
125
|
+
try { fs.mkdirSync(outDir, { recursive: true }); } catch (_) { /* the session reports it */ }
|
|
126
|
+
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
127
|
+
const dump = path.join(outDir, `${projectName}--before-sql-drop--${stamp}.sql.gz`);
|
|
128
|
+
line = `echo "Backing the database up first…" && ddev export-db --gzip --file ${JSON.stringify(dump)}`
|
|
129
|
+
+ ` && echo "Saved ${dump}" && ${line}`;
|
|
130
|
+
}
|
|
131
|
+
// `ddev restart` recreates the router like start and stop do, so it queues with
|
|
132
|
+
// them rather than racing. The rest (drush, composer, snapshot, logs) leave the
|
|
133
|
+
// router alone and stay parallel — serialising those would make the dashboard feel
|
|
134
|
+
// broken for no reason.
|
|
135
|
+
if (touchesRouter(args)) line = serialiseRouter(line);
|
|
136
|
+
// Every DDEV command runs detached in a screen named after the project: no
|
|
137
|
+
// toast to follow the user around, and the row's SCREENS group lists it.
|
|
138
|
+
const verb = pathname.replace('/actions/ddev-', '');
|
|
139
|
+
const started = await startInScreen({ workspace, project: projectName, projectDir, verb, cmdLine: line, label: act.label });
|
|
140
|
+
return send(screenStartedHtml(started, act.label, projectName), started.ok ? 200 : (started.already ? 409 : 500));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Put a database back into a project.
|
|
145
|
+
*
|
|
146
|
+
* The dump is named by BASENAME and looked up in the workspace's own backups folder, so no path from
|
|
147
|
+
* a browser ever reaches a command line. A snapshot is taken first, in the same line with `&&`:
|
|
148
|
+
* importing replaces every table, and the database it replaces is often the only copy of an
|
|
149
|
+
* afternoon's work — the same shape `drush sql:drop` already uses before it drops.
|
|
150
|
+
*/
|
|
151
|
+
async function ddev_import_db({ form, send, workspace }) {
|
|
152
|
+
const projectName = String(form.projectName || '');
|
|
153
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
154
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
155
|
+
if (!ddevProjectName(projectDir)) return send('<div class="msg error">Not a DDEV project.</div>', 400);
|
|
156
|
+
if (form.confirm !== 'yes') {
|
|
157
|
+
return send('<div class="msg error">Importing replaces every table in the database — it needs confirmation.</div>', 400);
|
|
158
|
+
}
|
|
159
|
+
const backupsDir = loadWorkspaces()[workspace].backupsDir;
|
|
160
|
+
const dump = ddevImport.findDump(backupsDir, form.dump);
|
|
161
|
+
if (!dump) return send('<div class="msg error">No such dump in this workspace\'s backups.</div>', 404);
|
|
162
|
+
const file = path.join(backupsDir, dump.name);
|
|
163
|
+
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
164
|
+
const cmdLine = `echo "Taking a snapshot first…" && ddev snapshot --name before-import-${stamp}`
|
|
165
|
+
+ ` && ddev import-db --file ${JSON.stringify(file)}`
|
|
166
|
+
+ ` && echo "Imported ${dump.name}. The database as it was is the snapshot before-import-${stamp}."`;
|
|
167
|
+
const started = await startInScreen({
|
|
168
|
+
workspace, project: projectName, projectDir, verb: 'import-db', cmdLine, label: `ddev import-db ${dump.name}`,
|
|
169
|
+
});
|
|
170
|
+
return send(screenStartedHtml(started, `ddev import-db ${dump.name}`, projectName),
|
|
171
|
+
started.ok ? 200 : (started.already ? 409 : 500));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// One of the project's own `.ddev/commands/<service>/*`. The name is checked against
|
|
175
|
+
// what the project actually ships, so only a real command can be run.
|
|
176
|
+
async function ddev_project_command({ form, send, workspace }) {
|
|
177
|
+
const projectName = String(form.projectName || '');
|
|
178
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
179
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
180
|
+
if (!ddevProjectName(projectDir)) return send('<div class="msg error">Not a DDEV project.</div>', 400);
|
|
181
|
+
const cmd = String(form.command || '');
|
|
182
|
+
if (!projectDdevCommands(projectDir).some((c) => c.name === cmd)) return send('<div class="msg error">This project does not ship that command.</div>', 400);
|
|
183
|
+
const started = await startInScreen({ workspace, project: projectName, projectDir, verb: cmd, cmdLine: `ddev ${cmd}`, label: `ddev ${cmd}` });
|
|
184
|
+
return send(screenStartedHtml(started, `ddev ${cmd}`, projectName), started.ok ? 200 : (started.already ? 409 : 500));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
'/actions/ddev-project-command': ddev_project_command,
|
|
189
|
+
'/actions/ddev-import-db': ddev_import_db,
|
|
190
|
+
'/actions/ddev-restart': ddev_verb,
|
|
191
|
+
'/actions/ddev-describe': ddev_verb,
|
|
192
|
+
'/actions/ddev-logs': ddev_verb,
|
|
193
|
+
'/actions/ddev-drush-cr': ddev_verb,
|
|
194
|
+
'/actions/ddev-drush-uli': ddev_verb,
|
|
195
|
+
'/actions/ddev-export-db': ddev_verb,
|
|
196
|
+
'/actions/ddev-mutagen-sync': ddev_verb,
|
|
197
|
+
'/actions/ddev-xdebug-on': ddev_verb,
|
|
198
|
+
'/actions/ddev-xdebug-off': ddev_verb,
|
|
199
|
+
'/actions/ddev-xdebug-status': ddev_verb,
|
|
200
|
+
'/actions/ddev-auth-ssh': ddev_verb,
|
|
201
|
+
'/actions/ddev-share': ddev_verb,
|
|
202
|
+
'/actions/ddev-diagnose': ddev_verb,
|
|
203
|
+
'/actions/ddev-tls-diagnose': ddev_verb,
|
|
204
|
+
'/actions/ddev-port-diagnose': ddev_verb,
|
|
205
|
+
'/actions/ddev-db-match': ddev_verb,
|
|
206
|
+
'/actions/ddev-mutagen-status': ddev_verb,
|
|
207
|
+
'/actions/ddev-mutagen-reset': ddev_verb,
|
|
208
|
+
'/actions/ddev-drush-sql-drop': ddev_verb,
|
|
209
|
+
'/actions/ddev-snapshot': ddev_verb,
|
|
210
|
+
'/actions/ddev-snapshot-restore': ddev_verb,
|
|
211
|
+
'/actions/ddev-snapshot-delete': ddev_verb,
|
|
212
|
+
'/actions/ddev-drush-status': ddev_verb,
|
|
213
|
+
'/actions/ddev-drush-cex': ddev_verb,
|
|
214
|
+
'/actions/ddev-drush-cim': ddev_verb,
|
|
215
|
+
'/actions/ddev-drush-updb': ddev_verb,
|
|
216
|
+
'/actions/ddev-composer-update': ddev_verb,
|
|
217
|
+
'/actions/ddev-composer-install': ddev_verb,
|
|
218
|
+
'/actions/ddev-start': ddev_start,
|
|
219
|
+
'/actions/ddev-stop': ddev_start,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// `ddev config` for a folder DDEV does not manage yet, or one it does. Every value is picked from the lists in
|
|
223
|
+
// ddev-options.js (the DDEV docs), so nothing typed reaches the shell line.
|
|
224
|
+
async function ddev_config({ form, send, workspace }) {
|
|
225
|
+
const dir = workspaceDir(workspace);
|
|
226
|
+
const projectName = String(form.projectName || '');
|
|
227
|
+
if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
|
|
228
|
+
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
229
|
+
}
|
|
230
|
+
const opts = require('../ddev-options');
|
|
231
|
+
const args = ['config', '--auto=false', `--project-name=${projectName}`, ...opts.configArgs(form)];
|
|
232
|
+
return screenAction(send, { workspace, project: projectName, projectDir: path.join(dir, projectName),
|
|
233
|
+
verb: 'config', cmdLine: `ddev ${opts.shellArgs(args)}`, label: 'ddev config' });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports['/actions/ddev-config'] = ddev_config;
|
|
237
|
+
module.exports.CONFIG_CHOICES = require('../ddev-options').CHOICES;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make an empty folder in a workspace, set it up, and optionally open a terminal or Claude Code in it.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const { esc } = require('../html');
|
|
9
|
+
const { isValidWorkspace, loadWorkspaces, loadSettings, workspaceDir } = require('../workspaces');
|
|
10
|
+
const folders = require('../folders');
|
|
11
|
+
const notes = require('../notes');
|
|
12
|
+
const { startInScreen, screenStartedHtml } = require('../jobs');
|
|
13
|
+
const { pluginFolderSetups } = require('../plugin-loader');
|
|
14
|
+
|
|
15
|
+
/** What the plugins get to decide with: the actions this workspace's type allows. */
|
|
16
|
+
const setupCtx = (ws) => { const m = loadWorkspaces()[ws]; return { allowed: (m && m.typeActions) || new Set() }; };
|
|
17
|
+
|
|
18
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
19
|
+
|
|
20
|
+
/** Whether this workspace offers New folder: project workspaces whose type allows it. */
|
|
21
|
+
function canMakeFolder(ws) {
|
|
22
|
+
if (!isValidWorkspace(ws)) return false;
|
|
23
|
+
const meta = loadWorkspaces()[ws];
|
|
24
|
+
if (!meta || !(meta.typeActions && meta.typeActions.has('folder'))) return false;
|
|
25
|
+
// A files workspace shows folders only when it is browsable; elsewhere a new folder would be invisible.
|
|
26
|
+
return meta.kind !== 'files' || require('../files-browser').BROWSABLE.has(ws);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether the workspace is a files one, where a folder is made inside the folder being viewed. */
|
|
30
|
+
function isFilesWorkspace(ws) {
|
|
31
|
+
const meta = loadWorkspaces()[ws];
|
|
32
|
+
return !!meta && meta.kind === 'files';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ships = (ws, script) => fs.existsSync(path.join(workspaceDir(ws), script));
|
|
36
|
+
const allows = (ws, action) => { const m = loadWorkspaces()[ws]; return !!(m && m.typeActions && m.typeActions.has(action)); };
|
|
37
|
+
|
|
38
|
+
/** Whether the workspace ships the testing command the webship-js setup runs. */
|
|
39
|
+
function canTest(ws) { return ships(ws, 'cmd-tools-testing.sh'); }
|
|
40
|
+
|
|
41
|
+
/** DDEV project types the form offers, the same list Configure DDEV uses. */
|
|
42
|
+
function projectTypes() {
|
|
43
|
+
return require('../ddev-options').PROJECT_TYPES;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The setup shell line: DDEV, then the testing stack, then what the plugins add. Each step needs the one before. */
|
|
47
|
+
function setupLine(ws, name, form) {
|
|
48
|
+
const steps = [];
|
|
49
|
+
const wantE2e = form.e2e === '1' && canTest(ws);
|
|
50
|
+
if (form.ddev === '1' || wantE2e) {
|
|
51
|
+
const opts = require('../ddev-options');
|
|
52
|
+
const chosen = { docroot: '', ...form };
|
|
53
|
+
if (!opts.PROJECT_TYPES.includes(String(chosen.project_type))) chosen.project_type = 'php';
|
|
54
|
+
const args = ['config', '--auto=false', `--project-name=${name}`, ...opts.configArgs(chosen)];
|
|
55
|
+
steps.push(`( cd ${shq(name)} && ddev ${opts.shellArgs(args)} )`);
|
|
56
|
+
}
|
|
57
|
+
if (wantE2e) steps.push(`bash cmd-tools-testing.sh ${shq(name)}`);
|
|
58
|
+
for (const s of pluginFolderSetups()) if (s.steps) steps.push(...(s.steps(ws, name, form, setupCtx(ws)) || []));
|
|
59
|
+
return steps.join(' && ');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the Claude Code session is asked to do: the AI tasks the form chose, then the user's own message. */
|
|
63
|
+
function claudePrompt(ws, name, form) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
const what = String(form.note || '').trim();
|
|
66
|
+
for (const s of pluginFolderSetups()) if (s.prompt) parts.push(...(s.prompt(ws, name, form, setupCtx(ws)) || []));
|
|
67
|
+
const own = String(form.prompt || '').trim();
|
|
68
|
+
if (own) parts.push(own);
|
|
69
|
+
return parts.join('\n\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Why the form cannot be run as sent, or ''. */
|
|
73
|
+
function formProblem(ws, form) {
|
|
74
|
+
for (const s of pluginFolderSetups()) if (s.problem) { const why = s.problem(ws, form, setupCtx(ws)); if (why) return why; }
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A folder in a files workspace: inside the folder being viewed, with its note, and nothing else to set up. */
|
|
79
|
+
function filesFolderNew({ ws, form, send, res }) {
|
|
80
|
+
const where = require('../files-browser').safeSub(ws, String(form.sub || ''));
|
|
81
|
+
if (!where.ok) return send('<div class="msg error">That folder is not in this workspace.</div>', 400);
|
|
82
|
+
const name = String(form.name || '').trim();
|
|
83
|
+
const made = folders.createFolder(where.abs, name, { files: true });
|
|
84
|
+
if (!made.ok) return send(`<div class="msg error">${made.error}</div>`, made.status);
|
|
85
|
+
const note = String(form.note || '').trim();
|
|
86
|
+
if (note) notes.writeNote(made.dir, { title: '', body: note });
|
|
87
|
+
const shown = [ws, where.rel, name].filter(Boolean).join('/');
|
|
88
|
+
try { res.setHeader('HX-Trigger', JSON.stringify({ 'refresh-projects': true })); } catch (_) { /* headers already sent */ }
|
|
89
|
+
return send(`<div class="msg assistant"><p>Made <strong>${esc(shown)}</strong>.</p></div>`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function folder_new({ form, send, res }) {
|
|
93
|
+
const ws = String(form.workspace || '');
|
|
94
|
+
if (!canMakeFolder(ws)) return send('<div class="msg error">This workspace does not take new folders.</div>', 400);
|
|
95
|
+
if (isFilesWorkspace(ws)) return filesFolderNew({ ws, form, send, res });
|
|
96
|
+
const problem = formProblem(ws, form);
|
|
97
|
+
if (problem) return send(`<div class="msg error">${esc(problem)}</div>`, 400);
|
|
98
|
+
const name = String(form.name || '').trim();
|
|
99
|
+
const made = folders.createFolder(workspaceDir(ws), name);
|
|
100
|
+
if (!made.ok) return send(`<div class="msg error">${made.error}</div>`, made.status);
|
|
101
|
+
|
|
102
|
+
const note = String(form.note || '').trim();
|
|
103
|
+
if (note) notes.writeNote(made.dir, { title: '', body: note });
|
|
104
|
+
|
|
105
|
+
let setup = '';
|
|
106
|
+
const line = setupLine(ws, name, form);
|
|
107
|
+
if (line) {
|
|
108
|
+
const label = 'set up the folder';
|
|
109
|
+
const started = await startInScreen({ workspace: ws, project: name, projectDir: workspaceDir(ws), ownerDir: made.dir,
|
|
110
|
+
verb: 'setup', cmdLine: line, label });
|
|
111
|
+
setup = started.ok
|
|
112
|
+
? `<p>Setting it up in <code>${esc(started.session)}</code>: <a href="/screens">watch it on Screens</a>.</p>`
|
|
113
|
+
: screenStartedHtml(started, label, name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// An AI task needs Claude Code, so it is started even when Then said otherwise.
|
|
117
|
+
const prompt = claudePrompt(ws, name, form);
|
|
118
|
+
let then = Object.prototype.hasOwnProperty.call(folders.THEN, form.then) ? String(form.then) : 'none';
|
|
119
|
+
if (prompt && then !== 'claude') then = 'claude';
|
|
120
|
+
let started = '';
|
|
121
|
+
const triggers = { 'refresh-projects': true };
|
|
122
|
+
if (then !== 'none') {
|
|
123
|
+
let out = null;
|
|
124
|
+
await require('./screens')['/actions/screen-new']({
|
|
125
|
+
form: { target: `p:${ws}:${name}`, kind: then, session: then === 'claude' ? 'claude' : 'shell',
|
|
126
|
+
command: then === 'claude' ? prompt : '' },
|
|
127
|
+
send: (html, status = 200) => { out = { html, status }; },
|
|
128
|
+
res,
|
|
129
|
+
});
|
|
130
|
+
const session = `${ws}--${name}--${then === 'claude' ? 'claude' : 'shell'}`;
|
|
131
|
+
started = out && out.status < 400
|
|
132
|
+
? `<p>${then === 'claude' ? 'Claude Code is' : 'A terminal is'} running in it as <code>${esc(session)}</code>.
|
|
133
|
+
<a class="uk-button uk-button-primary uk-button-small" href="/terminal/${encodeURIComponent(session)}" target="_blank" rel="noopener">Open the terminal</a></p>`
|
|
134
|
+
: `<div class="msg error">The folder is made, but the session did not start.</div>${out ? out.html : ''}`;
|
|
135
|
+
triggers['screens-changed'] = { session };
|
|
136
|
+
}
|
|
137
|
+
try { res.setHeader('HX-Trigger', JSON.stringify(triggers)); } catch (_) { /* headers already sent */ }
|
|
138
|
+
return send(`<div class="msg assistant">
|
|
139
|
+
<p>Made <strong>${esc(ws)}/${esc(name)}</strong>. It is listed with the projects, and its Screens, IDE,
|
|
140
|
+
GIT and command buttons work on it now.</p>${setup.startsWith('<p>') ? setup : ''}${started}</div>${setup.startsWith('<p>') ? '' : setup}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The New folder form for a workspace. A files workspace gets the folder being viewed, read from the page address
|
|
145
|
+
* htmx sends or the last one browsed; a project workspace gets the full form with its setup options.
|
|
146
|
+
*/
|
|
147
|
+
function folderFormFor(ws, req, { readPageState } = {}) {
|
|
148
|
+
const meta = loadWorkspaces()[ws];
|
|
149
|
+
if (isFilesWorkspace(ws)) {
|
|
150
|
+
let sub = '';
|
|
151
|
+
try { sub = new URL(String(req.headers['hx-current-url'] || ''), 'http://x').searchParams.get('path') || ''; } catch (_) { /* no page address */ }
|
|
152
|
+
if (!sub && readPageState) sub = readPageState(req, `${ws}/items`).path || '';
|
|
153
|
+
const where = require('../files-browser').safeSub(ws, sub);
|
|
154
|
+
return folders.filesFolderFormHtml(ws, { label: meta.label, sub: where.ok ? where.rel : '',
|
|
155
|
+
suggestion: folders.suggestName(where.ok ? where.abs : meta.dir, 'new-folder-') });
|
|
156
|
+
}
|
|
157
|
+
return folders.folderFormHtml(ws, { label: meta.label, suggestion: folders.suggestName(meta.dir),
|
|
158
|
+
projectTypes: projectTypes(), canTest: canTest(ws),
|
|
159
|
+
pluginSetups: pluginFolderSetups().map((s) => (s.html ? s.html(ws, setupCtx(ws)) : '')).filter(Boolean).join('') });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
'/actions/folder-new': folder_new,
|
|
164
|
+
canMakeFolder, isFilesWorkspace, folderFormFor, canTest, projectTypes, setupLine, claudePrompt, formProblem,
|
|
165
|
+
};
|