@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.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
+
});
|
package/cli/commands/hub.mjs
CHANGED
|
@@ -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());
|