@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/actions/node.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NODE menu on a project row: npm and yarn for any folder with a package.json.
|
|
3
|
+
*
|
|
4
|
+
* It follows the COMPOSER menu (actions/composer.js) and reuses its machinery: every command runs in
|
|
5
|
+
* a SCREEN SESSION that stays open to be read, the page shows only the toast at the top, and a second
|
|
6
|
+
* click replaces a session only once its command has finished — a run still going is left alone.
|
|
7
|
+
* Unlike COMPOSER there is no manifest mode: every workspace gets the whole menu.
|
|
8
|
+
*
|
|
9
|
+
* DDEV only, as with composer:
|
|
10
|
+
*
|
|
11
|
+
* - A DDEV project runs `ddev npm` / `ddev yarn`, in its own web container (the site projects all
|
|
12
|
+
* carry `corepack_enable: true`, so a pinned Yarn 4 is what runs).
|
|
13
|
+
* - Any other folder runs in the DASHBOARD's DDEV container: `ddev exec -d <folder> npm …`. When the
|
|
14
|
+
* project pins yarn in `packageManager`, it runs `corepack yarn`, so the pinned version runs rather
|
|
15
|
+
* than the container's Yarn 1.
|
|
16
|
+
*
|
|
17
|
+
* Which manager: the menu offers both, as a yarn and an npm tab, each saying whether its lock file is
|
|
18
|
+
* there. The tab that opens is the one the project uses — its `packageManager` pin, else its lock file.
|
|
19
|
+
* Yarn 1 (classic) and Yarn 2+ (berry) differ in several commands, so every entry carries three forms;
|
|
20
|
+
* which Yarn a folder has is read from the folder (`packageManager`, `.yarnrc.yml`), never from the request.
|
|
21
|
+
*
|
|
22
|
+
* The handler runs only what these tables name for that manager, a package that looks like an npm
|
|
23
|
+
* package name (never a git URL, a tarball or a file: path), and a script package.json declares.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const { esc } = require('../html');
|
|
30
|
+
const { workspaceDir } = require('../workspaces');
|
|
31
|
+
const { screenStartedHtml } = require('../jobs');
|
|
32
|
+
const { NAME_RE } = require('../validate');
|
|
33
|
+
const { lingerTail } = require('../linger');
|
|
34
|
+
const { startUnlessRunning, terminalLink, isDdevProject, dashboardDir, sq, SAFE_PATH_RE } = require('./composer');
|
|
35
|
+
|
|
36
|
+
// The order of the tabs. yarn first: every site project in this tree uses it.
|
|
37
|
+
const MANAGERS = ['yarn', 'npm'];
|
|
38
|
+
const NODE_GROUPS = ['Daily', 'Inspect', 'Maintenance'];
|
|
39
|
+
|
|
40
|
+
// Each command in its three forms: npm, Yarn 1 (yarn1) and Yarn 2+ (yarn). null = that manager has no
|
|
41
|
+
// such command, and the entry is not offered. In the order people reach for them.
|
|
42
|
+
const NODE_RUN_ACTIONS = {
|
|
43
|
+
'/actions/node-install': { group: 'Daily', npm: ['install'], yarn1: ['install'], yarn: ['install'] },
|
|
44
|
+
'/actions/node-ci': { group: 'Daily', npm: ['ci'], yarn1: ['install', '--frozen-lockfile'], yarn: ['install', '--immutable'],
|
|
45
|
+
needsLock: true, title: 'Install exactly what the lock file says, and fail if package.json disagrees with it' },
|
|
46
|
+
'/actions/node-outdated': { group: 'Daily', npm: ['outdated'], yarn1: ['outdated'], yarn: ['upgrade-interactive'] },
|
|
47
|
+
'/actions/node-audit': { group: 'Daily', npm: ['audit'], yarn1: ['audit'], yarn: ['npm', 'audit'] },
|
|
48
|
+
'/actions/node-list': { group: 'Inspect', npm: ['ls', '--depth=0'], yarn1: ['list', '--depth=0'], yarn: ['info', '--name-only'] },
|
|
49
|
+
'/actions/node-version': { group: 'Inspect', npm: ['--version'], yarn1: ['--version'], yarn: ['--version'] },
|
|
50
|
+
'/actions/node-update': { group: 'Maintenance', npm: ['update'], yarn1: ['upgrade'], yarn: null },
|
|
51
|
+
'/actions/node-dedupe': { group: 'Maintenance', npm: ['dedupe'], yarn1: null, yarn: ['dedupe'] },
|
|
52
|
+
'/actions/node-cache': { group: 'Maintenance', npm: ['cache', 'verify'], yarn1: ['cache', 'clean'], yarn: ['cache', 'clean'] },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Take a package from the field. Only add accepts a version after the name.
|
|
56
|
+
const NODE_PACKAGE_ACTIONS = {
|
|
57
|
+
'/actions/node-add': { button: 'add', npm: ['install'], yarn1: ['add'], yarn: ['add'], version: true },
|
|
58
|
+
'/actions/node-upgrade': { button: 'upgrade', npm: ['update'], yarn1: ['upgrade'], yarn: ['up'] },
|
|
59
|
+
'/actions/node-remove': { button: 'remove', npm: ['uninstall'], yarn1: ['remove'], yarn: ['remove'] },
|
|
60
|
+
'/actions/node-add-dev': { button: 'add --dev', npm: ['install', '--save-dev'], yarn1: ['add', '--dev'], yarn: ['add', '--dev'], version: true },
|
|
61
|
+
'/actions/node-why': { button: 'why', npm: ['explain'], yarn1: ['why'], yarn: ['why'] },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const NODE_SCRIPT_ROUTE = '/actions/node-script';
|
|
65
|
+
|
|
66
|
+
// npm's own package-name shape, optionally scoped. A version is range characters only: no quote, $,
|
|
67
|
+
// backtick, backslash or colon, so a git URL, a tarball or a file: path can never pass.
|
|
68
|
+
const NPM_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
69
|
+
const NPM_VERSION_RE = /^[A-Za-z0-9.^~*|<>=_ -]{1,60}$/;
|
|
70
|
+
const SCRIPT_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_:.@/-]{0,80}$/;
|
|
71
|
+
// Run by npm and yarn themselves around install, publish and version — not something to run by hand.
|
|
72
|
+
const LIFECYCLE = new Set(['install', 'preinstall', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly',
|
|
73
|
+
'prepack', 'postpack', 'publish', 'postpublish', 'preversion', 'version', 'postversion', 'dependencies',
|
|
74
|
+
'preuninstall', 'uninstall', 'postuninstall']);
|
|
75
|
+
|
|
76
|
+
function hasPackageJson(dir) {
|
|
77
|
+
try { return fs.statSync(path.join(dir, 'package.json')).isFile(); } catch (_) { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What the folder says about itself: the manifest, the pin, the lock files, and which Yarn. */
|
|
81
|
+
function nodeInfo(dir) {
|
|
82
|
+
let pkg = null;
|
|
83
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch (_) { /* valid: false */ }
|
|
84
|
+
if (!pkg || typeof pkg !== 'object') pkg = null;
|
|
85
|
+
const has = (f) => { try { return fs.existsSync(path.join(dir, f)); } catch (_) { return false; } };
|
|
86
|
+
const m = pkg && typeof pkg.packageManager === 'string' ? /^(npm|yarn|pnpm)@(\d+)[^+\s]*/.exec(pkg.packageManager) : null;
|
|
87
|
+
const pinned = m ? { name: m[1], major: Number(m[2]), spec: m[0] } : null;
|
|
88
|
+
const locks = { yarn: has('yarn.lock'), npm: has('package-lock.json') };
|
|
89
|
+
const yarnBerry = (pinned && pinned.name === 'yarn' && pinned.major >= 2) || has('.yarnrc.yml');
|
|
90
|
+
const preferred = (pinned && MANAGERS.includes(pinned.name)) ? pinned.name
|
|
91
|
+
: (locks.yarn ? 'yarn' : (locks.npm ? 'npm' : 'npm'));
|
|
92
|
+
return { valid: !!pkg, pkg, pinned, locks, yarnBerry, preferred, nodeModules: has('node_modules') };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The arguments an entry runs with for this manager in this folder, or null when it has none. */
|
|
96
|
+
function nodeArgsFor(action, manager, info) {
|
|
97
|
+
if (manager === 'npm') return action.npm || null;
|
|
98
|
+
return ((info && info.yarnBerry) ? action.yarn : action.yarn1) || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Why this package value must not run, or '' when it may. */
|
|
102
|
+
function nodePackageRefusal(value, { version = false } = {}) {
|
|
103
|
+
const v = String(value || '').trim();
|
|
104
|
+
if (!v) return 'type a package first, as name or @scope/name';
|
|
105
|
+
const at = v.indexOf('@', 1);
|
|
106
|
+
const name = at > 0 ? v.slice(0, at) : v;
|
|
107
|
+
if (!NPM_NAME_RE.test(name)) return 'that is not a package name (name or @scope/name, lower case)';
|
|
108
|
+
if (at < 0) return '';
|
|
109
|
+
if (!version) return 'a version only goes with add';
|
|
110
|
+
return NPM_VERSION_RE.test(v.slice(at + 1)) ? '' : 'that is not a version or range';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The folder's own scripts. Lifecycle hooks — install, prepare… and the pre/post twin of another
|
|
115
|
+
* script — run by themselves, so they are not offered; `prettier` or `postcss` are ordinary scripts.
|
|
116
|
+
*/
|
|
117
|
+
function nodeScripts(pkg) {
|
|
118
|
+
const scripts = (pkg && typeof pkg.scripts === 'object' && pkg.scripts) || {};
|
|
119
|
+
const names = Object.keys(scripts);
|
|
120
|
+
const isHook = (n) => LIFECYCLE.has(n)
|
|
121
|
+
|| names.some((o) => o !== n && (n === `pre${o}` || n === `post${o}`));
|
|
122
|
+
return names
|
|
123
|
+
.filter((n) => !isHook(n) && SCRIPT_NAME_RE.test(n))
|
|
124
|
+
.map((n) => ({ name: n, command: typeof scripts[n] === 'string' ? scripts[n] : '' }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The line a node session runs. No `$` anywhere: the runner hands it to an outer `bash -lc "…"`.
|
|
129
|
+
* Paths are checked against SAFE_PATH_RE and single-quoted.
|
|
130
|
+
*/
|
|
131
|
+
function nodeScreenCommand(label, manager, args, { isDdev, projectDir, pinned }) {
|
|
132
|
+
let line;
|
|
133
|
+
if (isDdev) {
|
|
134
|
+
line = `ddev ${manager} ${args.join(' ')}`;
|
|
135
|
+
} else {
|
|
136
|
+
// Corepack would stop to ask before downloading the pinned Yarn; `env` sets that off without a `$`.
|
|
137
|
+
const bin = (manager === 'yarn' && pinned && pinned.name === 'yarn')
|
|
138
|
+
? 'env COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack yarn' : manager;
|
|
139
|
+
line = `cd ${sq(dashboardDir())} && ddev exec -d ${sq(projectDir)} ${bin} ${args.join(' ')}`;
|
|
140
|
+
}
|
|
141
|
+
return `${line}; ${lingerTail(label)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function node_run({ form, send, workspace, pathname }) {
|
|
145
|
+
const isScript = pathname === NODE_SCRIPT_ROUTE;
|
|
146
|
+
const act = NODE_RUN_ACTIONS[pathname] || NODE_PACKAGE_ACTIONS[pathname];
|
|
147
|
+
if (!act && !isScript) return send('<div class="msg error">Unknown node command.</div>', 404);
|
|
148
|
+
const projectName = String(form.projectName || '');
|
|
149
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Invalid project name.</div>', 400);
|
|
150
|
+
const manager = String(form.manager || '');
|
|
151
|
+
if (!MANAGERS.includes(manager)) return send('<div class="msg error">Pick npm or yarn.</div>', 400);
|
|
152
|
+
|
|
153
|
+
// What the request carries is checked before anything on disk is looked at.
|
|
154
|
+
const isPackage = !!NODE_PACKAGE_ACTIONS[pathname];
|
|
155
|
+
if (isPackage) {
|
|
156
|
+
const why = nodePackageRefusal(form.package, act);
|
|
157
|
+
if (why) return send(`<div class="msg error">Not running ${esc(manager)} ${esc(act.button)}: ${esc(why)}.</div>`, 400);
|
|
158
|
+
}
|
|
159
|
+
const script = String(form.script || '');
|
|
160
|
+
if (isScript && !SCRIPT_NAME_RE.test(script)) return send('<div class="msg error">Invalid script name.</div>', 400);
|
|
161
|
+
|
|
162
|
+
const projectDir = path.join(workspaceDir(workspace), projectName);
|
|
163
|
+
if (!hasPackageJson(projectDir)) {
|
|
164
|
+
return send(`<div class="msg error">${esc(projectName)} has no package.json.</div>`, 400);
|
|
165
|
+
}
|
|
166
|
+
if (!SAFE_PATH_RE.test(projectDir)) {
|
|
167
|
+
return send('<div class="msg error">Not running it: the folder path has characters a shell line cannot carry safely.</div>', 400);
|
|
168
|
+
}
|
|
169
|
+
const info = nodeInfo(projectDir);
|
|
170
|
+
|
|
171
|
+
let args;
|
|
172
|
+
let verb;
|
|
173
|
+
if (isScript) {
|
|
174
|
+
if (!nodeScripts(info.pkg).some((s) => s.name === script)) {
|
|
175
|
+
return send(`<div class="msg error">${esc(projectName)}'s package.json has no script called ${esc(script)}.</div>`, 400);
|
|
176
|
+
}
|
|
177
|
+
args = ['run', script];
|
|
178
|
+
verb = `${manager}-run-${script.replace(/[^\w-]/g, '-')}`;
|
|
179
|
+
} else {
|
|
180
|
+
const base = nodeArgsFor(act, manager, info);
|
|
181
|
+
if (!base) return send(`<div class="msg error">${esc(manager)} has no such command in ${esc(projectName)}.</div>`, 400);
|
|
182
|
+
if (act.needsLock && !info.locks[manager]) {
|
|
183
|
+
return send(`<div class="msg error">${esc(projectName)} has no ${manager === 'npm' ? 'package-lock.json' : 'yarn.lock'} to install from.</div>`, 400);
|
|
184
|
+
}
|
|
185
|
+
args = [...base];
|
|
186
|
+
verb = `${manager}-${pathname.replace('/actions/node-', '')}`;
|
|
187
|
+
}
|
|
188
|
+
// The label goes into the session's closing echo: the manager and the command, never the typed package.
|
|
189
|
+
const label = `${manager} ${args.join(' ')}`;
|
|
190
|
+
if (isPackage) args.push(sq(String(form.package).trim()));
|
|
191
|
+
|
|
192
|
+
const isDdev = isDdevProject(projectDir);
|
|
193
|
+
if (!isDdev && !isDdevProject(dashboardDir())) {
|
|
194
|
+
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 it in.</div>', 500);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const started = await startUnlessRunning({ workspace, project: projectName, projectDir, verb,
|
|
198
|
+
cmdLine: nodeScreenCommand(label, manager, args, { isDdev, projectDir, pinned: info.pinned }), label });
|
|
199
|
+
if (started.stillRunning) {
|
|
200
|
+
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);
|
|
201
|
+
}
|
|
202
|
+
if (!started.ok) return send(screenStartedHtml(started, label, projectName), started.already ? 409 : 500);
|
|
203
|
+
// These change node_modules or a lock file: reload as the toast leaves, so the caption is current.
|
|
204
|
+
const reload = !isScript && /^(install|ci|add|remove|uninstall|update|upgrade|up|dedupe)$/.test(args[0]);
|
|
205
|
+
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>`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
MANAGERS,
|
|
210
|
+
NODE_GROUPS,
|
|
211
|
+
NODE_RUN_ACTIONS,
|
|
212
|
+
NODE_PACKAGE_ACTIONS,
|
|
213
|
+
NODE_SCRIPT_ROUTE,
|
|
214
|
+
hasPackageJson,
|
|
215
|
+
nodeInfo,
|
|
216
|
+
nodeArgsFor,
|
|
217
|
+
nodePackageRefusal,
|
|
218
|
+
nodeScripts,
|
|
219
|
+
nodeScreenCommand,
|
|
220
|
+
...Object.fromEntries([...Object.keys(NODE_RUN_ACTIONS), ...Object.keys(NODE_PACKAGE_ACTIONS), NODE_SCRIPT_ROUTE]
|
|
221
|
+
.map((route) => [route, node_run])),
|
|
222
|
+
};
|
package/actions/notes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing the note on a folder or a project.
|
|
3
|
+
*
|
|
4
|
+
* The note is a file INSIDE the folder it is about, so the only thing this has to be careful about is
|
|
5
|
+
* which folder: the workspace and the project name come from the browser, and a path built from them
|
|
6
|
+
* without checking would write `.workspace-note` anywhere on the machine that `..` can reach.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { esc } = require('../html');
|
|
13
|
+
const { isValidWorkspace, workspaceDir } = require('../workspaces');
|
|
14
|
+
const notes = require('../notes');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The folder a request names, or null.
|
|
18
|
+
*
|
|
19
|
+
* Three checks, and each one is the one that matters: the workspace has to be a workspace this
|
|
20
|
+
* dashboard knows, the project name has to be a plain name rather than a path, and the resolved
|
|
21
|
+
* directory has to still be inside the workspace — the same containment check the command editor makes
|
|
22
|
+
* before it writes a script.
|
|
23
|
+
*/
|
|
24
|
+
function folderOf(form) {
|
|
25
|
+
const ws = String(form.workspace || '');
|
|
26
|
+
const project = String(form.projectName || '');
|
|
27
|
+
if (!isValidWorkspace(ws)) return null;
|
|
28
|
+
const root = workspaceDir(ws);
|
|
29
|
+
if (project && !/^[a-zA-Z0-9_-]+$/.test(project)) return null;
|
|
30
|
+
const dir = project ? path.join(root, project) : root;
|
|
31
|
+
const resolved = fs.realpathSync.native ? safeReal(dir) : dir;
|
|
32
|
+
if (!resolved || !(resolved === safeReal(root) || resolved.startsWith(`${safeReal(root)}${path.sep}`))) return null;
|
|
33
|
+
return fs.existsSync(resolved) ? resolved : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeReal(p) {
|
|
37
|
+
try { return fs.realpathSync(p); } catch (_) { return ''; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function note_save({ form, send, res }) {
|
|
41
|
+
const dir = folderOf(form);
|
|
42
|
+
if (!dir) return send('<div class="msg error">No such folder.</div>', 400);
|
|
43
|
+
const r = notes.writeNote(dir, { title: form.title, body: form.body });
|
|
44
|
+
if (!r.ok) return send(`<div class="msg error">The note could not be written: ${esc(r.error)}</div>`, 500);
|
|
45
|
+
const what = form.projectName ? `${form.workspace}/${form.projectName}` : `${form.workspace}/`;
|
|
46
|
+
// The rows carry the note as their tooltip and their heading, so the page behind this has to redraw
|
|
47
|
+
// — otherwise the note is saved and nothing on screen says so until the next navigation. Set the way
|
|
48
|
+
// every other action here asks for a refresh, on the response rather than through send().
|
|
49
|
+
try { res.setHeader('HX-Trigger', 'refresh-projects'); } catch (_) { /* headers already sent */ }
|
|
50
|
+
return send(`<div class="msg assistant">${r.title || r.body
|
|
51
|
+
? `Noted on <strong>${esc(what)}</strong>${r.title ? ` — it is called <strong>${esc(r.title)}</strong> here now.` : '.'}`
|
|
52
|
+
: `The note on <strong>${esc(what)}</strong> was removed.`}</div>`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { '/actions/note-save': note_save, folderOf };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building and removing a project's Obsidian vault.
|
|
3
|
+
*
|
|
4
|
+
* Both shell out to `cmd-tools-obsidian.sh` in the project's own workspace rather than
|
|
5
|
+
* reimplementing the conversion, for the same reason every other action here does: the button and
|
|
6
|
+
* the command line cannot then drift apart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { listProjects, workspaceDir } = require('../workspaces');
|
|
13
|
+
const { esc } = require('../html');
|
|
14
|
+
const { jobFragment, runningJobKey, startJob } = require('../jobs');
|
|
15
|
+
const { NAME_RE } = require('../validate');
|
|
16
|
+
const { graphStoreDir } = require('../graphs');
|
|
17
|
+
const { vaultDir } = require('../obsidian');
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
'/actions/obsidian': async ({ form, res, send, workspace, req }) => {
|
|
21
|
+
const dir = workspaceDir(workspace);
|
|
22
|
+
const script = 'cmd-tools-obsidian.sh';
|
|
23
|
+
const projectName = String(form.projectName || '');
|
|
24
|
+
if (!fs.existsSync(path.join(dir, script))) return send('<div class="msg error">This workspace has no Obsidian script.</div>', 400);
|
|
25
|
+
if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
|
|
26
|
+
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
27
|
+
}
|
|
28
|
+
// Said here rather than left to the script, because the menu is reached from a row that may
|
|
29
|
+
// have had its graph deleted since the page was drawn.
|
|
30
|
+
if (!fs.existsSync(path.join(graphStoreDir(workspace, projectName), 'graph.json'))) {
|
|
31
|
+
return send('<div class="msg error">No graph to convert yet — build the graph first, from the same menu.</div>', 409);
|
|
32
|
+
}
|
|
33
|
+
// One writer per vault: the build writes to a sibling directory and swaps it in, so two runs
|
|
34
|
+
// would race on that swap and the loser's vault would vanish under it.
|
|
35
|
+
const jobKey = `obsidian:${workspace}/${projectName}`;
|
|
36
|
+
if (runningJobKey(jobKey)) {
|
|
37
|
+
return send('<div class="msg error">A vault is already being built for this project.</div>', 409);
|
|
38
|
+
}
|
|
39
|
+
const id = startJob(`🗂️ Obsidian <strong>${esc(projectName)}</strong>`, 'bash', [script, projectName], dir,
|
|
40
|
+
{ timeoutMs: 10 * 60 * 1000, echoLine: `bash ${script} ${projectName}`, key: jobKey });
|
|
41
|
+
return send(jobFragment(id).html);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
'/actions/obsidian-remove': async ({ form, res, send, workspace }) => {
|
|
45
|
+
if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
|
|
46
|
+
const projectName = String(form.projectName || '');
|
|
47
|
+
// The project may be long gone — a vault outlives it, and that is exactly a vault worth
|
|
48
|
+
// throwing away. So the name is validated but not looked up.
|
|
49
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
50
|
+
const dir = vaultDir(workspace, projectName);
|
|
51
|
+
if (!fs.existsSync(dir)) return send('<div class="msg">There was no vault to delete.</div>');
|
|
52
|
+
try {
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return send(`<div class="msg error">Could not delete the vault: ${esc(e.message)}</div>`, 500);
|
|
56
|
+
}
|
|
57
|
+
return send('<div class="msg">Vault deleted. The graph it was made from is untouched.</div>');
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Plugins page's buttons: each one is core/scripts/plugins.sh with a verb, run as a job so the
|
|
3
|
+
* output streams to the page and the record of what happened is the same as on the command line.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const { esc } = require('../html');
|
|
9
|
+
const { ROOT } = require('../workspaces');
|
|
10
|
+
const { jobFragment, runningJobKey, startJob } = require('../jobs');
|
|
11
|
+
const { pluginByName } = require('../plugin-manifest');
|
|
12
|
+
|
|
13
|
+
const VERBS = new Set(['require', 'activate', 'install', 'configure', 'deactivate', 'uninstall']);
|
|
14
|
+
const NAME_RE = /^[a-z0-9-]+$/;
|
|
15
|
+
// An npm spec: a package name, github:owner/repo[#ref], a git+https url, a tarball or a path. Nothing a shell could read as more than one word.
|
|
16
|
+
const SPEC_RE = /^[A-Za-z0-9@:/._#+~-]+$/;
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
'/actions/plugins': async ({ form, send }) => {
|
|
20
|
+
const verb = String(form.verb || '');
|
|
21
|
+
if (!VERBS.has(verb)) return send('<div class="msg error">Unknown plugin action.</div>', 400);
|
|
22
|
+
let arg = '';
|
|
23
|
+
if (verb === 'require') {
|
|
24
|
+
arg = String(form.spec || '').trim();
|
|
25
|
+
if (!SPEC_RE.test(arg)) return send('<div class="msg error">That is not a package spec: a name, github:owner/repo, a git url, a tarball or a path.</div>', 400);
|
|
26
|
+
} else {
|
|
27
|
+
arg = String(form.name || '');
|
|
28
|
+
if (!NAME_RE.test(arg) || !pluginByName(arg)) return send('<div class="msg error">Unknown plugin.</div>', 400);
|
|
29
|
+
}
|
|
30
|
+
const key = `plugins:${verb}:${arg}`;
|
|
31
|
+
if (runningJobKey(key)) return send('<div class="msg error">That is already running.</div>', 409);
|
|
32
|
+
const id = startJob(`🧩 Plugin <strong>${esc(verb)}</strong> ${esc(arg)}`, 'bash', ['core/scripts/plugins.sh', `--${verb}`, arg], ROOT,
|
|
33
|
+
{ timeoutMs: 10 * 60 * 1000, echoLine: `bash core/scripts/plugins.sh --${verb} ${arg}`, key });
|
|
34
|
+
return send(jobFragment(id).html);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a video or an audio recording about a project: the form of the row's More menu posts here.
|
|
3
|
+
* It starts a Claude Code session in the project through the same handler New screen uses, briefed by
|
|
4
|
+
* `project-make.js`, so it shows on Screens like every other session.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { esc } = require('../html');
|
|
8
|
+
const make = require('../project-make');
|
|
9
|
+
|
|
10
|
+
async function project_make({ form, send, res }) {
|
|
11
|
+
const brief = make.brief(form);
|
|
12
|
+
if (brief.error) return send(`<div class="msg error">${esc(brief.error)}</div>`, 400);
|
|
13
|
+
let out = null;
|
|
14
|
+
await require('./screens')['/actions/screen-new']({
|
|
15
|
+
form: { target: brief.target, kind: 'claude', session: brief.session, command: brief.prompt },
|
|
16
|
+
send: (html, status = 200) => { out = { html, status }; },
|
|
17
|
+
res,
|
|
18
|
+
});
|
|
19
|
+
if (!out || out.status >= 400) return send((out && out.html) || '<div class="msg error">The session did not start.</div>', (out && out.status) || 500);
|
|
20
|
+
const name = `${brief.project ? `${brief.ws}--${brief.project}` : brief.ws}--${brief.session}`.toLowerCase();
|
|
21
|
+
const dest = make.KIND[brief.kind].dest;
|
|
22
|
+
return send(`<div class="msg assistant">
|
|
23
|
+
<p>The AI (Claude Code) is working${brief.project ? ` in <strong>${esc(brief.ws)}/${esc(brief.project)}</strong>` : ''}, in the session <code>${esc(name)}</code>.</p>
|
|
24
|
+
<p class="uk-text-meta">It writes the words first and waits for your yes. Watch and answer it on <a href="/screens">Screens</a>, or
|
|
25
|
+
<code>screen -r ${esc(name)}</code> in your terminal. The result lands in <a href="https://${esc(dest)}.${esc(require('../workspaces').hubDomain())}">${esc(dest)}</a>.</p></div>`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { '/actions/project-make': project_make };
|
package/actions/rag.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexing a project into Milvus, searching it, and choosing which server holds it.
|
|
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 } = require('../ddev');
|
|
35
|
+
|
|
36
|
+
async function rag_mcp_command({ form, res, send, workspace, pathname }) {
|
|
37
|
+
const projectName = String(form.projectName || '');
|
|
38
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
39
|
+
const instance = ragInstanceFor(workspace, projectName);
|
|
40
|
+
const collection = ragCollectionName(workspace, projectName);
|
|
41
|
+
return send(commandBlock(
|
|
42
|
+
`Serve the ${instance} RAG server to the Claude Code CLI`,
|
|
43
|
+
[`cd ${ROOT}/rag && bash cmd-milvus-mcp.sh ${instance} -a register`,
|
|
44
|
+
`claude mcp add --scope user rag-${instance} -- uvx --from mcp-server-milvus==0.1.1.dev9 --with "mcp<2" mcp-server-milvus --milvus-uri http://127.0.0.1:19530`],
|
|
45
|
+
`Run either one <strong>on the host</strong>, in a terminal. One registration covers the whole `
|
|
46
|
+
+ `server, so every collection on it — including <code>${esc(collection)}</code> — is searchable `
|
|
47
|
+
+ `from the same 13 tools, and a new index needs no new registration. The workspace command is `
|
|
48
|
+
+ `the safer of the two: it resolves the published port for you and proves the server starts `
|
|
49
|
+
+ `before registering it.`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bind a project to a RAG server, or unbind it back to the shared default.
|
|
53
|
+
async function rag_bind({ form, res, send, workspace, pathname }) {
|
|
54
|
+
const projectName = String(form.projectName || '');
|
|
55
|
+
const instance = String(form.instance || '');
|
|
56
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
57
|
+
if (instance && !ragInstances().includes(instance)) {
|
|
58
|
+
return send('<div class="msg error">No such RAG server in the rag workspace.</div>', 400);
|
|
59
|
+
}
|
|
60
|
+
ragWriteBinding(workspace, projectName, instance);
|
|
61
|
+
ragInvalidate();
|
|
62
|
+
res.setHeader('HX-Trigger', 'refresh-projects');
|
|
63
|
+
const where = instance || `${milvusProjectName()} (the shared default)`;
|
|
64
|
+
return send(`<div class="msg assistant">🔗 <strong>${esc(projectName)}</strong> now indexes into <strong>${esc(where)}</strong>.
|
|
65
|
+
Re-index it to fill that server — the old collection stays where it was, so drop it first if it is stale.</div>`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build a new RAG server, and bind this project to it. Two steps deliberately kept in one
|
|
69
|
+
// action: a server built and then not bound is a 2 GB stack nothing uses.
|
|
70
|
+
async function rag_server_new({ form, res, send, workspace, pathname }) {
|
|
71
|
+
const projectName = String(form.projectName || '');
|
|
72
|
+
const name = String(form.name || '').trim();
|
|
73
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
74
|
+
if (!NAME_RE.test(name)) return send('<div class="msg error">A server name is letters, digits, dashes and underscores.</div>', 400);
|
|
75
|
+
if (ragInstances().includes(name)) return send(`<div class="msg error">A RAG server called ${esc(name)} already exists — pick it from the list instead.</div>`, 409);
|
|
76
|
+
const ragDir = workspaceDir('rag');
|
|
77
|
+
const script = 'cmd-milvus-project.sh';
|
|
78
|
+
if (!fs.existsSync(path.join(ragDir, script))) return send('<div class="msg error">No Milvus builder in the rag workspace.</div>', 400);
|
|
79
|
+
const jobKey = `milvus-build:${name}`;
|
|
80
|
+
if (runningJobKey(jobKey)) return send('<div class="msg error">That server is already being built.</div>', 409);
|
|
81
|
+
// Bound first: the build takes minutes, and the binding is what makes the row point at the new
|
|
82
|
+
// server the moment it answers.
|
|
83
|
+
ragWriteBinding(workspace, projectName, name);
|
|
84
|
+
ragInvalidate();
|
|
85
|
+
return screenAction(send, { workspace: 'rag', project: name, projectDir: ragDir, verb: 'milvus-build',
|
|
86
|
+
cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(name)}`, label: `build the RAG server ${name}` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build or rebuild a project's RAG index. The script does the work — this only guards the
|
|
90
|
+
// arguments and keeps two runs off one collection, since a re-index drops and recreates it and
|
|
91
|
+
// two of those racing would leave half of each.
|
|
92
|
+
async function ragify({ form, res, send, workspace, pathname }) {
|
|
93
|
+
const dir = workspaceDir(workspace);
|
|
94
|
+
const script = findRagifyScript(dir);
|
|
95
|
+
const projectName = String(form.projectName || '');
|
|
96
|
+
if (!script) return send('<div class="msg error">No ragify script in this workspace.</div>', 400);
|
|
97
|
+
if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
|
|
98
|
+
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
99
|
+
}
|
|
100
|
+
const instance = ragInstanceFor(workspace, projectName);
|
|
101
|
+
const { up } = await ragCollections(instance);
|
|
102
|
+
if (!up) {
|
|
103
|
+
return send(`<div class="msg error">The ${esc(instance)} RAG server is not running — start it from the <a href="${wsUrl('rag')}">RAG workspace</a> first.</div>`, 409);
|
|
104
|
+
}
|
|
105
|
+
const jobKey = `ragify:${workspace}/${projectName}`;
|
|
106
|
+
if (runningJobKey(jobKey)) {
|
|
107
|
+
return send('<div class="msg error">This project is already being indexed.</div>', 409);
|
|
108
|
+
}
|
|
109
|
+
// BM25 inside the database: no embedding model, no API key, nothing leaves the machine. Long
|
|
110
|
+
// timeout because a large tree is thousands of chunks.
|
|
111
|
+
const started = await startInScreen({ workspace, project: projectName, projectDir: dir, ownerDir: path.join(dir, projectName), verb: 'ragify',
|
|
112
|
+
cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(projectName)}`, label: 'ragify' });
|
|
113
|
+
return send(screenStartedHtml(started, 'ragify', projectName), started.ok ? 200 : (started.already ? 409 : 500));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function rag_info({ form, res, send, workspace, pathname }) {
|
|
117
|
+
const projectName = String(form.projectName || '');
|
|
118
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
119
|
+
const collection = ragCollectionName(workspace, projectName);
|
|
120
|
+
const instance = ragInstanceFor(workspace, projectName);
|
|
121
|
+
const described = await milvusPost('/v2/vectordb/collections/describe', { collectionName: collection }, 4000, instance);
|
|
122
|
+
const stats = await milvusPost('/v2/vectordb/collections/get_stats', { collectionName: collection }, 4000, instance);
|
|
123
|
+
if (!described || described.code !== 0) {
|
|
124
|
+
return send('<div class="msg error">Milvus did not answer for this collection — is the instance running?</div>', 409);
|
|
125
|
+
}
|
|
126
|
+
const fields = (described.data.fields || []).map((f) => f.name);
|
|
127
|
+
const functions = (described.data.functions || []).map((f) => `${f.name} (${f.type || 'function'})`);
|
|
128
|
+
const rows = stats && stats.code === 0 ? stats.data.rowCount : '?';
|
|
129
|
+
return send(`<div class="msg assistant">
|
|
130
|
+
<strong>${esc(collection)}</strong><br>
|
|
131
|
+
Chunks: <strong>${esc(String(rows))}</strong><br>
|
|
132
|
+
Fields: <code>${esc(fields.join(', '))}</code><br>
|
|
133
|
+
Functions: <code>${esc(functions.join(', ') || '—')}</code><br>
|
|
134
|
+
<span class="uk-text-meta">Retrieval is BM25, built inside Milvus — no embedding model and no API key.</span>
|
|
135
|
+
</div>`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Search one index. Straight over Milvus's REST API rather than through the Python worker: the
|
|
139
|
+
// dashboard container has no pymilvus, and full-text search on a BM25 function field takes the
|
|
140
|
+
// query as plain text in `data`.
|
|
141
|
+
async function rag_search({ form, res, send, workspace, pathname }) {
|
|
142
|
+
const projectName = String(form.projectName || '');
|
|
143
|
+
const query = String(form.query || '').trim();
|
|
144
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
145
|
+
if (!query) return send('<p class="uk-text-meta">Type a question first.</p>');
|
|
146
|
+
const collection = ragCollectionName(workspace, projectName);
|
|
147
|
+
const body = await milvusPost('/v2/vectordb/entities/search', {
|
|
148
|
+
collectionName: collection,
|
|
149
|
+
data: [query],
|
|
150
|
+
annsField: 'sparse',
|
|
151
|
+
limit: 8,
|
|
152
|
+
outputFields: ['path', 'start_line', 'end_line', 'text'],
|
|
153
|
+
}, 20000, ragInstanceFor(workspace, projectName));
|
|
154
|
+
if (!body || body.code !== 0) {
|
|
155
|
+
return send(`<div class="msg error">Milvus refused the search${body && body.message ? `: ${esc(body.message)}` : ''}.</div>`);
|
|
156
|
+
}
|
|
157
|
+
const hits = body.data || [];
|
|
158
|
+
if (!hits.length) return send(`<p class="uk-text-meta">No passage in <code>${esc(collection)}</code> matches ${esc(query)}.</p>`);
|
|
159
|
+
const cards = hits.map((h) => {
|
|
160
|
+
// The path is indexed as the first line of the chunk (so a filename matches even when the
|
|
161
|
+
// words are not in the file) — show the body, not that duplicate.
|
|
162
|
+
const text = String(h.text || '');
|
|
163
|
+
const nl = text.indexOf('\n');
|
|
164
|
+
const bodyText = nl === -1 ? text : text.slice(nl + 1);
|
|
165
|
+
return `<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small">
|
|
166
|
+
<div class="uk-text-bold">${esc(h.path)}<span class="uk-text-meta">:${esc(String(h.start_line))}-${esc(String(h.end_line))}</span>
|
|
167
|
+
<span class="uk-label">score ${esc(String(Math.round((h.distance || 0) * 100) / 100))}</span></div>
|
|
168
|
+
<pre class="uk-margin-small-top" style="max-height:16rem;overflow:auto"><code>${esc(bodyText.split('\n').slice(0, 24).join('\n'))}</code></pre>
|
|
169
|
+
</div>`;
|
|
170
|
+
}).join('');
|
|
171
|
+
return send(`<p class="uk-text-meta">${hits.length} passages for ${esc(query)} in <code>${esc(collection)}</code>, best first.</p>${cards}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Drop one index. Regenerable by definition — a re-index rebuilds it from the tree — and the
|
|
175
|
+
// project itself is never touched: nothing of the index lives in it.
|
|
176
|
+
async function rag_remove({ form, res, send, workspace, pathname }) {
|
|
177
|
+
if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
|
|
178
|
+
const projectName = String(form.projectName || '');
|
|
179
|
+
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
180
|
+
if (runningJobKey(`ragify:${workspace}/${projectName}`)) {
|
|
181
|
+
return send('<div class="msg error">An index run is in progress for this project — wait for it to finish.</div>', 409);
|
|
182
|
+
}
|
|
183
|
+
const collection = ragCollectionName(workspace, projectName);
|
|
184
|
+
const body = await milvusPost('/v2/vectordb/collections/drop', { collectionName: collection }, 4000,
|
|
185
|
+
ragInstanceFor(workspace, projectName));
|
|
186
|
+
if (!body || body.code !== 0) {
|
|
187
|
+
return send('<div class="msg error">Milvus did not drop the collection — is the instance running?</div>', 409);
|
|
188
|
+
}
|
|
189
|
+
ragInvalidate();
|
|
190
|
+
res.setHeader('HX-Trigger', 'refresh-projects');
|
|
191
|
+
return send(`<div class="msg assistant">🗑️ Deleted the index <strong>${esc(collection)}</strong>. Rebuild it any time from the RAG menu.</div>`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
'/actions/rag-mcp-command': rag_mcp_command,
|
|
196
|
+
'/actions/rag-bind': rag_bind,
|
|
197
|
+
'/actions/rag-server-new': rag_server_new,
|
|
198
|
+
'/actions/ragify': ragify,
|
|
199
|
+
'/actions/rag-info': rag_info,
|
|
200
|
+
'/actions/rag-search': rag_search,
|
|
201
|
+
'/actions/rag-remove': rag_remove,
|
|
202
|
+
};
|