@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.
Files changed (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. 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
+ });
@@ -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
@@ -1,16 +1,16 @@
1
1
  // maude hub — self-hostable Yjs sync hub control plane.
2
2
  //
3
- // Phase 9 Task 2 (minimal subset): serve + token generate + status.
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 { existsSync, readFileSync } from 'node:fs';
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.json dir (default ./data, env DATA_DIR)
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 — drops tokens.json on exit
47
- is NOT performed (tokens persist; clean by hand).
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 append it to
54
- <data>/tokens.json with the given label. Prints the raw token ONCE,
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
- Local dev only in v1.1 Task 2 slice production-install packaging
68
- (adding plugins/design/hub to package.json:files + dep hoisting) lands
69
- in a follow-up slice. For now, run inside a 'maude' source checkout.
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.json:\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`
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: only "generate" is supported in v1.1 Task 2 slice.\n` +
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('maude hub token generate: --label <name> is required.\n');
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('maude hub token generate: hub workspace not found.\n');
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
- const record = addToken(dataDir, { label, dev: !!flags.dev });
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 written to ${dataDir}/tokens.json:\n label: ${record.label}\n value: ${record.value}\n created: ${new Date(record.createdAt).toISOString()}\n\nConnect from a peer (replace HOST):\n maude design link https://HOST --token=${record.value}\n\nRestart the hub if it is already running for the new token to take effect.\n`
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}\n` +
199
- ` ok: ${payload.ok}\n` +
200
- ` version: ${payload.version}\n` +
201
- ` uptime: ${formatDuration(uptimeS)}\n` +
202
- ` port: ${payload.port}\n` +
203
- ` dataDir: ${payload.dataDir}\n` +
204
- ` tokens: ${payload.tokenCount} (mode: ${payload.authMode})\n`
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) {