@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/plugin-loader.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard plugins: optional features turned on from settings.yml.
|
|
3
|
+
*
|
|
4
|
+
* A plugin is a node package with a `workspacePlugin` manifest in its package.json (see
|
|
5
|
+
* plugin-manifest.js) and an `index.js` exporting `register(api)` — or a folder under plugins/
|
|
6
|
+
* with the same shape. It is REQUIRED with npm, ACTIVATED by a `plugins.<name>` entry in
|
|
7
|
+
* settings.yml, INSTALLED by core/scripts/plugins.sh (which places what the manifest names in the
|
|
8
|
+
* workspace), and CONFIGURED in its own core/config/plugin.<name>.settings.yml.
|
|
9
|
+
*
|
|
10
|
+
* Everything a plugin adds is namespaced under its own name — actions at /actions/p/<plugin>/…,
|
|
11
|
+
* fragments at /fragments/p/<plugin>/… — so it can never shadow a built-in, and turning it off
|
|
12
|
+
* removes its whole surface at once. A row-menu slot or a page it registers is the one exception,
|
|
13
|
+
* and both are declared, so the layout settings and the router know them by name.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const yaml = require('js-yaml');
|
|
19
|
+
|
|
20
|
+
const { esc } = require('./html');
|
|
21
|
+
const { humanSize, humanTime, cliError } = require('./format');
|
|
22
|
+
const { ROOT, CONFIG_DIR, loadSettings, loadWorkspaces, workspaceDir, isValidWorkspace } = require('./workspaces');
|
|
23
|
+
const {
|
|
24
|
+
run, startJob, jobFragment, runningJobKey, listProjects, screenAction, startInScreen, screenStartedHtml,
|
|
25
|
+
} = require('./jobs');
|
|
26
|
+
const { NAME_RE } = require('./validate');
|
|
27
|
+
const { wsUrl } = require('./urls');
|
|
28
|
+
const { TYPES, addTypeAction } = require('./workspace-types');
|
|
29
|
+
const rowLayout = require('./row-layout');
|
|
30
|
+
const { upsertSettingsValue, upsertSettingsList } = require('./settings-file');
|
|
31
|
+
const { fileFor, readPluginSettings, ensurePluginSettingsFile } = require('./plugin-settings');
|
|
32
|
+
const googleAuth = require('./auth-google');
|
|
33
|
+
const { iconHtml } = require('./icons');
|
|
34
|
+
const { commandBlock } = require('./command-block');
|
|
35
|
+
const { tabModalHtml } = require('./tab-modal');
|
|
36
|
+
const claudeSessions = require('./claude-sessions');
|
|
37
|
+
const { installedPlugins } = require('./plugin-manifest');
|
|
38
|
+
|
|
39
|
+
// What register(api) hands every plugin. Shared helpers rather than reaching into server.js: the
|
|
40
|
+
// same set the extracted actions/ modules already build on.
|
|
41
|
+
const lib = {
|
|
42
|
+
esc, humanSize, humanTime, cliError,
|
|
43
|
+
ROOT, CONFIG_DIR, loadSettings, loadWorkspaces, workspaceDir, isValidWorkspace,
|
|
44
|
+
run, startJob, jobFragment, runningJobKey, listProjects, screenAction, startInScreen, screenStartedHtml,
|
|
45
|
+
NAME_RE, wsUrl, iconHtml, commandBlock, tabModalHtml,
|
|
46
|
+
// The dashboard's hostname, for links a plugin posts elsewhere; the screen sessions on this machine;
|
|
47
|
+
// which of them are agents; and the Claude Code transcripts a folder holds.
|
|
48
|
+
hubDomain: () => require('./workspaces').hubDomain(),
|
|
49
|
+
listScreens: (...a) => require('./screens').listScreens(...a),
|
|
50
|
+
agentSessions: (...a) => require('./screens').agentSessions(...a),
|
|
51
|
+
screenLogFor: (...a) => require('./screens').screenLogFor(...a),
|
|
52
|
+
claudeSessionsFor: (...a) => claudeSessions.sessionsFor(...a),
|
|
53
|
+
// The last `screen -ls`, for a renderer that cannot wait: refreshed in the background, at most every 10s.
|
|
54
|
+
screensCached: () => screensCached(),
|
|
55
|
+
// The type registry, and what a workspace resolved to — so a plugin can offer itself only
|
|
56
|
+
// where it makes sense ("sites only", "not on media").
|
|
57
|
+
TYPES,
|
|
58
|
+
// The dashboard's Google sign-in, for a plugin that wants to know who is signed in or draw the button.
|
|
59
|
+
auth: { sessionOf: googleAuth.sessionOf, googleButton: googleAuth.googleButton, BUTTON_CSS: googleAuth.BUTTON_CSS },
|
|
60
|
+
// What git says about a folder (its own .git, worktree role, branch, what a remove destroys), the
|
|
61
|
+
// lingering-session tail every short command ends with, a path with ~ for the home, and the host
|
|
62
|
+
// runner (stop a session before starting it afresh): what the GIT plugin and its kin draw from.
|
|
63
|
+
projectHasGit: (dir) => require('./git-info').projectHasGit(dir),
|
|
64
|
+
hasOwnGit: (dir) => require('./git-info').hasOwnGit(dir),
|
|
65
|
+
gitWorktreeInfo: (dir) => require('./git-info').gitWorktreeInfo(dir),
|
|
66
|
+
gitBranchOf: (dir) => require('./git-info').gitBranchOf(dir),
|
|
67
|
+
lingerTail: (label) => require('./linger').lingerTail(label),
|
|
68
|
+
LINGER_MARK: require('./linger').LINGER_MARK,
|
|
69
|
+
tildePath: (p) => require('./format').tildePath(p),
|
|
70
|
+
hostRunnerRequest: (...a) => require('./jobs').hostRunnerRequest(...a),
|
|
71
|
+
// The list helpers a plugin's own list page draws with (paging, sorting, the controls), the
|
|
72
|
+
// stylesheet version a standalone page links, and the script finder (cmd-tools-<x>.sh in a folder).
|
|
73
|
+
lists: require('./lists'),
|
|
74
|
+
cssVersionNow: () => require('./themes').cssVersion(require('./workspaces').styleSettings().theme),
|
|
75
|
+
findScript: (dir, patterns) => require('./workspaces').findScript(dir, patterns),
|
|
76
|
+
workspaceType: (key) => (loadWorkspaces()[key] || {}).type || null,
|
|
77
|
+
typeAllows: (key, action) => {
|
|
78
|
+
const meta = loadWorkspaces()[key];
|
|
79
|
+
return !!(meta && meta.typeActions && meta.typeActions.has(action));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const actions = {}; // '/actions/p/<plugin>/<action>' -> handler(ctx)
|
|
84
|
+
const fragments = {}; // '<plugin>' -> handler({ req, res, send, rest, searchParams })
|
|
85
|
+
const pageActionFns = []; // ({ view, key, meta }) -> { label, items: [html] } | null
|
|
86
|
+
const projectActionFns = []; // ({ key, project, dir, meta }) -> html | null
|
|
87
|
+
const rowMenus = {}; // '<slot>' -> ({ key, project, dir, meta, allowed, vals }) -> html | null
|
|
88
|
+
const pages = []; // { re, handler({ req, res, match, send }) }
|
|
89
|
+
const chipFns = []; // ({ key, project, dir, meta }) -> html | null
|
|
90
|
+
const folderSetups = []; // { html(ws, ctx), steps(ws, name, form, ctx), prompt(ws, name, form, ctx), problem(ws, form, ctx) }
|
|
91
|
+
const cssFiles = []; // absolute paths, appended to the stylesheet after the components
|
|
92
|
+
let labels = {}; // settings labels, merged into settings.labels.yml's
|
|
93
|
+
const badgeFns = []; // ({ key, project, dir, sessions }) -> html | null, in the card's corner, refreshed with the screens poll
|
|
94
|
+
const settingsOptionFns = []; // ({ file, dotted }) -> Promise<[value, label][] | null>
|
|
95
|
+
const screenStartFns = []; // async ({ session, workKey, workspace, project, dir, form }) -> html | ''
|
|
96
|
+
const workKeyLinkFns = []; // (key) -> url | null
|
|
97
|
+
const workPromptFns = []; // ({ key, workspace, project }) -> prompt | null
|
|
98
|
+
const sessionLogFns = []; // (session, dirs) -> file | ''
|
|
99
|
+
const assistantContextFns = []; // () -> a paragraph for the assistant's system context, or ''
|
|
100
|
+
const forges = {}; // '<host>' -> { name, label, env(), auth() }: who answers for a git forge host
|
|
101
|
+
const openWithFns = []; // (abs, { label, caption, git }) -> html | null: the Open-with menu for a folder (the IDE plugin)
|
|
102
|
+
const workspaceViews = {}; // '<key>' -> { name, listHtml(state), count(), noFilesActions, noNewForm }: a plugin that owns a workspace folder's page
|
|
103
|
+
const folderLinkFns = []; // (abs) -> { href, label, icon?, title? } | null: a link to open a folder elsewhere (the Web IDE)
|
|
104
|
+
|
|
105
|
+
let _screens = { at: 0, rows: [], running: false };
|
|
106
|
+
function screensCached() {
|
|
107
|
+
if (!_screens.running && Date.now() - _screens.at > 10 * 1000) {
|
|
108
|
+
_screens.running = true;
|
|
109
|
+
require('./screens').listScreens().then((rows) => { _screens = { at: Date.now(), rows: rows || [], running: false }; })
|
|
110
|
+
.catch(() => { _screens.running = false; });
|
|
111
|
+
}
|
|
112
|
+
return _screens.rows;
|
|
113
|
+
}
|
|
114
|
+
const loaded = []; // { name, source } for the settings page and diagnostics
|
|
115
|
+
|
|
116
|
+
// Plugins that are on without a line in settings.yml. A machine's settings.yml is never
|
|
117
|
+
// overwritten by an update, so a plugin that needed its entry added by hand would simply not
|
|
118
|
+
// appear for anybody who had installed before it existed. A line in the file still wins, so
|
|
119
|
+
// `enabled: false` turns one off.
|
|
120
|
+
const SHIPPED_PLUGINS = {};
|
|
121
|
+
|
|
122
|
+
function pluginConfigs() {
|
|
123
|
+
const block = loadSettings().plugins;
|
|
124
|
+
const own = (block && typeof block === 'object') ? block : {};
|
|
125
|
+
const merged = {};
|
|
126
|
+
for (const [name, config] of Object.entries(SHIPPED_PLUGINS)) merged[name] = { ...config };
|
|
127
|
+
for (const [name, config] of Object.entries(own)) merged[name] = { ...(merged[name] || {}), ...(config && typeof config === 'object' ? config : {}) };
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appliesTo(config, workspaceKey) {
|
|
132
|
+
const list = String(config.workspaces || '').split(',').map((w) => w.trim()).filter(Boolean);
|
|
133
|
+
return list.length === 0 || list.includes(workspaceKey);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeApi(name, config) {
|
|
137
|
+
return {
|
|
138
|
+
name,
|
|
139
|
+
config,
|
|
140
|
+
lib,
|
|
141
|
+
// This plugin's own settings file (core/config/plugin.<name>.settings.yml): read it, or change one
|
|
142
|
+
// key or one list in place with comments kept. Secret-named keys stay out of the browser like
|
|
143
|
+
// any setting.
|
|
144
|
+
settings: {
|
|
145
|
+
file: fileFor(name),
|
|
146
|
+
read: () => readPluginSettings(name),
|
|
147
|
+
set: (dotted, value) => { upsertSettingsValue(ensurePluginSettingsFile(name), dotted, value); },
|
|
148
|
+
setList: (dotted, items) => { upsertSettingsList(ensurePluginSettingsFile(name), dotted, items); },
|
|
149
|
+
},
|
|
150
|
+
appliesTo: (workspaceKey) => appliesTo(config, workspaceKey),
|
|
151
|
+
addAction(action, handler) {
|
|
152
|
+
if (!/^[a-z0-9-]+$/.test(action)) throw new Error(`${name}: invalid action name '${action}'`);
|
|
153
|
+
actions[`/actions/p/${name}/${action}`] = handler;
|
|
154
|
+
},
|
|
155
|
+
addFragment(handler) {
|
|
156
|
+
fragments[name] = handler;
|
|
157
|
+
},
|
|
158
|
+
addPageActions(fn) {
|
|
159
|
+
pageActionFns.push((ctx) => fn(ctx));
|
|
160
|
+
},
|
|
161
|
+
addProjectActions(fn) {
|
|
162
|
+
projectActionFns.push((ctx) => fn(ctx));
|
|
163
|
+
},
|
|
164
|
+
// A named menu on every project row, in the slot the layout settings arrange: `spec` is
|
|
165
|
+
// `{ label, row }` like a built-in button's. The function draws it, or returns null for a row
|
|
166
|
+
// the plugin has nothing to say about.
|
|
167
|
+
addRowMenu(slot, spec, fn) {
|
|
168
|
+
if (!/^[a-z0-9-]+$/.test(slot)) throw new Error(`${name}: invalid row menu slot '${slot}'`);
|
|
169
|
+
rowLayout.registerButton({ id: slot, label: spec.label || slot, row: spec.row || 0, columns: spec.columns, menu: spec.menu });
|
|
170
|
+
rowMenus[slot] = fn;
|
|
171
|
+
},
|
|
172
|
+
// A whole page: GET requests whose path matches `re`. The handler writes the response itself.
|
|
173
|
+
addPage(re, handler) {
|
|
174
|
+
pages.push({ re, handler });
|
|
175
|
+
},
|
|
176
|
+
// A chip in the project card's meta line, beside the folder name and the test badge.
|
|
177
|
+
addProjectChip(fn) {
|
|
178
|
+
chipFns.push(fn);
|
|
179
|
+
},
|
|
180
|
+
// A section of the New-folder form, and what it adds to the setup line, the AI prompt and the
|
|
181
|
+
// validation. Each member is optional.
|
|
182
|
+
addFolderSetup(setup) {
|
|
183
|
+
folderSetups.push({ name, ...setup });
|
|
184
|
+
},
|
|
185
|
+
// A stylesheet of the plugin's own, appended after the components (it may only add rules).
|
|
186
|
+
addCss(file) {
|
|
187
|
+
cssFiles.push(file);
|
|
188
|
+
},
|
|
189
|
+
// An action name workspace types may allow ('upsun' on sites): what `typeAllows` answers.
|
|
190
|
+
addTypeAction(action, types) {
|
|
191
|
+
addTypeAction(action, types);
|
|
192
|
+
},
|
|
193
|
+
addLabels(map) {
|
|
194
|
+
labels = { ...labels, ...map };
|
|
195
|
+
},
|
|
196
|
+
// A badge in the project card's corner, sent with every screens poll (so it clears itself): what an
|
|
197
|
+
// agent here is working on, say. `sessions` is this project's live screen list.
|
|
198
|
+
addProjectBadge(fn) {
|
|
199
|
+
badgeFns.push(fn);
|
|
200
|
+
},
|
|
201
|
+
// Live options for a field of a settings file ('plugin.jira.settings.yml', 'default_project'): what a
|
|
202
|
+
// CLI can see, rather than a hand-typed value. Async; null means a plain input.
|
|
203
|
+
addSettingsOptions(file, dotted, fn) {
|
|
204
|
+
settingsOptionFns.push({ file, dotted, fn });
|
|
205
|
+
},
|
|
206
|
+
// Called after a screen session started for a work item (the New screen form's `work` key): note
|
|
207
|
+
// the run, say so on the item. Returns HTML to show, or ''.
|
|
208
|
+
addScreenStartHook(fn) {
|
|
209
|
+
screenStartFns.push(fn);
|
|
210
|
+
},
|
|
211
|
+
// Where a work-item key found in a project's name links to.
|
|
212
|
+
addWorkKeyLink(fn) {
|
|
213
|
+
workKeyLinkFns.push(fn);
|
|
214
|
+
},
|
|
215
|
+
// The prompt a screen started for a work item begins with, or null for the dashboard's own.
|
|
216
|
+
addWorkPrompt(fn) {
|
|
217
|
+
workPromptFns.push(fn);
|
|
218
|
+
},
|
|
219
|
+
// Where a session's log lives when the dashboard does not know ('jira-<key>' logs inside the project).
|
|
220
|
+
addSessionLog(fn) {
|
|
221
|
+
sessionLogFns.push(fn);
|
|
222
|
+
},
|
|
223
|
+
// A paragraph of the assistant's system context: what this plugin lets it reach, and the rules.
|
|
224
|
+
addAssistantContext(fn) {
|
|
225
|
+
assistantContextFns.push(fn);
|
|
226
|
+
},
|
|
227
|
+
// Who answers for a git forge host ('github.com'): `env()` is the environment a child process
|
|
228
|
+
// needs to authenticate there (a token), or null; `auth()` says who, for the settings page.
|
|
229
|
+
addForge(host, spec) {
|
|
230
|
+
forges[String(host).toLowerCase()] = { name, ...spec };
|
|
231
|
+
},
|
|
232
|
+
// The "Open with" menu for a folder: the Web IDE here, an editor on the browser's machine. Drawn on
|
|
233
|
+
// project rows (the IDE slot), the files pages and the images gallery. The first plugin that answers wins.
|
|
234
|
+
addOpenWith(fn) {
|
|
235
|
+
openWithFns.push(fn);
|
|
236
|
+
},
|
|
237
|
+
// A link that opens a folder elsewhere (the Web IDE on it), offered inside other menus (COMPOSER).
|
|
238
|
+
addFolderLink(fn) {
|
|
239
|
+
folderLinkFns.push(fn);
|
|
240
|
+
},
|
|
241
|
+
// A plugin that owns a workspace folder's page (the graphs workspace): its list section
|
|
242
|
+
// (`listHtml(state)`), the count on the home card (`count()`), and whether the files-page
|
|
243
|
+
// actions and the New-folder form stay away from it.
|
|
244
|
+
addWorkspaceView(key, view) {
|
|
245
|
+
if (!/^[a-z0-9_-]+$/.test(String(key))) throw new Error(`${name}: invalid workspace key '${key}'`);
|
|
246
|
+
workspaceViews[key] = { name, ...view };
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** What the manifest declares, wired in before register(api) runs. */
|
|
252
|
+
function applyManifest(name, plugin) {
|
|
253
|
+
const m = plugin.manifest || {};
|
|
254
|
+
for (const [action, types] of Object.entries(m.typeActions || {})) addTypeAction(action, types);
|
|
255
|
+
for (const f of m.css || []) cssFiles.push(path.join(plugin.dir, f));
|
|
256
|
+
if (m.labels) {
|
|
257
|
+
try {
|
|
258
|
+
const data = yaml.load(fs.readFileSync(path.join(plugin.dir, m.labels), 'utf8')) || {};
|
|
259
|
+
// Both the bare key, as the plugin's own settings file is edited, and the plugin-prefixed one.
|
|
260
|
+
for (const [k, v] of Object.entries(data)) { labels[k] = v; labels[`plugin.${name}.${k}`] = v; }
|
|
261
|
+
} catch (_) { /* no labels: the keys show as they are */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function loadPlugins() {
|
|
266
|
+
const known = new Map(installedPlugins().map((p) => [p.name, p]));
|
|
267
|
+
for (const [name, rawConfig] of Object.entries(pluginConfigs())) {
|
|
268
|
+
const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
269
|
+
if (String(config.enabled) !== 'true' && config.enabled !== true) continue;
|
|
270
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
271
|
+
console.error(`plugin '${name}' skipped: names are lowercase letters, digits and hyphens.`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// A node module first: `module:` names a package (installed from npm, or linked from this repo
|
|
275
|
+
// with a `file:` dependency in package.json), which is how a plugin is shared between machines.
|
|
276
|
+
// A folder under plugins/<name>/ is the fallback, and the whole mechanism for a plugin that
|
|
277
|
+
// needs no package.
|
|
278
|
+
const local = path.join(require('./plugin-manifest').PLUGINS_DIR, name, 'index.js');
|
|
279
|
+
let source = '';
|
|
280
|
+
if (config.module) {
|
|
281
|
+
try { require.resolve(String(config.module)); source = String(config.module); } catch (_) { /* not installed: say so below */ }
|
|
282
|
+
}
|
|
283
|
+
if (!source && known.has(name) && known.get(name).module) {
|
|
284
|
+
try { require.resolve(known.get(name).module); source = known.get(name).module; } catch (_) { /* fall through */ }
|
|
285
|
+
}
|
|
286
|
+
if (!source && fs.existsSync(local)) {
|
|
287
|
+
source = local;
|
|
288
|
+
if (config.module) console.warn(`plugin '${name}': module ${config.module} is not installed, using plugins/${name}. Run npm install in core/workspace-app.`);
|
|
289
|
+
}
|
|
290
|
+
if (!source) {
|
|
291
|
+
console.error(`plugin '${name}' skipped: ${config.module ? `module ${config.module} is not installed (npm install) and` : ''} there is no plugins/${name}/index.js.`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
if (known.has(name)) applyManifest(name, known.get(name));
|
|
296
|
+
const mod = require(source);
|
|
297
|
+
if (typeof mod.register !== 'function') throw new Error('no register(api) export');
|
|
298
|
+
mod.register(makeApi(name, config));
|
|
299
|
+
loaded.push({ name, source: source === local ? `plugins/${name}` : `module:${source}` });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// One broken plugin must not take the dashboard down.
|
|
302
|
+
console.error(`plugin '${name}' failed to load: ${err.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Handle a GET /fragments/p/<plugin>/<rest> request. Returns true when handled. */
|
|
308
|
+
function pluginFragment(pathname, req, res) {
|
|
309
|
+
const m = pathname.match(/^\/fragments\/p\/([a-z0-9-]+)(?:\/(.*))?$/);
|
|
310
|
+
if (!m || !fragments[m[1]]) return false;
|
|
311
|
+
const send = (html, status = 200) => {
|
|
312
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
313
|
+
res.end(html);
|
|
314
|
+
};
|
|
315
|
+
const searchParams = new URL(req.url, 'http://localhost').searchParams;
|
|
316
|
+
// Run it NOW, not in a microtask: a handler that answers synchronously has answered when this
|
|
317
|
+
// returns, which is what the callers (and the tests) rely on. An async handler's rejection is
|
|
318
|
+
// still caught, so a plugin cannot leave a request hanging.
|
|
319
|
+
const fail = (err) => { try { send(`<div class="msg error">Plugin ${esc(m[1])} failed: ${esc(err.message)}</div>`, 500); } catch (_) { /* headers sent */ } };
|
|
320
|
+
try {
|
|
321
|
+
const r = fragments[m[1]]({ req, res, send, rest: m[2] || '', searchParams });
|
|
322
|
+
if (r && typeof r.catch === 'function') r.catch(fail);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
fail(err);
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Handle a GET of a page a plugin registered. Returns true when one matched. */
|
|
330
|
+
async function pluginPage(pathname, req, res) {
|
|
331
|
+
for (const { re, handler } of pages) {
|
|
332
|
+
const match = pathname.match(re);
|
|
333
|
+
if (!match) continue;
|
|
334
|
+
const send = (html, status = 200) => {
|
|
335
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
336
|
+
res.end(html);
|
|
337
|
+
};
|
|
338
|
+
try {
|
|
339
|
+
await handler({ req, res, match, send });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
try { send(`<p>Plugin page failed: ${esc(err.message)}</p>`, 500); } catch (_) { /* headers sent */ }
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Rail groups every loaded plugin wants on this page. */
|
|
349
|
+
function pluginPageActionGroups(view, key, meta) {
|
|
350
|
+
const groups = [];
|
|
351
|
+
for (const fn of pageActionFns) {
|
|
352
|
+
try {
|
|
353
|
+
const group = fn({ view, key, meta });
|
|
354
|
+
if (group && group.items && group.items.length) groups.push(group);
|
|
355
|
+
} catch (_) {
|
|
356
|
+
// A plugin that cannot draw its buttons should not blank the rail.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return groups;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Extra buttons plugins put on one project row. Concatenated after the built-in menus. */
|
|
363
|
+
function pluginProjectActionsHtml(ctx) {
|
|
364
|
+
let html = '';
|
|
365
|
+
for (const fn of projectActionFns) {
|
|
366
|
+
try {
|
|
367
|
+
html += fn(ctx) || '';
|
|
368
|
+
} catch (_) {
|
|
369
|
+
// A plugin that cannot draw its button should not blank the row.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return html;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The named row menus, by slot, for the row layout to place. A slot a plugin has nothing for is ''. */
|
|
376
|
+
function pluginRowMenus(ctx) {
|
|
377
|
+
const out = {};
|
|
378
|
+
for (const [slot, fn] of Object.entries(rowMenus)) {
|
|
379
|
+
try { out[slot] = fn(ctx) || ''; } catch (_) { out[slot] = ''; }
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The chips plugins put in a project card's meta line. */
|
|
385
|
+
function pluginProjectChipsHtml(ctx) {
|
|
386
|
+
let html = '';
|
|
387
|
+
for (const fn of chipFns) {
|
|
388
|
+
try { html += fn(ctx) || ''; } catch (_) { /* a broken chip is no chip */ }
|
|
389
|
+
}
|
|
390
|
+
return html;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const pluginFolderSetups = () => folderSetups;
|
|
394
|
+
|
|
395
|
+
function pluginProjectBadgesHtml(ctx) {
|
|
396
|
+
let html = '';
|
|
397
|
+
for (const fn of badgeFns) { try { html += fn(ctx) || ''; } catch (_) { /* a broken badge is no badge */ } }
|
|
398
|
+
return html;
|
|
399
|
+
}
|
|
400
|
+
async function pluginSettingsOptions(file, dotted) {
|
|
401
|
+
for (const o of settingsOptionFns) {
|
|
402
|
+
if (o.file !== file || o.dotted !== dotted) continue;
|
|
403
|
+
try { const r = await o.fn({ file, dotted }); if (r && r.length) return r; } catch (_) { /* a plain input then */ }
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
async function pluginScreenStarted(ctx) {
|
|
408
|
+
let html = '';
|
|
409
|
+
for (const fn of screenStartFns) { try { html += (await fn(ctx)) || ''; } catch (err) { html += `<div class="msg error">${esc(err.message)}</div>`; } }
|
|
410
|
+
return html;
|
|
411
|
+
}
|
|
412
|
+
function pluginWorkKeyLink(key) {
|
|
413
|
+
for (const fn of workKeyLinkFns) { try { const u = fn(key); if (u) return u; } catch (_) { /* next */ } }
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
function pluginWorkPrompt(ctx) {
|
|
417
|
+
for (const fn of workPromptFns) { try { const p = fn(ctx); if (p) return p; } catch (_) { /* next */ } }
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/** The plugin view of a workspace folder's page, or null. */
|
|
421
|
+
function pluginWorkspaceView(key) {
|
|
422
|
+
return workspaceViews[String(key || '')] || null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The Open-with menu for a folder, from the first plugin that draws one; '' without an IDE plugin. */
|
|
426
|
+
function pluginOpenWith(abs, opts = {}) {
|
|
427
|
+
for (const fn of openWithFns) {
|
|
428
|
+
try { const html = fn(abs, opts); if (html) return html; } catch (e) { console.error(`plugin open-with failed: ${e.message}`); }
|
|
429
|
+
}
|
|
430
|
+
return '';
|
|
431
|
+
}
|
|
432
|
+
/** Links that open a folder elsewhere: [{ href, label, icon, title }]. */
|
|
433
|
+
function pluginFolderLinks(abs) {
|
|
434
|
+
const out = [];
|
|
435
|
+
for (const fn of folderLinkFns) {
|
|
436
|
+
try { const l = fn(abs); if (l && l.href) out.push(l); } catch (e) { console.error(`plugin folder link failed: ${e.message}`); }
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The plugin answering for a forge host, or null. */
|
|
442
|
+
function pluginForge(host) {
|
|
443
|
+
return forges[String(host || '').toLowerCase()] || null;
|
|
444
|
+
}
|
|
445
|
+
function pluginForges() { return { ...forges }; }
|
|
446
|
+
|
|
447
|
+
function pluginAssistantContext() {
|
|
448
|
+
const out = [];
|
|
449
|
+
for (const fn of assistantContextFns) { try { const s = fn(); if (s) out.push(s); } catch (_) { /* next */ } }
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
function pluginSessionLog(session, dirs) {
|
|
453
|
+
for (const fn of sessionLogFns) { try { const f = fn(session, dirs); if (f) return f; } catch (_) { /* next */ } }
|
|
454
|
+
return '';
|
|
455
|
+
}
|
|
456
|
+
const pluginCssFiles = () => cssFiles.filter((f) => fs.existsSync(f));
|
|
457
|
+
const pluginLabels = () => labels;
|
|
458
|
+
|
|
459
|
+
loadPlugins();
|
|
460
|
+
|
|
461
|
+
module.exports = {
|
|
462
|
+
pluginActions: actions, pluginFragment, pluginPage, pluginPageActionGroups, pluginProjectActionsHtml,
|
|
463
|
+
pluginRowMenus, pluginProjectChipsHtml, pluginFolderSetups, pluginCssFiles, pluginLabels,
|
|
464
|
+
pluginProjectBadgesHtml, pluginSettingsOptions, pluginScreenStarted, pluginWorkKeyLink, pluginWorkPrompt, pluginSessionLog, pluginAssistantContext, pluginForge, pluginForges, pluginOpenWith, pluginFolderLinks, pluginWorkspaceView,
|
|
465
|
+
loadedPlugins: loaded, pluginConfigs,
|
|
466
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which plugins this dashboard has, and what each one ships.
|
|
3
|
+
*
|
|
4
|
+
* A plugin is a node package whose package.json carries a `workspacePlugin` block — the manifest —
|
|
5
|
+
* named as a dependency of this app (`npm install github:webship/workspace-upsun` is the whole
|
|
6
|
+
* "require" step), or a folder under plugins/ with the same shape. The manifest says what the
|
|
7
|
+
* installer has to place in the workspace (bash functions, per-workspace commands, DDEV compose
|
|
8
|
+
* files, themes, a settings template) and what the loader wires in at startup (type actions, CSS,
|
|
9
|
+
* labels). Nothing here loads a plugin's code: that is plugin-loader.js, and only for the ones that
|
|
10
|
+
* are activated.
|
|
11
|
+
*
|
|
12
|
+
* Also a command: `node plugin-manifest.js [--json] [name]` lists them, for plugins.sh.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const APP_DIR = __dirname;
|
|
19
|
+
// Overridable for tests, like WORKSPACE_ROOT: a temporary folder of fake plugins.
|
|
20
|
+
const PLUGINS_DIR = process.env.WORKSPACE_PLUGINS_DIR || path.join(APP_DIR, 'plugins');
|
|
21
|
+
|
|
22
|
+
function readJson(file) {
|
|
23
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The directory a package resolves to, through this app's node_modules, or null. */
|
|
27
|
+
function packageDir(moduleName) {
|
|
28
|
+
try {
|
|
29
|
+
return path.dirname(require.resolve(`${moduleName}/package.json`, { paths: [APP_DIR] }));
|
|
30
|
+
} catch (_) {
|
|
31
|
+
// A package whose package.json is not exported still has a directory under node_modules.
|
|
32
|
+
const guess = path.join(APP_DIR, 'node_modules', ...moduleName.split('/'));
|
|
33
|
+
return fs.existsSync(path.join(guess, 'package.json')) ? guess : null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every plugin package the app can see: `{ name, module, dir, manifest, version, description }`,
|
|
39
|
+
* by manifest name. Dependencies first; a folder under plugins/ with the same name is the
|
|
40
|
+
* fallback, the way the loader treats it.
|
|
41
|
+
*/
|
|
42
|
+
function installedPlugins() {
|
|
43
|
+
const out = new Map();
|
|
44
|
+
const own = readJson(path.join(APP_DIR, 'package.json')) || {};
|
|
45
|
+
const deps = { ...(own.dependencies || {}), ...(own.devDependencies || {}) };
|
|
46
|
+
for (const moduleName of Object.keys(deps)) {
|
|
47
|
+
const dir = packageDir(moduleName);
|
|
48
|
+
if (!dir) continue;
|
|
49
|
+
const pkg = readJson(path.join(dir, 'package.json'));
|
|
50
|
+
const m = pkg && pkg.workspacePlugin;
|
|
51
|
+
if (!m || !m.name) continue;
|
|
52
|
+
out.set(m.name, { name: m.name, module: moduleName, dir: fs.realpathSync(dir), manifest: m, version: pkg.version || '', description: pkg.description || '', spec: deps[moduleName] });
|
|
53
|
+
}
|
|
54
|
+
let folders = [];
|
|
55
|
+
try { folders = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch (_) { /* no plugins/ */ }
|
|
56
|
+
for (const folder of folders) {
|
|
57
|
+
const dir = path.join(PLUGINS_DIR, folder);
|
|
58
|
+
const pkg = readJson(path.join(dir, 'package.json'));
|
|
59
|
+
const m = (pkg && pkg.workspacePlugin) || (fs.existsSync(path.join(dir, 'index.js')) ? { name: folder } : null);
|
|
60
|
+
if (!m || !m.name || out.has(m.name)) continue;
|
|
61
|
+
out.set(m.name, { name: m.name, module: pkg ? pkg.name : '', dir, manifest: m, version: (pkg && pkg.version) || '', description: (pkg && pkg.description) || '', spec: `plugins/${folder}` });
|
|
62
|
+
}
|
|
63
|
+
return [...out.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pluginByName(name) {
|
|
67
|
+
return installedPlugins().find((p) => p.name === name) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { installedPlugins, pluginByName, packageDir, APP_DIR, PLUGINS_DIR };
|
|
71
|
+
|
|
72
|
+
if (require.main === module) {
|
|
73
|
+
const args = process.argv.slice(2);
|
|
74
|
+
const json = args.includes('--json');
|
|
75
|
+
const name = args.find((a) => !a.startsWith('--'));
|
|
76
|
+
const list = name ? [pluginByName(name)].filter(Boolean) : installedPlugins();
|
|
77
|
+
if (json) {
|
|
78
|
+
process.stdout.write(`${JSON.stringify(name ? list[0] || null : list, null, 2)}\n`);
|
|
79
|
+
} else {
|
|
80
|
+
for (const p of list) process.stdout.write(`${p.name}\t${p.module || '-'}\t${p.version || '-'}\t${p.dir}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (name && !list.length) process.exit(1);
|
|
83
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's own settings file: core/config/plugin.<name>.settings.yml.
|
|
3
|
+
*
|
|
4
|
+
* Each plugin keeps its settings in a file of its own, the way each workspace does, instead of a block in
|
|
5
|
+
* settings.yml: they are edited, backed up and (because they can hold a secret) protected separately, and
|
|
6
|
+
* removing a plugin removes its configuration with it. sync-tooling copies only workspace.*.settings.yml, so
|
|
7
|
+
* these files stay local like settings.yml does.
|
|
8
|
+
*
|
|
9
|
+
* A plugin whose settings used to be a block in settings.yml names it in LEGACY_KEYS, so an install that has
|
|
10
|
+
* not been moved yet still reads (never writes) the old block.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const yaml = require('js-yaml');
|
|
16
|
+
|
|
17
|
+
const { CONFIG_DIR, loadSettings } = require('./workspaces');
|
|
18
|
+
|
|
19
|
+
const LEGACY_KEYS = {};
|
|
20
|
+
// A plugin package says in its manifest which settings.yml block its file replaces.
|
|
21
|
+
try {
|
|
22
|
+
for (const p of require('./plugin-manifest').installedPlugins()) {
|
|
23
|
+
if (p.manifest && p.manifest.legacySettingsKey) LEGACY_KEYS[p.name] = p.manifest.legacySettingsKey;
|
|
24
|
+
}
|
|
25
|
+
} catch (_) { /* no manifests: the static map is the whole story */ }
|
|
26
|
+
const fileFor = (name) => `plugin.${name}.settings.yml`;
|
|
27
|
+
|
|
28
|
+
function readPluginSettings(name) {
|
|
29
|
+
const full = path.join(CONFIG_DIR, fileFor(name));
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(full)) return yaml.load(fs.readFileSync(full, 'utf8')) || {};
|
|
32
|
+
} catch (_) { /* unreadable: treated as empty */ }
|
|
33
|
+
const legacy = LEGACY_KEYS[name];
|
|
34
|
+
return (legacy && String(legacy).split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), loadSettings())) || {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Make sure the file exists, private to the owner, before something writes into it. */
|
|
38
|
+
function ensurePluginSettingsFile(name) {
|
|
39
|
+
const full = path.join(CONFIG_DIR, fileFor(name));
|
|
40
|
+
if (!fs.existsSync(full)) fs.writeFileSync(full, '', { mode: 0o600 });
|
|
41
|
+
return fileFor(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { fileFor, readPluginSettings, ensurePluginSettingsFile, LEGACY_KEYS };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace notes — the reference plugin, and a real feature.
|
|
3
|
+
*
|
|
4
|
+
* Adds a "Notes" button to the rail of every workspace it is enabled for
|
|
5
|
+
* (settings.yml `plugins.workspace-notes.workspaces`), opening NOTES.md from
|
|
6
|
+
* that workspace folder in the editor modal: why this folder is what it is,
|
|
7
|
+
* which projects matter, what the next person should know. The file lives in
|
|
8
|
+
* the folder itself, beside the work it describes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
module.exports.register = (api) => {
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { esc, workspaceDir, isValidWorkspace } = api.lib;
|
|
15
|
+
|
|
16
|
+
const notesFile = (key) => path.join(workspaceDir(key), 'NOTES.md');
|
|
17
|
+
|
|
18
|
+
api.addPageActions(({ view, key, meta }) => {
|
|
19
|
+
if (view !== 'workspace' || !key || !api.appliesTo(key)) return null;
|
|
20
|
+
const exists = fs.existsSync(notesFile(key));
|
|
21
|
+
return {
|
|
22
|
+
label: 'Notes',
|
|
23
|
+
items: [
|
|
24
|
+
`<button class="uk-button uk-button-default action-btn" hx-get="/fragments/p/${api.name}/edit?workspace=${esc(key)}"`
|
|
25
|
+
+ ` hx-target="#editor-modal-body" hx-swap="innerHTML" title="${esc(meta.label)}'s NOTES.md — kept in the folder itself">`
|
|
26
|
+
+ `<span uk-icon="icon: file-edit; ratio: .8"></span><span class="action-label">Notes${exists ? '' : ' (new)'}</span></button>`,
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
api.addFragment(({ send, rest, searchParams }) => {
|
|
32
|
+
if (rest !== 'edit') return send('<div class="msg error">Unknown notes fragment.</div>', 404);
|
|
33
|
+
const key = String(searchParams.get('workspace') || '');
|
|
34
|
+
if (!isValidWorkspace(key) || !api.appliesTo(key)) return send('<div class="msg error">Unknown workspace.</div>', 400);
|
|
35
|
+
let content = '';
|
|
36
|
+
try { content = fs.readFileSync(notesFile(key), 'utf-8'); } catch (_) { /* new file */ }
|
|
37
|
+
send(`
|
|
38
|
+
<div class="editor-card">
|
|
39
|
+
<h3 class="uk-margin-small-bottom">Notes — ${esc(key)}/NOTES.md</h3>
|
|
40
|
+
<p class="uk-text-meta">Lives in the workspace folder itself, beside the work it describes.</p>
|
|
41
|
+
<form hx-post="/actions/p/${api.name}/save" hx-target="#webship-workspace-output" hx-swap="innerHTML"
|
|
42
|
+
hx-on::after-request="window.UIkit && UIkit.modal('#editor-modal').hide()">
|
|
43
|
+
<input type="hidden" name="workspace" value="${esc(key)}">
|
|
44
|
+
<textarea class="uk-textarea" name="content" rows="16" placeholder="What the next person should know about this folder…">${esc(content)}</textarea>
|
|
45
|
+
<div class="uk-margin-small-top">
|
|
46
|
+
<button type="submit" class="uk-button uk-button-primary"><span uk-icon="icon: check; ratio: .8"></span> Save</button>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
</div>`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
api.addAction('save', ({ form, send, workspace }) => {
|
|
53
|
+
if (!api.appliesTo(workspace)) return send('<div class="msg error">Notes are not enabled for this workspace.</div>', 400);
|
|
54
|
+
const content = String(form.content || '').replace(/\r\n/g, '\n');
|
|
55
|
+
if (content.length > 500000) return send('<div class="msg error">Content too large.</div>', 400);
|
|
56
|
+
fs.writeFileSync(notesFile(workspace), content);
|
|
57
|
+
return send(`<div class="msg assistant">✅ Saved <strong>${esc(workspace)}/NOTES.md</strong>.</div>`);
|
|
58
|
+
});
|
|
59
|
+
};
|