@1agh/maude 0.22.2 → 0.24.0
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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// `maude design <verb>` bin-dispatch (Phase C / DDR-062). The whitelisted
|
|
2
|
+
// dev-tooling verbs dispatch to the dev-server bash helpers, resolved from
|
|
3
|
+
// maude's OWN package root and run with CLAUDE_PLUGIN_ROOT set authoritatively
|
|
4
|
+
// for the child — so they work in a marketplace install where the plugin is
|
|
5
|
+
// copied alone and CLAUDE_PLUGIN_ROOT is unset in the parent env. These tests
|
|
6
|
+
// run from a temp cwd with CLAUDE_PLUGIN_ROOT scrubbed to prove resolution
|
|
7
|
+
// doesn't depend on either cwd or the env var.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
|
|
18
|
+
|
|
19
|
+
function runDesign(args) {
|
|
20
|
+
const cwd = mkdtempSync(join(tmpdir(), 'maude-design-'));
|
|
21
|
+
const env = { ...process.env };
|
|
22
|
+
env.CLAUDE_PLUGIN_ROOT = undefined; // prove maude sets it itself
|
|
23
|
+
try {
|
|
24
|
+
return spawnSync(process.execPath, [BIN, 'design', ...args], { cwd, encoding: 'utf8', env });
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('slug verb resolves from pkgRoot with CLAUDE_PLUGIN_ROOT unset + cwd elsewhere', () => {
|
|
31
|
+
const res = runDesign(['slug', 'Some Canvas Name']);
|
|
32
|
+
assert.equal(res.status, 0, res.stderr);
|
|
33
|
+
assert.equal(res.stdout.trim(), 'some_canvas_name'); // clean stdout — no maude banner
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('slug stdout is capturable (command-substitution idiom)', () => {
|
|
37
|
+
const res = runDesign(['slug', 'Foo/Bar Baz.tsx']);
|
|
38
|
+
assert.equal(res.status, 0, res.stderr);
|
|
39
|
+
// Exactly one line of output so `$(maude design slug …)` captures cleanly.
|
|
40
|
+
assert.equal(res.stdout.trim().split('\n').length, 1);
|
|
41
|
+
assert.equal(res.stdout.trim(), 'foo-bar_baz');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('unknown verb exits 2', () => {
|
|
45
|
+
const res = runDesign(['frobnicate']);
|
|
46
|
+
assert.equal(res.status, 2);
|
|
47
|
+
assert.match(res.stderr, /unknown subcommand/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('design help lists the dev-tooling verbs', () => {
|
|
51
|
+
const res = runDesign(['help']);
|
|
52
|
+
assert.equal(res.status, 0, res.stderr);
|
|
53
|
+
for (const verb of ['screenshot', 'server-up', 'prep', 'slug', 'smoke', 'visual-sanity']) {
|
|
54
|
+
assert.match(res.stdout, new RegExp(verb), `usage should mention ${verb}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
package/cli/commands/help.mjs
CHANGED
|
@@ -19,6 +19,16 @@ COMMANDS
|
|
|
19
19
|
against the flow plugin's config.schema.json. Values that parse as
|
|
20
20
|
JSON (numbers, booleans, arrays, objects) are stored typed.
|
|
21
21
|
|
|
22
|
+
cache get <layer> <key> [--ttl-ms N]
|
|
23
|
+
cache put <layer> <key> [file] [--meta JSON]
|
|
24
|
+
cache list | stats | inspect <layer> [key] | clear [layer]
|
|
25
|
+
Inspect and manage the sidecar cache (.ai/cache/, Phase C / DDR-061)
|
|
26
|
+
that reuses expensive cross-session work (domain research, codebase
|
|
27
|
+
scans, design-context parsing, security reviews). 'get'/'put' are the
|
|
28
|
+
programmatic surface plugin commands call (maude on PATH is the reachable
|
|
29
|
+
contract — see DDR-061); 'list' shows layers + sizes; 'stats' shows
|
|
30
|
+
hit-rate; 'inspect' pretty-prints entries; 'clear' wipes a layer or all.
|
|
31
|
+
|
|
22
32
|
design serve [--port N] [--root PATH]
|
|
23
33
|
Start the design plugin's dev server in the current repo. Equivalent
|
|
24
34
|
to invoking 'claude-design-server'. Forwards all remaining args.
|
|
@@ -31,6 +41,20 @@ COMMANDS
|
|
|
31
41
|
full discovery. --no-discovery uses Recommended defaults; --discovery-
|
|
32
42
|
payload reads pre-computed answers from JSON.
|
|
33
43
|
|
|
44
|
+
design <screenshot|server-up|prep|slug|bootstrap-check|runtime-health
|
|
45
|
+
|smoke|canvas-edit|handoff|asset-sweep|visual-sanity> [args]
|
|
46
|
+
Dispatch to the dev-server's bundled bash helper of the same name.
|
|
47
|
+
maude resolves it from its own package root and sets CLAUDE_PLUGIN_ROOT
|
|
48
|
+
for the child; stdout/stderr/exit-code pass straight through. This is
|
|
49
|
+
the contract plugin markdown uses instead of a raw bin path (DDR-062).
|
|
50
|
+
See 'maude design help' for usage.
|
|
51
|
+
|
|
52
|
+
scenario-report <run-dir> [--out <path>]
|
|
53
|
+
Deterministically generate the mechanical sections of a cross-platform
|
|
54
|
+
scenario report.md (TL;DR, counter-delta parity, per-step pivot, path
|
|
55
|
+
listing) from a scenario run directory. The LLM authors only the prose
|
|
56
|
+
sections. Used by /flow:scenario + the scenario-runner agent (DDR-061).
|
|
57
|
+
|
|
34
58
|
hub serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
|
|
35
59
|
Start the self-hostable Yjs sync hub (Phase 9). Defaults to port 1234,
|
|
36
60
|
data dir ./data. --dev mints a mau_dev_<hex> token + prints the
|
|
@@ -43,6 +67,11 @@ COMMANDS
|
|
|
43
67
|
hub status [URL] [--json]
|
|
44
68
|
Probe a hub's /health endpoint. URL defaults to http://localhost:1234.
|
|
45
69
|
|
|
70
|
+
preflight --plugin <design|flow> [--json|--shell-export|--warn-only] [--cache PATH]
|
|
71
|
+
Run a plugin's dependency health check (the engine behind 'doctor').
|
|
72
|
+
Plugin bin scripts + hooks call this via PATH instead of a relative
|
|
73
|
+
cli/lib path that the marketplace never copies beside a plugin (DDR-061).
|
|
74
|
+
|
|
46
75
|
doctor [--plugin <name>] [--fix] [--json]
|
|
47
76
|
Unified workspace health check. Reports missing dependencies, config
|
|
48
77
|
schema errors, stack drift, and missing quality-gate declarations in
|
|
@@ -57,8 +86,13 @@ EXAMPLES
|
|
|
57
86
|
maude init --name acme-app
|
|
58
87
|
maude config set platforms '["web-desktop","web-mobile"]'
|
|
59
88
|
maude config get motion.complex
|
|
89
|
+
maude cache list
|
|
90
|
+
maude cache clear research/domain
|
|
60
91
|
maude design serve --port 4399
|
|
61
92
|
maude design init --no-discovery --name acme-app
|
|
93
|
+
maude design slug "Some Canvas Name"
|
|
94
|
+
maude design smoke --changed-only
|
|
95
|
+
maude scenario-report .ai/device/scenario-runs/signup/2026-05-29-1200
|
|
62
96
|
maude hub serve --dev
|
|
63
97
|
maude hub token generate --label alice
|
|
64
98
|
maude hub status http://localhost:1234
|
package/cli/commands/hub.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// maude hub — self-hostable Yjs sync hub control plane.
|
|
2
2
|
//
|
|
3
|
-
// Phase 9 Task 2
|
|
4
|
-
// Token rotate, deploy fly|docker land in subsequent slices.
|
|
3
|
+
// Phase 9 Task 2 + Task 7: serve + token generate|rotate + status + deploy.
|
|
5
4
|
// See .ai/plans/phase-9-self-hosted-hub-file-sync.md.
|
|
6
5
|
|
|
7
6
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import {
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { parseArgs } from '../lib/argv.mjs';
|
|
12
12
|
|
|
13
|
-
const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'help']);
|
|
13
|
+
const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'deploy', 'help']);
|
|
14
14
|
|
|
15
15
|
export async function run({ args, pkgRoot }) {
|
|
16
16
|
const { positional } = parseArgs(args);
|
|
@@ -28,50 +28,80 @@ export async function run({ args, pkgRoot }) {
|
|
|
28
28
|
if (sub === 'serve') return runServe({ args, pkgRoot });
|
|
29
29
|
if (sub === 'token') return runToken({ args, pkgRoot });
|
|
30
30
|
if (sub === 'status') return runStatus({ args });
|
|
31
|
+
if (sub === 'deploy') return runDeploy({ args, pkgRoot });
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function usage() {
|
|
34
|
-
return `maude hub <serve|token|status> [options]
|
|
35
|
+
return `maude hub <serve|token|status|deploy> [options]
|
|
35
36
|
|
|
36
37
|
serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
|
|
37
38
|
Start the self-hostable Yjs sync hub in the current process tree.
|
|
38
39
|
--port listen port (default 1234, env PORT)
|
|
39
|
-
--data hub.db + tokens.
|
|
40
|
+
--data hub.db + tokens.db dir (default ./data, env DATA_DIR)
|
|
40
41
|
--secret HUB_SECRET escape-hatch token (env HUB_SECRET).
|
|
41
42
|
If unset on an empty hub, a one-time bootstrap link is
|
|
42
43
|
printed to logs — open it in a browser to claim admin.
|
|
43
44
|
--insecure-http cosmetic log-only flag for non-TLS dev
|
|
44
45
|
--dev generate a one-shot mau_dev_<hex> token, print the
|
|
45
46
|
connect command, then run the hub. Convenience for
|
|
46
|
-
contributor onboarding —
|
|
47
|
-
|
|
47
|
+
contributor onboarding — token persists in tokens.db
|
|
48
|
+
after exit (clean the --data dir by hand to reset).
|
|
48
49
|
|
|
49
50
|
On boot the hub prints its /admin URL. The admin UI generates invite
|
|
50
51
|
tokens, lists peers, and rotates tokens without shelling into the host.
|
|
51
52
|
|
|
52
|
-
token generate --label NAME [--data PATH] [--dev]
|
|
53
|
-
Generate a new mau_<32hex> token and
|
|
54
|
-
<data>/tokens.
|
|
53
|
+
token generate --label NAME [--data PATH] [--dev] [--scope SCOPE]
|
|
54
|
+
Generate a new mau_<32hex> token and store it (HMAC-hashed) in
|
|
55
|
+
<data>/tokens.db with the given label. Prints the raw token ONCE,
|
|
55
56
|
plus the ready-to-paste 'maude design link' connect command.
|
|
56
57
|
|
|
57
58
|
--dev produces a mau_dev_<hex> token (convention only — same auth).
|
|
59
|
+
--scope '*' mints a hub-wide token (authorizes any canvas). Omitted →
|
|
60
|
+
the scope defaults to the label (DDR-053). Canvas sync uses a
|
|
61
|
+
flat per-canvas slug as the documentName, so a label-scoped
|
|
62
|
+
token will NOT authorize a real canvas — pass --scope '*' for
|
|
63
|
+
a peer that syncs canvases (this is what /admin invites do).
|
|
58
64
|
|
|
59
65
|
Equivalent to the "Generate invite" button in the /admin UI — use
|
|
60
66
|
whichever is more convenient for the deploy.
|
|
61
67
|
|
|
68
|
+
token rotate --label NAME [--data PATH]
|
|
69
|
+
Invalidate the named token and mint a fresh value with the same label
|
|
70
|
+
+ scope. Prints the new raw token ONCE. New connections with the OLD
|
|
71
|
+
token are rejected immediately; ALREADY-CONNECTED peers stay until they
|
|
72
|
+
reconnect — use the /admin UI "Rotate" button (kicks live sessions) or
|
|
73
|
+
restart the hub to force-disconnect them now.
|
|
74
|
+
|
|
62
75
|
status [URL] [--json]
|
|
63
76
|
HTTP GET <url>/health, print uptime/version/token-count/peers. URL
|
|
64
77
|
defaults to http://localhost:1234. --json emits the raw response.
|
|
65
78
|
|
|
79
|
+
deploy <fly|docker> [--name NAME] [--region CODE] [--tag TAG] [--out DIR] [--force]
|
|
80
|
+
Emit the deploy templates for the chosen target into the current
|
|
81
|
+
directory (or --out DIR) with placeholders substituted, then print the
|
|
82
|
+
exact commands to run next. Does NOT execute fly/docker for you —
|
|
83
|
+
review the emitted files first, then run the printed command.
|
|
84
|
+
|
|
85
|
+
fly → fly.toml (+ reuses plugins/design/hub/Dockerfile).
|
|
86
|
+
--name app name (default maude-hub-<rand>)
|
|
87
|
+
--region Fly region code (default iad)
|
|
88
|
+
docker → docker-compose.yml + Caddyfile (universal — Lightsail, EC2,
|
|
89
|
+
Hetzner, DigitalOcean, Coolify, home server).
|
|
90
|
+
--tag ghcr.io/1agh/maude-hub image tag (default latest)
|
|
91
|
+
|
|
66
92
|
NOTES
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
serve / token / deploy run inside a 'maude' source checkout (they read the
|
|
94
|
+
bundled hub workspace at plugins/design/hub). Production hubs run the
|
|
95
|
+
published Docker image (ghcr.io/1agh/maude-hub) — see 'maude hub deploy'.
|
|
96
|
+
status works from anywhere (plain HTTP GET against /health).
|
|
70
97
|
|
|
71
98
|
EXAMPLES
|
|
72
99
|
maude hub serve --port 4400
|
|
73
100
|
maude hub token generate --label alice
|
|
101
|
+
maude hub token rotate --label alice
|
|
74
102
|
maude hub status http://localhost:4400
|
|
103
|
+
maude hub deploy fly --name maude-hub-acme --region fra
|
|
104
|
+
maude hub deploy docker --tag latest --out ./deploy
|
|
75
105
|
|
|
76
106
|
# Recommended flow on a fresh deploy:
|
|
77
107
|
# 1. maude hub serve → copy bootstrap link from logs
|
|
@@ -106,11 +136,12 @@ async function runServe({ args, pkgRoot }) {
|
|
|
106
136
|
|
|
107
137
|
if (flags.dev) {
|
|
108
138
|
const dataDir = env.DATA_DIR ?? resolve(process.cwd(), 'data');
|
|
139
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
109
140
|
const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
110
141
|
const port = env.PORT ?? '1234';
|
|
111
142
|
const record = addToken(dataDir, { label: 'dev', dev: true });
|
|
112
143
|
process.stdout.write(
|
|
113
|
-
`[hub] --dev token written to ${dataDir}/tokens.
|
|
144
|
+
`[hub] --dev token written to ${dataDir}/tokens.db:\n label: ${record.label}\n value: ${record.value}\n\n Connect from a peer:\n maude design link http://localhost:${port} --token=${record.value}\n\n`
|
|
114
145
|
);
|
|
115
146
|
}
|
|
116
147
|
|
|
@@ -132,10 +163,9 @@ async function runToken({ args, pkgRoot }) {
|
|
|
132
163
|
const tail = args.slice(args.indexOf('token') + 1);
|
|
133
164
|
const op = tail[0];
|
|
134
165
|
|
|
135
|
-
if (op !== 'generate') {
|
|
166
|
+
if (op !== 'generate' && op !== 'rotate') {
|
|
136
167
|
process.stderr.write(
|
|
137
|
-
`maude hub token:
|
|
138
|
-
`"rotate" lands in a follow-up. Use "generate" with --label to mint a new token.\n`
|
|
168
|
+
`maude hub token: unknown op "${op ?? ''}". Use "generate" or "rotate" with --label.\n`
|
|
139
169
|
);
|
|
140
170
|
process.exit(2);
|
|
141
171
|
}
|
|
@@ -143,21 +173,56 @@ async function runToken({ args, pkgRoot }) {
|
|
|
143
173
|
const { flags } = parseArgs(tail.slice(1), { booleans: ['dev'] });
|
|
144
174
|
const label = flags.label;
|
|
145
175
|
if (!label || typeof label !== 'string') {
|
|
146
|
-
process.stderr.write(
|
|
176
|
+
process.stderr.write(`maude hub token ${op}: --label <name> is required.\n`);
|
|
147
177
|
process.exit(2);
|
|
148
178
|
}
|
|
149
179
|
const dataDir = flags.data ? resolve(String(flags.data)) : resolve(process.cwd(), 'data');
|
|
150
180
|
|
|
151
181
|
const hubRoot = findHubRoot(pkgRoot);
|
|
152
182
|
if (!hubRoot) {
|
|
153
|
-
process.stderr.write(
|
|
183
|
+
process.stderr.write(`maude hub token ${op}: hub workspace not found.\n`);
|
|
154
184
|
process.exit(1);
|
|
155
185
|
}
|
|
156
|
-
const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
157
|
-
|
|
186
|
+
const { addToken, rotateToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
187
|
+
|
|
188
|
+
// better-sqlite3 won't create the parent dir; the running hub does this in
|
|
189
|
+
// createHub, but a CLI generate against a fresh --data path must too.
|
|
190
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
191
|
+
|
|
192
|
+
let record;
|
|
193
|
+
if (op === 'rotate') {
|
|
194
|
+
try {
|
|
195
|
+
record = rotateToken(dataDir, label);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
process.stderr.write(`maude hub token rotate: ${err.message}\n`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
// --scope '*' mints a hub-wide token (authorizes any documentName); an
|
|
202
|
+
// explicit value scopes it to that prefix. Omitted → addToken defaults the
|
|
203
|
+
// scope to the label (DDR-053 §3). NOTE: canvas sync uses flat per-canvas
|
|
204
|
+
// slugs as the documentName, so a label-scoped token does NOT authorize a
|
|
205
|
+
// canvas unless its label equals the slug — pass --scope '*' for sync use.
|
|
206
|
+
const scope = typeof flags.scope === 'string' ? flags.scope : undefined;
|
|
207
|
+
record = addToken(dataDir, {
|
|
208
|
+
label,
|
|
209
|
+
dev: !!flags.dev,
|
|
210
|
+
...(scope !== undefined ? { scope } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
158
213
|
|
|
214
|
+
const verb = op === 'rotate' ? 'rotated' : 'written';
|
|
215
|
+
const liveNote =
|
|
216
|
+
op === 'rotate'
|
|
217
|
+
? 'Old token is rejected on new connections immediately. Already-connected\npeers persist until they reconnect — use the /admin UI "Rotate" button (kicks\nlive sessions) or restart the hub to force-disconnect them now.\n'
|
|
218
|
+
: 'Restart the hub if it is already running for the new token to take effect.\n';
|
|
219
|
+
const shownScope = record.scope ?? '*';
|
|
220
|
+
const scopeNote =
|
|
221
|
+
shownScope === '*'
|
|
222
|
+
? 'hub-wide — authorizes any canvas'
|
|
223
|
+
: `only authorizes documentName "${shownScope}" or "${shownScope}/…" — canvas sync uses flat slugs, so pass --scope '*' for whole-hub sync`;
|
|
159
224
|
process.stdout.write(
|
|
160
|
-
`[hub] token
|
|
225
|
+
`[hub] token ${verb} in ${dataDir}/tokens.db:\n label: ${record.label}\n value: ${record.value}\n scope: ${shownScope} (${scopeNote})\n created: ${new Date(record.createdAt).toISOString()}\n\nConnect from a peer (replace HOST):\n maude design link https://HOST --token=${record.value}\n\n${liveNote}`
|
|
161
226
|
);
|
|
162
227
|
}
|
|
163
228
|
|
|
@@ -194,17 +259,177 @@ async function runStatus({ args }) {
|
|
|
194
259
|
}
|
|
195
260
|
|
|
196
261
|
const uptimeS = Math.round((payload.uptimeMs ?? 0) / 1000);
|
|
262
|
+
// `dataDir` is omitted from the unauthenticated /health payload (it's a
|
|
263
|
+
// server filesystem path — recon over-share). Only print it if present.
|
|
264
|
+
const dataDirLine = payload.dataDir ? ` dataDir: ${payload.dataDir}\n` : '';
|
|
197
265
|
process.stdout.write(
|
|
198
|
-
`Maude Hub @ ${url}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
266
|
+
`Maude Hub @ ${url}
|
|
267
|
+
ok: ${payload.ok}
|
|
268
|
+
version: ${payload.version}
|
|
269
|
+
uptime: ${formatDuration(uptimeS)}
|
|
270
|
+
port: ${payload.port}
|
|
271
|
+
${dataDirLine} tokens: ${payload.tokenCount} (mode: ${payload.authMode})
|
|
272
|
+
`
|
|
205
273
|
);
|
|
206
274
|
}
|
|
207
275
|
|
|
276
|
+
// ---------------------------------------------------------------- deploy
|
|
277
|
+
|
|
278
|
+
const VALID_TARGETS = new Set(['fly', 'docker']);
|
|
279
|
+
// Fly app names: lowercase alnum + hyphen, must start/end alnum.
|
|
280
|
+
const FLY_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/;
|
|
281
|
+
const REGION_REGEX = /^[a-z]{3}$/;
|
|
282
|
+
const IMAGE_TAG_REGEX = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
283
|
+
|
|
284
|
+
function runDeploy({ args, pkgRoot }) {
|
|
285
|
+
const tail = args.slice(args.indexOf('deploy') + 1);
|
|
286
|
+
const { flags, positional } = parseArgs(tail, { booleans: ['force'] });
|
|
287
|
+
const target = positional[0];
|
|
288
|
+
|
|
289
|
+
if (!target || !VALID_TARGETS.has(target)) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`maude hub deploy: target must be one of ${[...VALID_TARGETS].join(' | ')} (got "${target ?? ''}").\n`
|
|
292
|
+
);
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hubRoot = findHubRoot(pkgRoot);
|
|
297
|
+
if (!hubRoot) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
'maude hub deploy: hub workspace not found.\n\n' +
|
|
300
|
+
'Deploy templates ship in the maude source tree. Run inside a checkout,\n' +
|
|
301
|
+
'or clone https://github.com/1aGh/maude and run from there.\n'
|
|
302
|
+
);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const outDir = flags.out ? resolve(String(flags.out)) : process.cwd();
|
|
307
|
+
|
|
308
|
+
if (target === 'fly') return deployFly({ hubRoot, outDir, flags });
|
|
309
|
+
return deployDocker({ hubRoot, outDir, flags });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function deployFly({ hubRoot, outDir, flags }) {
|
|
313
|
+
const appName = flags.name ? String(flags.name) : `maude-hub-${randHex(4)}`;
|
|
314
|
+
if (!FLY_NAME_REGEX.test(appName)) {
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
`maude hub deploy fly: invalid --name "${appName}" (lowercase alphanumeric + hyphen, 2-63 chars).\n`
|
|
317
|
+
);
|
|
318
|
+
process.exit(2);
|
|
319
|
+
}
|
|
320
|
+
const region = flags.region ? String(flags.region) : 'iad';
|
|
321
|
+
if (!REGION_REGEX.test(region)) {
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
`maude hub deploy fly: invalid --region "${region}" (3-letter Fly code, e.g. iad, fra, nrt).\n`
|
|
324
|
+
);
|
|
325
|
+
process.exit(2);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const flyToml = renderTemplate(hubRoot, 'fly.toml.template', {
|
|
329
|
+
APP_NAME: appName,
|
|
330
|
+
REGION: region,
|
|
331
|
+
});
|
|
332
|
+
const flyOut = resolve(outDir, 'fly.toml');
|
|
333
|
+
guardOverwrite(flyOut, flags.force, 'fly.toml');
|
|
334
|
+
writeFileSync(flyOut, flyToml, 'utf8');
|
|
335
|
+
|
|
336
|
+
// The Dockerfile is referenced by fly.toml [build]; copy it alongside so a
|
|
337
|
+
// `fly deploy` from outDir finds it (Fly builds remotely from the context).
|
|
338
|
+
const dockerfileSrc = resolve(hubRoot, 'Dockerfile');
|
|
339
|
+
const dockerfileOut = resolve(outDir, 'Dockerfile');
|
|
340
|
+
if (existsSync(dockerfileSrc) && (flags.force || !existsSync(dockerfileOut))) {
|
|
341
|
+
copyFileSync(dockerfileSrc, dockerfileOut);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
`[hub deploy fly] wrote:
|
|
346
|
+
${flyOut}
|
|
347
|
+
${dockerfileOut} (copied)
|
|
348
|
+
|
|
349
|
+
App: ${appName}
|
|
350
|
+
Region: ${region}
|
|
351
|
+
URL: https://${appName}.fly.dev
|
|
352
|
+
|
|
353
|
+
Next steps (review fly.toml first, then run):
|
|
354
|
+
fly launch --copy-config --no-deploy --name ${appName} --region ${region}
|
|
355
|
+
fly volumes create maude_hub_data --region ${region} --size 3 --yes
|
|
356
|
+
fly deploy
|
|
357
|
+
|
|
358
|
+
After deploy, the boot logs print a single-use /admin bootstrap link.
|
|
359
|
+
Open it → "Generate invite" → copy the \`maude design link …\` command.
|
|
360
|
+
Hub auth uses the token store; HUB_SECRET stays unset unless you want a
|
|
361
|
+
headless escape hatch (\`fly secrets set HUB_SECRET=$(openssl rand -hex 32)\`).
|
|
362
|
+
`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function deployDocker({ hubRoot, outDir, flags }) {
|
|
367
|
+
const tag = flags.tag ? String(flags.tag) : 'latest';
|
|
368
|
+
if (!IMAGE_TAG_REGEX.test(tag)) {
|
|
369
|
+
process.stderr.write(`maude hub deploy docker: invalid --tag "${tag}".\n`);
|
|
370
|
+
process.exit(2);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const compose = renderTemplate(hubRoot, 'docker-compose.yml.template', { IMAGE_TAG: tag });
|
|
374
|
+
const composeOut = resolve(outDir, 'docker-compose.yml');
|
|
375
|
+
guardOverwrite(composeOut, flags.force, 'docker-compose.yml');
|
|
376
|
+
writeFileSync(composeOut, compose, 'utf8');
|
|
377
|
+
|
|
378
|
+
// Caddyfile uses {$ENV} runtime substitution — no placeholders to render.
|
|
379
|
+
const caddySrc = resolve(hubRoot, 'Caddyfile.template');
|
|
380
|
+
const caddyOut = resolve(outDir, 'Caddyfile');
|
|
381
|
+
guardOverwrite(caddyOut, flags.force, 'Caddyfile');
|
|
382
|
+
copyFileSync(caddySrc, caddyOut);
|
|
383
|
+
|
|
384
|
+
process.stdout.write(
|
|
385
|
+
`[hub deploy docker] wrote:
|
|
386
|
+
${composeOut}
|
|
387
|
+
${caddyOut}
|
|
388
|
+
|
|
389
|
+
Image: ghcr.io/1agh/maude-hub:${tag}
|
|
390
|
+
|
|
391
|
+
Next steps (point DNS at this box, then run):
|
|
392
|
+
cat > .env <<EOF
|
|
393
|
+
HUB_SECRET=$(openssl rand -hex 32)
|
|
394
|
+
PUBLIC_DOMAIN=maude-hub.example.com
|
|
395
|
+
ACME_EMAIL=you@example.com
|
|
396
|
+
EOF
|
|
397
|
+
docker compose up -d
|
|
398
|
+
docker compose logs hub # copy the single-use /admin bootstrap link
|
|
399
|
+
|
|
400
|
+
Caddy fetches a Let's Encrypt cert for PUBLIC_DOMAIN automatically and
|
|
401
|
+
proxies WSS to the hub. Works on Lightsail, EC2, Hetzner, DigitalOcean,
|
|
402
|
+
Coolify, or a home server. See the hub-deploy docs for per-provider notes.
|
|
403
|
+
`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderTemplate(hubRoot, name, vars) {
|
|
408
|
+
const path = resolve(hubRoot, name);
|
|
409
|
+
if (!existsSync(path)) {
|
|
410
|
+
process.stderr.write(`maude hub deploy: template not found: ${path}\n`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
let out = readFileSync(path, 'utf8');
|
|
414
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
415
|
+
out = out.replaceAll(`{{${key}}}`, value);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function guardOverwrite(path, force, label) {
|
|
421
|
+
if (existsSync(path) && !force) {
|
|
422
|
+
process.stderr.write(
|
|
423
|
+
`maude hub deploy: ${label} already exists at ${path}. Pass --force to overwrite.\n`
|
|
424
|
+
);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function randHex(bytes) {
|
|
430
|
+
return randomBytes(bytes).toString('hex');
|
|
431
|
+
}
|
|
432
|
+
|
|
208
433
|
// ---------------------------------------------------------------- helpers
|
|
209
434
|
|
|
210
435
|
function findHubRoot(pkgRoot) {
|