@1agh/maude 0.23.0 → 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 (101) 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 +32 -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 +126 -2
  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 +74 -10
  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/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -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) {
@@ -3,7 +3,7 @@
3
3
 
4
4
  import assert from 'node:assert/strict';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { mkdtempSync, rmSync } from 'node:fs';
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { dirname, join, resolve } from 'node:path';
9
9
  import { test } from 'node:test';
@@ -32,8 +32,10 @@ function withDataDir(fn) {
32
32
  test('hub help prints subcommand summary on stdout', () => {
33
33
  const res = runCli(['hub', 'help']);
34
34
  assert.equal(res.status, 0, res.stderr);
35
- assert.match(res.stdout, /maude hub <serve\|token\|status>/);
35
+ assert.match(res.stdout, /maude hub <serve\|token\|status\|deploy>/);
36
36
  assert.match(res.stdout, /token generate --label NAME/);
37
+ assert.match(res.stdout, /token rotate --label NAME/);
38
+ assert.match(res.stdout, /deploy <fly\|docker>/);
37
39
  });
38
40
 
39
41
  test('hub token generate without --label exits 2', () => {
@@ -75,6 +77,128 @@ test('hub token generate --dev uses mau_dev_ prefix', () => {
75
77
  });
76
78
  });
77
79
 
80
+ test("hub token generate --scope '*' mints a hub-wide (wildcard) token", () => {
81
+ withDataDir((dataDir) => {
82
+ // Array-arg spawn (no shell) → '*' is passed literally, not glob-expanded.
83
+ const res = runCli([
84
+ 'hub',
85
+ 'token',
86
+ 'generate',
87
+ '--label',
88
+ 'peer',
89
+ '--scope',
90
+ '*',
91
+ '--data',
92
+ dataDir,
93
+ ]);
94
+ assert.equal(res.status, 0, res.stderr);
95
+ assert.match(res.stdout, /scope:\s+\*/);
96
+ const { tokens } = readTokens(dataDir);
97
+ assert.equal(tokens.length, 1);
98
+ // Wildcard is stored as NULL and surfaced as '*' — authorizes any documentName.
99
+ assert.equal(tokens[0].scope, '*');
100
+ });
101
+ });
102
+
103
+ test('hub token generate --scope <value> binds the token to that documentName prefix', () => {
104
+ withDataDir((dataDir) => {
105
+ const res = runCli([
106
+ 'hub',
107
+ 'token',
108
+ 'generate',
109
+ '--label',
110
+ 'peer',
111
+ '--scope',
112
+ 'team-x',
113
+ '--data',
114
+ dataDir,
115
+ ]);
116
+ assert.equal(res.status, 0, res.stderr);
117
+ assert.match(res.stdout, /scope:\s+team-x/);
118
+ assert.equal(readTokens(dataDir).tokens[0].scope, 'team-x');
119
+ });
120
+ });
121
+
122
+ test('hub token generate without --scope defaults the scope to the label (DDR-053)', () => {
123
+ withDataDir((dataDir) => {
124
+ runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
125
+ assert.equal(readTokens(dataDir).tokens[0].scope, 'alice');
126
+ });
127
+ });
128
+
129
+ test('hub token rotate mints a fresh value for an existing label', () => {
130
+ withDataDir((dataDir) => {
131
+ const gen = runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
132
+ const firstValue = /value:\s+(mau_[0-9a-f]{32})/.exec(gen.stdout)?.[1];
133
+ assert.ok(firstValue, 'generate printed a value');
134
+
135
+ const rot = runCli(['hub', 'token', 'rotate', '--label', 'alice', '--data', dataDir]);
136
+ assert.equal(rot.status, 0, rot.stderr);
137
+ const rotatedValue = /value:\s+(mau_[0-9a-f]{32})/.exec(rot.stdout)?.[1];
138
+ assert.ok(rotatedValue, 'rotate printed a value');
139
+ assert.notEqual(rotatedValue, firstValue, 'rotate changed the value');
140
+ assert.match(rot.stdout, /rotated in/);
141
+ // Still exactly one token under that label.
142
+ assert.equal(readTokens(dataDir).tokens.length, 1);
143
+ });
144
+ });
145
+
146
+ test('hub token rotate on a missing label exits 1', () => {
147
+ withDataDir((dataDir) => {
148
+ const res = runCli(['hub', 'token', 'rotate', '--label', 'ghost', '--data', dataDir]);
149
+ assert.equal(res.status, 1);
150
+ assert.match(res.stderr, /no token with label "ghost"/);
151
+ });
152
+ });
153
+
154
+ test('hub deploy fly emits fly.toml + Dockerfile with substituted placeholders', () => {
155
+ withDataDir((outDir) => {
156
+ const res = runCli([
157
+ 'hub',
158
+ 'deploy',
159
+ 'fly',
160
+ '--name',
161
+ 'maude-hub-test',
162
+ '--region',
163
+ 'fra',
164
+ '--out',
165
+ outDir,
166
+ ]);
167
+ assert.equal(res.status, 0, res.stderr);
168
+ const flyToml = readFileSync(join(outDir, 'fly.toml'), 'utf8');
169
+ assert.match(flyToml, /app = "maude-hub-test"/);
170
+ assert.match(flyToml, /primary_region = "fra"/);
171
+ assert.doesNotMatch(flyToml, /\{\{/, 'no unsubstituted placeholders');
172
+ assert.ok(existsSync(join(outDir, 'Dockerfile')), 'Dockerfile copied alongside');
173
+ });
174
+ });
175
+
176
+ test('hub deploy docker emits compose + Caddyfile with the image tag', () => {
177
+ withDataDir((outDir) => {
178
+ const res = runCli(['hub', 'deploy', 'docker', '--tag', 'v9.9.9', '--out', outDir]);
179
+ assert.equal(res.status, 0, res.stderr);
180
+ const compose = readFileSync(join(outDir, 'docker-compose.yml'), 'utf8');
181
+ assert.match(compose, /ghcr\.io\/1agh\/maude-hub:v9\.9\.9/);
182
+ assert.doesNotMatch(compose, /\{\{/, 'no unsubstituted placeholders');
183
+ assert.ok(existsSync(join(outDir, 'Caddyfile')), 'Caddyfile emitted');
184
+ });
185
+ });
186
+
187
+ test('hub deploy refuses to overwrite without --force', () => {
188
+ withDataDir((outDir) => {
189
+ runCli(['hub', 'deploy', 'docker', '--out', outDir]);
190
+ const res = runCli(['hub', 'deploy', 'docker', '--out', outDir]);
191
+ assert.equal(res.status, 1);
192
+ assert.match(res.stderr, /already exists.*--force/s);
193
+ });
194
+ });
195
+
196
+ test('hub deploy with an unknown target exits 2', () => {
197
+ const res = runCli(['hub', 'deploy', 'kubernetes']);
198
+ assert.equal(res.status, 2);
199
+ assert.match(res.stderr, /target must be one of/);
200
+ });
201
+
78
202
  test('hub status against an unreachable URL exits 1 with diagnostic', () => {
79
203
  const res = runCli(['hub', 'status', 'http://127.0.0.1:1']);
80
204
  assert.equal(res.status, 1);
@@ -74,6 +74,9 @@ export async function run({ args, pkgRoot }) {
74
74
  const result = await copyTree(skeleton, aiDir, {
75
75
  force: !!flags.force,
76
76
  dryRun: !!flags['dry-run'],
77
+ // npm strips `.gitignore` from published tarballs, so the cache-ignore
78
+ // template ships as `gitignore` and is renamed to `.gitignore` on copy.
79
+ rename: (name) => (name === 'gitignore' ? '.gitignore' : name),
77
80
  transformMatch: (p) => TEMPLATED.some((t) => p.endsWith(t)),
78
81
  transform: ({ srcPath, contents }) => {
79
82
  let out = contents.replaceAll(PLACEHOLDER, projectName);
@@ -0,0 +1,11 @@
1
+ import { runPreflight } from '../lib/preflight.mjs';
2
+
3
+ // `maude preflight --plugin <design|flow> [--json|--shell-export|--warn-only|--quiet] [--cache PATH]`
4
+ // Thin command over cli/lib/preflight.mjs. Plugin bin scripts + hooks call this
5
+ // instead of shelling to a relative `$PKG_ROOT/cli/lib/preflight.mjs` — in a
6
+ // marketplace install the plugin is copied alone (no sibling cli/), so the
7
+ // only reachable entry is the on-PATH `maude` binary. See DDR-061.
8
+
9
+ export async function run({ args, pkgRoot }) {
10
+ return runPreflight({ args, pkgRoot });
11
+ }
@@ -0,0 +1,46 @@
1
+ // `maude preflight` resolves the plugin manifest from the maude PACKAGE root,
2
+ // never the caller's cwd. This is the DDR-061 regression: in a marketplace
3
+ // install the plugin is copied alone (no sibling cli/), and bin scripts reach
4
+ // the check through the on-PATH `maude` binary — invoked from an arbitrary
5
+ // target-repo cwd. Spawning from a temp cwd that has no `plugins/` proves the
6
+ // resolution doesn't depend on cwd.
7
+
8
+ import assert from 'node:assert/strict';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { test } from 'node:test';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
17
+
18
+ function runFromTmp(args) {
19
+ const cwd = mkdtempSync(join(tmpdir(), 'maude-preflight-'));
20
+ try {
21
+ return spawnSync(process.execPath, [BIN, 'preflight', ...args], { cwd, encoding: 'utf8' });
22
+ } finally {
23
+ rmSync(cwd, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ test('preflight resolves the manifest from pkgRoot, not cwd (marketplace-layout regression)', () => {
28
+ // cwd has no plugins/ — if resolution used cwd this would error "manifest not found".
29
+ const res = runFromTmp(['--plugin', 'design', '--shell-export']);
30
+ assert.equal(res.status === 0 || res.status === 1, true, res.stderr); // 0/1 = ran; 2 = bad args
31
+ assert.match(res.stdout, /DEPS_PLUGIN="design"/);
32
+ });
33
+
34
+ test('preflight --json emits a structured envelope for the named plugin', () => {
35
+ const res = runFromTmp(['--plugin', 'flow', '--json']);
36
+ const env = JSON.parse(res.stdout);
37
+ assert.equal(env.plugin, 'flow');
38
+ assert.ok(Array.isArray(env.results));
39
+ assert.ok(env.summary && typeof env.summary.total === 'number');
40
+ });
41
+
42
+ test('preflight without --plugin exits 2', () => {
43
+ const res = runFromTmp([]);
44
+ assert.equal(res.status, 2);
45
+ assert.match(res.stderr, /--plugin/);
46
+ });