@hearthlog/tooling 0.1.3 → 0.1.5

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.
@@ -1,395 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * Copy one tenant into a new tenant on its own stack, with new identities.
4
- *
5
- * The source is only read. Both tenants keep working and diverge afterwards;
6
- * nothing is merged back. See docs/tenant-copy.md for the full runbook.
7
- *
8
- * events Export the source app through the Ledger administrator API (the
9
- * source app's own token is neither needed nor rotated), restore it
10
- * into the target Ledger as a new app named after the target site,
11
- * with new log IDs, and verify. Event IDs, timestamps, authors,
12
- * action grouping, fork lineage, and hidden logs are kept. Writes
13
- * `mapping.json` (IDs only, no tokens).
14
- * files Deploy a short-lived copy Worker bound to the source and target
15
- * buckets, copy invoice files, Gallery files, and the source app's
16
- * Gallery publications with app and log IDs rewritten from the
17
- * mapping, compare every object, then delete the Worker.
18
- *
19
- * Usage:
20
- * pnpm tenant:copy -- events --from alpha --to jst [--allow-skipped-forks]
21
- * pnpm tenant:copy -- files --from alpha --to jst --run <directory>
22
- *
23
- * Environment: the administrator tokens of both Ledgers (LEDGER_ADMIN_TOKEN or
24
- * .wrangler/tenants/.admin-token for the shared Ledger, LEDGER_ADMIN_TOKEN_<SITE>
25
- * or .wrangler/tenants/<id>/.admin-token for a site's own Ledger), optional
26
- * LEDGER_BASE_URL / LEDGER_BASE_URL_<SITE> overrides, and a wrangler login for
27
- * `files`.
28
- */
29
-
30
- import { spawn } from "node:child_process";
31
- import { randomBytes } from "node:crypto";
32
- import { mkdir, readFile, writeFile } from "node:fs/promises";
33
- import { join, relative, resolve } from "node:path";
34
- import { fileURLToPath } from "node:url";
35
-
36
- import { dedicatedLedgerBaseUrl, resolveDedicatedAdminToken } from "./dedicated-ledger.js";
37
- import { runWrangler } from "./deploy-runner.js";
38
- import { resolveLedgerAdminToken, resolveLedgerBaseUrl } from "./ledger-credential.js";
39
- import {
40
- infrastructureFor,
41
- loadTenantConfig,
42
- REPOSITORY_ROOT,
43
- siteById,
44
- type InfrastructureDefinition,
45
- type ResolvedSite,
46
- } from "../tenants/tenant-config.js";
47
- import { planFileCopies, validateMapping, type CopyJob, type TenantCopyMapping } from "../tenants/tenant-copy-plan.js";
48
- import { WRANGLER_BASE } from "../tenants/wrangler-base.js";
49
- import { ledgerScript } from "./ledger-scripts.js";
50
-
51
- const COPY_ROOT = join(REPOSITORY_ROOT, ".wrangler", "tenant-copies");
52
- const COPY_WORKER_CONFIG = "wrangler.tenant-copy.jsonc";
53
-
54
- interface Options {
55
- command: "events" | "files" | "help";
56
- from: string;
57
- to: string;
58
- run?: string;
59
- allowSkippedForks: boolean;
60
- }
61
-
62
- interface LedgerAccess {
63
- baseUrl: string;
64
- adminToken: string;
65
- }
66
-
67
- interface AppSummary {
68
- id: string;
69
- name: string;
70
- revokedAt: string | null;
71
- }
72
-
73
- interface LogSummary {
74
- id: string;
75
- headVersion: number;
76
- sourceLogId: string | null;
77
- sourceVersion: number | null;
78
- hiddenAt: string | null;
79
- status: string;
80
- }
81
-
82
- interface OwnedPage { owned: string[]; cursor: string | null }
83
- interface CopyPage { copied: number; skipped: number; bytes: number; cursor: string | null }
84
- interface ComparePage { checked: number; problems: string[]; cursor: string | null }
85
-
86
- export interface CopyRecord {
87
- source: { site: string; appId: string };
88
- target: { site: string; appId: string };
89
- copiedAt: string;
90
- manifestSha256: string;
91
- logs: Array<{ sourceLogId: string; targetLogId: string; headVersion: number; hidden: boolean }>;
92
- skippedLogIds: string[];
93
- }
94
-
95
- export function parseOptions(argv: string[]): Options {
96
- const args = argv.filter((argument) => argument !== "--");
97
- const command = args[0];
98
- const options: Options = {
99
- command: command === "events" || command === "files" ? command : "help",
100
- from: "",
101
- to: "",
102
- allowSkippedForks: false,
103
- };
104
- for (let index = 1; index < args.length; index += 1) {
105
- const argument = args[index];
106
- const value = () => {
107
- const next = args[index + 1]?.trim();
108
- if (!next) throw new Error(`${argument} requires a value`);
109
- index += 1;
110
- return next;
111
- };
112
- switch (argument) {
113
- case "--from": options.from = value(); break;
114
- case "--to": options.to = value(); break;
115
- case "--run": options.run = value(); break;
116
- case "--allow-skipped-forks": options.allowSkippedForks = true; break;
117
- case "--help": case "-h": options.command = "help"; break;
118
- default: throw new Error(`Unknown option: ${argument}`);
119
- }
120
- }
121
- if (options.command !== "help" && (!options.from || !options.to)) throw new Error("--from and --to are required");
122
- if (options.command === "files" && !options.run) throw new Error("files needs --run <directory> from the events step");
123
- return options;
124
- }
125
-
126
- async function ledgerAccess(site: ResolvedSite, shared: InfrastructureDefinition): Promise<LedgerAccess> {
127
- if (site.deployment) {
128
- const variable = site.id.toUpperCase().replace(/-/g, "_");
129
- const adminToken = await resolveDedicatedAdminToken(site.id);
130
- if (!adminToken) throw new Error(`No administrator token for the ${site.id} Ledger`);
131
- return {
132
- baseUrl: process.env[`LEDGER_BASE_URL_${variable}`]?.trim().replace(/\/$/, "") || dedicatedLedgerBaseUrl(site, shared),
133
- adminToken,
134
- };
135
- }
136
- const adminToken = await resolveLedgerAdminToken();
137
- if (!adminToken) throw new Error("No administrator token for the shared Ledger (LEDGER_ADMIN_TOKEN)");
138
- return { baseUrl: resolveLedgerBaseUrl(process.env.LEDGER_BASE_URL, shared.ledgerWorker, shared.workersDevDomain), adminToken };
139
- }
140
-
141
- async function adminApi<T>(ledger: LedgerAccess, route: string): Promise<T> {
142
- const response = await fetch(`${ledger.baseUrl}${route}`, { headers: { Authorization: `Bearer ${ledger.adminToken}` } });
143
- if (!response.ok) throw new Error(`GET ${route} failed with ${response.status}`);
144
- return await response.json() as T;
145
- }
146
-
147
- async function activeApp(ledger: LedgerAccess, name: string): Promise<AppSummary | null> {
148
- const { apps } = await adminApi<{ apps: AppSummary[] }>(ledger, "/admin/v1/apps");
149
- const matches = apps.filter((app) => app.name === name && app.revokedAt === null);
150
- if (matches.length > 1) throw new Error(`Ledger has ${matches.length} active apps named "${name}"`);
151
- return matches[0] ?? null;
152
- }
153
-
154
- /** Run the logical backup script; stdout is its JSON result, never shown. */
155
- function logicalBackup(args: string[], ledger: LedgerAccess): Promise<unknown> {
156
- return new Promise((resolvePromise, reject) => {
157
- const backup = ledgerScript("logical-backup", args);
158
- const child = spawn(backup.command, backup.args, {
159
- cwd: REPOSITORY_ROOT,
160
- env: { ...process.env, LEDGER_BASE_URL: ledger.baseUrl, LEDGER_ADMIN_TOKEN: ledger.adminToken },
161
- stdio: ["ignore", "pipe", "inherit"],
162
- });
163
- let output = "";
164
- child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString("utf8"); });
165
- child.on("error", reject);
166
- child.on("close", (code) => {
167
- if (code !== 0) return reject(new Error(`logical backup ${args[0]} failed with exit code ${code}`));
168
- try {
169
- resolvePromise(JSON.parse(output));
170
- } catch {
171
- reject(new Error(`logical backup ${args[0]} printed no JSON result`));
172
- }
173
- });
174
- });
175
- }
176
-
177
- async function copyEvents(options: Options): Promise<void> {
178
- const { infrastructure, sites } = await loadTenantConfig();
179
- const source = siteById(sites, options.from);
180
- const target = siteById(sites, options.to);
181
- if (!target.deployment) throw new Error(`Target ${target.id} must run on its own stack (tenants/${target.id}/deployment.json)`);
182
- const sourceLedger = await ledgerAccess(source, infrastructure);
183
- const targetLedger = await ledgerAccess(target, infrastructure);
184
- if (sourceLedger.baseUrl === targetLedger.baseUrl) throw new Error("Source and target use the same Ledger");
185
-
186
- const sourceApp = await activeApp(sourceLedger, source.ledgerAppName);
187
- if (!sourceApp) throw new Error(`The source Ledger has no active app named "${source.ledgerAppName}"`);
188
- const existing = await activeApp(targetLedger, target.ledgerAppName);
189
- if (existing) {
190
- throw new Error(
191
- `The ${target.id} Ledger already has an app named "${target.ledgerAppName}" (${existing.id}). ` +
192
- "A copy only goes into a new app; to redo it, revoke that app first (see docs/tenant-copy.md).",
193
- );
194
- }
195
-
196
- const run = join(COPY_ROOT, `${target.id}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}`);
197
- await mkdir(run, { recursive: true, mode: 0o700 });
198
- console.log(`Exporting ${source.id} (${sourceApp.id}) through the administrator API...`);
199
- const exported = await logicalBackup(["export", "--app", sourceApp.id, "--output", join(run, "export")], sourceLedger) as {
200
- manifestSha256: string;
201
- skippedLogIds: string[];
202
- };
203
- if (exported.skippedLogIds.length > 0 && !options.allowSkippedForks) {
204
- throw new Error(
205
- `Forks still being prepared were skipped: ${exported.skippedLogIds.join(", ")}. ` +
206
- "Let them finish and run again, or pass --allow-skipped-forks to copy without them.",
207
- );
208
- }
209
-
210
- console.log(`Restoring into the ${target.id} Ledger as "${target.ledgerAppName}"...`);
211
- const restored = await logicalBackup(["restore", "--input", join(run, "export"), "--name", target.ledgerAppName], targetLedger) as {
212
- app: { id: string };
213
- manifestSha256: string;
214
- logs: Array<{ sourceLogId: string; targetLogId: string }>;
215
- };
216
- if (restored.manifestSha256 !== exported.manifestSha256) throw new Error("Restore read a different export");
217
-
218
- const record = await verifyEvents(sourceLedger, targetLedger, sourceApp.id, restored.app.id, restored.logs);
219
- const result: CopyRecord = {
220
- source: { site: source.id, appId: sourceApp.id },
221
- target: { site: target.id, appId: restored.app.id },
222
- copiedAt: new Date().toISOString(),
223
- manifestSha256: exported.manifestSha256,
224
- logs: record,
225
- skippedLogIds: exported.skippedLogIds,
226
- };
227
- await writeFile(join(run, "mapping.json"), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
228
- console.log(
229
- `\nCopied ${record.length} log(s) into app ${restored.app.id}. Mapping: ${relative(REPOSITORY_ROOT, run)}/mapping.json\n` +
230
- `Next: pnpm tenant:copy -- files --from ${source.id} --to ${target.id} --run ${relative(REPOSITORY_ROOT, run)}\n` +
231
- `Then give the ${target.id} gateway a token: pnpm ensure:tenant -- --site ${target.id} --rotate-token`,
232
- );
233
- }
234
-
235
- /** Heads, lineage, and hidden state match across the mapping; restore already compared every event page. */
236
- async function verifyEvents(
237
- sourceLedger: LedgerAccess,
238
- targetLedger: LedgerAccess,
239
- sourceAppId: string,
240
- targetAppId: string,
241
- pairs: ReadonlyArray<{ sourceLogId: string; targetLogId: string }>,
242
- ): Promise<CopyRecord["logs"]> {
243
- const sourceLogs = new Map((await adminApi<{ logs: LogSummary[] }>(sourceLedger, `/admin/v1/apps/${sourceAppId}/logs`)).logs.map((log) => [log.id, log]));
244
- const targetLogs = new Map((await adminApi<{ logs: LogSummary[] }>(targetLedger, `/admin/v1/apps/${targetAppId}/logs`)).logs.map((log) => [log.id, log]));
245
- const mapped = new Map(pairs.map((pair) => [pair.sourceLogId, pair.targetLogId]));
246
- if (targetLogs.size !== pairs.length) throw new Error(`Target app has ${targetLogs.size} logs, expected ${pairs.length}`);
247
- return pairs.map(({ sourceLogId, targetLogId }) => {
248
- const from = sourceLogs.get(sourceLogId);
249
- const to = targetLogs.get(targetLogId);
250
- if (!from || !to) throw new Error(`Log ${sourceLogId} is missing after the copy`);
251
- // The source may have moved on since the export; the copy holds the exported head.
252
- if (to.headVersion > from.headVersion) throw new Error(`Log ${targetLogId} is ahead of its source`);
253
- const expectedParent = from.sourceLogId ? mapped.get(from.sourceLogId) ?? null : null;
254
- if ((to.sourceLogId ?? null) !== expectedParent || (to.sourceVersion ?? null) !== (from.sourceVersion ?? null)) {
255
- throw new Error(`Fork lineage of ${targetLogId} does not match ${sourceLogId}`);
256
- }
257
- if (Boolean(to.hiddenAt) !== Boolean(from.hiddenAt)) throw new Error(`Hidden state of ${targetLogId} does not match`);
258
- return { sourceLogId, targetLogId, headVersion: to.headVersion, hidden: Boolean(to.hiddenAt) };
259
- });
260
- }
261
-
262
- export function buildCopyWorkerConfig(
263
- name: string,
264
- source: InfrastructureDefinition,
265
- target: InfrastructureDefinition,
266
- compatibilityDate: string,
267
- ): Record<string, unknown> {
268
- return {
269
- name,
270
- main: "tenant-copy/index.ts",
271
- compatibility_date: compatibilityDate,
272
- workers_dev: true,
273
- preview_urls: false,
274
- r2_buckets: [
275
- { binding: "SOURCE_INVOICE_PDFS", bucket_name: source.invoicePdfsBucket },
276
- { binding: "TARGET_INVOICE_PDFS", bucket_name: target.invoicePdfsBucket },
277
- { binding: "SOURCE_GALLERY_FILES", bucket_name: source.galleryFilesBucket },
278
- { binding: "TARGET_GALLERY_FILES", bucket_name: target.galleryFilesBucket },
279
- { binding: "SOURCE_GALLERY_PUBLIC", bucket_name: source.galleryPublicBucket },
280
- { binding: "TARGET_GALLERY_PUBLIC", bucket_name: target.galleryPublicBucket },
281
- ],
282
- };
283
- }
284
-
285
- async function copyFiles(options: Options): Promise<void> {
286
- const { infrastructure, sites } = await loadTenantConfig();
287
- const source = siteById(sites, options.from);
288
- const target = siteById(sites, options.to);
289
- const run = resolve(REPOSITORY_ROOT, options.run!);
290
- const record = JSON.parse(await readFile(join(run, "mapping.json"), "utf8")) as CopyRecord;
291
- if (record.source.site !== source.id || record.target.site !== target.id) throw new Error("The mapping belongs to another copy");
292
- const mapping: TenantCopyMapping = {
293
- sourceAppId: record.source.appId,
294
- targetAppId: record.target.appId,
295
- logs: Object.fromEntries(record.logs.map((log) => [log.sourceLogId, log.targetLogId])),
296
- };
297
- validateMapping(mapping);
298
-
299
- const name = `${target.id}-tenant-copy`;
300
- const config = buildCopyWorkerConfig(name, infrastructureFor(source, infrastructure), infrastructureFor(target, infrastructure), WRANGLER_BASE.compatibility_date);
301
- await writeFile(join(REPOSITORY_ROOT, COPY_WORKER_CONFIG), `${JSON.stringify(config, null, 2)}\n`, "utf8");
302
- const token = randomBytes(32).toString("base64url");
303
- const url = `https://${name}.${infrastructureFor(target, infrastructure).workersDevDomain}`;
304
-
305
- console.log(`Deploying the short-lived copy Worker ${name}...`);
306
- await runWrangler(COPY_WORKER_CONFIG, null, ["deploy"]);
307
- try {
308
- await runWrangler(COPY_WORKER_CONFIG, null, ["secret", "bulk"], { input: `${JSON.stringify({ COPY_TOKEN: token })}\n` });
309
- const report = await copyFilesThrough(workerClient(url, token), mapping);
310
- await writeFile(join(run, "files-report.json"), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
311
- const total = report.jobs.reduce((sum, job) => sum + job.checked, 0);
312
- console.log(`\nCopied and compared ${total} object(s), ${report.publications} publication(s). Report: ${relative(REPOSITORY_ROOT, run)}/files-report.json`);
313
- } finally {
314
- console.log(`Deleting the copy Worker ${name}...`);
315
- await runWrangler(COPY_WORKER_CONFIG, null, ["delete", "--force"]);
316
- }
317
- }
318
-
319
- export type CopyCall = <T>(path: string, body: unknown) => Promise<T>;
320
-
321
- export interface FilesReport {
322
- copiedAt: string;
323
- publications: number;
324
- jobs: Array<CopyJob & { copied: number; skipped: number; bytes: number; checked: number }>;
325
- }
326
-
327
- /** Copy, then compare, every planned prefix through a running copy Worker. */
328
- export async function copyFilesThrough(call: CopyCall, mapping: TenantCopyMapping): Promise<FilesReport> {
329
- const owned: string[] = [];
330
- for (let cursor: string | null | undefined = undefined; cursor !== null;) {
331
- const page: OwnedPage = await call<OwnedPage>("/publications", { appId: mapping.sourceAppId, cursor });
332
- owned.push(...page.owned);
333
- cursor = page.cursor;
334
- }
335
- const report: FilesReport = { copiedAt: new Date().toISOString(), publications: owned.length, jobs: [] };
336
- for (const job of planFileCopies(mapping, owned)) {
337
- const body = { ...job, ...(job.rewriteOwner ? { mapping } : {}) };
338
- let copied = 0, skipped = 0, bytes = 0, checked = 0;
339
- for (let cursor: string | null | undefined = undefined; cursor !== null;) {
340
- const page: CopyPage = await call<CopyPage>("/copy", { ...body, cursor });
341
- copied += page.copied; skipped += page.skipped; bytes += page.bytes; cursor = page.cursor;
342
- }
343
- for (let cursor: string | null | undefined = undefined; cursor !== null;) {
344
- const page: ComparePage = await call<ComparePage>("/compare", { ...body, cursor });
345
- if (page.problems.length > 0) throw new Error(`Copied objects differ under ${job.toPrefix}: ${page.problems.slice(0, 5).join(", ")}`);
346
- checked += page.checked; cursor = page.cursor;
347
- }
348
- report.jobs.push({ ...job, copied, skipped, bytes, checked });
349
- if (checked > 0) console.log(`${job.pair} ${job.fromPrefix} -> ${job.toPrefix}: ${checked} object(s), ${copied} copied`);
350
- }
351
- return report;
352
- }
353
-
354
- export function workerClient(url: string, token: string): CopyCall {
355
- return async function call<T>(path: string, body: unknown): Promise<T> {
356
- // A new workers.dev name can take a few seconds to answer.
357
- for (let attempt = 0; ; attempt += 1) {
358
- const response = await fetch(`${url}${path}`, {
359
- method: "POST",
360
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
361
- body: JSON.stringify(body),
362
- });
363
- if (response.ok) return await response.json() as T;
364
- if (attempt < 10 && (response.status === 404 || response.status >= 520)) {
365
- await new Promise((wait) => setTimeout(wait, 3000));
366
- continue;
367
- }
368
- const detail = await response.text();
369
- throw new Error(`Copy Worker ${path} failed with ${response.status}: ${detail.slice(0, 300)}`);
370
- }
371
- };
372
- }
373
-
374
- function usage(): string {
375
- return `Copy a tenant into a new tenant on its own stack (new identities; the source is only read).
376
-
377
- Usage:
378
- pnpm tenant:copy -- events --from <site> --to <site> [--allow-skipped-forks]
379
- pnpm tenant:copy -- files --from <site> --to <site> --run <directory>`;
380
- }
381
-
382
- export async function main(argv = process.argv.slice(2)): Promise<void> {
383
- const options = parseOptions(argv);
384
- process.chdir(REPOSITORY_ROOT);
385
- if (options.command === "events") await copyEvents(options);
386
- else if (options.command === "files") await copyFiles(options);
387
- else console.log(usage());
388
- }
389
-
390
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
391
- main().catch((error: unknown) => {
392
- console.error(`\n${error instanceof Error ? error.message : String(error)}`);
393
- process.exitCode = 1;
394
- });
395
- }