@acms-subir/core 0.3.15 → 0.3.17

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/cli/sync.mjs ADDED
@@ -0,0 +1,876 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { mkdir, open, unlink } from "node:fs/promises";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { atomicJson, readJson, fail } from "./common.mjs";
6
+ import {
7
+ TYPES,
8
+ PROTOCOL,
9
+ canonical,
10
+ hash,
11
+ differences,
12
+ classify,
13
+ matchEntity,
14
+ } from "../src/lib/sync/protocol.mjs";
15
+
16
+ export const SYNC_HELP = `acms sync — selective environment transfers
17
+ init --from <site-profile> --to <site-profile> choose a direction
18
+ status [--type <category>] [--selected] [--conflicts]
19
+ list [--type <category>] [--calendar <slug>] [--search <text>]
20
+ add <ref>... | add --type <category> | add --all
21
+ add calendar:<slug> --include-events
22
+ remove <ref>... | remove --type <category>
23
+ clear clear selection (never content)
24
+ select interactive numbered selection
25
+ diff [<ref>] [--selected] field-level before/after
26
+ resolve <ref> --use source|destination resolve this exact pair of versions
27
+ resolve <ref> --interactive choose top-level fields
28
+ plan [--mode draft|live] [--out <file>] default: draft
29
+ plan --publish <run-id> | plan --rollback <run-id>
30
+ show <plan-or-run-id> | history
31
+ apply --plan <id> | apply --file <plan.json> [--yes]
32
+ --json --dry-run --cwd <project> --help
33
+ Categories: ${Object.keys(TYPES).join(", ")}
34
+ Both sites must run a sync-capable ACMS version. Use scoped sync keys.
35
+ Plans bind exact payloads to one destination installation and revision.
36
+ --yes never bypasses revisions, conflicts, permissions or dependencies.
37
+ No deletion propagation, account/booking/submission transfer, or code upload.
38
+ Documentation: acms docs sync
39
+ `;
40
+ const configRoot = () =>
41
+ process.env.ACMS_CONFIG_DIR ?? join(homedir(), ".config/acms");
42
+ const UUID = /^[a-f0-9-]{36}$/i;
43
+ function assertId(id) {
44
+ if (!UUID.test(id ?? "")) fail("USAGE", "Supply a valid plan or run ID.");
45
+ return id;
46
+ }
47
+ function endpoint(raw) {
48
+ const u = new URL(raw);
49
+ if (
50
+ u.username ||
51
+ u.password ||
52
+ u.search ||
53
+ u.hash ||
54
+ !["https:", "http:"].includes(u.protocol) ||
55
+ (u.protocol === "http:" &&
56
+ !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))
57
+ )
58
+ fail(
59
+ "USAGE",
60
+ "Sync profiles require HTTPS, except loopback development sites.",
61
+ );
62
+ return u.origin;
63
+ }
64
+ async function profile(name) {
65
+ const p = (await readJson(join(configRoot(), "sites.json"))).sites?.[name];
66
+ if (!p)
67
+ fail("USAGE", `Unknown site profile ${name}. Run acms site connect first.`);
68
+ const token = process.env[p.tokenEnv];
69
+ if (!token) fail("AUTH", `Set ${p.tokenEnv}.`);
70
+ return { name, url: endpoint(p.url), token };
71
+ }
72
+ async function request(
73
+ p,
74
+ path = "/api/sync",
75
+ body,
76
+ method = body ? "POST" : "GET",
77
+ ) {
78
+ let response;
79
+ try {
80
+ response = await fetch(new URL(path, p.url), {
81
+ method,
82
+ headers: {
83
+ Authorization: `Bearer ${p.token}`,
84
+ ...(body ? { "Content-Type": "application/json" } : {}),
85
+ },
86
+ body: body ? JSON.stringify(body) : undefined,
87
+ redirect: "error",
88
+ signal: AbortSignal.timeout(60000),
89
+ });
90
+ } catch {
91
+ fail(
92
+ "NETWORK",
93
+ `Could not reach ${p.url}. Rerun apply with the same plan ID to check an interrupted transfer.`,
94
+ );
95
+ }
96
+ const bytes = await readBounded(response, 10 * 1024 * 1024);
97
+ let value;
98
+ try {
99
+ value = JSON.parse(new TextDecoder().decode(bytes));
100
+ } catch {
101
+ fail(
102
+ "NETWORK",
103
+ "The site did not return a sync response. Upgrade both sites to a sync-capable ACMS version.",
104
+ );
105
+ }
106
+ if (!response.ok)
107
+ fail(
108
+ response.status === 409
109
+ ? "CONFLICT"
110
+ : [401, 403].includes(response.status)
111
+ ? "AUTH"
112
+ : "NETWORK",
113
+ value.error ?? `Sync failed (${response.status}).`,
114
+ );
115
+ return value;
116
+ }
117
+ async function readBounded(response, limit) {
118
+ const reader = response.body?.getReader();
119
+ if (!reader) return new Uint8Array();
120
+ let size = 0;
121
+ const chunks = [];
122
+ try {
123
+ for (;;) {
124
+ const r = await reader.read();
125
+ if (r.done) break;
126
+ size += r.value.length;
127
+ if (size > limit) {
128
+ await reader.cancel();
129
+ fail("TOO_LARGE", "Sync response exceeds its size limit.");
130
+ }
131
+ chunks.push(r.value);
132
+ }
133
+ } finally {
134
+ reader.releaseLock();
135
+ }
136
+ const out = new Uint8Array(size);
137
+ let i = 0;
138
+ for (const chunk of chunks) {
139
+ out.set(chunk, i);
140
+ i += chunk.length;
141
+ }
142
+ return out;
143
+ }
144
+ async function snapshots(source, destination) {
145
+ const results = await Promise.allSettled([
146
+ request(source, undefined, { op: "snapshot" }),
147
+ request(destination, undefined, { op: "snapshot" }),
148
+ ]);
149
+ for (const result of results)
150
+ if (result.status === "rejected") throw result.reason;
151
+ const [from, to] = results.map((r) => r.value);
152
+ if (from.protocol !== PROTOCOL || to.protocol !== PROTOCOL)
153
+ fail("USAGE", "Incompatible sync protocol. Upgrade both sites.");
154
+ if (from.siteId === to.siteId)
155
+ fail("USAGE", "Both profiles point to the same installation.");
156
+ return { from, to };
157
+ }
158
+ function rowsFor(from, to, state) {
159
+ return from.entities.map((entity) => {
160
+ const match = matchEntity(entity, to.entities),
161
+ saved = state.resolutions[entity.key];
162
+ const resolution =
163
+ saved &&
164
+ saved.source === entity.hash &&
165
+ saved.destination === (match.entity?.stateHash ?? null)
166
+ ? saved
167
+ : null;
168
+ const decision = classify(
169
+ entity,
170
+ match,
171
+ state.baselines[entity.key],
172
+ resolution,
173
+ );
174
+ return {
175
+ entity,
176
+ target: match.entity,
177
+ linked: match.linked,
178
+ resolution,
179
+ ...decision,
180
+ selected: state.selected.includes(entity.key),
181
+ excluded: state.excluded.includes(entity.key),
182
+ };
183
+ });
184
+ }
185
+ function kindMatches(kind, type) {
186
+ return type === "booking-config"
187
+ ? ["booking", "booking-settings"].includes(kind)
188
+ : TYPES[type] === kind;
189
+ }
190
+ function filterRows(rows, flags) {
191
+ if (flags.type && !TYPES[flags.type])
192
+ fail("USAGE", `Unknown category. Choose ${Object.keys(TYPES).join(", ")}.`);
193
+ return rows.filter(
194
+ (r) =>
195
+ (!flags.type || kindMatches(r.entity.kind, flags.type)) &&
196
+ (!flags.selected || r.selected) &&
197
+ (!flags.conflicts || r.action === "conflict") &&
198
+ (!flags.search ||
199
+ `${r.entity.ref} ${r.entity.data.title ?? ""}`
200
+ .toLowerCase()
201
+ .includes(flags.search.toLowerCase())),
202
+ );
203
+ }
204
+ function findRow(rows, ref) {
205
+ const found = rows.filter(
206
+ (r) =>
207
+ r.entity.key === ref ||
208
+ r.entity.ref === ref ||
209
+ (r.entity.kind === "post" && `post:${r.entity.data.slug}` === ref),
210
+ );
211
+ if (found.length !== 1)
212
+ fail(
213
+ "USAGE",
214
+ found.length
215
+ ? `Ambiguous reference ${ref}; use the full key from sync list.`
216
+ : `Item ${ref} not found. Run sync list.`,
217
+ );
218
+ return found[0];
219
+ }
220
+ function closure(rows, state) {
221
+ const picked = new Map(),
222
+ issues = [];
223
+ function include(key, parent) {
224
+ if (picked.has(key)) return;
225
+ const row = rows.find((r) => r.entity.key === key);
226
+ if (!row) {
227
+ issues.push(`Missing dependency ${key}`);
228
+ return;
229
+ }
230
+ if (row.excluded) {
231
+ issues.push(
232
+ `${parent ?? "Selection"} requires excluded ${row.entity.ref}`,
233
+ );
234
+ return;
235
+ }
236
+ if (row.action === "preserve") {
237
+ if (!parent) return;
238
+ if (!row.target) {
239
+ issues.push(`Dependency ${row.entity.ref} has no destination item`);
240
+ return;
241
+ }
242
+ // Existing destination is deliberately retained, but its alias must be
243
+ // linked for references in the parent to resolve.
244
+ picked.set(key, { ...row, action: "skip", preserved: true });
245
+ return;
246
+ }
247
+ picked.set(key, { ...row, dependency: !!parent });
248
+ if (row.action === "conflict")
249
+ issues.push(`${row.entity.ref}: ${row.reason}`);
250
+ for (const dep of row.entity.dependencies) include(dep, row.entity.ref);
251
+ }
252
+ state.selected.forEach((k) => include(k));
253
+ return { picked: [...picked.values()], issues };
254
+ }
255
+ async function prompt(question) {
256
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
257
+ fail(
258
+ "USAGE",
259
+ "Interactive sync requires a terminal. Use explicit selection flags or --yes for apply.",
260
+ );
261
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
262
+ try {
263
+ return (await rl.question(question)).trim();
264
+ } finally {
265
+ rl.close();
266
+ }
267
+ }
268
+ function summary(rows) {
269
+ return rows.map((r) => ({
270
+ ref: r.entity.ref,
271
+ key: r.entity.key,
272
+ type: r.entity.kind,
273
+ action: r.action,
274
+ reason: r.reason,
275
+ selected: r.selected,
276
+ dependency: !!r.dependency,
277
+ hash: r.entity.hash,
278
+ }));
279
+ }
280
+
281
+ export async function syncCommand(command, args, flags) {
282
+ const commands = [
283
+ "init",
284
+ "status",
285
+ "list",
286
+ "add",
287
+ "remove",
288
+ "clear",
289
+ "select",
290
+ "diff",
291
+ "resolve",
292
+ "plan",
293
+ "show",
294
+ "history",
295
+ "apply",
296
+ ];
297
+ if (!commands.includes(command))
298
+ fail("USAGE", "Unknown sync command. Run acms sync --help.");
299
+ if (flags.force)
300
+ fail(
301
+ "USAGE",
302
+ "Sync does not support --force. Review and resolve conflicts.",
303
+ );
304
+ if (flags.json && (command === "select" || flags.interactive))
305
+ fail("USAGE", "Interactive selection and --json cannot be combined.");
306
+ const root = join(configRoot(), "sync", await hash(resolve(process.cwd())));
307
+ await mkdir(root, { recursive: true, mode: 0o700 });
308
+ const lockPath = join(root, "lock");
309
+ let lock;
310
+ try {
311
+ lock = await open(lockPath, "wx", 0o600);
312
+ await lock.writeFile(String(process.pid));
313
+ } catch (e) {
314
+ if (e.code === "EEXIST")
315
+ fail(
316
+ "CONFLICT",
317
+ `Another sync command is using this workspace. If it crashed, verify its PID in ${lockPath} before removing that lock.`,
318
+ );
319
+ throw e;
320
+ }
321
+ try {
322
+ return await run(command, args, flags, root);
323
+ } finally {
324
+ await lock.close();
325
+ await unlink(lockPath);
326
+ }
327
+ }
328
+ async function run(command, args, flags, root) {
329
+ const current = join(root, "current.json");
330
+ if (command === "init") {
331
+ if (!flags.from || !flags.to)
332
+ fail("USAGE", "Use sync init --from <profile> --to <profile>.");
333
+ const [source, destination] = await Promise.all([
334
+ profile(flags.from),
335
+ profile(flags.to),
336
+ ]);
337
+ if (source.url === destination.url)
338
+ fail("USAGE", "Choose two different sites.");
339
+ const { from, to } = await snapshots(source, destination);
340
+ const pair = await hash({
341
+ from: from.siteId,
342
+ to: to.siteId,
343
+ source: source.url,
344
+ destination: destination.url,
345
+ });
346
+ const path = join(root, `${pair}.json`);
347
+ let state;
348
+ try {
349
+ state = await readJson(path);
350
+ } catch (e) {
351
+ if (e.code !== "ENOENT") throw e;
352
+ state = {
353
+ version: 1,
354
+ source: { name: source.name, url: source.url, siteId: from.siteId },
355
+ destination: {
356
+ name: destination.name,
357
+ url: destination.url,
358
+ siteId: to.siteId,
359
+ },
360
+ selected: [],
361
+ excluded: [],
362
+ resolutions: {},
363
+ baselines: {},
364
+ plans: {},
365
+ };
366
+ }
367
+ if (!flags["dry-run"]) {
368
+ await atomicJson(path, state);
369
+ await atomicJson(current, { pair });
370
+ }
371
+ return {
372
+ direction: `${source.name} → ${destination.name}`,
373
+ destination: destination.url,
374
+ selected: state.selected.length,
375
+ dryRun: !!flags["dry-run"],
376
+ };
377
+ }
378
+ let active;
379
+ try {
380
+ active = await readJson(current);
381
+ } catch {
382
+ fail("USAGE", "Run sync init --from <profile> --to <profile> first.");
383
+ }
384
+ if (!/^[a-f0-9]{64}$/.test(active.pair))
385
+ fail("USAGE", "Invalid local sync workspace.");
386
+ const path = join(root, `${active.pair}.json`),
387
+ state = await readJson(path);
388
+ const [source, destination] = await Promise.all([
389
+ profile(state.source.name),
390
+ profile(state.destination.name),
391
+ ]);
392
+ if (
393
+ source.url !== state.source.url ||
394
+ destination.url !== state.destination.url
395
+ )
396
+ fail("CONFLICT", "A profile URL changed. Run sync init again.");
397
+ const direction = `${source.name} → ${destination.name}`,
398
+ base = { direction, destination: destination.url };
399
+ const save = async () => {
400
+ if (!flags["dry-run"]) await atomicJson(path, state);
401
+ };
402
+ if (command === "history")
403
+ return {
404
+ ...base,
405
+ ...(await request(destination, undefined, { op: "history" })),
406
+ };
407
+ if (command === "show")
408
+ return {
409
+ ...base,
410
+ ...(await request(destination, undefined, {
411
+ op: "show",
412
+ id: assertId(args[0]),
413
+ })),
414
+ };
415
+ if (command === "clear") {
416
+ state.selected = [];
417
+ state.excluded = [];
418
+ state.resolutions = {};
419
+ await save();
420
+ return { ...base, selected: [], dryRun: !!flags["dry-run"] };
421
+ }
422
+ if (command === "apply")
423
+ return apply(source, destination, state, flags, save, base);
424
+ if (command === "plan" && (flags.publish || flags.rollback)) {
425
+ if (flags.publish && flags.rollback)
426
+ fail("USAGE", "Choose publication or rollback.");
427
+ const op = flags.publish ? "publish" : "rollback",
428
+ id = assertId(flags.publish ?? flags.rollback);
429
+ const plan = await request(destination, undefined, {
430
+ op,
431
+ id,
432
+ validateOnly: !!flags["dry-run"],
433
+ });
434
+ if (flags["dry-run"]) return { ...base, ...plan, dryRun: true };
435
+ return persistPlan(plan, state, flags, save, base);
436
+ }
437
+ const { from, to } = await snapshots(source, destination);
438
+ if (
439
+ from.siteId !== state.source.siteId ||
440
+ to.siteId !== state.destination.siteId
441
+ )
442
+ fail(
443
+ "CONFLICT",
444
+ "An installation identity changed. Run sync init to establish a new baseline.",
445
+ );
446
+ const rows = rowsFor(from, to, state);
447
+ let filtered = filterRows(rows, flags);
448
+ if (flags.calendar) {
449
+ const cal = findRow(rows, `calendar:${flags.calendar}`);
450
+ filtered = filtered.filter(
451
+ (r) =>
452
+ r.entity.kind === "event" &&
453
+ r.entity.dependencies.includes(cal.entity.key),
454
+ );
455
+ }
456
+ if (command === "status" || command === "list") {
457
+ const extra = closure(rows, state);
458
+ return {
459
+ ...base,
460
+ items: summary(filtered.filter((r) => r.entity.kind !== "author")),
461
+ dependencies: summary(extra.picked.filter((r) => r.dependency)),
462
+ issues: extra.issues,
463
+ destinationOnly: to.entities
464
+ .filter((e) => !from.entities.some((s) => matchEntity(s, [e]).entity))
465
+ .map((e) => ({ ref: e.ref, action: "preserve" })),
466
+ };
467
+ }
468
+ if (command === "diff") {
469
+ const picked = args.length
470
+ ? [findRow(rows, args[0])]
471
+ : flags.selected
472
+ ? closure(rows, state).picked
473
+ : filtered;
474
+ return {
475
+ ...base,
476
+ changes: picked.map((r) => ({
477
+ ref: r.entity.ref,
478
+ action: r.action,
479
+ fields: differences(
480
+ r.target?.data,
481
+ r.resolution?.data ?? r.entity.data,
482
+ ),
483
+ })),
484
+ };
485
+ }
486
+ if (["add", "remove", "select"].includes(command)) {
487
+ let picked = args.map((ref) => findRow(rows, ref));
488
+ if (flags.type || flags.all)
489
+ picked = filtered.filter(
490
+ (r) =>
491
+ r.entity.kind !== "author" &&
492
+ (command === "remove" || !["skip", "preserve"].includes(r.action)),
493
+ );
494
+ if (command === "select") {
495
+ const options = rows.filter(
496
+ (r) =>
497
+ r.entity.kind !== "author" &&
498
+ !["skip", "preserve"].includes(r.action),
499
+ );
500
+ process.stderr.write(
501
+ `${direction} (${destination.url})\n` +
502
+ options
503
+ .map(
504
+ (r, i) =>
505
+ `${i + 1}. [${r.selected ? "x" : " "}] ${r.entity.ref} (${r.action})`,
506
+ )
507
+ .join("\n") +
508
+ "\n",
509
+ );
510
+ const answer = await prompt(
511
+ "Select item numbers separated by commas (blank keeps current selection): ",
512
+ );
513
+ if (!answer) return { ...base, selected: state.selected };
514
+ const numbers = answer.split(",").map((n) => Number(n.trim()));
515
+ if (
516
+ numbers.some((n) => !Number.isInteger(n) || n < 1 || n > options.length)
517
+ )
518
+ fail("USAGE", "Invalid selection.");
519
+ picked = numbers.map((n) => options[n - 1]);
520
+ state.selected = [];
521
+ }
522
+ if (!picked.length && !flags.all && !flags.type)
523
+ fail("USAGE", "Select item references, --type, or --all.");
524
+ if (flags["include-events"])
525
+ for (const cal of [...picked].filter((r) => r.entity.kind === "calendar"))
526
+ picked.push(
527
+ ...rows.filter(
528
+ (r) =>
529
+ r.entity.kind === "event" &&
530
+ r.entity.dependencies.includes(cal.entity.key),
531
+ ),
532
+ );
533
+ for (const row of picked) {
534
+ const key = row.entity.key;
535
+ delete state.resolutions[key];
536
+ if (command === "remove") {
537
+ state.selected = state.selected.filter((k) => k !== key);
538
+ state.excluded = [...new Set([...state.excluded, key])];
539
+ } else {
540
+ state.selected = [...new Set([...state.selected, key])];
541
+ state.excluded = state.excluded.filter((k) => k !== key);
542
+ }
543
+ }
544
+ await save();
545
+ return {
546
+ ...base,
547
+ selected: state.selected.map(
548
+ (k) => rows.find((r) => r.entity.key === k)?.entity.ref ?? k,
549
+ ),
550
+ dryRun: !!flags["dry-run"],
551
+ };
552
+ }
553
+ if (command === "resolve") {
554
+ const row = findRow(rows, args[0]);
555
+ let use = flags.use,
556
+ data;
557
+ if (flags.interactive) {
558
+ if (!row.target)
559
+ fail("USAGE", "This item has no destination version to merge.");
560
+ data = structuredClone(row.entity.data);
561
+ for (const field of Object.keys({
562
+ ...row.target.data,
563
+ ...row.entity.data,
564
+ }))
565
+ if (
566
+ canonical(row.target.data[field] ?? null) !==
567
+ canonical(row.entity.data[field] ?? null)
568
+ ) {
569
+ process.stderr.write(
570
+ JSON.stringify(
571
+ {
572
+ field,
573
+ source: row.entity.data[field],
574
+ destination: row.target.data[field],
575
+ },
576
+ null,
577
+ 2,
578
+ ) + "\n",
579
+ );
580
+ const choice = await prompt(
581
+ `${field}: source or destination [destination]? `,
582
+ );
583
+ if (choice && choice !== "source" && choice !== "destination")
584
+ fail("USAGE", "Choose source or destination.");
585
+ if (choice !== "source") data[field] = row.target.data[field];
586
+ }
587
+ use = "fields";
588
+ }
589
+ if (!["source", "destination", "fields"].includes(use))
590
+ fail("USAGE", "Use --use source|destination or --interactive.");
591
+ state.resolutions[row.entity.key] = {
592
+ use,
593
+ ...(data ? { data } : {}),
594
+ source: row.entity.hash,
595
+ destination: row.target?.stateHash ?? null,
596
+ };
597
+ if (use !== "destination") {
598
+ state.selected = [...new Set([...state.selected, row.entity.key])];
599
+ state.excluded = state.excluded.filter((k) => k !== row.entity.key);
600
+ }
601
+ await save();
602
+ return {
603
+ ...base,
604
+ ref: row.entity.ref,
605
+ resolution: use,
606
+ dryRun: !!flags["dry-run"],
607
+ };
608
+ }
609
+ if (command === "plan") {
610
+ const { picked, issues } = closure(rows, state);
611
+ if (issues.length)
612
+ fail(
613
+ "CONFLICT",
614
+ "Resolve selection conflicts and dependencies before planning.",
615
+ issues,
616
+ );
617
+ if (!picked.length)
618
+ fail("USAGE", "Nothing selected. Use sync add or sync select.");
619
+ const mode = flags.mode ?? "draft";
620
+ if (!["draft", "live"].includes(mode))
621
+ fail("USAGE", "Mode must be draft or live.");
622
+ const choices = picked.map((r) => ({
623
+ key: r.entity.key,
624
+ kind: r.entity.kind,
625
+ aliases: r.entity.aliases,
626
+ data: r.preserved ? r.target.data : (r.resolution?.data ?? r.entity.data),
627
+ targetId: r.target?.id ?? null,
628
+ expected: r.target?.stateHash ?? null,
629
+ action: r.action,
630
+ sourceHash: r.entity.hash,
631
+ }));
632
+ const input = {
633
+ sourceSite: from.siteId,
634
+ sourceUrl: source.url,
635
+ destinationSite: to.siteId,
636
+ revision: to.revision,
637
+ mode,
638
+ choices,
639
+ };
640
+ const plan = await request(destination, undefined, {
641
+ op: "plan",
642
+ input,
643
+ validateOnly: !!flags["dry-run"],
644
+ });
645
+ if (flags["dry-run"]) return { ...base, ...plan, dryRun: true };
646
+ const files = picked.flatMap((r) => r.entity.files ?? []);
647
+ state.plans[plan.id] = {
648
+ files,
649
+ keys: picked.filter((r) => !r.preserved).map((r) => r.entity.key),
650
+ };
651
+ return persistPlan(plan, state, flags, save, base);
652
+ }
653
+ }
654
+ async function persistPlan(plan, state, flags, save, base) {
655
+ state.plans[plan.id] = { ...state.plans[plan.id], digest: plan.digest };
656
+ await save();
657
+ if (flags.out)
658
+ await atomicJson(resolve(flags.out), {
659
+ format: "acms-sync-plan/1",
660
+ id: plan.id,
661
+ digest: plan.digest,
662
+ source: state.source,
663
+ destination: state.destination,
664
+ plan,
665
+ files: state.plans[plan.id].files ?? [],
666
+ });
667
+ return {
668
+ ...base,
669
+ id: plan.id,
670
+ digest: plan.digest,
671
+ mode: plan.mode,
672
+ purpose: plan.purpose,
673
+ changes: plan.changes,
674
+ apply: `acms sync apply --plan ${plan.id}`,
675
+ };
676
+ }
677
+ async function apply(source, destination, state, flags, save, base) {
678
+ if (!!flags.plan === !!flags.file)
679
+ fail("USAGE", "Use exactly one of --plan or --file.");
680
+ let id = flags.plan,
681
+ digest,
682
+ files = [];
683
+ if (flags.file) {
684
+ const saved = await readJson(resolve(flags.file));
685
+ if (
686
+ saved.format !== "acms-sync-plan/1" ||
687
+ saved.destination?.url !== destination.url ||
688
+ saved.destination?.siteId !== state.destination.siteId ||
689
+ saved.source?.url !== source.url ||
690
+ saved.source?.siteId !== state.source.siteId
691
+ )
692
+ fail("CONFLICT", "This plan belongs to a different environment pair.");
693
+ id = saved.id;
694
+ digest = saved.digest;
695
+ files = saved.files ?? [];
696
+ } else {
697
+ digest = state.plans[id]?.digest;
698
+ files = state.plans[id]?.files ?? [];
699
+ }
700
+ assertId(id);
701
+ const stored = await request(destination, undefined, { op: "show", id });
702
+ if (digest && stored.digest !== digest)
703
+ fail("CONFLICT", "Saved plan digest differs from the destination plan.");
704
+ digest = stored.digest;
705
+ if (
706
+ stored.body.destinationSite !== state.destination.siteId ||
707
+ stored.body.sourceSite !== state.source.siteId
708
+ )
709
+ fail(
710
+ "CONFLICT",
711
+ "Plan installation identities do not match this workspace.",
712
+ );
713
+ if (flags["dry-run"])
714
+ return { ...base, id, dryRun: true, changes: stored.body.changes };
715
+ if (!stored.result && !flags.yes) {
716
+ process.stderr.write(
717
+ renderSync({
718
+ ...base,
719
+ id,
720
+ mode: stored.body.mode,
721
+ changes: stored.body.changes,
722
+ }) + "\n",
723
+ );
724
+ if ((await prompt(`Apply to ${destination.url}? Type yes: `)) !== "yes")
725
+ return { ...base, id, cancelled: true };
726
+ }
727
+ if (!stored.result) {
728
+ const objects = stored.body.operations
729
+ .filter(
730
+ (o) => o.kind === "media" && ["create", "update"].includes(o.action),
731
+ )
732
+ .flatMap((o) => o.data.objects);
733
+ for (const object of objects) {
734
+ const path = `/api/sync/media?key=${encodeURIComponent(object.key)}`;
735
+ const head = await fetch(new URL(path, destination.url), {
736
+ method: "HEAD",
737
+ headers: { Authorization: `Bearer ${destination.token}` },
738
+ redirect: "error",
739
+ signal: AbortSignal.timeout(60000),
740
+ });
741
+ if (head.ok && head.headers.get("etag") === object.digest) continue;
742
+ if (head.status !== 404 && !head.ok)
743
+ fail(
744
+ head.status === 401 || head.status === 403 ? "AUTH" : "NETWORK",
745
+ `Could not verify media on ${destination.url}.`,
746
+ );
747
+ let file = files.find(
748
+ (f) => f.key === object.key && f.digest === object.digest,
749
+ );
750
+ if (!file) {
751
+ const snap = await request(source, undefined, { op: "snapshot" });
752
+ file = snap.entities
753
+ .flatMap((e) => e.files ?? [])
754
+ .find((f) => f.key === object.key && f.digest === object.digest);
755
+ }
756
+ if (
757
+ !file ||
758
+ file.sourceKey.includes("..") ||
759
+ file.sourceKey.startsWith("/")
760
+ )
761
+ fail(
762
+ "CONFLICT",
763
+ "The planned source media is no longer available. Generate a new plan.",
764
+ );
765
+ const response = await fetch(
766
+ new URL(
767
+ `/api/sync/media?key=${encodeURIComponent(file.sourceKey)}`,
768
+ source.url,
769
+ ),
770
+ {
771
+ headers: { Authorization: `Bearer ${source.token}` },
772
+ redirect: "error",
773
+ signal: AbortSignal.timeout(60000),
774
+ },
775
+ );
776
+ if (!response.ok) fail("NETWORK", "Could not read planned source media.");
777
+ const bytes = await readBounded(response, 16 * 1024 * 1024);
778
+ if ((await hash(bytes)) !== object.digest)
779
+ fail(
780
+ "CONFLICT",
781
+ "Source media changed after planning. Generate a new plan.",
782
+ );
783
+ const upload = await fetch(new URL(path, destination.url), {
784
+ method: "PUT",
785
+ headers: {
786
+ Authorization: `Bearer ${destination.token}`,
787
+ "Content-Type":
788
+ response.headers.get("content-type") ?? "application/octet-stream",
789
+ },
790
+ body: bytes,
791
+ redirect: "error",
792
+ signal: AbortSignal.timeout(60000),
793
+ });
794
+ if (!upload.ok)
795
+ fail(
796
+ "NETWORK",
797
+ "Media upload failed. Reapply the same plan to resume.",
798
+ );
799
+ await upload.body?.cancel();
800
+ }
801
+ }
802
+ const result = await request(destination, undefined, {
803
+ op: "apply",
804
+ id,
805
+ digest,
806
+ });
807
+ // Reconcile from the server even after a lost response / local crash.
808
+ if (state.plans[id]?.reconciled) return { ...base, ...result };
809
+ const currentSource = await request(source, undefined, { op: "snapshot" });
810
+ if (currentSource.siteId !== state.source.siteId)
811
+ fail(
812
+ "CONFLICT",
813
+ "Transfer completed, but the source installation changed before local reconciliation. Reinitialize the workspace.",
814
+ );
815
+ for (const item of result.items) {
816
+ if ((state.baselines[item.key]?.completedAt ?? 0) > result.completedAt)
817
+ continue;
818
+ if (item.action === "delete") {
819
+ delete state.baselines[item.key];
820
+ continue;
821
+ }
822
+ if (item.destinationHash && item.sourceHash)
823
+ state.baselines[item.key] = {
824
+ source: item.sourceHash,
825
+ destination: item.destinationHash,
826
+ completedAt: result.completedAt,
827
+ };
828
+ if (
829
+ currentSource.entities.find((e) => e.key === item.key)?.hash ===
830
+ item.sourceHash
831
+ ) {
832
+ state.selected = state.selected.filter((k) => k !== item.key);
833
+ delete state.resolutions[item.key];
834
+ }
835
+ }
836
+ state.plans[id] = { ...state.plans[id], digest, reconciled: true };
837
+ await save();
838
+ return { ...base, ...result };
839
+ }
840
+ export function renderSync(data) {
841
+ const lines = [];
842
+ if (data.direction) lines.push(`${data.direction} (${data.destination})`);
843
+ if (data.id)
844
+ lines.push(
845
+ `${data.purpose ?? "Plan"} ${data.id}${data.mode ? ` · ${data.mode}` : ""}`,
846
+ );
847
+ if (data.dryRun) lines.push("Dry run — no transfer applied.");
848
+ for (const item of data.items ?? [])
849
+ lines.push(
850
+ `${item.selected ? "[x]" : "[ ]"} ${String(item.action).padEnd(9)} ${item.ref ?? item.key}${item.reason ? ` — ${item.reason}` : ""}`,
851
+ );
852
+ for (const dep of data.dependencies ?? [])
853
+ lines.push(` requires ${dep.ref} (${dep.action})`);
854
+ for (const issue of data.issues ?? []) lines.push(`Conflict: ${issue}`);
855
+ for (const change of data.changes ?? []) {
856
+ lines.push(
857
+ `${change.action ?? ""} ${change.ref}${change.effect ? ` (${change.effect})` : ""}`,
858
+ );
859
+ for (const field of change.fields ?? [])
860
+ lines.push(
861
+ ` ${field.field}: ${JSON.stringify(field.before)} → ${JSON.stringify(field.after)}`,
862
+ );
863
+ }
864
+ if (Array.isArray(data.selected))
865
+ lines.push(`Selected: ${data.selected.join(", ") || "(none)"}`);
866
+ if (data.apply) lines.push(`Apply: ${data.apply}`);
867
+ if (data.completedAt) lines.push("Transfer completed.");
868
+ if (
869
+ !data.items &&
870
+ !data.changes &&
871
+ !Array.isArray(data.selected) &&
872
+ !data.completedAt
873
+ )
874
+ lines.push(JSON.stringify(data, null, 2));
875
+ return lines.join("\n");
876
+ }