@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/auth-google.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with Google for the dashboard, off by default.
|
|
3
|
+
*
|
|
4
|
+
* On a laptop the dashboard answers only that laptop and has never asked who you are. On a remote hub
|
|
5
|
+
* (hub_domain set to a public domain) it must: it starts builds, opens terminals and edits settings. When
|
|
6
|
+
* `auth.google.enabled` is true, every request to the dashboard needs a session from this module, and a
|
|
7
|
+
* session is only given to a Google account whose verified email is in `allowed_domains` (or in
|
|
8
|
+
* `allowed_emails`). Nothing allowed means nobody is let in.
|
|
9
|
+
*
|
|
10
|
+
* auth:
|
|
11
|
+
* google:
|
|
12
|
+
* enabled: true
|
|
13
|
+
* allowed_domains: [example.com]
|
|
14
|
+
* session_hours: 12
|
|
15
|
+
*
|
|
16
|
+
* The OAuth client is the Drive plugin's (client_id and client_secret in plugin.google-drive-sync.settings.yml),
|
|
17
|
+
* so there is a single client to set up. Add
|
|
18
|
+
* https://<hub_domain>/auth/google/callback to its redirect URIs.
|
|
19
|
+
*
|
|
20
|
+
* Fails closed: enabled with no OAuth client or no allow-list answers 503 with what to fix, rather than
|
|
21
|
+
* letting everyone through. To switch it off by hand set `auth.google.enabled: false` in settings.yml;
|
|
22
|
+
* settings are read live, so no restart is needed.
|
|
23
|
+
*
|
|
24
|
+
* The ID token comes straight from Google's token endpoint over TLS in exchange for our one-time code, so
|
|
25
|
+
* its claims (issuer, audience, expiry, nonce, verified email, hosted domain) are checked but its
|
|
26
|
+
* signature is not: the channel is the proof, which is what OpenID Connect allows for that flow.
|
|
27
|
+
*
|
|
28
|
+
* The session is a signed cookie on the hub domain, so one sign-in covers every <workspace>.<hub> page.
|
|
29
|
+
* Project sites at <project>.<workspace>.<hub> are served by the DDEV router, not by this app, and are not
|
|
30
|
+
* covered.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
|
|
37
|
+
const { loadSettings, hubDomain } = require('./workspaces');
|
|
38
|
+
const { esc } = require('./html');
|
|
39
|
+
const { readPluginSettings } = require('./plugin-settings');
|
|
40
|
+
|
|
41
|
+
const COOKIE = 'ws-session';
|
|
42
|
+
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
43
|
+
const tokenUrl = () => process.env.GOOGLE_TOKEN_URL || 'https://oauth2.googleapis.com/token';
|
|
44
|
+
const SECRET_FILE = () => path.join(process.env.WORKSPACE_AUTH_DIR || path.join(__dirname, '.auth'), 'session-secret');
|
|
45
|
+
|
|
46
|
+
const real = (v) => {
|
|
47
|
+
const s = String(v == null ? '' : v).trim();
|
|
48
|
+
return s && !/^(CHANGE_ME|sk-change-this)$/i.test(s) ? s : '';
|
|
49
|
+
};
|
|
50
|
+
const list = (v) => (Array.isArray(v) ? v : []).map((x) => String(x).trim().toLowerCase()).filter(Boolean);
|
|
51
|
+
|
|
52
|
+
function config() {
|
|
53
|
+
const s = loadSettings();
|
|
54
|
+
const g = (s.auth && s.auth.google) || {};
|
|
55
|
+
const d = readPluginSettings('google-drive-sync'); // the team's one OAuth client lives with the Drive plugin
|
|
56
|
+
const hours = Number(g.session_hours);
|
|
57
|
+
return {
|
|
58
|
+
enabled: g.enabled === true || String(g.enabled) === 'true',
|
|
59
|
+
domains: list(g.allowed_domains),
|
|
60
|
+
emails: list(g.allowed_emails),
|
|
61
|
+
hours: hours > 0 && hours <= 24 * 30 ? hours : 12,
|
|
62
|
+
clientId: real(d.client_id),
|
|
63
|
+
clientSecret: real(d.client_secret),
|
|
64
|
+
redirectUri: `https://${hubDomain()}/auth/google/callback`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- Session cookie ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function secret() {
|
|
71
|
+
const file = SECRET_FILE();
|
|
72
|
+
try { return fs.readFileSync(file, 'utf8').trim(); } catch (_) { /* first use */ }
|
|
73
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
74
|
+
const made = crypto.randomBytes(32).toString('hex');
|
|
75
|
+
try { fs.writeFileSync(file, made, { mode: 0o600, flag: 'wx' }); } catch (_) { return fs.readFileSync(file, 'utf8').trim(); }
|
|
76
|
+
return made;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
80
|
+
const sign = (body) => crypto.createHmac('sha256', secret()).update(body).digest('base64url');
|
|
81
|
+
|
|
82
|
+
function makeSession(user, hours) {
|
|
83
|
+
const body = b64({ e: user.email, n: user.name || '', x: Date.now() + hours * 3600 * 1000 });
|
|
84
|
+
return `${body}.${sign(body)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The signed-in user of a request, or null. A tampered or expired cookie is nobody. */
|
|
88
|
+
function sessionOf(req) {
|
|
89
|
+
const raw = String((req.headers && req.headers.cookie) || '').split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
|
|
90
|
+
if (!raw) return null;
|
|
91
|
+
const [body, mac] = raw.slice(COOKIE.length + 1).split('.');
|
|
92
|
+
if (!body || !mac) return null;
|
|
93
|
+
const want = Buffer.from(sign(body));
|
|
94
|
+
const got = Buffer.from(mac);
|
|
95
|
+
if (want.length !== got.length || !crypto.timingSafeEqual(want, got)) return null;
|
|
96
|
+
try {
|
|
97
|
+
const p = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
98
|
+
return p.x > Date.now() && p.e ? { email: p.e, name: p.n || '' } : null;
|
|
99
|
+
} catch (_) { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cookieHeader = (value, maxAge) => `${COOKIE}=${value}; Path=/; Domain=.${hubDomain()}; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
|
103
|
+
|
|
104
|
+
// ---- Who may sign in --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** { ok, reason } for the claims of a Google ID token. */
|
|
107
|
+
function allowed(claims, cfg) {
|
|
108
|
+
const email = String(claims.email || '').toLowerCase();
|
|
109
|
+
if (!email || claims.email_verified !== true) return { ok: false, reason: 'Google has not verified that email address.' };
|
|
110
|
+
if (!cfg.domains.length && !cfg.emails.length) return { ok: false, reason: 'No allowed domain or email is set, so nobody may sign in.' };
|
|
111
|
+
if (cfg.emails.includes(email)) return { ok: true };
|
|
112
|
+
const domain = email.split('@')[1];
|
|
113
|
+
if (cfg.domains.includes(domain) && String(claims.hd || '').toLowerCase() === domain) return { ok: true };
|
|
114
|
+
return { ok: false, reason: `${email} is not allowed to use this dashboard.` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function idClaims(idToken) {
|
|
118
|
+
try { return JSON.parse(Buffer.from(String(idToken).split('.')[1], 'base64url').toString('utf8')); } catch (_) { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- Sign-in flow -----------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const pending = new Map(); // state -> { nonce, next, until }
|
|
124
|
+
|
|
125
|
+
// Where to send somebody afterwards. Only the hub or one of its workspace pages: anything else would make
|
|
126
|
+
// the sign-in a way to bounce a user to a site of somebody's choosing.
|
|
127
|
+
function safeNext(next) {
|
|
128
|
+
try {
|
|
129
|
+
const u = new URL(String(next || ''), `https://${hubDomain()}`);
|
|
130
|
+
const hub = hubDomain();
|
|
131
|
+
if (u.protocol === 'https:' && (u.hostname === hub || u.hostname.endsWith(`.${hub}`)) && !u.pathname.startsWith('//')) return u.toString();
|
|
132
|
+
} catch (_) { /* fall through */ }
|
|
133
|
+
return `https://${hubDomain()}/`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function startUrl(cfg, next) {
|
|
137
|
+
for (const [k, v] of pending) if (v.until < Date.now()) pending.delete(k);
|
|
138
|
+
const state = crypto.randomBytes(18).toString('hex');
|
|
139
|
+
const nonce = crypto.randomBytes(18).toString('hex');
|
|
140
|
+
pending.set(state, { nonce, next: safeNext(next), until: Date.now() + 10 * 60 * 1000 });
|
|
141
|
+
const q = new URLSearchParams({
|
|
142
|
+
client_id: cfg.clientId, redirect_uri: cfg.redirectUri, response_type: 'code', scope: 'openid email profile',
|
|
143
|
+
state, nonce, prompt: 'select_account',
|
|
144
|
+
});
|
|
145
|
+
if (cfg.domains.length === 1) q.set('hd', cfg.domains[0]); // a hint for the account chooser, not the check
|
|
146
|
+
return `${AUTH_URL}?${q}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function finish(cfg, code, state) {
|
|
150
|
+
const p = pending.get(state);
|
|
151
|
+
pending.delete(state);
|
|
152
|
+
if (!p || p.until < Date.now()) return { ok: false, reason: 'That sign-in link expired. Start again.' };
|
|
153
|
+
const res = await fetch(tokenUrl(), {
|
|
154
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
155
|
+
body: new URLSearchParams({ code, client_id: cfg.clientId, client_secret: cfg.clientSecret, redirect_uri: cfg.redirectUri, grant_type: 'authorization_code' }),
|
|
156
|
+
});
|
|
157
|
+
const body = await res.json().catch(() => ({}));
|
|
158
|
+
if (!res.ok) return { ok: false, reason: `Google refused the sign-in: ${body.error_description || body.error || res.status}` };
|
|
159
|
+
const c = idClaims(body.id_token);
|
|
160
|
+
if (!c || !['accounts.google.com', 'https://accounts.google.com'].includes(c.iss) || c.aud !== cfg.clientId || !(c.exp * 1000 > Date.now()) || c.nonce !== p.nonce) {
|
|
161
|
+
return { ok: false, reason: 'Google returned a sign-in that does not match this request.' };
|
|
162
|
+
}
|
|
163
|
+
const verdict = allowed(c, cfg);
|
|
164
|
+
if (!verdict.ok) return verdict;
|
|
165
|
+
return { ok: true, user: { email: String(c.email).toLowerCase(), name: c.name || '' }, next: p.next };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- Pages ------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const GOOGLE_G = '<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>';
|
|
171
|
+
|
|
172
|
+
/** The standard white Google button. Used by the sign-in page and the Drive panel. */
|
|
173
|
+
const googleButton = (href, label = 'Sign in with Google', attrs = '') => `<a class="google-btn" href="${esc(href)}" ${attrs}>${GOOGLE_G}<span>${esc(label)}</span></a>`;
|
|
174
|
+
|
|
175
|
+
const BUTTON_CSS = '.google-btn{display:inline-flex;align-items:center;gap:.6rem;padding:.55rem 1rem;border:1px solid #dadce0;border-radius:4px;background:#fff;color:#3c4043;font:500 .9rem/1 system-ui,sans-serif;text-decoration:none}.google-btn:hover{background:#f8f9fa;box-shadow:0 1px 2px rgba(60,64,67,.3)}';
|
|
176
|
+
|
|
177
|
+
function page(title, body, status = 200) {
|
|
178
|
+
const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
|
|
179
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:30rem;margin:12vh auto;padding:0 1rem;color:#1a1a1a}h1{font-size:1.4rem}.meta{color:#666;font-size:.85rem}${BUTTON_CSS}@media(prefers-color-scheme:dark){body{background:#12151f;color:#e8e8e8}.meta{color:#999}}</style>
|
|
180
|
+
<body>${body}</body>`;
|
|
181
|
+
return { status, html };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const signinPage = (cfg, next) => page('Sign in', `<h1>Webship Workspace</h1><p>Sign in with your ${cfg.domains.length ? esc(cfg.domains.join(' or ')) : ''} Google account to continue.</p>
|
|
185
|
+
<p>${googleButton(`/auth/google/login?next=${encodeURIComponent(next || '')}`)}</p>`);
|
|
186
|
+
|
|
187
|
+
const deniedPage = (reason) => page('Not allowed', `<h1>Not signed in</h1><p>${esc(reason)}</p><p><a href="/auth/signin">Try another account</a></p>
|
|
188
|
+
<p class="meta">Locked yourself out? Set <code>auth.google.enabled: false</code> in core/config/settings.yml. It takes effect at once.</p>`, 403);
|
|
189
|
+
|
|
190
|
+
const unavailablePage = () => page('Sign-in is not set up', `<h1>Sign-in is on, but not set up</h1><p>The dashboard needs Google sign-in and has no OAuth client or no allowed domain to check against, so it is letting nobody in.</p>
|
|
191
|
+
<p class="meta">Set <code>google_drive.client_id</code>, <code>client_secret</code> and <code>auth.google.allowed_domains</code> in core/config/settings.yml, or turn it off with <code>auth.google.enabled: false</code>. Settings are read live.</p>`, 503);
|
|
192
|
+
|
|
193
|
+
// ---- The gate ---------------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const EXEMPT = /^\/auth\/(signin|signout|me|google\/(login|callback))$/;
|
|
196
|
+
|
|
197
|
+
function send(res, p, extra = {}) {
|
|
198
|
+
res.writeHead(p.status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...extra });
|
|
199
|
+
res.end(p.html);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const hostUrl = (req) => `https://${req.headers.host || hubDomain()}${req.url}`;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Called first for every request. Returns true when it has answered (sign-in routes, or a refusal);
|
|
206
|
+
* false means the request may carry on to the dashboard. With sign-in off it is always false.
|
|
207
|
+
*/
|
|
208
|
+
async function gate(req, res, pathname) {
|
|
209
|
+
const cfg = config();
|
|
210
|
+
if (!cfg.enabled) {
|
|
211
|
+
if (pathname === '/auth/me') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"enabled":false}'); return true; }
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const url = new URL(req.url, 'http://x');
|
|
215
|
+
const usable = cfg.clientId && cfg.clientSecret && (cfg.domains.length || cfg.emails.length);
|
|
216
|
+
|
|
217
|
+
if (EXEMPT.test(pathname)) {
|
|
218
|
+
if (pathname === '/auth/me') {
|
|
219
|
+
const u = sessionOf(req);
|
|
220
|
+
res.writeHead(u ? 200 : 401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
221
|
+
res.end(JSON.stringify(u ? { enabled: true, ...u } : { enabled: true }));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (!usable) { send(res, unavailablePage()); return true; }
|
|
225
|
+
if (pathname === '/auth/signin') { send(res, signinPage(cfg, url.searchParams.get('next'))); return true; }
|
|
226
|
+
if (pathname === '/auth/signout') { res.writeHead(302, { Location: '/auth/signin', 'Set-Cookie': cookieHeader('', 0) }); res.end(); return true; }
|
|
227
|
+
if (pathname === '/auth/google/login') { res.writeHead(302, { Location: startUrl(cfg, url.searchParams.get('next')) }); res.end(); return true; }
|
|
228
|
+
if (pathname === '/auth/google/callback') {
|
|
229
|
+
if (url.searchParams.get('error')) { send(res, deniedPage(`Google said: ${url.searchParams.get('error')}`)); return true; }
|
|
230
|
+
try {
|
|
231
|
+
const r = await finish(cfg, String(url.searchParams.get('code') || ''), String(url.searchParams.get('state') || ''));
|
|
232
|
+
if (!r.ok) { send(res, deniedPage(r.reason)); return true; }
|
|
233
|
+
res.writeHead(302, { Location: r.next, 'Set-Cookie': cookieHeader(makeSession(r.user, cfg.hours), cfg.hours * 3600), 'Cache-Control': 'no-store' });
|
|
234
|
+
res.end();
|
|
235
|
+
} catch (err) { send(res, deniedPage(`Could not sign in: ${err.message}`)); }
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!usable) { send(res, unavailablePage()); return true; }
|
|
241
|
+
if (sessionOf(req)) return false;
|
|
242
|
+
|
|
243
|
+
// Not signed in. A page navigation goes to the sign-in page; a fragment or action says 401, and htmx is
|
|
244
|
+
// told where to go, so a session that expired mid-use sends the person to sign in instead of showing a blank.
|
|
245
|
+
const login = `https://${hubDomain()}/auth/signin?next=${encodeURIComponent(hostUrl(req))}`;
|
|
246
|
+
if (req.headers['hx-request']) { res.writeHead(401, { 'HX-Redirect': login, 'Content-Type': 'text/plain' }); res.end('Sign in required'); return true; }
|
|
247
|
+
if (req.method === 'GET' || req.method === 'HEAD') { res.writeHead(302, { Location: login, 'Cache-Control': 'no-store' }); res.end(); return true; }
|
|
248
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
249
|
+
res.end('Sign in required');
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** For a WebSocket upgrade, which cannot be redirected: may this request through? */
|
|
254
|
+
function upgradeAllowed(req) {
|
|
255
|
+
const cfg = config();
|
|
256
|
+
if (!cfg.enabled) return true;
|
|
257
|
+
return !!(cfg.clientId && cfg.clientSecret && (cfg.domains.length || cfg.emails.length) && sessionOf(req));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { gate, upgradeAllowed, sessionOf, makeSession, allowed, safeNext, config, googleButton, BUTTON_CSS, COOKIE };
|
package/build-ai.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code after a build: the shell step a build ends with, so the agent starts in the new project once
|
|
3
|
+
* the build (and the install, when it was asked for) has finished.
|
|
4
|
+
*
|
|
5
|
+
* The prompt is free text, so it travels in a private file and reaches claude through `$(cat …)`, the same
|
|
6
|
+
* way New screen does; the shell line holds only paths the server made.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { ROOT } = require('./workspaces');
|
|
13
|
+
|
|
14
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
15
|
+
const MAX_PROMPT = 4000;
|
|
16
|
+
|
|
17
|
+
/** The session the agent runs in, named like every other session on the row. */
|
|
18
|
+
function sessionFor(ws, project) {
|
|
19
|
+
return `${ws}--${project}--claude`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 60);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The prompt claude opens with: what was just built, then the person's own words. */
|
|
23
|
+
function promptFor({ ws, project, label, installed, text }) {
|
|
24
|
+
const said = String(text || '').trim().slice(0, MAX_PROMPT);
|
|
25
|
+
const what = `The project ${ws}/${project} was just built here${label ? ` with ${label}` : ''}${installed ? ' and installed' : ''}.`;
|
|
26
|
+
return said ? `${what}\n\n${said}` : `${what} Look around and tell me what is here and how to start it.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write the prompt and the runner payload, and return the shell step that starts the session.
|
|
31
|
+
* `dir` is the workspace folder, `project` the folder the build makes in it.
|
|
32
|
+
*/
|
|
33
|
+
function afterBuildStep({ ws, dir, project, label, installed, text, flags = [] }) {
|
|
34
|
+
const promptsDir = path.join(ROOT, '.screens', 'prompts');
|
|
35
|
+
fs.mkdirSync(promptsDir, { recursive: true, mode: 0o700 });
|
|
36
|
+
const session = sessionFor(ws, project);
|
|
37
|
+
const projectDir = path.join(dir, project);
|
|
38
|
+
const promptFile = path.join(promptsDir, `${session}.txt`);
|
|
39
|
+
fs.writeFileSync(promptFile, promptFor({ ws, project, label, installed, text }), { mode: 0o600 });
|
|
40
|
+
const vars = { WEBSHIP_WORKSPACE: ws, WEBSHIP_PROJECT: project, WEBSHIP_WORKSPACE_DIR: projectDir };
|
|
41
|
+
const env = Object.entries(vars).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
|
|
42
|
+
const payloadFile = path.join(promptsDir, `${session}.start.json`);
|
|
43
|
+
fs.writeFileSync(payloadFile, JSON.stringify({
|
|
44
|
+
session, dir: projectDir, owner: projectDir,
|
|
45
|
+
cmd: `exec env ${env} claude ${flags.length ? `${flags.join(' ')} ` : ''}"$(cat ${shq(promptFile)})"`,
|
|
46
|
+
}), { mode: 0o600 });
|
|
47
|
+
// From ROOT, not __dirname: the dashboard runs in its container at another path, and this line runs on the host.
|
|
48
|
+
const helper = path.join(ROOT, 'core', 'workspace-app', 'scripts', 'screen-then.js');
|
|
49
|
+
return { session, step: `node ${shq(helper)} ${shq(payloadFile)}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { sessionFor, promptFor, afterBuildStep };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Command order: what a workspace offers, and in what order.
|
|
3
|
+
*
|
|
4
|
+
* Two lists per scope, because a workspace folder holds two kinds of command and they answer different
|
|
5
|
+
* questions. A **builder** (`cmd-*-project.sh`) makes a project and is what **Build a new project**
|
|
6
|
+
* offers; a **tool** (`cmd-tools-backup-dev.sh`, `cmd-tools-clone.sh`, `cmd-all-*-themes.sh`) acts on
|
|
7
|
+
* one, and is what Run a tool and the Commands page list. Alphabetical put them through each other —
|
|
8
|
+
* `cmd-tools-backup-dev.sh` landing between two Webship builders — which reads as a mistake before it
|
|
9
|
+
* reads as an order.
|
|
10
|
+
*
|
|
11
|
+
* One page per scope: every workspace first, then each workspace on its own tab. Drag by the handle, or
|
|
12
|
+
* move with the arrows, which are not decoration — dragging is a mouse gesture, and this is the only way
|
|
13
|
+
* to arrange the thing from a keyboard.
|
|
14
|
+
*
|
|
15
|
+
* What it lists is what the tree HAS. Unlike the navigation, which can only offer what its pages have
|
|
16
|
+
* drawn, a command is a file in a folder — so the list is read straight off disk and a command added
|
|
17
|
+
* this morning is arrangeable this morning.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const { esc } = require('./html');
|
|
24
|
+
const { loadWorkspaces, findBuilderScripts, builderLabel, ROOT } = require('./workspaces');
|
|
25
|
+
const order = require('./build-order');
|
|
26
|
+
|
|
27
|
+
const icon = (name, ratio = 0.7) => `<span uk-icon="icon: ${name}; ratio: ${ratio}"></span>`;
|
|
28
|
+
|
|
29
|
+
const KIND_META = {
|
|
30
|
+
builders: {
|
|
31
|
+
title: 'Builders',
|
|
32
|
+
what: 'what Build a new project offers, in this order',
|
|
33
|
+
field: 'builder',
|
|
34
|
+
empty: 'No builders here. A builder is a <code>cmd-*-project.sh</code> in the workspace folder.',
|
|
35
|
+
},
|
|
36
|
+
tools: {
|
|
37
|
+
title: 'Tools',
|
|
38
|
+
what: 'what Run a tool and the Commands page list, in this order',
|
|
39
|
+
field: 'tool',
|
|
40
|
+
empty: 'No tools here yet.',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Every workspace that holds any command at all — the only ones with an order to arrange. */
|
|
45
|
+
function orderableWorkspaces() {
|
|
46
|
+
return Object.values(loadWorkspaces())
|
|
47
|
+
.map((w) => ({ ...w, dir: w.dir || path.join(ROOT, w.key) }))
|
|
48
|
+
.filter((w) => commandFiles(w.dir).length);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every `cmd-*.sh` in a folder, builders included.
|
|
53
|
+
*
|
|
54
|
+
* Read with `fs` rather than through commands.js, which requires row-layout.js, which requires
|
|
55
|
+
* commands.js back — a cycle this page has no reason to join for a directory listing.
|
|
56
|
+
*/
|
|
57
|
+
function commandFiles(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.readdirSync(dir).filter(order.isCommand);
|
|
60
|
+
} catch (_) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every command of one kind a scope can arrange, with the label to show and where it came from.
|
|
67
|
+
*
|
|
68
|
+
* For a workspace that is its own folder. For `global` it is the union of every folder's, because the
|
|
69
|
+
* global order applies on all of them — and a union is the only honest answer: an order that could only
|
|
70
|
+
* mention what `dev` happens to hold would quietly refuse to arrange `modules`' own tools.
|
|
71
|
+
*/
|
|
72
|
+
function commandsFor(scope, kind) {
|
|
73
|
+
const seen = new Map();
|
|
74
|
+
const folders = scope === 'global'
|
|
75
|
+
? orderableWorkspaces()
|
|
76
|
+
: orderableWorkspaces().filter((w) => w.key === scope);
|
|
77
|
+
for (const w of folders) {
|
|
78
|
+
const files = kind === 'builders'
|
|
79
|
+
? findBuilderScripts(w.dir)
|
|
80
|
+
: commandFiles(w.dir).filter((f) => !order.isBuilder(f));
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
if (seen.has(file)) { seen.get(file).where.push(w.label || w.key); continue; }
|
|
83
|
+
seen.set(file, { file, label: builderLabel(w.dir, file), where: [w.label || w.key] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// In the order the scope itself shows them, so the page you are looking at IS the list you are
|
|
87
|
+
// arranging. The same calls the Build form and the Commands page make.
|
|
88
|
+
const sorted = kind === 'builders'
|
|
89
|
+
? order.sortBuilders([...seen.keys()], scope)
|
|
90
|
+
: order.sortTools([...seen.keys()], scope);
|
|
91
|
+
return sorted.map((f) => seen.get(f));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The scope picker: every workspace first, then one tab per workspace, dotted when it has its own order. */
|
|
95
|
+
function scopeTabsHtml(scope) {
|
|
96
|
+
const tab = (s, label, title) => `<a class="uk-button uk-button-${scope === s ? 'primary' : 'default'} uk-button-small settings-tab"
|
|
97
|
+
href="/settings/command-order?scope=${encodeURIComponent(s)}"${scope === s ? ' aria-current="page"' : ''}
|
|
98
|
+
title="${esc(title)}">${esc(label)}${order.scopeHasOrder(s) ? ' •' : ''}</a>`;
|
|
99
|
+
return `<p class="settings-tabs nav-scopes">
|
|
100
|
+
${tab('global', 'Every workspace', 'The order in every workspace')}
|
|
101
|
+
${orderableWorkspaces().map((w) => tab(w.key, w.label,
|
|
102
|
+
`The order in ${w.label}. Applied before the global one.`)).join('')}
|
|
103
|
+
</p>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function moveArrows(what) {
|
|
107
|
+
return `<span class="ws-move">
|
|
108
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" title="Move ${esc(what)} up"
|
|
109
|
+
hx-on:click="const li = this.closest('li'); if (li.previousElementSibling) li.parentNode.insertBefore(li, li.previousElementSibling)">${icon('chevron-up')}</button>
|
|
110
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" title="Move ${esc(what)} down"
|
|
111
|
+
hx-on:click="const li = this.closest('li'); if (li.nextElementSibling) li.parentNode.insertBefore(li.nextElementSibling, li)">${icon('chevron-down')}</button>
|
|
112
|
+
</span>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One command, as a row that can be dragged. */
|
|
116
|
+
function commandRow(c, scope, kind, named, folderCount) {
|
|
117
|
+
// Where it came from, on the global tab only, and only when it is not in every folder: the same
|
|
118
|
+
// builder is in five of them, and on a workspace's own tab saying so again would be noise.
|
|
119
|
+
const where = scope === 'global' && c.where.length < folderCount
|
|
120
|
+
? `<span class="uk-text-meta build-order-where">${esc(c.where.join(', '))}</span>` : '';
|
|
121
|
+
return `<li class="nav-entry build-order-row" data-id="${esc(c.file)}">
|
|
122
|
+
<input type="hidden" name="${KIND_META[kind].field}" value="${esc(c.file)}">
|
|
123
|
+
<span class="nav-handle" title="Drag to move it">${icon('table')}</span>
|
|
124
|
+
<span class="nav-entry-label">${esc(c.label)}</span>
|
|
125
|
+
${where}
|
|
126
|
+
<code class="uk-text-meta build-order-file">${esc(c.file)}</code>
|
|
127
|
+
${named.has(c.file) ? '<span class="uk-label nav-yours" title="This scope names it: it is placed by this list">placed</span>' : ''}
|
|
128
|
+
${moveArrows(c.label)}
|
|
129
|
+
</li>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One kind's list, with its own heading. Dragging is confined to it: a builder cannot become a tool. */
|
|
133
|
+
function groupHtml(scope, kind, own, folderCount) {
|
|
134
|
+
const meta = KIND_META[kind];
|
|
135
|
+
const items = commandsFor(scope, kind);
|
|
136
|
+
const named = new Set(own[kind]);
|
|
137
|
+
return `<section class="build-order-group">
|
|
138
|
+
<header class="nav-group-head"><strong>${esc(meta.title)}</strong>
|
|
139
|
+
<span class="uk-text-meta">· ${esc(meta.what)}</span></header>
|
|
140
|
+
${items.length
|
|
141
|
+
? `<ol class="nav-entries build-order-list" uk-sortable="group: build-order-${kind}; handle: .nav-handle">
|
|
142
|
+
${items.map((c) => commandRow(c, scope, kind, named, folderCount)).join('')}
|
|
143
|
+
</ol>`
|
|
144
|
+
: `<p class="uk-text-meta">${meta.empty}</p>`}
|
|
145
|
+
</section>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The editor for one scope.
|
|
150
|
+
*
|
|
151
|
+
* Both lists are in ONE form and nothing is saved until Save — the same rule as the navigation, so a
|
|
152
|
+
* drag is never half-applied and leaving the page changes nothing. One form and not two because they
|
|
153
|
+
* are one arrangement: rearranging the tools and then the builders should be one save, not two.
|
|
154
|
+
*/
|
|
155
|
+
function editorHtml(scope, message) {
|
|
156
|
+
const { raw, mtime } = order.loadOrder();
|
|
157
|
+
const own = order.scopeOrder(raw, scope);
|
|
158
|
+
const label = scope === 'global' ? 'every workspace' : ((loadWorkspaces()[scope] || {}).label || scope);
|
|
159
|
+
const folderCount = orderableWorkspaces().length;
|
|
160
|
+
const placed = order.KINDS.reduce((n, k) => n + own[k].length, 0);
|
|
161
|
+
|
|
162
|
+
return `<div id="build-order-editor">
|
|
163
|
+
${message || ''}
|
|
164
|
+
${scopeTabsHtml(scope)}
|
|
165
|
+
<p class="uk-text-meta">The order of the commands in ${esc(label)}.
|
|
166
|
+
${scope === 'global'
|
|
167
|
+
? 'A workspace can put its own first on its own tab, and what it does not mention falls back to this list.'
|
|
168
|
+
: 'Applied before the global order. What this list does not mention falls back to it.'}
|
|
169
|
+
Drag by the handle, or move with the arrows. Saving writes down only what sits above the commands
|
|
170
|
+
you left alone — everything else keeps its shipped place, then its alphabetical one, so a command
|
|
171
|
+
added later still arrives where it belongs without anyone editing this.</p>
|
|
172
|
+
|
|
173
|
+
<form id="build-order-form" hx-post="/actions/build-order-save" hx-target="#build-order-editor" hx-swap="outerHTML">
|
|
174
|
+
<input type="hidden" name="scope" value="${esc(scope)}">
|
|
175
|
+
<!-- The file's mtime, so a save that would land on top of somebody else's is refused with a 409
|
|
176
|
+
and the list it would have overwritten, the way every other writer here works. -->
|
|
177
|
+
<input type="hidden" name="mtime" value="${mtime}">
|
|
178
|
+
${order.KINDS.map((k) => groupHtml(scope, k, own, folderCount)).join('')}
|
|
179
|
+
<p class="settings-actions">
|
|
180
|
+
<button class="uk-button uk-button-primary uk-button-small" type="submit">Save the order</button>
|
|
181
|
+
${placed ? `<button class="uk-button uk-button-default uk-button-small arm-step" type="button"
|
|
182
|
+
title="Forget this scope's order and go back to the shipped one"
|
|
183
|
+
hx-post="/actions/build-order-reset" hx-vals='${esc(JSON.stringify({ scope }))}'
|
|
184
|
+
hx-target="#build-order-editor" hx-swap="outerHTML"
|
|
185
|
+
hx-trigger="confirmed-remove">Reset ${esc(scope === 'global' ? 'every workspace' : label)}</button>` : ''}
|
|
186
|
+
</p>
|
|
187
|
+
</form>
|
|
188
|
+
</div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { editorHtml, commandsFor, orderableWorkspaces, scopeTabsHtml, KIND_META };
|