@1agh/maude 0.60.7 → 1.0.3

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +201 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -24,6 +24,7 @@ import {
24
24
  renderCaddyfile,
25
25
  renderCompose,
26
26
  renderEnv,
27
+ safeSeedUrl,
27
28
  validateWorkspaceConfig,
28
29
  verificationPlan,
29
30
  workspaceBaseUrl,
@@ -37,6 +38,11 @@ export function usage() {
37
38
  before saying so.
38
39
 
39
40
  --domain HOST public hostname (design.acme.com)
41
+ --canvas-domain HOST SECOND hostname for the canvas origin
42
+ (canvas.acme.com). The studio renders canvases on
43
+ their own origin; without a public name for it the
44
+ iframe points at a container-internal port and every
45
+ canvas is a blank frame. Point DNS here too.
40
46
  --acme-email EMAIL Let's Encrypt contact
41
47
  --admin-email EMAIL the first person who can sign in
42
48
  --admin-password PASS their initial password (>= 12 chars; generated if omitted)
@@ -53,6 +59,11 @@ export function usage() {
53
59
  verification step — on a laptop, with no domain and
54
60
  no paid account. Never serve a real workspace this
55
61
  way: sign-in passwords would travel in the clear.
62
+ --render add the maude-render sidecar — the one container
63
+ that carries a browser. Enables PNG/PDF/PPTX/video
64
+ export from the hosted studio (DDR-230). Without
65
+ it those formats say so and point at the desktop
66
+ app; ZIP export works either way.
56
67
  --seed-repo URL clone an existing project; omit to start fresh
57
68
  --image-tag TAG default "latest" — pin it before you rely on this
58
69
  --config FILE read all of the above from a JSON file
@@ -72,7 +83,7 @@ export function usage() {
72
83
 
73
84
  export async function run({ args, pkgRoot }) {
74
85
  const { flags } = parseArgs(args, {
75
- booleans: ['help', 'dry-run', 'json', 'dev-minio', 'local'],
86
+ booleans: ['help', 'dry-run', 'json', 'dev-minio', 'local', 'render'],
76
87
  });
77
88
  if (flags.help) {
78
89
  process.stdout.write(usage());
@@ -90,7 +101,9 @@ export async function run({ args, pkgRoot }) {
90
101
  : {}),
91
102
  devMinio: flags['dev-minio'] === true || raw.devMinio === true,
92
103
  local: flags.local === true || raw.local === true,
104
+ render: flags.render === true || raw.render === true,
93
105
  seedRepo: flags['seed-repo'] ?? raw.seedRepo,
106
+ canvasDomain: flags['canvas-domain'] ?? raw.canvasDomain,
94
107
  imageTag: flags['image-tag'] ?? raw.imageTag,
95
108
  ...(flags['s3-endpoint'] || raw.s3
96
109
  ? {
@@ -103,9 +116,28 @@ export async function run({ args, pkgRoot }) {
103
116
  },
104
117
  }
105
118
  : {}),
119
+ // BYO identity travels via --config only: a client secret does not belong
120
+ // on a command line (argv is visible in `ps` and shell history — see
121
+ // _credentials.md). The config block is passed straight through to
122
+ // validateWorkspaceConfig, which renders and forwards the HUB_OIDC_* vars.
123
+ ...(raw.oidc ? { oidc: raw.oidc } : {}),
106
124
  };
107
125
 
108
- const { ok, errors, config } = validateWorkspaceConfig(merged);
126
+ // Read the existing .env BEFORE validating, because whether this deployment
127
+ // already exists decides the backup namespace (Phase 0 F3).
128
+ const envPath = resolve(outDir, '.env');
129
+ const envExists = existsSync(envPath);
130
+ const existing = readExistingEnv(envPath);
131
+
132
+ // NEVER force a namespace onto a deployment that has been running without
133
+ // one. A prefixed target lists a DISJOINT keyspace, so adding one makes every
134
+ // existing generation invisible to `listBackups` — orphaned, unprunable, and
135
+ // the next lost volume would see zero generations and seed over the loss. The
136
+ // write-side identity refusal already protects the bare root, so the prefix
137
+ // is a remedy here rather than the safety mechanism.
138
+ const backupPrefix = envExists ? existing.MAUDE_BACKUP_PREFIX || null : undefined;
139
+
140
+ const { ok, errors, config } = validateWorkspaceConfig({ ...merged, backupPrefix });
109
141
  if (!ok) {
110
142
  if (flags.json) {
111
143
  process.stdout.write(`${JSON.stringify({ ok: false, errors }, null, 2)}\n`);
@@ -117,15 +149,42 @@ export async function run({ args, pkgRoot }) {
117
149
  process.exit(2);
118
150
  }
119
151
 
152
+ // REFUSE a new admin password on a re-run, rather than writing one that goes
153
+ // nowhere.
154
+ //
155
+ // `seedFirstUser()` (first-user.mjs) creates the account on FIRST BOOT only.
156
+ // On a re-run the account already exists and keeps its original password, so
157
+ // letting `--admin-password` win here produces a `.env` that states one
158
+ // password and a database that holds another. The command's own verification
159
+ // then reports `HTTP 401 — the first user cannot sign in`, and the only
160
+ // repair found on the live AWS run was `docker compose down -v` — on a
161
+ // production box that is losing the project, not fixing the password.
162
+ //
163
+ // Checked BEFORE anything is written or started, so the refusal costs the
164
+ // operator a message rather than a half-applied run.
165
+ if (config.adminPassword && existing.MAUDE_ADMIN_PASSWORD) {
166
+ const message =
167
+ 'the first user already exists — `--admin-password` applies to a FIRST boot only. ' +
168
+ 'Change their password in the admin UI; re-run without the flag to leave it alone.';
169
+ if (flags.json) {
170
+ process.stdout.write(`${JSON.stringify({ ok: false, errors: [message] }, null, 2)}\n`);
171
+ } else {
172
+ process.stderr.write(`maude hub workspace-up: ${message}\n`);
173
+ }
174
+ process.exit(2);
175
+ }
176
+
120
177
  // Reuse existing secrets — re-minting on a re-run would lock out every peer
121
178
  // that already holds a token, and re-running is exactly what someone does
122
- // after a failed attempt.
123
- const existing = readExistingEnv(resolve(outDir, '.env'));
179
+ // after a failed attempt. (`existing` was read above, before validation.)
124
180
  const hubSecret = existing.HUB_SECRET || randomBytes(32).toString('hex');
125
181
  const adminPassword = config.adminPassword || existing.MAUDE_ADMIN_PASSWORD || generatePassword();
126
182
  const reusedSecret = Boolean(existing.HUB_SECRET);
183
+ // Its OWN secret (DDR-230) — rotating the render bearer must never lock out
184
+ // peers holding hub tokens, and vice versa. Reused on re-runs like the rest.
185
+ const renderSecret = existing.MAUDE_RENDER_SECRET || randomBytes(32).toString('hex');
127
186
 
128
- const entries = envEntries(config, { hubSecret, adminPassword });
187
+ const entries = envEntries(config, { hubSecret, adminPassword, renderSecret });
129
188
  const files = [
130
189
  { name: '.env', body: renderEnv(entries), mode: 0o600 },
131
190
  { name: 'docker-compose.yml', body: renderCompose(config), mode: 0o644 },
@@ -145,10 +204,15 @@ export async function run({ args, pkgRoot }) {
145
204
  reusedSecret,
146
205
  };
147
206
  if (flags.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
148
- else printDryRun({ config, outDir, files, plan, duties, reusedSecret });
207
+ else {
208
+ printDryRun({ config, outDir, files, plan, duties, reusedSecret });
209
+ warnNoCanvasDomain(config);
210
+ }
149
211
  return;
150
212
  }
151
213
 
214
+ warnNoCanvasDomain(config);
215
+
152
216
  mkdirSync(outDir, { recursive: true });
153
217
  for (const f of files) {
154
218
  const path = resolve(outDir, f.name);
@@ -255,7 +319,10 @@ function readExistingEnv(path) {
255
319
  try {
256
320
  for (const line of readFileSync(path, 'utf8').split('\n')) {
257
321
  const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
258
- if (m) out[m[1]] = m[2];
322
+ // Values are single-quoted by renderEnv (F8), so unquote on the way back
323
+ // in — otherwise a re-run reads `'secret'` (with quotes) and re-quotes it,
324
+ // and every peer holding the old HUB_SECRET is locked out.
325
+ if (m) out[m[1]] = unquoteEnvValue(m[2]);
259
326
  }
260
327
  } catch {
261
328
  /* unreadable → treat as absent */
@@ -263,6 +330,14 @@ function readExistingEnv(path) {
263
330
  return out;
264
331
  }
265
332
 
333
+ /** Reverse of `renderEnvValue`: strip the single quotes and unescape. */
334
+ function unquoteEnvValue(v) {
335
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
336
+ return v.slice(1, -1).replace(/'\\''/g, "'");
337
+ }
338
+ return v;
339
+ }
340
+
266
341
  /** Readable, high-entropy, and safe to paste — no ambiguous glyphs. */
267
342
  function generatePassword() {
268
343
  const alphabet = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
@@ -343,7 +418,9 @@ async function runVerification(step, { config, hubSecret, adminPassword, outDir,
343
418
  case 's3-no-expiry':
344
419
  return verifyNoLifecycle(config, pkgRoot);
345
420
  case 'restore-drill':
346
- return verifyRestoreDrill(config);
421
+ return verifyRestoreDrill(config, { outDir, pkgRoot });
422
+ case 'render-health':
423
+ return verifyRenderHealth(outDir);
347
424
  default:
348
425
  return {
349
426
  ok: false,
@@ -377,6 +454,44 @@ async function verifySignin(base, email, password) {
377
454
  }
378
455
  }
379
456
 
457
+ /**
458
+ * The render sidecar answers AND is configured (DDR-230).
459
+ *
460
+ * Probed from INSIDE the hub container, because that is the only place the
461
+ * service is reachable from — it deliberately has no public port. `configured`
462
+ * comes from the service's own /_health: a booted sidecar with a missing
463
+ * secret or an empty origin allowlist refuses every job, and "up but refusing
464
+ * everything" must not read as a pass.
465
+ */
466
+ async function verifyRenderHealth(outDir) {
467
+ const probe = await sh(
468
+ 'docker',
469
+ [
470
+ 'compose',
471
+ 'exec',
472
+ '-T',
473
+ 'hub',
474
+ 'sh',
475
+ '-c',
476
+ 'wget -q -O - http://render:8790/_health || curl -sf http://render:8790/_health',
477
+ ],
478
+ { cwd: outDir }
479
+ );
480
+ if (probe.code !== 0) return { ok: false, note: 'the render service did not answer /_health' };
481
+ try {
482
+ const body = JSON.parse(probe.stdout);
483
+ if (body?.ok && body?.configured) return { ok: true };
484
+ return {
485
+ ok: false,
486
+ note: body?.ok
487
+ ? 'render service is up but not configured (missing secret or canvas-origin allowlist)'
488
+ : 'render service /_health did not report ok',
489
+ };
490
+ } catch {
491
+ return { ok: false, note: 'render service /_health returned something that is not JSON' };
492
+ }
493
+ }
494
+
380
495
  /**
381
496
  * The server-side checkout has real commits (Cloud Phase 16).
382
497
  *
@@ -523,10 +638,21 @@ async function verifyNoLifecycle(config, pkgRoot) {
523
638
  if (res.status === 404) return { ok: true, note: 'no lifecycle configuration' };
524
639
  if (!res.ok) {
525
640
  // Cannot read the config ⇒ cannot claim it is safe. Skipped, never passed.
641
+ //
642
+ // 403 gets its own sentence because it is almost always OUR fault, not a
643
+ // misconfiguration: the least-privilege policy we ship omitted
644
+ // `s3:GetLifecycleConfiguration` until 2026-08-20, so following the docs
645
+ // to the letter left this check permanently skipped — and the thing it
646
+ // guards (an expiry rule silently deleting media that canvases still
647
+ // reference) has no recovery path once it fires.
648
+ const why =
649
+ res.status === 403
650
+ ? 'the credential may not read lifecycle config — add s3:GetLifecycleConfiguration on the bucket ARN'
651
+ : `HTTP ${res.status}`;
526
652
  return {
527
653
  ok: false,
528
654
  skipped: true,
529
- note: `could not read lifecycle config (HTTP ${res.status})`,
655
+ note: `could not read lifecycle config (${why})`,
530
656
  };
531
657
  }
532
658
  const xml = await res.text();
@@ -548,19 +674,92 @@ async function verifyNoLifecycle(config, pkgRoot) {
548
674
  }
549
675
  }
550
676
 
551
- /** A backup nobody has restored is a hypothesis. Runs the real drill. */
552
- async function verifyRestoreDrill(config) {
677
+ /**
678
+ * Turn a failed drill subprocess into a verdict — and separate the two very
679
+ * different things a non-zero exit can mean.
680
+ *
681
+ * Exported because this distinction, not the spawning, is the thing worth
682
+ * pinning: on the first live AWS run all three of "no generation yet",
683
+ * "nowhere to run" and "the restore is broken" printed as a single red
684
+ * `failed`, and the operator spent the evening on the third when the truth was
685
+ * the second.
686
+ */
687
+ export function classifyDrillFailure(text) {
688
+ if (/no complete backup generation/i.test(text)) {
689
+ return {
690
+ ok: false,
691
+ skipped: true,
692
+ note: 'no generation exists yet (the first one lands within 6h) — run `maude hub restore-drill` then',
693
+ };
694
+ }
695
+ // "NOWHERE TO RUN" IS NOT "THE BACKUP IS BROKEN". The standard deployment
696
+ // this command generates is container-only: the host clone has no
697
+ // `node_modules` (so `backup.mjs` dies on `Cannot find module
698
+ // 'better-sqlite3'`) and the image carries the bundled server, not
699
+ // `cli/bin/maude.mjs` — so the drill has nowhere to execute, through no
700
+ // fault of the backups. Reported as `failed`, that reads as data loss.
701
+ if (/cannot find module|backup engine.*not found|ERR_MODULE_NOT_FOUND/i.test(text)) {
702
+ return {
703
+ ok: false,
704
+ skipped: true,
705
+ note:
706
+ 'the drill could not run here — this host has no installed backup engine, so nothing ' +
707
+ 'about the backups was proven either way. Run `maude hub restore-drill` from a full ' +
708
+ 'checkout against the same target.',
709
+ };
710
+ }
711
+ return { ok: false, note: `provisioning drill failed: ${text.trim().slice(0, 160)}` };
712
+ }
713
+
714
+ /**
715
+ * A backup nobody has restored is a hypothesis. Runs the real drill.
716
+ *
717
+ * This used to report `skipped` unconditionally, on the grounds that "the
718
+ * drill needs the hub's own data dir, which lives inside the container". That
719
+ * was never true of the drill: `runRestoreDrill` restores into a SCRATCH
720
+ * directory from a target resolved off flags and env — it never touches the
721
+ * hub's data dir. And the credentials it needs are the ones we just rendered
722
+ * into `.env` on this machine.
723
+ *
724
+ * What the old note WAS right about is the claim it makes. Running it at
725
+ * provisioning time proves the code path works against this bucket with these
726
+ * credentials; it does not prove the deployment is restorable next month. So
727
+ * it is labelled a PROVISIONING drill and the recurring duty stays uncrossed
728
+ * in `operatorDuties()`.
729
+ */
730
+ async function verifyRestoreDrill(config, { outDir, pkgRoot }) {
553
731
  if (!config.s3) return { ok: false, skipped: true, note: 'no backup target configured' };
554
- // The drill needs the hub's own data dir, which lives inside the container.
555
- // Deliberately left to the operator's `maude hub restore-drill` rather than
556
- // reaching into a volume from out here: a half-run drill that reports
557
- // success is worse than an honest skip, and this is the one check whose
558
- // whole point is that somebody actually did it.
559
- return {
560
- ok: false,
561
- skipped: true,
562
- note: 'run `maude hub restore-drill` against this deployment — it needs the hub data dir',
563
- };
732
+
733
+ // PASS THE RENDERED ENV EXPLICITLY.
734
+ //
735
+ // The subprocess resolves its target through `engine.targetFromEnv()` — the
736
+ // SHELL's environment. `MAUDE_S3_*` was rendered into `.env` for the
737
+ // CONTAINER and was never exported here, so the drill reported "no backup
738
+ // target configured" on a deployment whose storage was configured and
739
+ // working. The note above this function said the credentials "are the ones
740
+ // we just rendered into `.env` on this machine": rendered, yes — loaded, no.
741
+ const rendered = readExistingEnv(resolve(outDir, '.env'));
742
+ const env = { ...process.env };
743
+ for (const [key, value] of Object.entries(rendered)) {
744
+ if (
745
+ key.startsWith('MAUDE_S3_') ||
746
+ key === 'MAUDE_BACKUP_TARGET' ||
747
+ key === 'MAUDE_BACKUP_PREFIX'
748
+ ) {
749
+ env[key] = value;
750
+ }
751
+ }
752
+
753
+ const drill = await sh(
754
+ process.execPath,
755
+ [resolve(pkgRoot, 'cli/bin/maude.mjs'), 'hub', 'restore-drill', '--json'],
756
+ { env }
757
+ );
758
+ if (drill.code !== 0) {
759
+ const text = `${drill.stdout}${drill.stderr}`;
760
+ return classifyDrillFailure(text);
761
+ }
762
+ return { ok: true, note: 'provisioning drill passed — schedule it, this proves today only' };
564
763
  }
565
764
 
566
765
  /** Retry a flaky-at-startup operation. Rethrows the LAST error, so the
@@ -596,13 +795,35 @@ async function tryFetch(url, init) {
596
795
  }
597
796
  }
598
797
 
798
+ /**
799
+ * The M7 warning — LOUD, in both the dry run and the real one.
800
+ *
801
+ * Without a canvas domain the stack comes up, every verification step passes,
802
+ * and every canvas renders as a blank frame: the iframe's origin defaults to
803
+ * `http://localhost:<container port>`, an address only the server itself can
804
+ * reach. The spike hit exactly this, concluded "the workspace can't render by
805
+ * design", and planned around a limitation that was one missing hostname.
806
+ */
807
+ function warnNoCanvasDomain(config) {
808
+ if (config.canvasDomain || config.local) return;
809
+ process.stderr.write(
810
+ '\n ⚠ no --canvas-domain: canvases will NOT render in remote browsers.\n' +
811
+ ' The canvas iframe needs its own public hostname (e.g. canvas.' +
812
+ config.domain.replace(/^[^.]+\./, '') +
813
+ ').\n' +
814
+ ' Point a DNS record at this machine and re-run with --canvas-domain <host>.\n' +
815
+ ' The studio chrome (file tree, comments) works either way.\n\n'
816
+ );
817
+ }
818
+
599
819
  function printDryRun({ config, outDir, files, plan, duties, reusedSecret }) {
600
820
  process.stdout.write(
601
821
  `maude hub workspace-up — DRY RUN, nothing was written\n\n` +
602
822
  ` workspace ${workspaceBaseUrl(config)}${config.local ? ' (LOCAL — plain HTTP)' : ''}\n` +
603
823
  ` first user ${config.adminEmail}\n` +
604
824
  ` storage ${config.s3 ? `${config.s3.bucket} @ ${config.s3.endpoint}${config.s3.dev ? ' (dev MinIO)' : ''}` : 'none — media stays in git'}\n` +
605
- ` project ${config.seedRepo ?? 'starts fresh'}\n` +
825
+ ` project ${safeSeedUrl(config.seedRepo) ?? 'starts fresh'}\n` +
826
+ ` canvas ${config.canvasDomain ? `${config.local ? 'http' : 'https'}://${config.canvasDomain}` : config.local ? 'same-machine (local mode — the browser can reach the container port)' : 'NOT SET — canvases will not render in remote browsers'}\n` +
606
827
  ` image ghcr.io/1agh/maude-hub:${config.imageTag}\n` +
607
828
  ` out ${outDir}\n` +
608
829
  (reusedSecret ? ' secrets reusing HUB_SECRET from the existing .env\n' : '') +
@@ -0,0 +1,171 @@
1
+ // `maude hub workspace-up` end-to-end via spawnSync — the properties that only
2
+ // hold on the REAL command surface, not on the planning layer under it.
3
+ //
4
+ // Both cases come from the first live AWS run of `workspace-up` (2026-08-20).
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
15
+
16
+ const { classifyDrillFailure } = await import('./hub-workspace.mjs');
17
+
18
+ function runCli(args, { cwd } = {}) {
19
+ return spawnSync(process.execPath, [BIN, ...args], { cwd, encoding: 'utf8' });
20
+ }
21
+
22
+ function withDir(fn) {
23
+ const dir = mkdtempSync(join(tmpdir(), 'maude-workspace-'));
24
+ try {
25
+ return fn(dir);
26
+ } finally {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ const BASE = [
32
+ 'hub',
33
+ 'workspace-up',
34
+ '--domain',
35
+ 'design.acme.com',
36
+ '--acme-email',
37
+ 'ops@acme.com',
38
+ '--admin-email',
39
+ 'ops@acme.com',
40
+ ];
41
+
42
+ // M3 — the recommended seed URL is
43
+ // `https://x-access-token:<PAT>@github.com/org/repo.git` (`seed-repo.mjs`
44
+ // accepts no other shape), and this command printed it unredacted, `--dry-run`
45
+ // included. On the live run the PAT landed in SSM command history, CloudTrail
46
+ // and a session transcript, and had to be revoked.
47
+ test('--dry-run never prints the seed URL credential', () => {
48
+ withDir((dir) => {
49
+ const r = runCli(
50
+ [...BASE, '--dry-run', '--seed-repo', 'https://x-access-token:SECRET123@github.com/o/r.git'],
51
+ { cwd: dir }
52
+ );
53
+ assert.equal(r.status, 0, r.stderr);
54
+ const out = `${r.stdout}${r.stderr}`;
55
+ assert.ok(!out.includes('SECRET123'), 'the token must not reach stdout or stderr');
56
+ assert.match(out, /https:\/\/\*\*\*@github\.com\/o\/r\.git/);
57
+ });
58
+ });
59
+
60
+ // M2 — `--admin-password` beat the existing `.env`, so the new value was
61
+ // written to disk while `seedFirstUser()` (first boot only) kept the old one.
62
+ // The verification step then reported `HTTP 401 — the first user cannot sign
63
+ // in`, and the only repair anyone found was `docker compose down -v`: on a
64
+ // live box, losing the project rather than fixing the password.
65
+ test('--admin-password on a re-run is refused, not silently written', () => {
66
+ withDir((dir) => {
67
+ const first = runCli([...BASE, '--dry-run', '--admin-password', 'first-password-ok'], {
68
+ cwd: dir,
69
+ });
70
+ assert.equal(first.status, 0, first.stderr);
71
+
72
+ // Stand in for a hub that has already booted once.
73
+ writeFileSync(
74
+ join(dir, '.env'),
75
+ "MAUDE_ADMIN_EMAIL='ops@acme.com'\nMAUDE_ADMIN_PASSWORD='first-password-ok'\nHUB_SECRET='abc'\n",
76
+ { mode: 0o600 }
77
+ );
78
+
79
+ const rerun = runCli([...BASE, '--admin-password', 'second-password-ok'], { cwd: dir });
80
+ assert.notEqual(rerun.status, 0, 'a re-run with a new password must not report success');
81
+ assert.match(`${rerun.stdout}${rerun.stderr}`, /already exists/i);
82
+ // The file on disk must still describe the password the database holds.
83
+ assert.match(readFileSync(join(dir, '.env'), 'utf8'), /first-password-ok/);
84
+ assert.ok(!readFileSync(join(dir, '.env'), 'utf8').includes('second-password-ok'));
85
+ });
86
+ });
87
+
88
+ test('a re-run WITHOUT --admin-password still proceeds past the password gate', () => {
89
+ withDir((dir) => {
90
+ writeFileSync(
91
+ join(dir, '.env'),
92
+ "MAUDE_ADMIN_EMAIL='ops@acme.com'\nMAUDE_ADMIN_PASSWORD='first-password-ok'\nHUB_SECRET='abc'\n",
93
+ { mode: 0o600 }
94
+ );
95
+ const r = runCli([...BASE, '--dry-run'], { cwd: dir });
96
+ assert.equal(r.status, 0, r.stderr);
97
+ assert.ok(existsSync(join(dir, '.env')));
98
+ });
99
+ });
100
+
101
+ // M1 — on the live AWS run `restore-drill` reported a flat red `failed` for
102
+ // something that was not a backup problem at all: the host clone had no
103
+ // `node_modules`, so `backup.mjs` died on `Cannot find module
104
+ // 'better-sqlite3'`, and the image ships the bundled server rather than
105
+ // `cli/bin/maude.mjs`, so there was no third place to try. An operator reading
106
+ // "the backup is broken" starts recovering from a problem they do not have.
107
+ test('a drill that could not RUN is skipped, not failed', () => {
108
+ const verdict = classifyDrillFailure(
109
+ "maude hub backup: Cannot find module 'better-sqlite3'\nRequire stack: /x/apps/hub/src/backup.mjs"
110
+ );
111
+ assert.equal(verdict.ok, false);
112
+ assert.equal(verdict.skipped, true, 'unrunnable must not be reported as a failure');
113
+ assert.match(verdict.note, /could not run/i);
114
+ });
115
+
116
+ test('a missing backup engine is skipped too', () => {
117
+ const verdict = classifyDrillFailure(
118
+ 'maude hub: the backup engine (apps/hub/src/backup.mjs) was not found.'
119
+ );
120
+ assert.equal(verdict.skipped, true);
121
+ });
122
+
123
+ test('no generation yet is skipped and says when to retry', () => {
124
+ const verdict = classifyDrillFailure('no complete backup generation exists');
125
+ assert.equal(verdict.skipped, true);
126
+ assert.match(verdict.note, /6h/);
127
+ });
128
+
129
+ // The drill doing its job must stay loud — this is the case the skips above
130
+ // must never swallow.
131
+ test('a REAL restore failure is still a failure', () => {
132
+ const verdict = classifyDrillFailure('restored database has documents 0 — refusing to pass');
133
+ assert.equal(verdict.ok, false);
134
+ assert.ok(!verdict.skipped, 'an empty restore is a genuine failure, not a skip');
135
+ assert.match(verdict.note, /provisioning drill failed/);
136
+ });
137
+
138
+ // M7 — without a canvas domain the stack comes up green and every canvas is a
139
+ // blank frame. Verification never catches it (all eight steps pass), so the
140
+ // COMMAND has to say it — loudly, in both the dry run and the real one.
141
+ test('--dry-run without --canvas-domain warns that canvases will not render remotely', () => {
142
+ withDir((dir) => {
143
+ const r = runCli([...BASE, '--dry-run'], { cwd: dir });
144
+ assert.equal(r.status, 0, r.stderr);
145
+ assert.match(`${r.stdout}${r.stderr}`, /canvases will NOT render in remote browsers/i);
146
+ assert.match(r.stdout, /canvas\s+NOT SET/);
147
+ });
148
+ });
149
+
150
+ test('--canvas-domain silences the warning and renders the full chain', () => {
151
+ withDir((dir) => {
152
+ const r = runCli([...BASE, '--dry-run', '--canvas-domain', 'canvas.acme.com'], { cwd: dir });
153
+ assert.equal(r.status, 0, r.stderr);
154
+ const out = `${r.stdout}${r.stderr}`;
155
+ assert.ok(!/NOT render in remote browsers/i.test(out), 'warning must be gone');
156
+ assert.match(r.stdout, /canvas\s+https:\/\/canvas\.acme\.com/);
157
+ // The duty list tells the operator the second DNS record is on them.
158
+ assert.match(r.stdout, /DNS for the canvas domain/);
159
+ });
160
+ });
161
+
162
+ test('--local does not warn — localhost IS reachable from the browser that matters there', () => {
163
+ withDir((dir) => {
164
+ const r = runCli(
165
+ ['hub', 'workspace-up', '--local', '--admin-email', 'ops@acme.com', '--dry-run'],
166
+ { cwd: dir }
167
+ );
168
+ assert.equal(r.status, 0, r.stderr);
169
+ assert.ok(!/NOT render in remote browsers/i.test(`${r.stdout}${r.stderr}`));
170
+ });
171
+ });
@@ -26,6 +26,7 @@ const SUBCOMMANDS = new Set([
26
26
  'backup',
27
27
  'restore-drill',
28
28
  'asset-check',
29
+ 'backup-owners',
29
30
  'workspace-up',
30
31
  'help',
31
32
  ]);
@@ -50,6 +51,7 @@ export async function run({ args, pkgRoot }) {
50
51
  if (sub === 'backup') return runBackupNow({ args, pkgRoot });
51
52
  if (sub === 'restore-drill') return runRestoreDrill({ args, pkgRoot });
52
53
  if (sub === 'asset-check') return runAssetCheck({ args, pkgRoot });
54
+ if (sub === 'backup-owners') return runBackupOwners({ args, pkgRoot });
53
55
  if (sub === 'workspace-up') {
54
56
  const mod = await import('./hub-workspace.mjs');
55
57
  return mod.run({ args, pkgRoot });
@@ -57,7 +59,7 @@ export async function run({ args, pkgRoot }) {
57
59
  }
58
60
 
59
61
  function usage() {
60
- return `maude hub <serve|token|status|deploy|backup|restore-drill|asset-check|workspace-up> [options]
62
+ return `maude hub <serve|token|status|deploy|backup|restore-drill|asset-check|backup-owners|workspace-up> [options]
61
63
 
62
64
  serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
63
65
  Start the self-hostable Yjs sync hub in the current process tree.
@@ -370,6 +372,13 @@ function runDeploy({ args, pkgRoot }) {
370
372
  }
371
373
 
372
374
  const outDir = flags.out ? resolve(String(flags.out)) : process.cwd();
375
+ // CREATE IT. The docs' own command is `--out ./deploy` on a fresh box, where
376
+ // that directory does not exist yet — and the emitter used to write straight
377
+ // into it, so the very first thing a self-hoster runs died on
378
+ // `ENOENT ... docker-compose.yml`. Found by running the operator path end to
379
+ // end (B2/D7) rather than reading it. `process.cwd()` always exists, so this
380
+ // only ever fires for an explicit --out.
381
+ mkdirSync(outDir, { recursive: true });
373
382
 
374
383
  if (target === 'fly') return deployFly({ hubRoot, outDir, flags });
375
384
  return deployDocker({ hubRoot, outDir, flags });
@@ -656,6 +665,67 @@ async function runRestoreDrill({ args, pkgRoot }) {
656
665
  * and no amount of syncing fixes it. Content addressing means we can check it
657
666
  * cheaply — the reference IS the identity.
658
667
  */
668
+ /**
669
+ * `maude hub backup-owners` — who owns the generations in this keyspace.
670
+ *
671
+ * ADVISORY, and deliberately so. Workspace identity (Phase 0 F1) is
672
+ * forward-only: from the upgrade on, every new generation names its owner and
673
+ * the write refusal stops two hubs destroying each other. It says nothing
674
+ * about a bucket whose generations are ALREADY interleaved — those predate the
675
+ * field, are indistinguishable from each other, and are still being pruned
676
+ * across. This reports that so a person can act on it.
677
+ *
678
+ * It never gates anything. Spacing and counts are evidence, not conditions:
679
+ * a merged series that happens to look evenly spaced would pass, and a
680
+ * single-owner series whose hub was down a day would fail.
681
+ */
682
+ async function runBackupOwners({ args, pkgRoot }) {
683
+ const { flags } = parseArgs(args);
684
+ const engine = await loadBackupEngine(pkgRoot);
685
+ const target = resolveTarget(engine, flags);
686
+ const identity = await loadWorkspaceIdentity(pkgRoot);
687
+ const dataDir = flags['data-dir'] ?? process.env.DATA_DIR ?? null;
688
+ const workspace = dataDir && identity ? identity.readWorkspaceId(dataDir) : null;
689
+
690
+ let report;
691
+ try {
692
+ report = await engine.inspectKeyspace(target, { workspace });
693
+ } catch (err) {
694
+ if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, error: err.message })}\n`);
695
+ else process.stderr.write(`maude hub backup-owners: ${err.message}\n`);
696
+ process.exit(1);
697
+ }
698
+
699
+ if (flags.json) {
700
+ process.stdout.write(`${JSON.stringify({ ok: true, ...report }, null, 2)}\n`);
701
+ return;
702
+ }
703
+ process.stdout.write(
704
+ `[backup-owners] ${report.describe}\n` +
705
+ ` generations ${report.generations}\n` +
706
+ ` owners ${report.owners.length ? report.owners.join(', ') : '(none recorded)'}\n` +
707
+ ` unidentified ${report.unidentified}\n` +
708
+ ` verdict ${report.verdict}\n`
709
+ );
710
+ if (report.shared) {
711
+ process.stdout.write(
712
+ '\nTwo hubs sharing one keyspace prune each other\u2019s history. Give each its own\n' +
713
+ 'MAUDE_BACKUP_PREFIX, or its own bucket. Nothing here has changed anything.\n'
714
+ );
715
+ }
716
+ }
717
+
718
+ /** The identity helpers live beside the backup engine; same resolution rule. */
719
+ async function loadWorkspaceIdentity(pkgRoot) {
720
+ for (const candidate of [
721
+ resolve(pkgRoot, 'apps/hub/src/workspace-identity.mjs'),
722
+ resolve(pkgRoot, '../apps/hub/src/workspace-identity.mjs'),
723
+ ]) {
724
+ if (existsSync(candidate)) return import(`file://${candidate}`);
725
+ }
726
+ return null;
727
+ }
728
+
659
729
  async function runAssetCheck({ args, pkgRoot }) {
660
730
  const { flags } = parseArgs(args);
661
731
  const root = resolve(flags.root ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd());