@cairn-tool/cairn 2.1.0 → 3.1.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.
Files changed (75) hide show
  1. package/README.md +4 -3
  2. package/dist/agent/conditionals.d.ts +43 -0
  3. package/dist/agent/conditionals.js +314 -0
  4. package/dist/agent/conditionals.js.map +1 -0
  5. package/dist/agent/install/config.d.ts +47 -0
  6. package/dist/agent/install/config.js +156 -0
  7. package/dist/agent/install/config.js.map +1 -0
  8. package/dist/agent/install/index.d.ts +95 -14
  9. package/dist/agent/install/index.js +460 -188
  10. package/dist/agent/install/index.js.map +1 -1
  11. package/dist/agent/parser.js +15 -41
  12. package/dist/agent/parser.js.map +1 -1
  13. package/dist/agent/render.d.ts +9 -1
  14. package/dist/agent/render.js +12 -9
  15. package/dist/agent/render.js.map +1 -1
  16. package/dist/agent/types.d.ts +2 -1
  17. package/dist/agent/types.js.map +1 -1
  18. package/dist/agent/verify/compare.d.ts +10 -1
  19. package/dist/agent/verify/compare.js +1 -1
  20. package/dist/agent/verify/compare.js.map +1 -1
  21. package/dist/agent/verify/config.js +1 -1
  22. package/dist/agent/verify/config.js.map +1 -1
  23. package/dist/agent/verify/index.js +20 -4
  24. package/dist/agent/verify/index.js.map +1 -1
  25. package/dist/agent/verify/resolve.d.ts +1 -0
  26. package/dist/agent/verify/resolve.js +2 -2
  27. package/dist/agent/verify/resolve.js.map +1 -1
  28. package/dist/cli.js +40 -5
  29. package/dist/cli.js.map +1 -1
  30. package/dist/commands/agent-install.d.ts +15 -1
  31. package/dist/commands/agent-install.js +121 -24
  32. package/dist/commands/agent-install.js.map +1 -1
  33. package/dist/commands/jira.d.ts +17 -0
  34. package/dist/commands/jira.js +195 -0
  35. package/dist/commands/jira.js.map +1 -0
  36. package/dist/config.js +2 -0
  37. package/dist/config.js.map +1 -1
  38. package/dist/contract/registry.js +39 -2
  39. package/dist/contract/registry.js.map +1 -1
  40. package/dist/contract/schemas/index.js +2 -0
  41. package/dist/contract/schemas/index.js.map +1 -1
  42. package/dist/contract/schemas/jira.d.ts +2 -0
  43. package/dist/contract/schemas/jira.js +102 -0
  44. package/dist/contract/schemas/jira.js.map +1 -0
  45. package/dist/jira/adf/diagnostics.d.ts +77 -0
  46. package/dist/jira/adf/diagnostics.js +100 -0
  47. package/dist/jira/adf/diagnostics.js.map +1 -0
  48. package/dist/jira/adf/from-markdown.d.ts +6 -0
  49. package/dist/jira/adf/from-markdown.js +650 -0
  50. package/dist/jira/adf/from-markdown.js.map +1 -0
  51. package/dist/jira/adf/inspect.d.ts +11 -0
  52. package/dist/jira/adf/inspect.js +56 -0
  53. package/dist/jira/adf/inspect.js.map +1 -0
  54. package/dist/jira/adf/profile.d.ts +101 -0
  55. package/dist/jira/adf/profile.js +248 -0
  56. package/dist/jira/adf/profile.js.map +1 -0
  57. package/dist/jira/adf/read.d.ts +33 -0
  58. package/dist/jira/adf/read.js +250 -0
  59. package/dist/jira/adf/read.js.map +1 -0
  60. package/dist/jira/adf/serialize.d.ts +5 -0
  61. package/dist/jira/adf/serialize.js +54 -0
  62. package/dist/jira/adf/serialize.js.map +1 -0
  63. package/dist/jira/adf/to-markdown.d.ts +14 -0
  64. package/dist/jira/adf/to-markdown.js +512 -0
  65. package/dist/jira/adf/to-markdown.js.map +1 -0
  66. package/dist/jira/adf/types.d.ts +73 -0
  67. package/dist/jira/adf/types.js +2 -0
  68. package/dist/jira/adf/types.js.map +1 -0
  69. package/dist/jira/adf/validate.d.ts +7 -0
  70. package/dist/jira/adf/validate.js +182 -0
  71. package/dist/jira/adf/validate.js.map +1 -0
  72. package/dist/mapping-quality.d.ts +18 -0
  73. package/dist/mapping-quality.js +12 -0
  74. package/dist/mapping-quality.js.map +1 -0
  75. package/package.json +13 -10
@@ -37,6 +37,30 @@ export const INSTALL_SCOPES = ["user", "project"];
37
37
  export function registeredPluginKeys(registration) {
38
38
  return registration.pluginKeys ?? (registration.pluginKey ? [registration.pluginKey] : []);
39
39
  }
40
+ /**
41
+ * An install's identity within one destination.
42
+ *
43
+ * Keyed on the bundle **and** the target, which is the whole fix: keyed on the
44
+ * bundle name alone, a second target's install read the first's inventory as
45
+ * its own stale files and deleted them.
46
+ *
47
+ * NUL-separated for the reason `artifactKey` and `sessionKey` are — no half can
48
+ * contain one. `profile` is redundant while `locationFor(target, scope)` fixes
49
+ * it, and is included so a future target declaring two locations for one scope
50
+ * does not silently collide. `destination` is deliberately absent: the key is
51
+ * only ever compared within one manifest file.
52
+ */
53
+ export function installKey(record) {
54
+ return [record.bundle.name, record.target, record.profile, record.scope].join("\0");
55
+ }
56
+ /** Byte comparison, never `localeCompare`: these bytes are generated output. */
57
+ function sortRecords(records) {
58
+ return [...records].sort((a, b) => {
59
+ const left = installKey(a);
60
+ const right = installKey(b);
61
+ return left < right ? -1 : left > right ? 1 : 0;
62
+ });
63
+ }
40
64
  function sha256(content) {
41
65
  return crypto.createHash("sha256").update(content).digest("hex");
42
66
  }
@@ -185,17 +209,13 @@ function inventoryOf(artifacts) {
185
209
  }))
186
210
  .sort(byPath);
187
211
  }
188
- function parseManifest(value) {
212
+ function parseRecord(value) {
189
213
  if (!value || typeof value !== "object" || Array.isArray(value))
190
214
  return null;
191
215
  const doc = value;
192
- const generator = doc.generator;
193
216
  const bundle = doc.bundle;
194
217
  const files = doc.files;
195
- if (!generator ||
196
- typeof generator.name !== "string" ||
197
- typeof generator.version !== "string" ||
198
- !bundle ||
218
+ if (!bundle ||
199
219
  typeof bundle.name !== "string" ||
200
220
  typeof bundle.version !== "string" ||
201
221
  typeof doc.target !== "string" ||
@@ -244,7 +264,6 @@ function parseManifest(value) {
244
264
  })
245
265
  : undefined;
246
266
  return {
247
- generator: { name: generator.name, version: generator.version },
248
267
  ...(doc.kind === "collection" ? { kind: "collection" } : {}),
249
268
  bundle: { name: bundle.name, version: bundle.version },
250
269
  ...(plugins ? { collection: { plugins } } : {}),
@@ -259,7 +278,61 @@ function parseManifest(value) {
259
278
  ...(parsedRegistration ? { registration: parsedRegistration } : {}),
260
279
  };
261
280
  }
262
- export function readInstallManifest(destination) {
281
+ /**
282
+ * Both serializations of the manifest document.
283
+ *
284
+ * A `bundle` at the top level is the single-record shape — which is also every
285
+ * manifest written before a destination could hold more than one, so an install
286
+ * made by an older cairn stays removable. `installs` is the multi-record shape.
287
+ * A document carrying both is `malformed` rather than a guess, the same rule as
288
+ * two manifest filenames and two matching install scopes.
289
+ */
290
+ function parseDocument(value) {
291
+ if (!value || typeof value !== "object" || Array.isArray(value))
292
+ return null;
293
+ const doc = value;
294
+ const generator = doc.generator;
295
+ if (!generator || typeof generator.name !== "string" || typeof generator.version !== "string")
296
+ return null;
297
+ const stamp = { name: generator.name, version: generator.version };
298
+ if (doc.installs !== undefined && doc.bundle !== undefined)
299
+ return null;
300
+ if (doc.installs !== undefined) {
301
+ if (!Array.isArray(doc.installs))
302
+ return null;
303
+ const installs = [];
304
+ for (const entry of doc.installs) {
305
+ const record = parseRecord(entry);
306
+ // One unparseable entry poisons the whole file. See InstallDocument.
307
+ if (!record)
308
+ return null;
309
+ installs.push(record);
310
+ }
311
+ return { generator: stamp, installs };
312
+ }
313
+ const single = parseRecord(doc);
314
+ return single ? { generator: stamp, installs: [single] } : null;
315
+ }
316
+ /**
317
+ * Writes the single-record shape while there is one record, and the `installs`
318
+ * shape only from two.
319
+ *
320
+ * A cairn predating multi-record destinations reads an `installs` document as
321
+ * `malformed`, which makes `agent uninstall` refuse (`AB806`) rather than
322
+ * mis-remove — but makes `agent install --force` overwrite the file and orphan
323
+ * every sibling's inventory. Keeping the old shape for the single-record case
324
+ * confines that hazard to destinations that could not have existed before.
325
+ */
326
+ function serializeDocument(document) {
327
+ const [only] = document.installs;
328
+ if (document.installs.length === 1)
329
+ return { generator: document.generator, ...only };
330
+ return { generator: document.generator, installs: document.installs };
331
+ }
332
+ function currentGenerator() {
333
+ return { name: packageName, version: packageVersion };
334
+ }
335
+ export function readInstallDocument(destination) {
263
336
  // Two manifests in one destination is the "two matches is an error rather than
264
337
  // a guess" rule again: only a hand edit produces it, and picking one would
265
338
  // silently orphan the other install's file list.
@@ -270,13 +343,20 @@ export function readInstallManifest(destination) {
270
343
  if (!file)
271
344
  return "missing";
272
345
  try {
273
- const parsed = parseManifest(JSON.parse(fs.readFileSync(file, "utf8")));
346
+ const parsed = parseDocument(JSON.parse(fs.readFileSync(file, "utf8")));
274
347
  return parsed ?? "malformed";
275
348
  }
276
349
  catch {
277
350
  return "malformed";
278
351
  }
279
352
  }
353
+ /** The record matching `key` at `destination`, if the document records one. */
354
+ export function readInstallRecord(destination, key) {
355
+ const document = readInstallDocument(destination);
356
+ if (document === "missing" || document === "malformed")
357
+ return document;
358
+ return document.installs.find((record) => installKey(record) === key) ?? "missing";
359
+ }
280
360
  export function resolveInstallDestination(target, scope, name, options = {}) {
281
361
  const location = locationFor(target, scope);
282
362
  if (!location)
@@ -292,29 +372,49 @@ export function resolveInstallDestination(target, scope, name, options = {}) {
292
372
  const destination = location.layout === "merge" ? locationRoot : path.join(locationRoot, name);
293
373
  return { location, locationRoot, destination };
294
374
  }
295
- function occupied(destination, layout, artifacts, prior, bundleName) {
296
- if (layout === "merge") {
297
- if (prior !== "missing" && prior !== "malformed" && prior.bundle.name !== bundleName)
298
- return true;
299
- const owned = prior !== "missing" && prior !== "malformed" && prior.bundle.name === bundleName
300
- ? new Set(prior.files.map((file) => file.path))
301
- : new Set();
302
- return artifacts.some((artifact) => {
303
- if (artifact.path === INSTALL_MANIFEST)
304
- return false;
305
- if (owned.has(artifact.path))
306
- return false;
307
- return existsAt(path.join(destination, artifact.path));
308
- });
375
+ function assessDestination(destination, layout, payload, prior, key) {
376
+ const records = prior === "missing" || prior === "malformed" ? [] : prior.installs;
377
+ const mine = records.find((record) => installKey(record) === key);
378
+ const owners = new Map();
379
+ for (const record of records) {
380
+ if (installKey(record) === key)
381
+ continue;
382
+ for (const file of record.files)
383
+ owners.set(file.path, { record, sha256: file.sha256 });
309
384
  }
310
- if (!existsAt(destination))
311
- return false;
312
- if (prior !== "missing" && prior !== "malformed" && prior.bundle.name === bundleName)
313
- return false;
314
- const listing = fs.lstatSync(destination);
315
- if (listing.isDirectory() && fs.readdirSync(destination).length === 0)
316
- return false;
317
- return true;
385
+ const result = { conflicts: [], ...(mine ? { mine } : {}) };
386
+ const ours = new Set(mine?.files.map((file) => file.path) ?? []);
387
+ // A destination that records nothing and is not empty is occupied wholesale
388
+ // for the layouts that own their directory. Merge shares its root by design,
389
+ // so it is only ever assessed per path.
390
+ if (layout !== "merge" && !records.length && existsAt(destination)) {
391
+ const listing = fs.lstatSync(destination);
392
+ if (!listing.isDirectory() || fs.readdirSync(destination).length)
393
+ return { ...result, foreign: destination };
394
+ }
395
+ for (const artifact of payload) {
396
+ if (artifact.path === INSTALL_MANIFEST)
397
+ continue;
398
+ const owner = owners.get(artifact.path);
399
+ if (owner) {
400
+ if (owner.sha256 !== sha256(artifact.content))
401
+ result.conflicts.push({ path: artifact.path, owner: owner.record });
402
+ continue;
403
+ }
404
+ if (ours.has(artifact.path))
405
+ continue;
406
+ if (!result.foreign && existsAt(path.join(destination, artifact.path)))
407
+ result.foreign = artifact.path;
408
+ }
409
+ return result;
410
+ }
411
+ /** `AB808`, in the form raised against an install already at the destination. */
412
+ function conflictDiagnostic(conflict, destination, target) {
413
+ return error("AB808", `Destination path '${conflict.path}' is already owned by the install of '${conflict.owner.bundle.name}' for ${conflict.owner.target}/${conflict.owner.profile}`, {
414
+ path: conflict.path,
415
+ target,
416
+ remediation: `Uninstall '${conflict.owner.bundle.name}' for ${conflict.owner.target} first, install to a different destination, or pass --force to overwrite it.`,
417
+ });
318
418
  }
319
419
  function writeJsonAtomically(file, value) {
320
420
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -414,6 +514,10 @@ function payloadMatches(destination, artifacts) {
414
514
  export function installIsCurrent(plan) {
415
515
  if (!payloadMatches(plan.destination, plan.artifacts))
416
516
  return false;
517
+ // The files matching is not enough: a record hand-removed from the manifest
518
+ // would leave --check reporting "current" while uninstall reports not-found.
519
+ if (readInstallRecord(plan.destination, installKey(plan.record)) === "missing")
520
+ return false;
417
521
  if (plan.register && plan.settings && !registrationCurrent(plan.settings, plan.destination))
418
522
  return false;
419
523
  return true;
@@ -433,52 +537,62 @@ function buildPayload(bundle, target, profile, layout) {
433
537
  diagnostics.push(...checkExecutables(artifacts), ...checkCaseCollisions(artifacts), ...checkPinning(bundle), ...featureDiagnostics(bundle, target, profile));
434
538
  return { artifacts, diagnostics };
435
539
  }
436
- function manifestArtifact(manifest) {
540
+ /**
541
+ * The manifest as an ordinary artifact, so it lands through the same atomic
542
+ * writer as everything else and `--dry-run` reports its real bytes.
543
+ *
544
+ * It takes the whole document rather than one record, and there is no cycle in
545
+ * that: a record's inventory comes from its own payload, which `inventoryOf`
546
+ * strips this file from; the document comes from the records. Deriving a
547
+ * sibling's record from a sibling's *artifacts* would introduce one.
548
+ */
549
+ function manifestArtifact(document) {
437
550
  return {
438
551
  path: INSTALL_MANIFEST,
439
- content: Buffer.from(JSON.stringify(manifest, null, 2) + "\n"),
552
+ content: Buffer.from(JSON.stringify(serializeDocument(document), null, 2) + "\n"),
440
553
  mode: 0o644,
441
554
  };
442
555
  }
443
- /**
444
- * Plans an install: renders and packages in memory, resolves the destination
445
- * from profile data, and records AB8xx findings. Nothing is written.
446
- */
447
- export function planInstall(bundle, target, options) {
556
+ /** An unresolvable install: AB800 already recorded, nothing to place. */
557
+ function unresolvedPlan(bundle, target, scope, mode, resolved) {
558
+ const record = {
559
+ bundle: { name: bundle.name, version: bundle.version },
560
+ target,
561
+ profile: "plugin",
562
+ scope,
563
+ layout: "plugin-dir",
564
+ mode: "copy",
565
+ destination: "",
566
+ files: [],
567
+ };
568
+ return {
569
+ bundle,
570
+ target,
571
+ profile: "plugin",
572
+ scope,
573
+ layout: "plugin-dir",
574
+ mode,
575
+ destination: "",
576
+ artifacts: [],
577
+ record,
578
+ document: { generator: currentGenerator(), installs: [record] },
579
+ prior: "missing",
580
+ diagnostics: [resolved],
581
+ register: false,
582
+ };
583
+ }
584
+ function draftInstall(bundle, target, options) {
448
585
  const scope = resolveScope(options.scope, "user");
449
586
  const resolved = resolveInstallDestination(target, scope, bundle.name, options);
450
- const diagnostics = [];
451
- if (!isResolved(resolved)) {
452
- return {
453
- bundle,
454
- target,
455
- profile: "plugin",
456
- scope,
457
- layout: "plugin-dir",
458
- mode: options.link ? "link" : "copy",
459
- destination: "",
460
- artifacts: [],
461
- manifest: {
462
- generator: { name: packageName, version: packageVersion },
463
- bundle: { name: bundle.name, version: bundle.version },
464
- target,
465
- profile: "plugin",
466
- scope,
467
- layout: "plugin-dir",
468
- mode: "copy",
469
- destination: "",
470
- files: [],
471
- },
472
- diagnostics: [resolved],
473
- register: Boolean(options.register),
474
- };
475
- }
587
+ const mode = options.link ? "link" : "copy";
588
+ if (!isResolved(resolved))
589
+ return unresolvedPlan(bundle, target, scope, mode, resolved);
476
590
  const { location, destination } = resolved;
477
591
  if (options.profile && options.profile !== "both" && options.profile !== location.profile)
478
592
  throw new Error(`Install location for ${target}/${scope} uses the ${location.profile} profile`);
479
593
  if (options.profile === "both")
480
594
  throw new Error("Install uses one profile per destination; pass plugin or project, or omit --profile");
481
- const mode = options.link ? "link" : "copy";
595
+ const diagnostics = [];
482
596
  const built = buildPayload(bundle, target, location.profile, location.layout);
483
597
  diagnostics.push(...built.diagnostics);
484
598
  for (const artifact of built.artifacts)
@@ -488,17 +602,6 @@ export function planInstall(bundle, target, options) {
488
602
  target,
489
603
  profile: location.profile,
490
604
  }));
491
- const prior = existsAt(destination) ? readInstallManifest(destination) : "missing";
492
- if (occupied(destination, location.layout, built.artifacts, prior, bundle.name)) {
493
- if (!options.force)
494
- diagnostics.push(error("AB801", `Destination is occupied by something that is not a prior install of '${bundle.name}'`, {
495
- path: destination,
496
- target,
497
- remediation: "Pass --force to replace it, or uninstall the occupant first.",
498
- }));
499
- }
500
- else if (prior !== "missing" && prior !== "malformed" && prior.bundle.name === bundle.name)
501
- diagnostics.push(diagnostic("AB802", `Replacing existing install of ${prior.bundle.name} ${prior.bundle.version} with ${bundle.version}`, "exact", { target, profile: location.profile, path: destination }));
502
605
  const settingsFile = location.activation
503
606
  ? expandInstallRoot(location.activation.file, options)
504
607
  : undefined;
@@ -519,20 +622,6 @@ export function planInstall(bundle, target, options) {
519
622
  if (mode === "link")
520
623
  diagnostics.push(diagnostic("AB807", "--link is in use; edits to the materialized tree are live and the host may not follow symlinks", "exact", { target, profile: location.profile, path: destination }));
521
624
  const materialized = mode === "link" ? path.join(bundle.root, INSTALL_CACHE, target, location.profile) : undefined;
522
- const manifest = {
523
- generator: { name: packageName, version: packageVersion },
524
- bundle: { name: bundle.name, version: bundle.version },
525
- target,
526
- profile: location.profile,
527
- scope,
528
- layout: location.layout,
529
- mode,
530
- destination,
531
- files: inventoryOf(built.artifacts),
532
- ...(materialized ? { materialized } : {}),
533
- ...(register && settings ? { registration: settings } : {}),
534
- };
535
- const artifacts = [...built.artifacts, manifestArtifact(manifest)].sort(byPath);
536
625
  return {
537
626
  bundle,
538
627
  target,
@@ -541,13 +630,170 @@ export function planInstall(bundle, target, options) {
541
630
  layout: location.layout,
542
631
  mode,
543
632
  destination,
544
- artifacts,
545
- manifest,
633
+ payload: built.artifacts,
634
+ record: {
635
+ bundle: { name: bundle.name, version: bundle.version },
636
+ target,
637
+ profile: location.profile,
638
+ scope,
639
+ layout: location.layout,
640
+ mode,
641
+ destination,
642
+ files: inventoryOf(built.artifacts),
643
+ ...(materialized ? { materialized } : {}),
644
+ ...(register && settings ? { registration: settings } : {}),
645
+ },
546
646
  diagnostics,
547
647
  register,
548
648
  settings,
549
649
  };
550
650
  }
651
+ /**
652
+ * Phase two: one destination's drafts become plans that share one document.
653
+ *
654
+ * The document is merged here rather than at commit so that `--dry-run` and
655
+ * `--check` report the bytes that would actually land. There is no cycle to
656
+ * untangle: a record's inventory comes from its own payload, which
657
+ * {@link inventoryOf} strips the manifest from; the document comes from the
658
+ * records; the manifest artifact comes from the document. Only deriving a
659
+ * sibling's record from a sibling's *artifacts* would reintroduce one.
660
+ */
661
+ function finishGroup(destination, drafts, options) {
662
+ // Read once per group: every plan must carry the identical snapshot.
663
+ const prior = existsAt(destination) ? readInstallDocument(destination) : "missing";
664
+ const priorRecords = prior === "missing" || prior === "malformed" ? [] : prior.installs;
665
+ const ordered = [...drafts].sort((a, b) => {
666
+ const left = installKey(a.record);
667
+ const right = installKey(b.record);
668
+ return left < right ? -1 : left > right ? 1 : 0;
669
+ });
670
+ const batchKeys = new Set(ordered.map((draft) => installKey(draft.record)));
671
+ const document = {
672
+ generator: currentGenerator(),
673
+ installs: sortRecords([
674
+ ...priorRecords.filter((record) => !batchKeys.has(installKey(record))),
675
+ ...ordered.map((draft) => draft.record),
676
+ ]),
677
+ };
678
+ const diagnostics = [];
679
+ const claimed = new Map();
680
+ const plans = [];
681
+ for (const draft of ordered) {
682
+ const key = installKey(draft.record);
683
+ const assessment = assessDestination(destination, draft.layout, draft.payload, prior, key);
684
+ const findings = [];
685
+ if (assessment.foreign && !options.force)
686
+ findings.push(error("AB801", `Destination is occupied by something that is not a prior install of '${draft.record.bundle.name}'`, {
687
+ path: destination,
688
+ target: draft.target,
689
+ remediation: "Pass --force to replace it, or uninstall the occupant first.",
690
+ }));
691
+ if (!options.force)
692
+ for (const conflict of assessment.conflicts)
693
+ findings.push(conflictDiagnostic(conflict, destination, draft.target));
694
+ if (assessment.mine)
695
+ findings.push(diagnostic("AB802", `Replacing existing install of ${assessment.mine.bundle.name} ${assessment.mine.bundle.version} with ${draft.record.bundle.version}`, "exact", { target: draft.target, profile: draft.profile, path: destination }));
696
+ // A --link install of a layout that owns its directory replaces the whole
697
+ // destination with a symlink, which cannot coexist with a sibling record.
698
+ if (draft.mode === "link" && draft.layout !== "merge" && document.installs.length > 1)
699
+ findings.push(error("AB809", `A --link install cannot share a destination with another install: ${destination} already records '${document.installs.find((record) => installKey(record) !== key)?.bundle.name ?? "another bundle"}'`, {
700
+ path: destination,
701
+ target: draft.target,
702
+ remediation: "Install without --link, or use a destination of its own.",
703
+ }));
704
+ // Within one run, two installs writing one path is not something --force can
705
+ // resolve: it cannot make a single run write two byte streams to one path,
706
+ // and suppressing it would make the result depend on commit order.
707
+ for (const artifact of draft.payload) {
708
+ if (artifact.path === INSTALL_MANIFEST)
709
+ continue;
710
+ const other = claimed.get(artifact.path);
711
+ if (other && !other.content.equals(artifact.content))
712
+ diagnostics.push(error("AB808", `Two installs in this run both write '${artifact.path}' at ${destination}: '${other.draft.record.bundle.name}' (${other.draft.target}/${other.draft.profile}) and '${draft.record.bundle.name}' (${draft.target}/${draft.profile})`, {
713
+ path: artifact.path,
714
+ target: draft.target,
715
+ remediation: "Install them to separate destinations, or drop one --target.",
716
+ }));
717
+ else if (!other)
718
+ claimed.set(artifact.path, { draft, content: artifact.content });
719
+ }
720
+ plans.push({
721
+ ...(draft.bundle ? { bundle: draft.bundle } : {}),
722
+ target: draft.target,
723
+ profile: draft.profile,
724
+ scope: draft.scope,
725
+ layout: draft.layout,
726
+ mode: draft.mode,
727
+ destination,
728
+ artifacts: [...draft.payload, manifestArtifact(document)].sort(byPath),
729
+ record: draft.record,
730
+ document,
731
+ prior,
732
+ diagnostics: [...draft.diagnostics, ...findings],
733
+ register: draft.register,
734
+ ...(draft.settings ? { settings: draft.settings } : {}),
735
+ });
736
+ }
737
+ return { plans, diagnostics };
738
+ }
739
+ /**
740
+ * Plans every requested install, grouped by destination.
741
+ *
742
+ * Nothing is written here, and the caller must treat the batch as all-or-nothing:
743
+ * committing a subset of a run whose remainder is blocked is how a destination
744
+ * ends up half-populated with no record of it.
745
+ */
746
+ export function planInstalls(requests, options) {
747
+ const drafted = requests.map((request) => draftInstall(request.bundle, request.target, options));
748
+ const groups = new Map();
749
+ const unresolved = [];
750
+ const order = [];
751
+ for (const draft of drafted) {
752
+ if ("payload" in draft) {
753
+ const destination = path.resolve(draft.destination);
754
+ const list = groups.get(destination) ?? [];
755
+ list.push(draft);
756
+ groups.set(destination, list);
757
+ order.push({ destination, key: installKey(draft.record) });
758
+ }
759
+ else {
760
+ unresolved.push(draft);
761
+ order.push({ plan: draft });
762
+ }
763
+ }
764
+ const planned = new Map();
765
+ const diagnostics = [];
766
+ for (const [destination, drafts] of groups) {
767
+ const finished = finishGroup(destination, drafts, options);
768
+ diagnostics.push(...finished.diagnostics);
769
+ for (const plan of finished.plans)
770
+ planned.set(`${destination}\0${installKey(plan.record)}`, plan);
771
+ }
772
+ // Requests are reported back in the order they were given, not in the order
773
+ // grouping happened to visit them.
774
+ const plans = [];
775
+ for (const item of order) {
776
+ if ("plan" in item)
777
+ plans.push(item.plan);
778
+ else {
779
+ const plan = planned.get(`${item.destination}\0${item.key}`);
780
+ if (plan)
781
+ plans.push(plan);
782
+ }
783
+ }
784
+ return { plans, diagnostics };
785
+ }
786
+ /**
787
+ * Plans a single install: renders and packages in memory, resolves the
788
+ * destination from profile data, and records AB8xx findings. Nothing is written.
789
+ */
790
+ export function planInstall(bundle, target, options) {
791
+ const batch = planInstalls([{ bundle, target }], options);
792
+ const plan = batch.plans[0];
793
+ return batch.diagnostics.length
794
+ ? { ...plan, diagnostics: [...plan.diagnostics, ...batch.diagnostics] }
795
+ : plan;
796
+ }
551
797
  /**
552
798
  * Plans an install of a whole collection into one host marketplace.
553
799
  *
@@ -574,19 +820,12 @@ export function planCollectionInstall(collection, options = {}) {
574
820
  mode: "copy",
575
821
  destination: "",
576
822
  artifacts: [],
577
- manifest: {
578
- generator: { name: packageName, version: packageVersion },
579
- kind: "collection",
580
- bundle: { name: collection.name, version: collection.version },
581
- collection: { plugins: collection.plugins },
582
- target: collection.target,
583
- profile: "plugin",
584
- scope,
585
- layout: "plugin-dir",
586
- mode: "copy",
587
- destination: "",
588
- files: [],
823
+ record: unresolvedCollectionRecord(collection, scope),
824
+ document: {
825
+ generator: currentGenerator(),
826
+ installs: [unresolvedCollectionRecord(collection, scope)],
589
827
  },
828
+ prior: "missing",
590
829
  diagnostics: [resolved],
591
830
  register: Boolean(options.register),
592
831
  };
@@ -601,17 +840,6 @@ export function planCollectionInstall(collection, options = {}) {
601
840
  target: collection.target,
602
841
  profile: "plugin",
603
842
  }));
604
- const prior = existsAt(destination) ? readInstallManifest(destination) : "missing";
605
- if (occupied(destination, location.layout, collection.artifacts, prior, collection.name)) {
606
- if (!options.force)
607
- diagnostics.push(error("AB801", `Destination is occupied by something that is not a prior install of '${collection.name}'`, {
608
- path: destination,
609
- target: collection.target,
610
- remediation: "Pass --force to replace it, or uninstall the occupant first.",
611
- }));
612
- }
613
- else if (prior !== "missing" && prior !== "malformed" && prior.bundle.name === collection.name)
614
- diagnostics.push(diagnostic("AB802", `Replacing existing install of ${prior.bundle.name} ${prior.bundle.version} with ${collection.version}`, "exact", { target: collection.target, profile: "plugin", path: destination }));
615
843
  const settingsFile = location.activation
616
844
  ? expandInstallRoot(location.activation.file, options)
617
845
  : undefined;
@@ -631,48 +859,73 @@ export function planCollectionInstall(collection, options = {}) {
631
859
  const materialized = mode === "link" ? options.materializeInto : undefined;
632
860
  if (mode === "link" && !materialized)
633
861
  throw new Error("A --link collection install needs a materialization directory");
634
- const manifest = {
635
- generator: { name: packageName, version: packageVersion },
636
- kind: "collection",
637
- bundle: { name: collection.name, version: collection.version },
638
- collection: { plugins: collection.plugins },
862
+ const draft = {
639
863
  target: collection.target,
640
864
  profile: "plugin",
641
865
  scope,
642
866
  layout: location.layout,
643
867
  mode,
644
868
  destination,
645
- files: inventoryOf(collection.artifacts),
646
- ...(materialized ? { materialized } : {}),
647
- ...(register && settings ? { registration: settings } : {}),
869
+ payload: collection.artifacts,
870
+ record: {
871
+ kind: "collection",
872
+ bundle: { name: collection.name, version: collection.version },
873
+ collection: { plugins: collection.plugins },
874
+ target: collection.target,
875
+ profile: "plugin",
876
+ scope,
877
+ layout: location.layout,
878
+ mode,
879
+ destination,
880
+ files: inventoryOf(collection.artifacts),
881
+ ...(materialized ? { materialized } : {}),
882
+ ...(register && settings ? { registration: settings } : {}),
883
+ },
884
+ diagnostics,
885
+ register,
886
+ ...(settings ? { settings } : {}),
648
887
  };
888
+ // Through the same grouping as a bundle install: a collection resolves every
889
+ // target to `<into>/<name>`, so without it `--into X --target all` reproduced
890
+ // the destruction this change exists to remove.
891
+ const finished = finishGroup(destination, [draft], options);
892
+ const plan = finished.plans[0];
893
+ return finished.diagnostics.length
894
+ ? { ...plan, diagnostics: [...plan.diagnostics, ...finished.diagnostics] }
895
+ : plan;
896
+ }
897
+ function unresolvedCollectionRecord(collection, scope) {
649
898
  return {
899
+ kind: "collection",
900
+ bundle: { name: collection.name, version: collection.version },
901
+ collection: { plugins: collection.plugins },
650
902
  target: collection.target,
651
903
  profile: "plugin",
652
904
  scope,
653
- layout: location.layout,
654
- mode,
655
- destination,
656
- artifacts: [...collection.artifacts, manifestArtifact(manifest)].sort(byPath),
657
- manifest,
658
- diagnostics,
659
- register,
660
- settings,
905
+ layout: "plugin-dir",
906
+ mode: "copy",
907
+ destination: "",
908
+ files: [],
661
909
  };
662
910
  }
663
911
  function writeCopy(plan) {
664
912
  const payload = plan.artifacts;
665
- if (plan.layout === "merge")
913
+ // Replacing the destination wholesale is what guarantees no leftovers when a
914
+ // manifest was lost and --force was used, so it is kept for the layouts that
915
+ // own their directory — but only while this install is the sole record there.
916
+ // With a sibling, it would delete the sibling's tree.
917
+ const wholesale = plan.layout !== "merge" && plan.document.installs.length === 1;
918
+ if (wholesale)
919
+ writeArtifactsAtomically(plan.destination, payload, { managedRoots: ["."], force: true });
920
+ else
666
921
  writeArtifactsAtomically(plan.destination, payload, {
667
922
  managedRoots: [],
668
923
  looseFiles: payload.map((artifact) => artifact.path),
669
924
  force: true,
670
925
  });
671
- else
672
- writeArtifactsAtomically(plan.destination, payload, { managedRoots: ["."], force: true });
673
926
  }
674
927
  function writeLink(plan) {
675
- const materialized = plan.manifest.materialized;
928
+ const materialized = plan.record.materialized;
676
929
  if (!materialized)
677
930
  throw new Error("Link install is missing a materialized tree");
678
931
  writeArtifactsAtomically(materialized, plan.artifacts, { managedRoots: ["."], force: true });
@@ -694,33 +947,29 @@ function writeLink(plan) {
694
947
  placeSymlink(plan.destination, materialized);
695
948
  }
696
949
  function retirePrior(plan) {
697
- if (!existsAt(plan.destination))
698
- return;
699
- const prior = readInstallManifest(plan.destination);
950
+ const prior = plan.prior;
700
951
  if (prior === "missing" || prior === "malformed")
701
952
  return;
702
- const next = new Set(plan.manifest.files.map((file) => file.path));
703
- if (prior.bundle.name !== plan.manifest.bundle.name) {
704
- commitUninstall({
705
- name: prior.bundle.name,
706
- target: prior.target,
707
- destination: plan.destination,
708
- manifest: prior,
709
- diagnostics: [],
710
- missing: false,
711
- });
953
+ const key = installKey(plan.record);
954
+ const previous = prior.installs.find((record) => installKey(record) === key);
955
+ if (!previous)
712
956
  return;
713
- }
714
- for (const file of prior.files) {
715
- if (next.has(file.path))
957
+ const next = new Set(plan.record.files.map((file) => file.path));
958
+ // Owners after this run, not before it: reading the prior document alone would
959
+ // let this delete a file a sibling in the same batch has just written.
960
+ const claimed = new Set(plan.document.installs
961
+ .filter((record) => installKey(record) !== key)
962
+ .flatMap((record) => record.files.map((file) => file.path)));
963
+ for (const file of previous.files) {
964
+ if (next.has(file.path) || claimed.has(file.path))
716
965
  continue;
717
966
  removePath(path.join(plan.destination, file.path));
718
967
  pruneEmptyAncestors(plan.destination, file.path);
719
968
  }
720
- if (prior.materialized && prior.materialized !== plan.manifest.materialized)
721
- removePath(prior.materialized);
722
- if (prior.registration && !plan.manifest.registration)
723
- revertRegistration(prior.registration, plan.destination);
969
+ if (previous.materialized && previous.materialized !== plan.record.materialized)
970
+ removePath(previous.materialized);
971
+ if (previous.registration && !plan.record.registration)
972
+ revertRegistration(previous.registration, plan.destination);
724
973
  }
725
974
  /** Writes a planned install. Caller must have already decided the run is not blocked. */
726
975
  export function commitInstall(plan) {
@@ -733,7 +982,7 @@ export function commitInstall(plan) {
733
982
  applyRegistration(plan.settings, plan.destination);
734
983
  }
735
984
  export function planToEntry(plan) {
736
- return toEntry(plan.manifest);
985
+ return toEntry(plan.record);
737
986
  }
738
987
  function candidatesFor(target, name, scopes, options) {
739
988
  const found = [];
@@ -757,7 +1006,7 @@ export function planUninstall(name, target, options) {
757
1006
  const matches = [];
758
1007
  for (const candidate of candidatesFor(target, name, scopes, options)) {
759
1008
  const read = existsAt(candidate.destination)
760
- ? readInstallManifest(candidate.destination)
1009
+ ? readInstallDocument(candidate.destination)
761
1010
  : "missing";
762
1011
  if (read === "malformed")
763
1012
  diagnostics.push(error("AB806", `Install manifest missing or malformed at ${candidate.destination}`, {
@@ -766,8 +1015,13 @@ export function planUninstall(name, target, options) {
766
1015
  target,
767
1016
  remediation: "Inspect the destination; uninstall refuses to guess.",
768
1017
  }));
769
- else if (read !== "missing" && read.bundle.name === name)
770
- matches.push({ ...candidate, manifest: read });
1018
+ else if (read !== "missing")
1019
+ // Filtering on the target as well as the name is required now that a
1020
+ // destination records several installs: matching on the name alone would
1021
+ // remove a different target's inventory from the same document.
1022
+ for (const record of read.installs)
1023
+ if (record.bundle.name === name && record.target === target)
1024
+ matches.push({ ...candidate, manifest: record });
771
1025
  }
772
1026
  if (diagnostics.length)
773
1027
  return { name, target, destination: "", manifest: null, diagnostics, missing: false };
@@ -828,41 +1082,62 @@ function removePath(file) {
828
1082
  fs.rmSync(file, { recursive: true, force: true });
829
1083
  }
830
1084
  export function commitUninstall(plan) {
831
- const manifest = plan.manifest;
832
- if (!manifest)
1085
+ const record = plan.manifest;
1086
+ if (!record)
833
1087
  return;
834
1088
  const destination = plan.destination;
1089
+ const document = readInstallDocument(destination);
1090
+ const key = installKey(record);
1091
+ const remaining = document === "missing" || document === "malformed"
1092
+ ? []
1093
+ : document.installs.filter((entry) => installKey(entry) !== key);
1094
+ // A path a sibling still owns is not this install's to remove.
1095
+ const claimed = new Set(remaining.flatMap((entry) => entry.files.map((file) => file.path)));
835
1096
  const listing = fs.lstatSync(destination, { throwIfNoEntry: false });
836
- if (listing?.isSymbolicLink())
1097
+ if (listing?.isSymbolicLink() && !remaining.length)
837
1098
  removePath(destination);
838
1099
  else {
839
- for (const file of manifest.files) {
1100
+ for (const file of record.files) {
1101
+ if (claimed.has(file.path))
1102
+ continue;
840
1103
  removePath(path.join(destination, file.path));
841
1104
  pruneEmptyAncestors(destination, file.path);
842
1105
  }
843
1106
  const manifestFile = installManifestIn(destination);
844
- if (manifestFile)
845
- removePath(manifestFile);
846
- if (manifest.layout !== "merge" &&
847
- existsAt(destination) &&
848
- fs.statSync(destination).isDirectory() &&
849
- fs.readdirSync(destination).length === 0)
850
- fs.rmdirSync(destination);
1107
+ if (remaining.length) {
1108
+ writeJsonAtomically(path.join(destination, INSTALL_MANIFEST), serializeDocument({ generator: currentGenerator(), installs: sortRecords(remaining) }));
1109
+ // The survivors are rewritten under the current name, so a legacy-named
1110
+ // file left beside it would read as `malformed` from here on.
1111
+ if (manifestFile && path.basename(manifestFile) === LEGACY_INSTALL_MANIFEST)
1112
+ removePath(manifestFile);
1113
+ }
1114
+ else {
1115
+ if (manifestFile)
1116
+ removePath(manifestFile);
1117
+ if (record.layout !== "merge" &&
1118
+ existsAt(destination) &&
1119
+ fs.statSync(destination).isDirectory() &&
1120
+ fs.readdirSync(destination).length === 0)
1121
+ fs.rmdirSync(destination);
1122
+ }
851
1123
  }
852
- if (manifest.materialized)
853
- removePath(manifest.materialized);
854
- if (manifest.registration)
855
- revertRegistration(manifest.registration, destination);
1124
+ if (record.materialized)
1125
+ removePath(record.materialized);
1126
+ if (record.registration)
1127
+ revertRegistration(record.registration, destination);
856
1128
  }
857
1129
  function scanRoot(target, scope, location, locationRoot) {
858
1130
  const entries = [];
1131
+ const collect = (destination) => {
1132
+ const read = existsAt(destination) ? readInstallDocument(destination) : "missing";
1133
+ if (read === "missing" || read === "malformed")
1134
+ return;
1135
+ for (const record of read.installs)
1136
+ if (record.target === target && record.scope === scope)
1137
+ entries.push(toEntry({ ...record, destination }));
1138
+ };
859
1139
  if (location.layout === "merge") {
860
- const read = existsAt(locationRoot) ? readInstallManifest(locationRoot) : "missing";
861
- if (read !== "missing" &&
862
- read !== "malformed" &&
863
- read.target === target &&
864
- read.scope === scope)
865
- entries.push(toEntry({ ...read, destination: locationRoot }));
1140
+ collect(locationRoot);
866
1141
  return entries;
867
1142
  }
868
1143
  if (!existsAt(locationRoot) || !fs.statSync(locationRoot).isDirectory())
@@ -872,12 +1147,7 @@ function scanRoot(target, scope, location, locationRoot) {
872
1147
  const listing = fs.lstatSync(destination);
873
1148
  if (!listing.isDirectory() && !listing.isSymbolicLink())
874
1149
  continue;
875
- const read = readInstallManifest(destination);
876
- if (read === "missing" || read === "malformed")
877
- continue;
878
- if (read.target !== target || read.scope !== scope)
879
- continue;
880
- entries.push(toEntry({ ...read, destination }));
1150
+ collect(destination);
881
1151
  }
882
1152
  return entries;
883
1153
  }
@@ -899,8 +1169,10 @@ export function listInstalled(targets, options = {}) {
899
1169
  }
900
1170
  }
901
1171
  return entries.sort((a, b) => {
902
- const left = `${a.target}/${a.scope}/${a.name}`;
903
- const right = `${b.target}/${b.scope}/${b.name}`;
1172
+ // Profile and destination are in the key because one destination now yields
1173
+ // several rows, and `agent installed -fj` byte order is what consumers read.
1174
+ const left = `${a.target}/${a.scope}/${a.name}/${a.profile}/${a.destination}`;
1175
+ const right = `${b.target}/${b.scope}/${b.name}/${b.profile}/${b.destination}`;
904
1176
  return left < right ? -1 : left > right ? 1 : 0;
905
1177
  });
906
1178
  }