@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code in a screen session: end it the way a person would, with /exit, and pick the conversation up again
|
|
3
|
+
* with `claude --resume <id>` in the same folder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const { esc } = require('../html');
|
|
10
|
+
const { hostRunnerUp, hostRunnerRequest } = require('../jobs');
|
|
11
|
+
const { ROOT, isValidWorkspace, workspaceDir } = require('../workspaces');
|
|
12
|
+
const claudeSessions = require('../claude-sessions');
|
|
13
|
+
|
|
14
|
+
const SESSION_RE = /^[a-z0-9][a-zA-Z0-9_.-]*$/;
|
|
15
|
+
const NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
16
|
+
|
|
17
|
+
/** The folder a session runs in, from the record the runner keeps beside its log. */
|
|
18
|
+
function sessionDir(session) {
|
|
19
|
+
try {
|
|
20
|
+
const dir = fs.readFileSync(path.join(ROOT, '.screens', `${session}.cwd`), 'utf8').trim();
|
|
21
|
+
return dir && dir.startsWith(ROOT) && fs.existsSync(dir) ? dir : '';
|
|
22
|
+
} catch (_) { return ''; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The newest conversation in a folder that `--resume` can open, or null. */
|
|
26
|
+
function lastConversation(dir) {
|
|
27
|
+
return claudeSessions.sessionsFor(dir, { limit: 1, resumableOnly: true })[0] || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a live session is running Claude Code. */
|
|
31
|
+
async function isClaudeSession(session) {
|
|
32
|
+
const screens = require('../screens');
|
|
33
|
+
const live = (await screens.listScreens()).filter((s) => s.session === session && !/dead/i.test(s.state || ''));
|
|
34
|
+
return live.length > 0 && screens.agentSessions(live).has(session);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const resumeButton = (attrs, label = 'Resume it') => `<button type="button" class="uk-button uk-button-primary uk-button-small"
|
|
38
|
+
hx-post="/actions/claude-resume" hx-vals='${JSON.stringify(attrs)}' hx-target="#webship-workspace-output" hx-swap="innerHTML">${esc(label)}</button>`;
|
|
39
|
+
|
|
40
|
+
async function claude_exit({ form, send, res }) {
|
|
41
|
+
const session = String(form.session || '');
|
|
42
|
+
if (!SESSION_RE.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
|
|
43
|
+
if (form.confirm !== 'yes') return send('<div class="msg error">Exiting needs confirmation.</div>', 400);
|
|
44
|
+
if (!(await isClaudeSession(session))) return send(`<div class="msg error">Claude Code is not running in <strong>${esc(session)}</strong>.</div>`, 409);
|
|
45
|
+
// Typed the way a person types it. Escape first closes a question or menu that is open (the trust prompt of a new
|
|
46
|
+
// folder, a permission ask), then the command, then Enter on its own, so the prompt does not take it as a paste.
|
|
47
|
+
const { onHostScreen } = require('./screens');
|
|
48
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
await onHostScreen(session, ['-S', session, '-X', 'stuff', '\x1b']);
|
|
50
|
+
await pause(500);
|
|
51
|
+
const typed = await onHostScreen(session, ['-S', session, '-X', 'stuff', '/exit']);
|
|
52
|
+
if (typed.ok) await pause(300);
|
|
53
|
+
const entered = typed.ok ? await onHostScreen(session, ['-S', session, '-X', 'stuff', '\r']) : typed;
|
|
54
|
+
if (!entered.ok) return send(`<div class="msg error">Could not reach ${esc(session)}: ${esc(String(entered.stderr || entered.stdout).slice(-200))}</div>`, 500);
|
|
55
|
+
try { res.setHeader('HX-Trigger', JSON.stringify({ 'screens-changed': { session } })); } catch (_) { /* headers already sent */ }
|
|
56
|
+
return send(`<div class="msg assistant"><p>Sent <code>/exit</code> to Claude Code in <strong>${esc(session)}</strong>. The conversation is kept.</p>
|
|
57
|
+
<p>${resumeButton({ session })}</p></div>`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function claude_resume({ form, send, res }) {
|
|
61
|
+
let session = String(form.session || '');
|
|
62
|
+
let dir = '';
|
|
63
|
+
if (session) {
|
|
64
|
+
if (!SESSION_RE.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
|
|
65
|
+
dir = sessionDir(session);
|
|
66
|
+
} else {
|
|
67
|
+
const ws = String(form.workspace || '');
|
|
68
|
+
const project = String(form.project || '');
|
|
69
|
+
if (!isValidWorkspace(ws) || !NAME_RE.test(project)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
70
|
+
dir = path.join(workspaceDir(ws), project);
|
|
71
|
+
if (!fs.existsSync(dir)) return send('<div class="msg error">No such folder.</div>', 404);
|
|
72
|
+
session = `${ws}--${project}--claude`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 60);
|
|
73
|
+
}
|
|
74
|
+
if (!dir) return send(`<div class="msg error">The folder of <strong>${esc(session)}</strong> is not known, so there is nothing to resume there.</div>`, 404);
|
|
75
|
+
const last = lastConversation(dir);
|
|
76
|
+
if (!last || !claudeSessions.isSessionId(last.id)) return send('<div class="msg error">No Claude Code conversation in this folder to resume.</div>', 404);
|
|
77
|
+
if (!(await hostRunnerUp())) return send('<div class="msg error">The screen runner on this machine is not up.</div>', 503);
|
|
78
|
+
const answer = await hostRunnerRequest('start', { session, dir, owner: dir, cmd: `exec claude --resume ${last.id}` });
|
|
79
|
+
if (!answer) return send('<div class="msg error">The screen runner on this machine is not up.</div>', 503);
|
|
80
|
+
if (answer.already) return send(`<div class="msg error"><strong>${esc(session)}</strong> is still running. Exit it first, or open it from Screens.</div>`, 409);
|
|
81
|
+
if (!answer.ok) return send(`<div class="msg error">Could not resume it: ${esc(String(answer.error || '').slice(-200))}</div>`, 500);
|
|
82
|
+
try { res.setHeader('HX-Trigger', JSON.stringify({ 'screens-changed': { session } })); } catch (_) { /* headers already sent */ }
|
|
83
|
+
return send(`<div class="msg assistant"><p>Resumed${last.title ? ` <em>${esc(last.title.slice(0, 80))}</em>` : ''} in <strong>${esc(session)}</strong>.</p>
|
|
84
|
+
<p><a class="uk-button uk-button-primary uk-button-small" href="/terminal/${encodeURIComponent(session)}" target="_blank" rel="noopener">Open the terminal</a></p></div>`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The menu entries for a project's SCREENS menu: exit for each live Claude session, resume when none runs. */
|
|
88
|
+
function menuItems({ ws, project, sessions, agents, dir }) {
|
|
89
|
+
const live = sessions.filter((s) => agents.has(s.session) && !/dead/i.test(s.state || ''));
|
|
90
|
+
const exits = live.map((s) => `
|
|
91
|
+
<li><a href class="arm-step" title="Exit Claude Code in ${esc(s.session)} (/exit)"
|
|
92
|
+
hx-post="/actions/claude-exit" hx-vals='${JSON.stringify({ session: s.session, confirm: 'yes' })}'
|
|
93
|
+
hx-trigger="confirmed-remove" hx-target="#webship-workspace-output" hx-swap="innerHTML"><span uk-icon="icon: sign-out; ratio: .7"></span> Exit Claude Code (/exit)</a></li>`);
|
|
94
|
+
const resume = !live.length && dir && lastConversation(dir) ? `
|
|
95
|
+
<li><a href title="Resume the last Claude Code conversation in this folder" hx-post="/actions/claude-resume" hx-vals='${JSON.stringify({ workspace: ws, project })}'
|
|
96
|
+
hx-target="#webship-workspace-output" hx-swap="innerHTML"><span uk-icon="icon: history; ratio: .7"></span> Resume Claude Code</a></li>` : '';
|
|
97
|
+
return exits.join('') + resume;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
'/actions/claude-exit': claude_exit,
|
|
102
|
+
'/actions/claude-resume': claude_resume,
|
|
103
|
+
sessionDir, lastConversation, menuItems,
|
|
104
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Commands page's writes, and running a command on what it runs on.
|
|
3
|
+
*
|
|
4
|
+
* Each form carries the mtime of core/config/commands.yml from when it was drawn, and a write refuses with 409 when
|
|
5
|
+
* the file changed since, rather than silently writing over another tab or a hand edit. A refused save answers
|
|
6
|
+
* with the same form holding what was typed, so nothing has to be typed again.
|
|
7
|
+
*
|
|
8
|
+
* A command is run by id only: its line comes from the catalog, never from the request. The only things a request
|
|
9
|
+
* carries into a run are the target it picked — resolved and kept inside its workspace or project — and option
|
|
10
|
+
* values, each checked against its option. Every value is single-quoted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
const { esc } = require('../html');
|
|
16
|
+
const { isValidWorkspace, workspaceDir } = require('../workspaces');
|
|
17
|
+
const { screenStartedHtml, screenSessionName } = require('../jobs');
|
|
18
|
+
const { lingerTail } = require('../linger');
|
|
19
|
+
const { hasOwnGit } = require('../git-info');
|
|
20
|
+
const { startUnlessRunning, terminalLink, isDdevProject, hasComposerJson } = require('./composer');
|
|
21
|
+
const { hasPackageJson } = require('./node');
|
|
22
|
+
const rl = require('../row-layout');
|
|
23
|
+
const cc = require('../commands');
|
|
24
|
+
const cp = require('../commands-page');
|
|
25
|
+
|
|
26
|
+
const ok = (text) => `<div class="msg assistant">✅ ${text}</div>`;
|
|
27
|
+
const bad = (text) => `<div class="msg error">${text}</div>`;
|
|
28
|
+
const saveError = (err) => (err && err.code === 'CHANGED'
|
|
29
|
+
? { code: 409, html: bad('Your commands changed since this page was loaded (another tab, or a hand edit), so nothing was saved. Reload to see them.') }
|
|
30
|
+
: { code: 500, html: bad(`Could not save: ${esc(String((err && err.message) || err))}`) });
|
|
31
|
+
|
|
32
|
+
/** A command from the add or edit form. Editing keeps the id and the options (those are changed in Definition). */
|
|
33
|
+
function fromForm(form, old) {
|
|
34
|
+
const f = (n) => (form[`cc_${n}`] === undefined ? '' : String(form[`cc_${n}`]));
|
|
35
|
+
const kind = f('kind') === 'script' ? 'script' : 'run';
|
|
36
|
+
return cc.cleanCommand({
|
|
37
|
+
id: old ? old.id : f('id').trim(),
|
|
38
|
+
label: f('label'),
|
|
39
|
+
group: f('group'),
|
|
40
|
+
description: f('description'),
|
|
41
|
+
on: f('on'),
|
|
42
|
+
when: f('when').trim().split(/\s+/).filter(Boolean),
|
|
43
|
+
run: kind === 'run' ? f('run') : '',
|
|
44
|
+
script: kind === 'script' ? f('script') : '',
|
|
45
|
+
args: kind === 'script' ? f('args') : '',
|
|
46
|
+
options: old ? old.options : [],
|
|
47
|
+
confirm: f('confirm') === '1',
|
|
48
|
+
open: f('open'),
|
|
49
|
+
tags: f('tags').split(/[\s,]+/).filter(Boolean),
|
|
50
|
+
}, 'yours');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function cc_save({ form, send, res }) {
|
|
54
|
+
const cat = cc.loadCatalog();
|
|
55
|
+
const scripts = cc.listScripts();
|
|
56
|
+
const editing = String(form.id || '');
|
|
57
|
+
const old = editing ? cat.commands.find((c) => c.id === editing) : null;
|
|
58
|
+
if (editing && !old) return send(`<div id="cc-command">${bad('No such command — it may have been deleted.')} <a href="/commands">Back to the commands</a></div>`, 404);
|
|
59
|
+
const again = (message, draft, code) => send(old
|
|
60
|
+
? cp.commandPageBody(old, { message, draft, scripts })
|
|
61
|
+
: cp.newPageBody({ scripts, draft, message }), code);
|
|
62
|
+
|
|
63
|
+
const e = fromForm(form, old);
|
|
64
|
+
if (!old) {
|
|
65
|
+
if (!e.id) {
|
|
66
|
+
const base = rl.slug(e.label);
|
|
67
|
+
let id = base;
|
|
68
|
+
for (let n = 2; cat.commands.some((c) => c.id === id); n += 1) id = `${base}-${n}`;
|
|
69
|
+
e.id = id;
|
|
70
|
+
} else if (cat.commands.some((c) => c.id === e.id)) {
|
|
71
|
+
return again(bad(`There is already a command with the id ${esc(e.id)} — pick another, or edit that one.`), e, 409);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const why = cc.commandRefusal(e);
|
|
75
|
+
if (why) return again(bad(`Not saved: ${esc(why)}.`), e, 400);
|
|
76
|
+
|
|
77
|
+
const list = cat.yours.slice();
|
|
78
|
+
const at = list.findIndex((c) => c.id === e.id);
|
|
79
|
+
if (at >= 0) list[at] = e; else list.push(e);
|
|
80
|
+
try { cc.saveYourCommands(list, form.mtime); } catch (err) {
|
|
81
|
+
const { code, html } = saveError(err);
|
|
82
|
+
return again(html, e, code);
|
|
83
|
+
}
|
|
84
|
+
if (res) res.setHeader('HX-Trigger', 'refresh-projects');
|
|
85
|
+
const saved = cc.findCommand(e.id);
|
|
86
|
+
const changedBuiltin = cat.builtin.some((c) => c.id === e.id);
|
|
87
|
+
return send(cp.commandPageBody(saved, { scripts,
|
|
88
|
+
message: ok(`${old ? 'Saved' : 'Added'} <strong>${esc(saved.label)}</strong>${changedBuiltin ? ' — your version of a built-in command' : ''}.`
|
|
89
|
+
+ `${old ? '' : ` Its page is <a href="/commands/c/${encodeURIComponent(saved.id)}">/commands/c/${esc(saved.id)}</a>.`}`) }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function cc_copy({ form, send, res }) {
|
|
93
|
+
const fromPage = form.from === 'page';
|
|
94
|
+
const cat = cc.loadCatalog();
|
|
95
|
+
const src = cat.commands.find((c) => c.id === String(form.id || ''));
|
|
96
|
+
if (!src) return send(fromPage ? `<div id="cc-command">${bad('No such command.')} <a href="/commands">Back to the commands</a></div>` : bad('No such command.'), 404);
|
|
97
|
+
const base = `${src.id.slice(0, 38)}-copy`;
|
|
98
|
+
let id = base;
|
|
99
|
+
for (let n = 2; cat.commands.some((c) => c.id === id); n += 1) id = `${base}-${n}`;
|
|
100
|
+
const copy = { ...src, id, label: `${src.label.slice(0, 55)} copy`, source: 'yours', overrides: undefined };
|
|
101
|
+
const why = cc.commandRefusal(copy);
|
|
102
|
+
if (why) return send(bad(`Not copied: ${esc(why)}.`), 400);
|
|
103
|
+
try { cc.saveYourCommands([...cat.yours, copy], form.mtime); } catch (err) {
|
|
104
|
+
const { code, html } = saveError(err);
|
|
105
|
+
return send(fromPage ? cp.commandPageBody(src, { message: html, scripts: cc.listScripts() }) : html, code);
|
|
106
|
+
}
|
|
107
|
+
if (res) res.setHeader('HX-Trigger', 'refresh-projects');
|
|
108
|
+
const saved = cc.findCommand(id);
|
|
109
|
+
if (fromPage) return send(cp.commandPageBody(saved, { scripts: cc.listScripts(), message: ok(`This is the copy, <strong>${esc(saved.label)}</strong> — change it below.`) }));
|
|
110
|
+
return send(ok(`Copied to <strong>${esc(saved.label)}</strong>. <a href="/commands/c/${encodeURIComponent(id)}">Open the copy</a> to change it.`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function cc_remove({ form, send, res }) {
|
|
114
|
+
const fromPage = form.from === 'page';
|
|
115
|
+
const cat = cc.loadCatalog();
|
|
116
|
+
const id = String(form.id || '');
|
|
117
|
+
const fail = (html, code) => send(fromPage ? `<div id="cc-command">${html} <a href="/commands">Back to the commands</a></div>` : html, code);
|
|
118
|
+
if (!cat.yours.some((c) => c.id === id)) {
|
|
119
|
+
return fail(bad(cat.builtin.some((c) => c.id === id)
|
|
120
|
+
? 'A built-in command cannot be deleted. Copy it and change the copy, or hide it from its button in Buttons.'
|
|
121
|
+
: 'No such command of yours — it may have been deleted already.'), 404);
|
|
122
|
+
}
|
|
123
|
+
// Deleting yours of a built-in id leaves the built-in one, so only a command of yours alone is refused while a button uses it.
|
|
124
|
+
const by = cat.builtin.some((c) => c.id === id) ? [] : cc.usedBy(id);
|
|
125
|
+
if (by.length) {
|
|
126
|
+
return fail(bad(`${esc(cat.yours.find((c) => c.id === id).label)} is on ${by.length === 1 ? 'a button' : `${by.length} buttons`}: `
|
|
127
|
+
+ `${esc(cc.describeUses(by))}. Take it off in Buttons first, then delete it.`), 409);
|
|
128
|
+
}
|
|
129
|
+
try { cc.saveYourCommands(cat.yours.filter((c) => c.id !== id), form.mtime); } catch (err) {
|
|
130
|
+
const { code, html } = saveError(err);
|
|
131
|
+
return fail(html, code);
|
|
132
|
+
}
|
|
133
|
+
if (res) res.setHeader('HX-Trigger', 'refresh-projects');
|
|
134
|
+
const builtin = cc.findCommand(id);
|
|
135
|
+
if (fromPage) {
|
|
136
|
+
return send(builtin
|
|
137
|
+
? cp.commandPageBody(builtin, { scripts: cc.listScripts(), message: ok('Yours is deleted: this is the built-in command again.') })
|
|
138
|
+
: `<div id="cc-command">${ok('Command deleted.')} <a href="/commands">Back to the commands</a></div>`);
|
|
139
|
+
}
|
|
140
|
+
return send(ok(builtin ? `Yours is deleted: <strong>${esc(builtin.label)}</strong> is the built-in command again.` : 'Command deleted.'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function cc_run({ form, send }) {
|
|
144
|
+
const e = cc.findCommand(form.id);
|
|
145
|
+
if (!e) return send(bad('No such command — it may have been deleted.'), 404);
|
|
146
|
+
const why = cc.commandRefusal(e);
|
|
147
|
+
if (why) return send(bad(`Not running ${esc(e.label)}: ${esc(why)}.`), 400);
|
|
148
|
+
const t = cc.resolveTarget(e, form);
|
|
149
|
+
if (t.error) return send(bad(`Not running ${esc(e.label)}: ${esc(t.error)}`), 400);
|
|
150
|
+
const ctx = { key: t.ws, isDdev: isDdevProject(t.base), hasComposer: hasComposerJson(t.base), hasNode: hasPackageJson(t.base), hasGit: hasOwnGit(t.base) };
|
|
151
|
+
if (!rl.whenMatches(e.when, ctx)) return send(bad(`${esc(e.label)} is only for ${esc(rl.describeWhen(e.when))}, and ${esc(t.name)} is not.`), 400);
|
|
152
|
+
|
|
153
|
+
let cwd = t.cwd;
|
|
154
|
+
if (e.script) {
|
|
155
|
+
const m = cc.SCRIPT_REF_RE.exec(e.script);
|
|
156
|
+
if (m[1] !== 'commands' && !isValidWorkspace(m[1])) return send(bad(`Not running ${esc(e.label)}: there is no ${esc(m[1])} workspace.`), 400);
|
|
157
|
+
const file = cc.scriptPath(e);
|
|
158
|
+
if (!fs.existsSync(file)) return send(bad(`Not running ${esc(e.label)}: ${esc(e.script)} does not exist.`), 404);
|
|
159
|
+
// A script runs from its own folder, the way it runs from a terminal; the target reaches it through its arguments.
|
|
160
|
+
cwd = m[1] === 'commands' ? require('path').dirname(file) : workspaceDir(m[1]);
|
|
161
|
+
}
|
|
162
|
+
if (e.options.length && form.submitted !== '1') {
|
|
163
|
+
return send(bad(`${esc(e.label)} asks for ${e.options.length === 1 ? 'an option' : 'options'} — run it from its form.`), 400);
|
|
164
|
+
}
|
|
165
|
+
const { values, error } = rl.resolveOptions(e, form);
|
|
166
|
+
if (error) return send(bad(`Not running ${esc(e.label)}: ${esc(error)}.`), 400);
|
|
167
|
+
|
|
168
|
+
const verb = `cmd-${e.id}`;
|
|
169
|
+
const project = t.project || t.ws;
|
|
170
|
+
// The last run of this command here is about to have its log truncated: write down how it ended first.
|
|
171
|
+
rl.settleRun(screenSessionName(project, verb, t.ws));
|
|
172
|
+
const started = await startUnlessRunning({ workspace: t.ws, project, projectDir: cwd, ownerDir: t.base, verb,
|
|
173
|
+
cmdLine: `${cc.commandLine(e, t.vars, values)}; ${lingerTail(e.label)}`, label: e.label });
|
|
174
|
+
if (started.stillRunning) {
|
|
175
|
+
return send(bad(`<code>${esc(e.label)}</code> is still running on <strong>${esc(t.name)}</strong> — ${terminalLink(started.session, 'open it')}. Left alone.`), 409);
|
|
176
|
+
}
|
|
177
|
+
if (!started.ok) return send(screenStartedHtml(started, e.label, t.name), started.already ? 409 : 500);
|
|
178
|
+
rl.recordRun({ at: new Date().toISOString(), id: `command:${e.id}`, label: e.label, workspace: t.ws, project: t.where || '.', session: started.session });
|
|
179
|
+
|
|
180
|
+
if (e.open === 'screen') {
|
|
181
|
+
return send(`<div hidden hx-get="/fragments/screen-term/${encodeURIComponent(started.session)}" hx-trigger="load" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
182
|
+
hx-on::after:request="UIkit.modal('#editor-modal').show()"></div>`);
|
|
183
|
+
}
|
|
184
|
+
return send(`<div class="msg assistant flash-now"><code>${esc(e.label)}</code> on <strong>${esc(t.name)}</strong> — ${terminalLink(started.session, 'open the output')}</div>`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Put a command on a button: an action there whose one step uses it, with the command's node filter. */
|
|
188
|
+
async function cc_add_to_button({ form, send }) {
|
|
189
|
+
const cmd = cc.findCommand(form.id);
|
|
190
|
+
if (!cmd) return send(`<div id="cc-command">${bad('No such command — it may have been deleted.')} <a href="/commands">Back to the commands</a></div>`, 404);
|
|
191
|
+
const scripts = cc.listScripts();
|
|
192
|
+
const again = (message, code) => send(cp.commandPageBody(cmd, { message, scripts }), code);
|
|
193
|
+
const layout = rl.loadRowLayoutFresh();
|
|
194
|
+
const label = String(form.label || '').trim() || cmd.label;
|
|
195
|
+
const taken = new Set(layout.custom.map((c) => c.id));
|
|
196
|
+
const base = rl.slug(label);
|
|
197
|
+
let id = base;
|
|
198
|
+
for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`;
|
|
199
|
+
const entry = rl.cleanCustom({ id, label, button: String(form.button || ''), group: String(form.group || '').trim() || cmd.group,
|
|
200
|
+
description: cmd.description, when: cmd.when, confirm: cmd.confirm, open: cmd.open, steps: [{ use: cmd.id }] });
|
|
201
|
+
const why = rl.customRefusal(entry) || rl.useRefusal(entry);
|
|
202
|
+
if (why) return again(bad(`Not added: ${esc(why)}.`), 400);
|
|
203
|
+
const doc = rl.toDoc(layout);
|
|
204
|
+
doc.custom.push(rl.customToDoc(entry));
|
|
205
|
+
try { rl.saveRowLayout(rl.normalizeLayout(doc), form.mtime); } catch (err) {
|
|
206
|
+
return again(bad(err && err.code === 'CHANGED'
|
|
207
|
+
? 'The buttons changed since this page was loaded (another tab, or a hand edit), so nothing was added. Reload to see them.'
|
|
208
|
+
: `Could not save: ${esc(String((err && err.message) || err))}`), err && err.code === 'CHANGED' ? 409 : 500);
|
|
209
|
+
}
|
|
210
|
+
return again(ok(`Added to ${esc(rl.buttonLabel(entry.button))} → ${esc(entry.group)} as <strong>${esc(label)}</strong>. Reload a workspace page to see it.`));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reportHtml(report) {
|
|
214
|
+
return `${report.errors.map((x) => bad(esc(x))).join('')}${report.notes.length
|
|
215
|
+
? `<div class="msg assistant">${report.errors.length ? '' : '✅ '}${report.notes.map(esc).join(' ')}</div>` : ''}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function cc_definition_check({ form, send }) {
|
|
219
|
+
const report = cc.checkDefinition(form.definition);
|
|
220
|
+
if (!report.errors.length) report.notes.unshift('It loads cleanly.');
|
|
221
|
+
return send(reportHtml(report), report.errors.length ? 400 : 200);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function cc_definition_save({ form, send }) {
|
|
225
|
+
const report = cc.checkDefinition(form.definition);
|
|
226
|
+
if (report.errors.length) {
|
|
227
|
+
return send(cp.definitionBody({ message: `${bad('Nothing was saved:')}${reportHtml(report)}`, draft: String(form.definition || '') }), 400);
|
|
228
|
+
}
|
|
229
|
+
try { cc.saveYourCommands(report.next, form.mtime); } catch (err) {
|
|
230
|
+
const { code, html } = saveError(err);
|
|
231
|
+
return send(cp.definitionBody({ message: html, draft: String(form.definition || '') }), code);
|
|
232
|
+
}
|
|
233
|
+
return send(cp.definitionBody({ message: ok(`Saved. ${report.notes.map(esc).join(' ')}`) }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function cc_reset({ form, send }) {
|
|
237
|
+
const cat = cc.loadCatalog();
|
|
238
|
+
const used = cat.yours.filter((c) => !cat.builtin.some((b) => b.id === c.id)).map((c) => ({ c, by: cc.usedBy(c.id) })).filter((x) => x.by.length);
|
|
239
|
+
if (used.length) {
|
|
240
|
+
return send(cp.definitionBody({ message: bad(`Nothing was removed: ${used.map((x) => `${esc(x.c.id)} is used by ${esc(cc.describeUses(x.by))}`).join('; ')}. Take those off their buttons first.`) }), 409);
|
|
241
|
+
}
|
|
242
|
+
try { cc.saveYourCommands([], form.mtime); } catch (err) {
|
|
243
|
+
const { code, html } = saveError(err);
|
|
244
|
+
return send(cp.definitionBody({ message: html }), code);
|
|
245
|
+
}
|
|
246
|
+
return send(cp.definitionBody({ message: ok('Every command of yours is removed; the built-in commands are as shipped.') }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
'/actions/cc-save': cc_save,
|
|
251
|
+
'/actions/cc-copy': cc_copy,
|
|
252
|
+
'/actions/cc-remove': cc_remove,
|
|
253
|
+
'/actions/cc-run': cc_run,
|
|
254
|
+
'/actions/cc-definition-check': cc_definition_check,
|
|
255
|
+
'/actions/cc-definition-save': cc_definition_save,
|
|
256
|
+
'/actions/cc-reset': cc_reset,
|
|
257
|
+
'/actions/cc-add-to-button': cc_add_to_button,
|
|
258
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The COMPOSER menu on a project row: composer commands for any folder with a composer.json.
|
|
3
|
+
*
|
|
4
|
+
* Every command runs in a SCREEN SESSION, like the DDEV menu's "Composer update", and the page
|
|
5
|
+
* shows only the toast at the top that links to it. The session stays open after the output,
|
|
6
|
+
* waiting for Enter, and closes by itself after 15 minutes — `composer validate` is over before
|
|
7
|
+
* anyone could open it otherwise.
|
|
8
|
+
*
|
|
9
|
+
* DDEV only, and there are two ways to be inside DDEV:
|
|
10
|
+
*
|
|
11
|
+
* - A DDEV project runs `ddev composer`, in that project's own web container.
|
|
12
|
+
* - A folder that is not a DDEV project (a library, a module clone in products/) runs composer in
|
|
13
|
+
* the DASHBOARD's DDEV container, which bind-mounts ~/workspace at the same path: `ddev exec -d
|
|
14
|
+
* <folder> composer`. There is no host composer to fall back on, and there must not be.
|
|
15
|
+
*
|
|
16
|
+
* Four kinds of entry: inspect (read-only), change (install, update…), one package (require,
|
|
17
|
+
* remove, why…, from a field) and the project's OWN scripts from composer.json, with their aliases.
|
|
18
|
+
* The handler runs only what these tables name, a package that looks like a package, and a script
|
|
19
|
+
* the folder's composer.json actually declares — so a hand-built request cannot run anything else.
|
|
20
|
+
*
|
|
21
|
+
* The full menu is only for the SITE workspaces — dev, test, demos, sandboxes, projects — which set
|
|
22
|
+
* `composer: full` in their own settings file. Everywhere else (products, modules, themes, recipes,
|
|
23
|
+
* profiles, and any workspace that says nothing) the mode is `manifest`: the menu only ADDS to
|
|
24
|
+
* composer.json without running an install — require / require --dev / remove with --no-update, which
|
|
25
|
+
* write composer.json and nothing else — plus validate. No install, no update, and scripts only in a DDEV
|
|
26
|
+
* folder that has a composer.lock (the DDEV menu lists them). Manifest is
|
|
27
|
+
* the default so that a new workspace never installs by accident, and the handler enforces the same
|
|
28
|
+
* list, so a hand-built request cannot install there either.
|
|
29
|
+
*
|
|
30
|
+
* A second click on a command whose session is still open replaces it only when that session has
|
|
31
|
+
* FINISHED (its log ends with the "Press Enter" line). One still running is left alone and the
|
|
32
|
+
* toast links to it: a `composer update` is minutes of work, and a click must never kill it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
const { esc } = require('../html');
|
|
39
|
+
const { ROOT, workspaceDir, loadWorkspaces } = require('../workspaces');
|
|
40
|
+
const { startInScreen, screenStartedHtml, hostRunnerRequest } = require('../jobs');
|
|
41
|
+
const { NAME_RE } = require('../validate');
|
|
42
|
+
const { lingerTail, LINGER_MARK } = require('../linger');
|
|
43
|
+
|
|
44
|
+
// In the order people reach for them: what is run every day first, then looking around, then the rare ones.
|
|
45
|
+
const COMPOSER_RUN_ACTIONS = {
|
|
46
|
+
'/actions/composer-install': { group: 'Daily', label: 'composer install', args: ['install'] },
|
|
47
|
+
'/actions/composer-update': { group: 'Daily', label: 'composer update', args: ['update'] },
|
|
48
|
+
'/actions/composer-outdated': { group: 'Daily', label: 'composer outdated --direct', args: ['outdated', '--direct'] },
|
|
49
|
+
'/actions/composer-validate': { group: 'Daily', label: 'composer validate', args: ['validate', '--no-check-publish'], manifest: true },
|
|
50
|
+
'/actions/composer-update-dry-run': { group: 'Inspect', label: 'composer update --dry-run', args: ['update', '--dry-run'] },
|
|
51
|
+
'/actions/composer-show': { group: 'Inspect', label: 'composer show --direct', args: ['show', '--direct'] },
|
|
52
|
+
'/actions/composer-show-tree': { group: 'Inspect', label: 'composer show --tree', args: ['show', '--tree'] },
|
|
53
|
+
'/actions/composer-audit': { group: 'Inspect', label: 'composer audit', args: ['audit'] },
|
|
54
|
+
'/actions/composer-platform': { group: 'Inspect', label: 'composer check-platform-reqs', args: ['check-platform-reqs'] },
|
|
55
|
+
'/actions/composer-licenses': { group: 'Inspect', label: 'composer licenses', args: ['licenses'] },
|
|
56
|
+
'/actions/composer-update-lock': { group: 'Maintenance', label: 'composer update --lock', args: ['update', '--lock'] },
|
|
57
|
+
'/actions/composer-dump-autoload': { group: 'Maintenance', label: 'composer dump-autoload -o', args: ['dump-autoload', '--optimize'] },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// The order the groups are shown in.
|
|
61
|
+
const COMPOSER_GROUPS = ['Daily', 'Inspect', 'Maintenance'];
|
|
62
|
+
|
|
63
|
+
// Take a package from the field. Only require accepts a version constraint after the name.
|
|
64
|
+
const COMPOSER_PACKAGE_ACTIONS = {
|
|
65
|
+
'/actions/composer-require': { label: 'composer require', args: ['require'], button: 'require', constraint: true, manifestArgs: ['require', '--no-update'] },
|
|
66
|
+
'/actions/composer-update-package': { label: 'composer update -W', args: ['update', '--with-all-dependencies'], button: 'update -W' },
|
|
67
|
+
'/actions/composer-remove': { label: 'composer remove', args: ['remove'], button: 'remove', manifestArgs: ['remove', '--no-update'] },
|
|
68
|
+
'/actions/composer-require-dev': { label: 'composer require --dev', args: ['require', '--dev'], button: 'require --dev', constraint: true, manifestArgs: ['require', '--dev', '--no-update'] },
|
|
69
|
+
'/actions/composer-why': { label: 'composer why', args: ['why'], button: 'why' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const SCRIPT_ROUTE = '/actions/composer-script';
|
|
73
|
+
|
|
74
|
+
/** What the menu offers, and the handler runs, in a workspace of this mode. */
|
|
75
|
+
function composerActionsFor(mode) {
|
|
76
|
+
if (mode === 'full') return { run: COMPOSER_RUN_ACTIONS, packages: COMPOSER_PACKAGE_ACTIONS, scripts: true };
|
|
77
|
+
const run = Object.fromEntries(Object.entries(COMPOSER_RUN_ACTIONS).filter(([, a]) => a.manifest));
|
|
78
|
+
const packages = Object.fromEntries(Object.entries(COMPOSER_PACKAGE_ACTIONS).filter(([, a]) => a.manifestArgs)
|
|
79
|
+
.map(([route, a]) => [route, { ...a, args: a.manifestArgs, label: `${a.label} --no-update` }]));
|
|
80
|
+
return { run, packages, scripts: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function composerModeOf(workspace) {
|
|
84
|
+
try { return (loadWorkspaces()[workspace] || {}).composerMode === 'full' ? 'full' : 'manifest'; } catch (_) { return 'manifest'; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// vendor/name as Composer itself defines it, then an optional `:constraint` of version characters
|
|
88
|
+
// only. No quote, $, backtick or backslash can pass: the value is single-quoted into a shell line.
|
|
89
|
+
const PACKAGE_RE = /^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$/;
|
|
90
|
+
const CONSTRAINT_RE = /^[A-Za-z0-9.^~*|,<>=@ -]{1,60}$/;
|
|
91
|
+
const SCRIPT_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_:.-]{0,80}$/;
|
|
92
|
+
const SAFE_PATH_RE = /^[\w/.@+~-]+$/;
|
|
93
|
+
|
|
94
|
+
/** The dashboard's own DDEV project, whose container runs composer for non-DDEV folders. */
|
|
95
|
+
function dashboardDir() { return path.join(ROOT, 'core', 'workspace-app'); }
|
|
96
|
+
|
|
97
|
+
function hasComposerJson(dir) {
|
|
98
|
+
try { return fs.statSync(path.join(dir, 'composer.json')).isFile(); } catch (_) { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isDdevProject(dir) {
|
|
102
|
+
try { return fs.existsSync(path.join(dir, '.ddev', 'config.yaml')); } catch (_) { return false; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Why this package value must not run, or '' when it may. */
|
|
106
|
+
function packageRefusal(value, { constraint = false } = {}) {
|
|
107
|
+
const v = String(value || '').trim();
|
|
108
|
+
if (!v) return 'type a package first, as vendor/name';
|
|
109
|
+
const [name, ...rest] = v.split(':');
|
|
110
|
+
if (!PACKAGE_RE.test(name)) return 'that is not a package name (vendor/name, lower case)';
|
|
111
|
+
if (!rest.length) return '';
|
|
112
|
+
if (!constraint) return 'a version constraint only goes with require';
|
|
113
|
+
return CONSTRAINT_RE.test(rest.join(':')) ? '' : 'that is not a version constraint';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The folder's own scripts, which is what "custom commands" are in Composer. Event hooks
|
|
118
|
+
* (pre-/post-…) run by themselves during install and update, so they are not offered by hand.
|
|
119
|
+
* A script whose name could not be passed safely to a shell is left out rather than escaped.
|
|
120
|
+
*/
|
|
121
|
+
function composerScripts(dir) {
|
|
122
|
+
let pkg;
|
|
123
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'composer.json'), 'utf8')); } catch (_) { return []; }
|
|
124
|
+
const scripts = (pkg && typeof pkg.scripts === 'object' && pkg.scripts) || {};
|
|
125
|
+
const aliases = (pkg && typeof pkg['scripts-aliases'] === 'object' && pkg['scripts-aliases']) || {};
|
|
126
|
+
const descriptions = (pkg && typeof pkg['scripts-descriptions'] === 'object' && pkg['scripts-descriptions']) || {};
|
|
127
|
+
return Object.keys(scripts)
|
|
128
|
+
.filter((name) => !/^(pre|post)-/.test(name) && !['init', 'command'].includes(name) && SCRIPT_NAME_RE.test(name))
|
|
129
|
+
.map((name) => ({
|
|
130
|
+
name,
|
|
131
|
+
aliases: (Array.isArray(aliases[name]) ? aliases[name] : []).filter((a) => typeof a === 'string' && SCRIPT_NAME_RE.test(a)),
|
|
132
|
+
description: typeof descriptions[name] === 'string' ? descriptions[name] : '',
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sq = (s) => `'${s}'`;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The line a composer session runs. No `$` anywhere: the runner hands it to an outer `bash -lc "…"`,
|
|
140
|
+
* which would expand it too early. Paths are checked against SAFE_PATH_RE and single-quoted.
|
|
141
|
+
*/
|
|
142
|
+
function composerScreenCommand(label, args, { isDdev, projectDir }) {
|
|
143
|
+
const composer = isDdev
|
|
144
|
+
? `ddev composer --no-interaction --ansi ${args.join(' ')}`
|
|
145
|
+
: `cd ${sq(dashboardDir())} && ddev exec -d ${sq(projectDir)} composer --no-interaction --ansi ${args.join(' ')}`;
|
|
146
|
+
return `${composer}; ${lingerTail(label)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Has this session's command finished, so that it is only waiting to be read? */
|
|
150
|
+
function sessionFinished(logText) {
|
|
151
|
+
const lines = String(logText || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
152
|
+
return lines.length > 0 && lines[lines.length - 1].includes(LINGER_MARK);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sessionLogTail(session) {
|
|
156
|
+
try {
|
|
157
|
+
const file = path.join(ROOT, '.screens', `${session}.log`);
|
|
158
|
+
const size = fs.statSync(file).size;
|
|
159
|
+
const fd = fs.openSync(file, 'r');
|
|
160
|
+
try {
|
|
161
|
+
const len = Math.min(size, 4096);
|
|
162
|
+
const buf = Buffer.alloc(len);
|
|
163
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
164
|
+
return buf.toString('utf8');
|
|
165
|
+
} finally { fs.closeSync(fd); }
|
|
166
|
+
} catch (_) { return ''; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Start the session; replace one still open from last time only if its command has finished. */
|
|
170
|
+
async function startUnlessRunning(opts) {
|
|
171
|
+
let started = await startInScreen(opts);
|
|
172
|
+
for (let i = 0; started.already && i < 3; i += 1) {
|
|
173
|
+
if (!sessionFinished(sessionLogTail(started.session))) return { ...started, stillRunning: true };
|
|
174
|
+
await hostRunnerRequest('stop', { session: started.session });
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
176
|
+
started = await startInScreen(opts);
|
|
177
|
+
}
|
|
178
|
+
return started;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function terminalLink(session, text) {
|
|
182
|
+
return `<a href="/terminal/${encodeURIComponent(session)}" target="_blank" rel="noopener">${esc(text)}</a>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function composer_run({ form, send, workspace, pathname }) {
|
|
186
|
+
const isScript = pathname === SCRIPT_ROUTE;
|
|
187
|
+
const act = COMPOSER_RUN_ACTIONS[pathname] || COMPOSER_PACKAGE_ACTIONS[pathname];
|
|
188
|
+
if (!act && !isScript) return send('<div class="msg error">Unknown composer command.</div>', 404);
|
|
189
|
+
const projectName = String(form.projectName || '');
|
|
190
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
191
|
+
|
|
192
|
+
// The workspace's mode decides before anything else: a package clone never installs.
|
|
193
|
+
const allowed = composerActionsFor(composerModeOf(workspace));
|
|
194
|
+
const use = isScript ? null : (allowed.run[pathname] || allowed.packages[pathname]);
|
|
195
|
+
// A DDEV folder that is installed (composer.lock) runs its own scripts in any workspace: they are its commands.
|
|
196
|
+
const installedDdev = (dir) => isDdevProject(dir) && fs.existsSync(path.join(dir, 'composer.lock'));
|
|
197
|
+
const scriptsHere = allowed.scripts || (NAME_RE.test(String(form.projectName || ''))
|
|
198
|
+
&& installedDdev(path.join(workspaceDir(workspace), String(form.projectName))));
|
|
199
|
+
if (isScript ? !scriptsHere : !use) {
|
|
200
|
+
return send(`<div class="msg error">Not run in ${esc(workspace)}: this workspace only edits composer.json — validate, and require or remove with --no-update.</div>`, 403);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// What the request carries is checked before anything on disk is looked at.
|
|
204
|
+
let args = use ? [...use.args] : [];
|
|
205
|
+
let label = use ? use.label : '';
|
|
206
|
+
let verb = pathname.replace('/actions/', '');
|
|
207
|
+
if (COMPOSER_PACKAGE_ACTIONS[pathname]) {
|
|
208
|
+
const why = packageRefusal(form.package, use);
|
|
209
|
+
if (why) return send(`<div class="msg error">Not running ${esc(use.label)}: ${esc(why)}.</div>`, 400);
|
|
210
|
+
args.push(sq(String(form.package).trim()));
|
|
211
|
+
}
|
|
212
|
+
const script = String(form.script || '');
|
|
213
|
+
if (isScript && !SCRIPT_NAME_RE.test(script)) return send('<div class="msg error">Invalid script name.</div>', 400);
|
|
214
|
+
|
|
215
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
216
|
+
if (!hasComposerJson(projectDir)) {
|
|
217
|
+
return send(`<div class="msg error">${esc(projectName)} has no composer.json.</div>`, 400);
|
|
218
|
+
}
|
|
219
|
+
if (!SAFE_PATH_RE.test(projectDir)) {
|
|
220
|
+
return send(`<div class="msg error">Not running composer: the folder path has characters a shell line cannot carry safely.</div>`, 400);
|
|
221
|
+
}
|
|
222
|
+
if (isScript) {
|
|
223
|
+
if (!composerScripts(projectDir).some((s) => s.name === script)) {
|
|
224
|
+
return send(`<div class="msg error">${esc(projectName)}'s composer.json has no script called ${esc(script)}.</div>`, 400);
|
|
225
|
+
}
|
|
226
|
+
args = ['run-script', script];
|
|
227
|
+
label = `composer run-script ${script}`;
|
|
228
|
+
verb = `composer-script-${script.replace(/[^\w-]/g, '-')}`;
|
|
229
|
+
}
|
|
230
|
+
const isDdev = isDdevProject(projectDir);
|
|
231
|
+
if (!isDdev && !isDdevProject(dashboardDir())) {
|
|
232
|
+
return send('<div class="msg error">This folder is not a DDEV project, and the dashboard\'s own DDEV project was not found to run composer in.</div>', 500);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const started = await startUnlessRunning({ workspace, project: projectName, projectDir, verb,
|
|
236
|
+
cmdLine: composerScreenCommand(label, args, { isDdev, projectDir }), label });
|
|
237
|
+
if (started.stillRunning) {
|
|
238
|
+
return send(`<div class="msg error"><code>${esc(label)}</code> is still running on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open it')}. Left alone.</div>`, 409);
|
|
239
|
+
}
|
|
240
|
+
if (!started.ok) return send(screenStartedHtml(started, label, projectName), started.already ? 409 : 500);
|
|
241
|
+
// install, update, require and remove change composer.lock: reload as the toast leaves, so the caption is current.
|
|
242
|
+
const reload = /^(install|update|require|remove)$/.test(args[0]) && !args.includes('--dry-run');
|
|
243
|
+
return send(`<div class="msg assistant flash-now"${reload ? ' data-reload-on-close="1"' : ''}><code>${esc(label)}</code> on <strong>${esc(projectName)}</strong> — ${terminalLink(started.session, 'open the output')}</div>`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
COMPOSER_RUN_ACTIONS,
|
|
248
|
+
COMPOSER_PACKAGE_ACTIONS,
|
|
249
|
+
COMPOSER_GROUPS,
|
|
250
|
+
SCRIPT_ROUTE,
|
|
251
|
+
composerActionsFor,
|
|
252
|
+
composerModeOf,
|
|
253
|
+
hasComposerJson,
|
|
254
|
+
isDdevProject,
|
|
255
|
+
packageRefusal,
|
|
256
|
+
composerScripts,
|
|
257
|
+
composerScreenCommand,
|
|
258
|
+
sessionFinished,
|
|
259
|
+
// Shared with the NODE menu (actions/node.js), which runs npm and yarn the same way.
|
|
260
|
+
sq,
|
|
261
|
+
SAFE_PATH_RE,
|
|
262
|
+
dashboardDir,
|
|
263
|
+
startUnlessRunning,
|
|
264
|
+
terminalLink,
|
|
265
|
+
...Object.fromEntries([...Object.keys(COMPOSER_RUN_ACTIONS), ...Object.keys(COMPOSER_PACKAGE_ACTIONS), SCRIPT_ROUTE]
|
|
266
|
+
.map((route) => [route, composer_run])),
|
|
267
|
+
};
|