@1agh/maude 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// `maude hub workspace-up` — Cloud Phase 4 Task 1, the effects layer.
|
|
2
|
+
//
|
|
3
|
+
// The decisions live in `cli/lib/workspace-plan.mjs` (pure, tested without a
|
|
4
|
+
// VPS). This file does the parts that genuinely touch the world: read config,
|
|
5
|
+
// write files, boot the stack, run the verification plan, print the result.
|
|
6
|
+
//
|
|
7
|
+
// IDEMPOTENT BY CONSTRUCTION. Re-running is the upgrade path: the rendered
|
|
8
|
+
// files are regenerated, but `.env` secrets that already exist are REUSED, not
|
|
9
|
+
// re-minted. Re-minting HUB_SECRET on every run would silently lock out every
|
|
10
|
+
// peer that already has a token, and it would do it to the person whose
|
|
11
|
+
// instinct after a failed run is to try again.
|
|
12
|
+
//
|
|
13
|
+
// It does NOT claim to own the deployment afterwards — see `operatorDuties`.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { parseArgs } from '../lib/argv.mjs';
|
|
21
|
+
import {
|
|
22
|
+
envEntries,
|
|
23
|
+
operatorDuties,
|
|
24
|
+
renderCaddyfile,
|
|
25
|
+
renderCompose,
|
|
26
|
+
renderEnv,
|
|
27
|
+
validateWorkspaceConfig,
|
|
28
|
+
verificationPlan,
|
|
29
|
+
} from '../lib/workspace-plan.mjs';
|
|
30
|
+
|
|
31
|
+
export function usage() {
|
|
32
|
+
return `maude hub workspace-up [options]
|
|
33
|
+
|
|
34
|
+
Stand up a self-hosted Maude WORKSPACE — a hub that owns the project, commits
|
|
35
|
+
autosaves, and stores media in object storage — and verify it actually works
|
|
36
|
+
before saying so.
|
|
37
|
+
|
|
38
|
+
--domain HOST public hostname (design.acme.com)
|
|
39
|
+
--acme-email EMAIL Let's Encrypt contact
|
|
40
|
+
--admin-email EMAIL the first person who can sign in
|
|
41
|
+
--admin-password PASS their initial password (>= 12 chars; generated if omitted)
|
|
42
|
+
--s3-endpoint URL object storage (R2 / MinIO / S3)
|
|
43
|
+
--s3-bucket NAME
|
|
44
|
+
--s3-access-key-id ID
|
|
45
|
+
--s3-secret-access-key SECRET
|
|
46
|
+
--s3-region REGION default "auto"
|
|
47
|
+
--dev-minio run a local MinIO under the compose 'dev' profile
|
|
48
|
+
--seed-repo URL clone an existing project; omit to start fresh
|
|
49
|
+
--image-tag TAG default "latest" — pin it before you rely on this
|
|
50
|
+
--config FILE read all of the above from a JSON file
|
|
51
|
+
--out DIR where to write compose/Caddyfile/.env (default: cwd)
|
|
52
|
+
--dry-run render + print the plan, touch nothing else
|
|
53
|
+
--json machine-readable result
|
|
54
|
+
|
|
55
|
+
Re-running is the UPGRADE path: files are regenerated, existing secrets in
|
|
56
|
+
.env are REUSED (re-minting HUB_SECRET would lock out every peer that already
|
|
57
|
+
has a token).
|
|
58
|
+
|
|
59
|
+
What it does NOT do: own the deployment. It scaffolds and verifies once.
|
|
60
|
+
Rotation, backups, upgrades and the bill stay with you — the run prints the
|
|
61
|
+
list.
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function run({ args, pkgRoot }) {
|
|
66
|
+
const { flags } = parseArgs(args, { booleans: ['dry-run', 'json', 'dev-minio'] });
|
|
67
|
+
if (flags.help) {
|
|
68
|
+
process.stdout.write(usage());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const outDir = resolve(String(flags.out ?? process.cwd()));
|
|
73
|
+
const raw = flags.config ? readConfigFile(String(flags.config)) : {};
|
|
74
|
+
const merged = {
|
|
75
|
+
domain: flags.domain ?? raw.domain,
|
|
76
|
+
acmeEmail: flags['acme-email'] ?? raw.acmeEmail,
|
|
77
|
+
adminEmail: flags['admin-email'] ?? raw.adminEmail,
|
|
78
|
+
...((flags['admin-password'] ?? raw.adminPassword)
|
|
79
|
+
? { adminPassword: flags['admin-password'] ?? raw.adminPassword }
|
|
80
|
+
: {}),
|
|
81
|
+
devMinio: flags['dev-minio'] === true || raw.devMinio === true,
|
|
82
|
+
seedRepo: flags['seed-repo'] ?? raw.seedRepo,
|
|
83
|
+
imageTag: flags['image-tag'] ?? raw.imageTag,
|
|
84
|
+
...(flags['s3-endpoint'] || raw.s3
|
|
85
|
+
? {
|
|
86
|
+
s3: {
|
|
87
|
+
endpoint: flags['s3-endpoint'] ?? raw.s3?.endpoint,
|
|
88
|
+
bucket: flags['s3-bucket'] ?? raw.s3?.bucket,
|
|
89
|
+
accessKeyId: flags['s3-access-key-id'] ?? raw.s3?.accessKeyId,
|
|
90
|
+
secretAccessKey: flags['s3-secret-access-key'] ?? raw.s3?.secretAccessKey,
|
|
91
|
+
region: flags['s3-region'] ?? raw.s3?.region,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const { ok, errors, config } = validateWorkspaceConfig(merged);
|
|
98
|
+
if (!ok) {
|
|
99
|
+
if (flags.json) {
|
|
100
|
+
process.stdout.write(`${JSON.stringify({ ok: false, errors }, null, 2)}\n`);
|
|
101
|
+
} else {
|
|
102
|
+
process.stderr.write('maude hub workspace-up: the configuration is not usable yet.\n\n');
|
|
103
|
+
for (const e of errors) process.stderr.write(` • ${e}\n`);
|
|
104
|
+
process.stderr.write('\nRun with --help for the full list of options.\n');
|
|
105
|
+
}
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Reuse existing secrets — re-minting on a re-run would lock out every peer
|
|
110
|
+
// that already holds a token, and re-running is exactly what someone does
|
|
111
|
+
// after a failed attempt.
|
|
112
|
+
const existing = readExistingEnv(resolve(outDir, '.env'));
|
|
113
|
+
const hubSecret = existing.HUB_SECRET || randomBytes(32).toString('hex');
|
|
114
|
+
const adminPassword = config.adminPassword || existing.MAUDE_ADMIN_PASSWORD || generatePassword();
|
|
115
|
+
const reusedSecret = Boolean(existing.HUB_SECRET);
|
|
116
|
+
|
|
117
|
+
const entries = envEntries(config, { hubSecret, adminPassword });
|
|
118
|
+
const files = [
|
|
119
|
+
{ name: '.env', body: renderEnv(entries), mode: 0o600 },
|
|
120
|
+
{ name: 'docker-compose.yml', body: renderCompose(config), mode: 0o644 },
|
|
121
|
+
{ name: 'Caddyfile', body: renderCaddyfile(config), mode: 0o644 },
|
|
122
|
+
];
|
|
123
|
+
const plan = verificationPlan(config);
|
|
124
|
+
const duties = operatorDuties(config);
|
|
125
|
+
|
|
126
|
+
if (flags['dry-run']) {
|
|
127
|
+
const result = {
|
|
128
|
+
ok: true,
|
|
129
|
+
dryRun: true,
|
|
130
|
+
outDir,
|
|
131
|
+
files: files.map((f) => ({ name: f.name, bytes: Buffer.byteLength(f.body), mode: f.mode })),
|
|
132
|
+
verification: plan.map((s) => ({ id: s.id, title: s.title })),
|
|
133
|
+
duties: duties.map((d) => d.title),
|
|
134
|
+
reusedSecret,
|
|
135
|
+
};
|
|
136
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
137
|
+
else printDryRun({ config, outDir, files, plan, duties, reusedSecret });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mkdirSync(outDir, { recursive: true });
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
const path = resolve(outDir, f.name);
|
|
144
|
+
writeFileSync(path, f.body, { encoding: 'utf8', mode: f.mode });
|
|
145
|
+
try {
|
|
146
|
+
chmodSync(path, f.mode);
|
|
147
|
+
} catch {
|
|
148
|
+
/* windows — best effort */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dockerAvailable = await which('docker');
|
|
153
|
+
if (!dockerAvailable) {
|
|
154
|
+
if (flags.json) {
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
`${JSON.stringify({ ok: false, wrote: files.map((f) => f.name), error: 'docker not found' }, null, 2)}\n`
|
|
157
|
+
);
|
|
158
|
+
} else {
|
|
159
|
+
process.stdout.write(
|
|
160
|
+
`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n\n` +
|
|
161
|
+
'Docker is not on PATH, so the stack was not started and NOTHING WAS VERIFIED.\n' +
|
|
162
|
+
'Install Docker and run:\n\n' +
|
|
163
|
+
` cd ${outDir} && docker compose up -d\n\n` +
|
|
164
|
+
'Then re-run this command to verify the deployment.\n'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
process.stdout.write(`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n`);
|
|
171
|
+
process.stdout.write('Starting the stack…\n');
|
|
172
|
+
const up = await sh('docker', ['compose', 'up', '-d'], { cwd: outDir });
|
|
173
|
+
if (up.code !== 0) {
|
|
174
|
+
process.stderr.write(`docker compose up failed:\n${up.stderr}\n`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verification is the deliverable. A URL printed without a proven round-trip
|
|
179
|
+
// tells the operator something this command does not know.
|
|
180
|
+
process.stdout.write('\nVerifying — this is the part that matters:\n');
|
|
181
|
+
const results = [];
|
|
182
|
+
let failed = 0;
|
|
183
|
+
for (const step of plan) {
|
|
184
|
+
const outcome = await runVerification(step, {
|
|
185
|
+
config,
|
|
186
|
+
hubSecret,
|
|
187
|
+
adminPassword,
|
|
188
|
+
outDir,
|
|
189
|
+
pkgRoot,
|
|
190
|
+
});
|
|
191
|
+
results.push({ id: step.id, title: step.title, ...outcome });
|
|
192
|
+
const mark = outcome.ok ? '✓' : outcome.skipped ? '–' : '✗';
|
|
193
|
+
process.stdout.write(` ${mark} ${step.title}${outcome.note ? ` — ${outcome.note}` : ''}\n`);
|
|
194
|
+
if (!outcome.ok && !outcome.skipped) failed++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (flags.json) {
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
`${JSON.stringify({ ok: failed === 0, outDir, verification: results, duties }, null, 2)}\n`
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
printDuties(duties);
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
failed === 0
|
|
205
|
+
? `\nWorkspace verified: https://${config.domain}\n`
|
|
206
|
+
: `\n${failed} check(s) did NOT pass. The stack is running but is not proven — fix and re-run.\n`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (failed > 0) process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ------------------------------------------------------------------ helpers
|
|
213
|
+
|
|
214
|
+
function readConfigFile(path) {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(readFileSync(resolve(path), 'utf8'));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
`maude hub workspace-up: couldn't read --config ${path}: ${err.message}\n`
|
|
220
|
+
);
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Parse an existing `.env` so a re-run reuses secrets instead of re-minting. */
|
|
226
|
+
function readExistingEnv(path) {
|
|
227
|
+
if (!existsSync(path)) return {};
|
|
228
|
+
const out = {};
|
|
229
|
+
try {
|
|
230
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
231
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
232
|
+
if (m) out[m[1]] = m[2];
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
/* unreadable → treat as absent */
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Readable, high-entropy, and safe to paste — no ambiguous glyphs. */
|
|
241
|
+
function generatePassword() {
|
|
242
|
+
const alphabet = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
243
|
+
const bytes = randomBytes(24);
|
|
244
|
+
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sh(cmd, args, opts = {}) {
|
|
248
|
+
return new Promise((resolvePromise) => {
|
|
249
|
+
const child = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
250
|
+
let stdout = '';
|
|
251
|
+
let stderr = '';
|
|
252
|
+
child.stdout.on('data', (d) => {
|
|
253
|
+
stdout += d;
|
|
254
|
+
});
|
|
255
|
+
child.stderr.on('data', (d) => {
|
|
256
|
+
stderr += d;
|
|
257
|
+
});
|
|
258
|
+
child.on('error', () => resolvePromise({ code: 127, stdout, stderr: 'spawn failed' }));
|
|
259
|
+
child.on('close', (code) => resolvePromise({ code: code ?? 1, stdout, stderr }));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function which(bin) {
|
|
264
|
+
const res = await sh(process.platform === 'win32' ? 'where' : 'which', [bin]);
|
|
265
|
+
return res.code === 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Execute one verification step.
|
|
270
|
+
*
|
|
271
|
+
* A step this build cannot yet perform reports `skipped` with a reason — it
|
|
272
|
+
* NEVER reports success. Counting an unrun check as passed is the single
|
|
273
|
+
* fastest way to make a verification suite worthless.
|
|
274
|
+
*/
|
|
275
|
+
async function runVerification(step, { config, hubSecret }) {
|
|
276
|
+
const base = `https://${config.domain}`;
|
|
277
|
+
switch (step.id) {
|
|
278
|
+
case 'health': {
|
|
279
|
+
const res = await tryFetch(`${base}/health`);
|
|
280
|
+
return res.ok ? { ok: true } : { ok: false, note: res.note };
|
|
281
|
+
}
|
|
282
|
+
case 'admin-claimed': {
|
|
283
|
+
const res = await tryFetch(`${base}/admin/api/status`, {
|
|
284
|
+
headers: { Authorization: `Bearer ${hubSecret}` },
|
|
285
|
+
});
|
|
286
|
+
return res.ok ? { ok: true } : { ok: false, note: res.note };
|
|
287
|
+
}
|
|
288
|
+
default:
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
skipped: true,
|
|
292
|
+
note: 'not yet automated — verify by hand, then close it in the plan',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function tryFetch(url, init) {
|
|
298
|
+
try {
|
|
299
|
+
const ctrl = new AbortController();
|
|
300
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
303
|
+
return res.ok ? { ok: true } : { ok: false, note: `HTTP ${res.status}` };
|
|
304
|
+
} finally {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return { ok: false, note: err.name === 'AbortError' ? 'timed out' : 'unreachable' };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function printDryRun({ config, outDir, files, plan, duties, reusedSecret }) {
|
|
313
|
+
process.stdout.write(
|
|
314
|
+
`maude hub workspace-up — DRY RUN, nothing was written\n\n` +
|
|
315
|
+
` workspace https://${config.domain}\n` +
|
|
316
|
+
` first user ${config.adminEmail}\n` +
|
|
317
|
+
` storage ${config.s3 ? `${config.s3.bucket} @ ${config.s3.endpoint}${config.s3.dev ? ' (dev MinIO)' : ''}` : 'none — media stays in git'}\n` +
|
|
318
|
+
` project ${config.seedRepo ?? 'starts fresh'}\n` +
|
|
319
|
+
` image ghcr.io/1agh/maude-hub:${config.imageTag}\n` +
|
|
320
|
+
` out ${outDir}\n` +
|
|
321
|
+
(reusedSecret ? ' secrets reusing HUB_SECRET from the existing .env\n' : '') +
|
|
322
|
+
'\nWould write:\n'
|
|
323
|
+
);
|
|
324
|
+
for (const f of files) {
|
|
325
|
+
process.stdout.write(
|
|
326
|
+
` ${f.name.padEnd(20)} ${Buffer.byteLength(f.body)} bytes mode ${f.mode.toString(8)}\n`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write('\nWould then verify:\n');
|
|
330
|
+
for (const s of plan) process.stdout.write(` • ${s.title} — ${s.detail}\n`);
|
|
331
|
+
printDuties(duties);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function printDuties(duties) {
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
'\nThis command scaffolded and verified your workspace once. It does not\noperate it. What stays yours:\n\n'
|
|
337
|
+
);
|
|
338
|
+
for (const d of duties) {
|
|
339
|
+
process.stdout.write(` ${d.title}\n ${d.detail}\n`);
|
|
340
|
+
}
|
|
341
|
+
}
|
package/cli/commands/hub.mjs
CHANGED
|
@@ -5,12 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
9
17
|
import { resolve } from 'node:path';
|
|
10
18
|
|
|
11
19
|
import { parseArgs } from '../lib/argv.mjs';
|
|
12
20
|
|
|
13
|
-
const SUBCOMMANDS = new Set([
|
|
21
|
+
const SUBCOMMANDS = new Set([
|
|
22
|
+
'serve',
|
|
23
|
+
'token',
|
|
24
|
+
'status',
|
|
25
|
+
'deploy',
|
|
26
|
+
'backup',
|
|
27
|
+
'restore-drill',
|
|
28
|
+
'asset-check',
|
|
29
|
+
'workspace-up',
|
|
30
|
+
'help',
|
|
31
|
+
]);
|
|
14
32
|
|
|
15
33
|
export async function run({ args, pkgRoot }) {
|
|
16
34
|
const { positional } = parseArgs(args);
|
|
@@ -29,10 +47,17 @@ export async function run({ args, pkgRoot }) {
|
|
|
29
47
|
if (sub === 'token') return runToken({ args, pkgRoot });
|
|
30
48
|
if (sub === 'status') return runStatus({ args });
|
|
31
49
|
if (sub === 'deploy') return runDeploy({ args, pkgRoot });
|
|
50
|
+
if (sub === 'backup') return runBackupNow({ args, pkgRoot });
|
|
51
|
+
if (sub === 'restore-drill') return runRestoreDrill({ args, pkgRoot });
|
|
52
|
+
if (sub === 'asset-check') return runAssetCheck({ args, pkgRoot });
|
|
53
|
+
if (sub === 'workspace-up') {
|
|
54
|
+
const mod = await import('./hub-workspace.mjs');
|
|
55
|
+
return mod.run({ args, pkgRoot });
|
|
56
|
+
}
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
function usage() {
|
|
35
|
-
return `maude hub <serve|token|status|deploy> [options]
|
|
60
|
+
return `maude hub <serve|token|status|deploy|backup|restore-drill|asset-check|workspace-up> [options]
|
|
36
61
|
|
|
37
62
|
serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
|
|
38
63
|
Start the self-hostable Yjs sync hub in the current process tree.
|
|
@@ -76,6 +101,47 @@ function usage() {
|
|
|
76
101
|
HTTP GET <url>/health, print uptime/version/token-count/peers. URL
|
|
77
102
|
defaults to http://localhost:1234. --json emits the raw response.
|
|
78
103
|
|
|
104
|
+
backup [--data PATH] [--target file://DIR] [--keep N]
|
|
105
|
+
Take one snapshot generation now (VACUUM INTO → gzip → target) and
|
|
106
|
+
prune to the retention limit. Target defaults to $MAUDE_BACKUP_TARGET,
|
|
107
|
+
or the MAUDE_S3_* env set (R2 / MinIO / S3).
|
|
108
|
+
|
|
109
|
+
restore-drill [--target file://DIR] [--sentinel DOCNAME] [--keep-dir] [--json]
|
|
110
|
+
Restore the NEWEST complete backup generation into a throwaway
|
|
111
|
+
directory and verify it: SQLite integrity_check, document count, and
|
|
112
|
+
(with --sentinel) that one named document came back with a non-empty
|
|
113
|
+
payload. Never touches the live data dir. Exits non-zero on failure so
|
|
114
|
+
it can be a CI step.
|
|
115
|
+
|
|
116
|
+
Run this on a schedule. A backup nobody has restored is a hypothesis:
|
|
117
|
+
a database that restores readable-but-empty looks exactly like a
|
|
118
|
+
working one until the day you need it.
|
|
119
|
+
|
|
120
|
+
asset-check [--root PATH] [--json]
|
|
121
|
+
Every 'assets/<sha8>' reference in the project must resolve — locally,
|
|
122
|
+
in the bucket, or both. Reports DANGLING references (referenced by a
|
|
123
|
+
canvas, present in neither) and, with a bucket configured, assets that
|
|
124
|
+
exist locally but were never mirrored.
|
|
125
|
+
|
|
126
|
+
A dangling reference is a permanently broken canvas: the 'assets/'
|
|
127
|
+
prefix is NEVER garbage-collected, and bucket lifecycle/expiry rules
|
|
128
|
+
must be OFF for it, because a canvas in git history can reference an
|
|
129
|
+
asset no current canvas does. Exits non-zero when anything dangles.
|
|
130
|
+
|
|
131
|
+
workspace-up [--domain HOST] [--admin-email EMAIL] [--s3-* ...] [--dry-run]
|
|
132
|
+
Stand up a self-hosted WORKSPACE — a hub that owns the project, commits
|
|
133
|
+
autosaves, and stores media in object storage — and verify it works
|
|
134
|
+
before saying so. Renders compose + Caddyfile + .env (0600), boots the
|
|
135
|
+
stack, then runs a verification plan (health, admin credential, sign-in,
|
|
136
|
+
canvas round-trip, git commit, object storage + no-expiry, restore
|
|
137
|
+
drill). Re-running is the upgrade path and REUSES existing secrets.
|
|
138
|
+
|
|
139
|
+
It scaffolds and verifies once — it does not operate the deployment.
|
|
140
|
+
Rotation, backups, upgrades and the bill stay with you; the run prints
|
|
141
|
+
that list rather than saying "done".
|
|
142
|
+
|
|
143
|
+
Run 'maude hub workspace-up --help' for every option.
|
|
144
|
+
|
|
79
145
|
deploy <fly|docker> [--name NAME] [--region CODE] [--tag TAG] [--out DIR] [--force]
|
|
80
146
|
Emit the deploy templates for the chosen target into the current
|
|
81
147
|
directory (or --out DIR) with placeholders substituted, then print the
|
|
@@ -468,3 +534,259 @@ function formatDuration(seconds) {
|
|
|
468
534
|
const h = Math.floor(m / 60);
|
|
469
535
|
return `${h}h${(m % 60).toString().padStart(2, '0')}m${s.toString().padStart(2, '0')}s`;
|
|
470
536
|
}
|
|
537
|
+
|
|
538
|
+
// --------------------------------------------------------- backup + drill
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolve the hub's backup engine. It lives in apps/hub (it is hub-internal,
|
|
542
|
+
* not part of the published npm surface), so it is imported by path rather
|
|
543
|
+
* than as a package — the same way runServe reaches the hub entry point.
|
|
544
|
+
*/
|
|
545
|
+
async function loadBackupEngine(pkgRoot) {
|
|
546
|
+
const candidates = [
|
|
547
|
+
resolve(pkgRoot, 'apps/hub/src/backup.mjs'),
|
|
548
|
+
resolve(pkgRoot, '../apps/hub/src/backup.mjs'),
|
|
549
|
+
];
|
|
550
|
+
for (const candidate of candidates) {
|
|
551
|
+
if (existsSync(candidate)) return import(`file://${candidate}`);
|
|
552
|
+
}
|
|
553
|
+
process.stderr.write(
|
|
554
|
+
'maude hub: the backup engine (apps/hub/src/backup.mjs) was not found.\n' +
|
|
555
|
+
'This verb runs from a full checkout or the hub image, not from a plain npm install.\n'
|
|
556
|
+
);
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resolveTarget(engine, flags) {
|
|
561
|
+
const explicit = flags.target;
|
|
562
|
+
const target = explicit
|
|
563
|
+
? explicit.startsWith('file://')
|
|
564
|
+
? engine.fileTarget(explicit)
|
|
565
|
+
: null
|
|
566
|
+
: engine.targetFromEnv();
|
|
567
|
+
if (!target) {
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
'maude hub: no backup target configured.\n' +
|
|
570
|
+
' --target file:///path/to/dir, or set MAUDE_BACKUP_TARGET,\n' +
|
|
571
|
+
' or the MAUDE_S3_{ENDPOINT,BUCKET,ACCESS_KEY_ID,SECRET_ACCESS_KEY} env set.\n'
|
|
572
|
+
);
|
|
573
|
+
process.exit(2);
|
|
574
|
+
}
|
|
575
|
+
return target;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function runBackupNow({ args, pkgRoot }) {
|
|
579
|
+
const { flags } = parseArgs(args);
|
|
580
|
+
const engine = await loadBackupEngine(pkgRoot);
|
|
581
|
+
const dataDir = resolve(flags.data ?? process.env.DATA_DIR ?? 'data');
|
|
582
|
+
const target = resolveTarget(engine, flags);
|
|
583
|
+
const keep = Number(flags.keep ?? 14);
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const result = await engine.runBackup({ dataDir, target, keep });
|
|
587
|
+
process.stdout.write(`backed up ${dataDir} → ${target.describe}\n ${result.prefix}\n`);
|
|
588
|
+
for (const f of result.files) {
|
|
589
|
+
process.stdout.write(` ${f.name.padEnd(12)} ${(f.bytes / 1024).toFixed(1)} KB gz\n`);
|
|
590
|
+
}
|
|
591
|
+
if (result.pruned.length > 0) {
|
|
592
|
+
process.stdout.write(` pruned ${result.pruned.length} old generation(s)\n`);
|
|
593
|
+
}
|
|
594
|
+
} catch (err) {
|
|
595
|
+
process.stderr.write(`maude hub backup: ${err.message}\n`);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function runRestoreDrill({ args, pkgRoot }) {
|
|
601
|
+
const { flags } = parseArgs(args);
|
|
602
|
+
const engine = await loadBackupEngine(pkgRoot);
|
|
603
|
+
const target = resolveTarget(engine, flags);
|
|
604
|
+
const scratchDir = resolve(
|
|
605
|
+
flags['scratch-dir'] ?? `${process.env.TMPDIR ?? '/tmp'}/maude-restore-drill-${process.pid}`
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
let verdict;
|
|
609
|
+
try {
|
|
610
|
+
verdict = await engine.restoreDrill({
|
|
611
|
+
target,
|
|
612
|
+
scratchDir,
|
|
613
|
+
sentinel: flags.sentinel,
|
|
614
|
+
which: flags.generation,
|
|
615
|
+
});
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, error: err.message })}\n`);
|
|
618
|
+
else process.stderr.write(`maude hub restore-drill: ${err.message}\n`);
|
|
619
|
+
process.exit(1);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (flags.json) {
|
|
623
|
+
process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
|
|
624
|
+
} else {
|
|
625
|
+
process.stdout.write(
|
|
626
|
+
`restore drill — ${target.describe}\n` +
|
|
627
|
+
` generation ${verdict.generation}\n` +
|
|
628
|
+
` restored ${verdict.restored.join(', ')}\n` +
|
|
629
|
+
` integrity ${verdict.integrity}\n` +
|
|
630
|
+
` documents ${verdict.documents}\n` +
|
|
631
|
+
(verdict.sentinel
|
|
632
|
+
? ` sentinel ${verdict.sentinel.name} — ${verdict.sentinel.present ? `${verdict.sentinel.bytes} bytes` : 'ABSENT'}\n`
|
|
633
|
+
: '') +
|
|
634
|
+
` ${verdict.ok ? 'PASS' : 'FAIL'}\n`
|
|
635
|
+
);
|
|
636
|
+
for (const p of verdict.problems) process.stderr.write(` ! ${p}\n`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!flags['keep-dir']) {
|
|
640
|
+
try {
|
|
641
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
642
|
+
} catch {
|
|
643
|
+
/* best effort */
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (!verdict.ok) process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ------------------------------------------------------- asset integrity
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Every `assets/<sha8>` a canvas points at must resolve somewhere.
|
|
653
|
+
*
|
|
654
|
+
* The failure this catches is quiet and permanent: a reference whose bytes
|
|
655
|
+
* exist on nobody's disk and in no bucket renders as a broken image forever,
|
|
656
|
+
* and no amount of syncing fixes it. Content addressing means we can check it
|
|
657
|
+
* cheaply — the reference IS the identity.
|
|
658
|
+
*/
|
|
659
|
+
async function runAssetCheck({ args, pkgRoot }) {
|
|
660
|
+
const { flags } = parseArgs(args);
|
|
661
|
+
const root = resolve(flags.root ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
|
|
662
|
+
const designRoot = resolveDesignRoot(root);
|
|
663
|
+
if (!designRoot) {
|
|
664
|
+
process.stderr.write(`maude hub asset-check: no .design/ found under ${root}\n`);
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Scan every text file under the design root for asset references. Regex over
|
|
669
|
+
// the whole tree rather than parsing TSX: a reference is a reference whether
|
|
670
|
+
// it appears in JSX, a meta sidecar, or a CSS url().
|
|
671
|
+
const referenced = new Map(); // key -> Set(files that reference it)
|
|
672
|
+
const REF = /assets\/([0-9a-f]{8})(?:\.[A-Za-z0-9]{1,8})?/g;
|
|
673
|
+
const SKIP_DIRS = new Set(['assets', 'node_modules', '.git']);
|
|
674
|
+
const walk = (dir) => {
|
|
675
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
676
|
+
if (entry.name.startsWith('.') && entry.name !== '.design') continue;
|
|
677
|
+
// Per-machine runtime state (DDR-115's `_*` taxonomy) is not a canvas
|
|
678
|
+
// reference — `_generate-history.json` recording an asset it once made
|
|
679
|
+
// is not a broken canvas, and scanning it would report noise as damage.
|
|
680
|
+
if (entry.name.startsWith('_')) continue;
|
|
681
|
+
const abs = resolve(dir, entry.name);
|
|
682
|
+
if (entry.isDirectory()) {
|
|
683
|
+
if (!SKIP_DIRS.has(entry.name)) walk(abs);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!/\.(tsx|jsx|ts|js|json|css|svg|md|html)$/i.test(entry.name)) continue;
|
|
687
|
+
let text;
|
|
688
|
+
try {
|
|
689
|
+
text = readFileSync(abs, 'utf8');
|
|
690
|
+
} catch {
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
for (const m of text.matchAll(REF)) {
|
|
694
|
+
const key = m[0].slice('assets/'.length);
|
|
695
|
+
if (!referenced.has(key)) referenced.set(key, new Set());
|
|
696
|
+
referenced.get(key).add(abs.slice(root.length + 1));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
walk(designRoot);
|
|
701
|
+
|
|
702
|
+
// Local presence, keyed by the LEADING 8 hex chars of the filename.
|
|
703
|
+
//
|
|
704
|
+
// Splitting on '.' looks equivalent and is not: the real corpus contains
|
|
705
|
+
// `<sha8>-<label>.<ext>` (ingested footage) and `<sha8>.<part>.json`
|
|
706
|
+
// (sidecars), so `name.split('.')[0]` yields `deadbeef-cloud` and the asset
|
|
707
|
+
// reads as missing. That produced a false DANGLING report against this repo's
|
|
708
|
+
// own design root — the reference was fine and the index was wrong.
|
|
709
|
+
const assetsDir = resolve(designRoot, 'assets');
|
|
710
|
+
const localBySha = new Map();
|
|
711
|
+
if (existsSync(assetsDir)) {
|
|
712
|
+
for (const name of readdirSync(assetsDir)) {
|
|
713
|
+
const sha = name.match(/^([0-9a-f]{8})(?:[-.]|$)/)?.[1];
|
|
714
|
+
if (sha && !localBySha.has(sha)) localBySha.set(sha, name);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const engine = await loadBackupEngine(pkgRoot);
|
|
719
|
+
const s3mod = await import(`file://${resolve(pkgRoot, 'apps/hub/src/s3.mjs')}`).catch(() => null);
|
|
720
|
+
const s3 = s3mod?.s3ConfigFromEnv?.() ?? null;
|
|
721
|
+
void engine;
|
|
722
|
+
|
|
723
|
+
const dangling = [];
|
|
724
|
+
const localOnly = [];
|
|
725
|
+
let inBucket = 0;
|
|
726
|
+
|
|
727
|
+
for (const [key, files] of referenced) {
|
|
728
|
+
const sha = key.split('.')[0];
|
|
729
|
+
const local = localBySha.has(sha);
|
|
730
|
+
let remote = false;
|
|
731
|
+
if (s3) {
|
|
732
|
+
try {
|
|
733
|
+
remote = !!(await s3mod.headObject(s3, `assets/${localBySha.get(sha) ?? key}`));
|
|
734
|
+
} catch {
|
|
735
|
+
remote = false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (remote) inBucket++;
|
|
739
|
+
if (!local && !remote) dangling.push({ key, files: [...files] });
|
|
740
|
+
else if (local && s3 && !remote) localOnly.push({ key, files: [...files] });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const report = {
|
|
744
|
+
designRoot: designRoot.slice(root.length + 1),
|
|
745
|
+
referenced: referenced.size,
|
|
746
|
+
local: localBySha.size,
|
|
747
|
+
bucket: s3 ? `s3://${s3.bucket}` : null,
|
|
748
|
+
inBucket: s3 ? inBucket : null,
|
|
749
|
+
dangling,
|
|
750
|
+
notMirrored: s3 ? localOnly : null,
|
|
751
|
+
ok: dangling.length === 0,
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
if (flags.json) {
|
|
755
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
756
|
+
} else {
|
|
757
|
+
process.stdout.write(
|
|
758
|
+
`asset check — ${report.designRoot}\n` +
|
|
759
|
+
` referenced ${report.referenced}\n` +
|
|
760
|
+
` on disk ${report.local}\n` +
|
|
761
|
+
(s3
|
|
762
|
+
? ` in bucket ${inBucket}/${report.referenced} (${report.bucket})\n`
|
|
763
|
+
: ' bucket not configured (set MAUDE_S3_* to check the mirror)\n')
|
|
764
|
+
);
|
|
765
|
+
for (const d of dangling) {
|
|
766
|
+
process.stderr.write(` DANGLING assets/${d.key} — referenced by ${d.files.join(', ')}\n`);
|
|
767
|
+
}
|
|
768
|
+
if (localOnly.length > 0) {
|
|
769
|
+
process.stdout.write(
|
|
770
|
+
` ${localOnly.length} asset(s) exist locally but are NOT mirrored — ` +
|
|
771
|
+
'a second machine cannot resolve them yet.\n'
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
process.stdout.write(` ${report.ok ? 'OK' : 'FAILED'}\n`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (!report.ok) process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** `.design/` under `root`, honouring a config-declared designRoot. */
|
|
781
|
+
function resolveDesignRoot(root) {
|
|
782
|
+
const configured = (() => {
|
|
783
|
+
for (const candidate of ['.design/config.json', '.maude/config.json']) {
|
|
784
|
+
const abs = resolve(root, candidate);
|
|
785
|
+
if (existsSync(abs)) return resolve(root, candidate, '..');
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
})();
|
|
789
|
+
if (configured) return configured;
|
|
790
|
+
const fallback = resolve(root, '.design');
|
|
791
|
+
return existsSync(fallback) ? fallback : null;
|
|
792
|
+
}
|