@cairn-tool/cairn 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -1367
- package/dist/agent/conditionals.d.ts +43 -0
- package/dist/agent/conditionals.js +314 -0
- package/dist/agent/conditionals.js.map +1 -0
- package/dist/agent/install/config.d.ts +47 -0
- package/dist/agent/install/config.js +156 -0
- package/dist/agent/install/config.js.map +1 -0
- package/dist/agent/install/index.d.ts +95 -14
- package/dist/agent/install/index.js +460 -188
- package/dist/agent/install/index.js.map +1 -1
- package/dist/agent/parser.js +15 -41
- package/dist/agent/parser.js.map +1 -1
- package/dist/agent/render.d.ts +9 -1
- package/dist/agent/render.js +12 -9
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/types.d.ts +4 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/agent/verify/compare.d.ts +58 -0
- package/dist/agent/verify/compare.js +159 -0
- package/dist/agent/verify/compare.js.map +1 -0
- package/dist/agent/verify/config.d.ts +54 -0
- package/dist/agent/verify/config.js +196 -0
- package/dist/agent/verify/config.js.map +1 -0
- package/dist/agent/verify/index.d.ts +87 -0
- package/dist/agent/verify/index.js +331 -0
- package/dist/agent/verify/index.js.map +1 -0
- package/dist/agent/verify/resolve.d.ts +9 -0
- package/dist/agent/verify/resolve.js +97 -0
- package/dist/agent/verify/resolve.js.map +1 -0
- package/dist/cli.js +18 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/agent-install.d.ts +15 -1
- package/dist/commands/agent-install.js +121 -24
- package/dist/commands/agent-install.js.map +1 -1
- package/dist/commands/agent-verify.d.ts +16 -0
- package/dist/commands/agent-verify.js +42 -0
- package/dist/commands/agent-verify.js.map +1 -0
- package/dist/commands/agent.js +23 -0
- package/dist/commands/agent.js.map +1 -1
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/contract/registry.js +11 -2
- package/dist/contract/registry.js.map +1 -1
- package/dist/contract/schemas/agent.js +141 -0
- package/dist/contract/schemas/agent.js.map +1 -1
- package/package.json +10 -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
|
|
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 (!
|
|
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
|
-
|
|
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 =
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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(
|
|
552
|
+
content: Buffer.from(JSON.stringify(serializeDocument(document), null, 2) + "\n"),
|
|
440
553
|
mode: 0o644,
|
|
441
554
|
};
|
|
442
555
|
}
|
|
443
|
-
/**
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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:
|
|
654
|
-
mode,
|
|
655
|
-
destination,
|
|
656
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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 (
|
|
721
|
-
removePath(
|
|
722
|
-
if (
|
|
723
|
-
revertRegistration(
|
|
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.
|
|
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
|
-
?
|
|
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"
|
|
770
|
-
|
|
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
|
|
832
|
-
if (!
|
|
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
|
|
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 (
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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 (
|
|
853
|
-
removePath(
|
|
854
|
-
if (
|
|
855
|
-
revertRegistration(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
}
|