@cairn-tool/cairn 4.0.0 → 4.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.
@@ -36,7 +36,9 @@ export interface InstallInventoryEntry {
36
36
  mode: string;
37
37
  sha256: string;
38
38
  }
39
- export interface InstallRegistration {
39
+ export interface ClaudeInstallRegistration {
40
+ /** Absent in manifests written before activation drivers were versioned. */
41
+ form?: "claude-enabled-plugins";
40
42
  file: string;
41
43
  marketplaceKey: string;
42
44
  /** The single plugin key a bundle install enables. */
@@ -47,6 +49,14 @@ export interface InstallRegistration {
47
49
  */
48
50
  pluginKeys?: string[];
49
51
  }
52
+ export interface CodexInstallRegistration {
53
+ form: "codex-plugin-cli";
54
+ command: string;
55
+ marketplaceKey: string;
56
+ pluginKey?: string;
57
+ pluginKeys?: string[];
58
+ }
59
+ export type InstallRegistration = ClaudeInstallRegistration | CodexInstallRegistration;
50
60
  /**
51
61
  * The plugin keys a registration activates, whichever spelling recorded them.
52
62
  * A manifest written before collections carries only `pluginKey`.
@@ -134,6 +144,7 @@ export declare function installKey(record: {
134
144
  export interface InstallContext {
135
145
  home?: string;
136
146
  cwd?: string;
147
+ env?: NodeJS.ProcessEnv;
137
148
  }
138
149
  export interface ResolvedInstall {
139
150
  location: InstallLocation;
@@ -181,6 +192,8 @@ export declare function resolveScope(value: string | undefined, fallback?: Insta
181
192
  * anything else is resolved against `cwd`.
182
193
  */
183
194
  export declare function expandInstallRoot(root: string, context?: InstallContext): string;
195
+ /** Resolves an install location, honoring a host-specific home override first. */
196
+ export declare function expandInstallLocationRoot(location: InstallLocation, context?: InstallContext): string;
184
197
  export declare function locationFor(target: AgentTarget, scope: InstallScope): InstallLocation | null;
185
198
  /**
186
199
  * True when `relative` would land outside `root`. Lexical: a `..` segment or an
@@ -193,6 +206,13 @@ export declare function readInstallRecord(destination: string, key: string): Ins
193
206
  export declare function resolveInstallDestination(target: AgentTarget, scope: InstallScope, name: string, options?: {
194
207
  into?: string;
195
208
  } & InstallContext): ResolvedInstall | AgentDiagnostic;
209
+ /**
210
+ * Checks host-side conflicts before a batch writes any files.
211
+ *
212
+ * In particular, `--force` never takes over a same-named Codex marketplace
213
+ * that points somewhere else: it is external state Cairn does not own.
214
+ */
215
+ export declare function preflightInstallRegistrations(plans: InstallPlan[]): void;
196
216
  export declare function installIsCurrent(plan: InstallPlan): boolean;
197
217
  /** Options shared by every install plan in one run. */
198
218
  export interface PlanInstallOptions extends InstallContext {
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -122,6 +123,14 @@ export function expandInstallRoot(root, context = {}) {
122
123
  return path.resolve(cwd);
123
124
  return path.resolve(cwd, root);
124
125
  }
126
+ /** Resolves an install location, honoring a host-specific home override first. */
127
+ export function expandInstallLocationRoot(location, context = {}) {
128
+ const configured = location.environmentRoot;
129
+ const value = configured ? (context.env ?? process.env)[configured.variable] : undefined;
130
+ if (configured && value)
131
+ return path.resolve(value, configured.suffix);
132
+ return expandInstallRoot(location.root, context);
133
+ }
125
134
  export function locationFor(target, scope) {
126
135
  return profileFor(target).install?.[scope] ?? null;
127
136
  }
@@ -241,19 +250,33 @@ function parseRecord(value) {
241
250
  const keys = Array.isArray(registration?.pluginKeys)
242
251
  ? registration.pluginKeys.filter((key) => typeof key === "string")
243
252
  : undefined;
244
- const parsedRegistration = registration &&
253
+ const hasRegistrationKey = typeof registration?.pluginKey === "string" || keys !== undefined;
254
+ let parsedRegistration;
255
+ if (registration?.form === "codex-plugin-cli" &&
256
+ typeof registration.command === "string" &&
257
+ typeof registration.marketplaceKey === "string" &&
258
+ hasRegistrationKey)
259
+ parsedRegistration = {
260
+ form: "codex-plugin-cli",
261
+ command: registration.command,
262
+ marketplaceKey: registration.marketplaceKey,
263
+ ...(typeof registration.pluginKey === "string" ? { pluginKey: registration.pluginKey } : {}),
264
+ ...(keys ? { pluginKeys: keys } : {}),
265
+ };
266
+ else if (registration &&
267
+ (registration.form === undefined || registration.form === "claude-enabled-plugins") &&
245
268
  typeof registration.file === "string" &&
246
269
  typeof registration.marketplaceKey === "string" &&
247
- (typeof registration.pluginKey === "string" || keys !== undefined)
248
- ? {
270
+ hasRegistrationKey)
271
+ parsedRegistration = {
272
+ ...(registration.form === "claude-enabled-plugins"
273
+ ? { form: "claude-enabled-plugins" }
274
+ : {}),
249
275
  file: registration.file,
250
276
  marketplaceKey: registration.marketplaceKey,
251
- ...(typeof registration.pluginKey === "string"
252
- ? { pluginKey: registration.pluginKey }
253
- : {}),
277
+ ...(typeof registration.pluginKey === "string" ? { pluginKey: registration.pluginKey } : {}),
254
278
  ...(keys ? { pluginKeys: keys } : {}),
255
- }
256
- : undefined;
279
+ };
257
280
  const collection = doc.collection;
258
281
  const plugins = collection && Array.isArray(collection.plugins)
259
282
  ? collection.plugins.flatMap((entry) => {
@@ -362,13 +385,11 @@ export function resolveInstallDestination(target, scope, name, options = {}) {
362
385
  if (!location)
363
386
  return error("AB800", `No recorded install location for ${target} ${scope} scope`, {
364
387
  target,
365
- remediation: scope === "user" && target === "codex"
366
- ? "Codex has no user-scope install; use --scope project, or pick another target."
367
- : "Use a target and scope the profile declares, or pass --into only after one exists.",
388
+ remediation: "Use a target and scope the profile declares, or pass --into only after one exists.",
368
389
  });
369
390
  const locationRoot = options.into
370
391
  ? path.resolve(options.into)
371
- : expandInstallRoot(location.root, options);
392
+ : expandInstallLocationRoot(location, options);
372
393
  const destination = location.layout === "merge" ? locationRoot : path.join(locationRoot, name);
373
394
  return { location, locationRoot, destination };
374
395
  }
@@ -430,7 +451,102 @@ function readJsonObject(file) {
430
451
  throw new Error(`Activation file is not a JSON object: ${file}`);
431
452
  return parsed;
432
453
  }
433
- function applyRegistration(settings, destination) {
454
+ function isCodexRegistration(registration) {
455
+ return registration.form === "codex-plugin-cli";
456
+ }
457
+ function commandJson(command, args) {
458
+ const result = spawnSync(command, args, {
459
+ encoding: "utf8",
460
+ env: process.env,
461
+ maxBuffer: 1024 * 1024,
462
+ });
463
+ const invocation = [command, ...args].join(" ");
464
+ if (result.error)
465
+ throw new Error(`Failed to run '${invocation}': ${result.error.message}`);
466
+ if (result.status !== 0) {
467
+ const detail = (result.stderr || result.stdout).trim();
468
+ throw new Error(`Host command '${invocation}' exited ${result.status ?? "without a status"}${detail ? `: ${detail}` : ""}`);
469
+ }
470
+ try {
471
+ const parsed = JSON.parse(result.stdout);
472
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
473
+ throw new Error("output is not an object");
474
+ return parsed;
475
+ }
476
+ catch (cause) {
477
+ throw new Error(`Host command '${invocation}' returned invalid JSON`, { cause });
478
+ }
479
+ }
480
+ function codexMarketplaces(registration) {
481
+ const value = commandJson(registration.command, [
482
+ "plugin",
483
+ "marketplace",
484
+ "list",
485
+ "--json",
486
+ ]).marketplaces;
487
+ if (!Array.isArray(value))
488
+ throw new Error("Codex marketplace list returned no marketplaces");
489
+ return value.flatMap((entry) => {
490
+ const row = entry;
491
+ return row && typeof row.name === "string" && typeof row.root === "string"
492
+ ? [{ name: row.name, root: row.root }]
493
+ : [];
494
+ });
495
+ }
496
+ function codexPlugins(registration) {
497
+ const value = commandJson(registration.command, ["plugin", "list", "--json"]).installed;
498
+ if (!Array.isArray(value))
499
+ throw new Error("Codex plugin list returned no installed plugins");
500
+ return value.flatMap((entry) => {
501
+ const row = entry;
502
+ return row && typeof row.pluginId === "string"
503
+ ? [
504
+ {
505
+ pluginId: row.pluginId,
506
+ ...(typeof row.version === "string" ? { version: row.version } : {}),
507
+ installed: row.installed === true,
508
+ enabled: row.enabled === true,
509
+ },
510
+ ]
511
+ : [];
512
+ });
513
+ }
514
+ function canonicalPath(file) {
515
+ try {
516
+ return fs.realpathSync(file);
517
+ }
518
+ catch {
519
+ return path.resolve(file);
520
+ }
521
+ }
522
+ function samePath(left, right) {
523
+ return canonicalPath(left) === canonicalPath(right);
524
+ }
525
+ function codexMarketplace(registration) {
526
+ return codexMarketplaces(registration).find((marketplace) => marketplace.name === registration.marketplaceKey);
527
+ }
528
+ /**
529
+ * Checks host-side conflicts before a batch writes any files.
530
+ *
531
+ * In particular, `--force` never takes over a same-named Codex marketplace
532
+ * that points somewhere else: it is external state Cairn does not own.
533
+ */
534
+ export function preflightInstallRegistrations(plans) {
535
+ const checked = new Set();
536
+ for (const plan of plans) {
537
+ const registration = plan.settings;
538
+ if (!plan.register || !registration || !isCodexRegistration(registration))
539
+ continue;
540
+ const key = `${registration.command}\0${registration.marketplaceKey}\0${plan.destination}`;
541
+ if (checked.has(key))
542
+ continue;
543
+ checked.add(key);
544
+ const current = codexMarketplace(registration);
545
+ if (current && !samePath(current.root, plan.destination))
546
+ throw new Error(`Codex marketplace '${registration.marketplaceKey}' already points to ${current.root}; refusing to replace it with ${plan.destination}`);
547
+ }
548
+ }
549
+ function applyClaudeRegistration(settings, destination) {
434
550
  const current = readJsonObject(settings.file);
435
551
  const extra = {
436
552
  ...(current.extraKnownMarketplaces ?? {}),
@@ -449,7 +565,55 @@ function applyRegistration(settings, destination) {
449
565
  enabledPlugins: enabled,
450
566
  });
451
567
  }
452
- function revertRegistration(registration, destination) {
568
+ function applyCodexRegistration(registration, destination) {
569
+ const marketplace = codexMarketplace(registration);
570
+ if (marketplace && !samePath(marketplace.root, destination))
571
+ throw new Error(`Codex marketplace '${registration.marketplaceKey}' already points to ${marketplace.root}; refusing to replace it with ${destination}`);
572
+ const installedBefore = new Set(codexPlugins(registration)
573
+ .filter((plugin) => plugin.installed)
574
+ .map((plugin) => plugin.pluginId));
575
+ const added = [];
576
+ try {
577
+ commandJson(registration.command, ["plugin", "marketplace", "add", destination, "--json"]);
578
+ for (const key of registeredPluginKeys(registration)) {
579
+ commandJson(registration.command, ["plugin", "add", key, "--json"]);
580
+ if (!installedBefore.has(key))
581
+ added.push(key);
582
+ }
583
+ }
584
+ catch (cause) {
585
+ for (const key of added.reverse()) {
586
+ try {
587
+ commandJson(registration.command, ["plugin", "remove", key, "--json"]);
588
+ }
589
+ catch {
590
+ // Best effort: preserve the original host failure.
591
+ }
592
+ }
593
+ if (!marketplace) {
594
+ try {
595
+ commandJson(registration.command, [
596
+ "plugin",
597
+ "marketplace",
598
+ "remove",
599
+ registration.marketplaceKey,
600
+ "--json",
601
+ ]);
602
+ }
603
+ catch {
604
+ // Best effort: preserve the original host failure.
605
+ }
606
+ }
607
+ throw cause;
608
+ }
609
+ }
610
+ function applyRegistration(registration, destination) {
611
+ if (isCodexRegistration(registration))
612
+ applyCodexRegistration(registration, destination);
613
+ else
614
+ applyClaudeRegistration(registration, destination);
615
+ }
616
+ function revertClaudeRegistration(registration, destination) {
453
617
  if (!existsAt(registration.file))
454
618
  return;
455
619
  const current = readJsonObject(registration.file);
@@ -470,7 +634,54 @@ function revertRegistration(registration, destination) {
470
634
  enabledPlugins: enabled,
471
635
  });
472
636
  }
473
- function registrationCurrent(settings, destination) {
637
+ function revertCodexRegistration(registration, destination) {
638
+ const marketplace = codexMarketplace(registration);
639
+ // The name now belongs to a different root. Removing either its plugins or
640
+ // the marketplace would mutate state Cairn did not install.
641
+ if (marketplace && !samePath(marketplace.root, destination))
642
+ return;
643
+ const installed = new Set(codexPlugins(registration)
644
+ .filter((plugin) => plugin.installed)
645
+ .map((plugin) => plugin.pluginId));
646
+ for (const key of registeredPluginKeys(registration))
647
+ if (installed.has(key))
648
+ commandJson(registration.command, ["plugin", "remove", key, "--json"]);
649
+ if (marketplace)
650
+ commandJson(registration.command, [
651
+ "plugin",
652
+ "marketplace",
653
+ "remove",
654
+ registration.marketplaceKey,
655
+ "--json",
656
+ ]);
657
+ }
658
+ function revertRegistration(registration, destination) {
659
+ if (isCodexRegistration(registration))
660
+ revertCodexRegistration(registration, destination);
661
+ else
662
+ revertClaudeRegistration(registration, destination);
663
+ }
664
+ function expectedPluginVersions(registration, record) {
665
+ if (record.kind === "collection" && record.collection)
666
+ return new Map(record.collection.plugins.map((plugin) => [
667
+ `${plugin.name}@${registration.marketplaceKey}`,
668
+ plugin.version,
669
+ ]));
670
+ return new Map(registeredPluginKeys(registration).map((key) => [key, record.bundle.version]));
671
+ }
672
+ function codexRegistrationCurrent(registration, destination, record) {
673
+ const marketplace = codexMarketplace(registration);
674
+ if (!marketplace || !samePath(marketplace.root, destination))
675
+ return false;
676
+ const installed = new Map(codexPlugins(registration).map((plugin) => [plugin.pluginId, plugin]));
677
+ for (const [key, version] of expectedPluginVersions(registration, record)) {
678
+ const plugin = installed.get(key);
679
+ if (!plugin?.installed || !plugin.enabled || plugin.version !== version)
680
+ return false;
681
+ }
682
+ return true;
683
+ }
684
+ function claudeRegistrationCurrent(settings, destination) {
474
685
  if (!existsAt(settings.file))
475
686
  return false;
476
687
  try {
@@ -484,6 +695,11 @@ function registrationCurrent(settings, destination) {
484
695
  return false;
485
696
  }
486
697
  }
698
+ function registrationCurrent(settings, destination, record) {
699
+ return isCodexRegistration(settings)
700
+ ? codexRegistrationCurrent(settings, destination, record)
701
+ : claudeRegistrationCurrent(settings, destination);
702
+ }
487
703
  function pruneEmptyAncestors(root, relative) {
488
704
  let current = path.dirname(path.join(root, relative));
489
705
  const stop = path.resolve(root);
@@ -518,7 +734,9 @@ export function installIsCurrent(plan) {
518
734
  // would leave --check reporting "current" while uninstall reports not-found.
519
735
  if (readInstallRecord(plan.destination, installKey(plan.record)) === "missing")
520
736
  return false;
521
- if (plan.register && plan.settings && !registrationCurrent(plan.settings, plan.destination))
737
+ if (plan.register &&
738
+ plan.settings &&
739
+ !registrationCurrent(plan.settings, plan.destination, plan.record))
522
740
  return false;
523
741
  return true;
524
742
  }
@@ -581,6 +799,42 @@ function unresolvedPlan(bundle, target, scope, mode, resolved) {
581
799
  register: false,
582
800
  };
583
801
  }
802
+ function registrationFor(location, marketplaceKey, pluginKeys, context, single) {
803
+ if (location.layout !== "marketplace" || !location.activation)
804
+ return undefined;
805
+ const keys = single ? { pluginKey: pluginKeys[0] } : { pluginKeys };
806
+ if (location.activation.form === "codex-plugin-cli")
807
+ return {
808
+ form: "codex-plugin-cli",
809
+ command: location.activation.command,
810
+ marketplaceKey,
811
+ ...keys,
812
+ };
813
+ return {
814
+ file: expandInstallRoot(location.activation.file, context),
815
+ marketplaceKey,
816
+ ...keys,
817
+ };
818
+ }
819
+ function activationDiagnostic(registration, destination, target) {
820
+ const keys = registeredPluginKeys(registration);
821
+ if (isCodexRegistration(registration)) {
822
+ const commands = [
823
+ `${registration.command} plugin marketplace add ${destination} --json`,
824
+ ...keys.map((key) => `${registration.command} plugin add ${key} --json`),
825
+ ];
826
+ return diagnostic("AB805", `Host activation required but --register was not given. Run: ${commands.join("; ")}.`, "unsupported", {
827
+ target,
828
+ path: destination,
829
+ remediation: "Re-run with --register, or run the reported Codex commands yourself.",
830
+ });
831
+ }
832
+ return diagnostic("AB805", `Host activation edit required but --register was not given. Add extraKnownMarketplaces.${registration.marketplaceKey} (directory ${destination}) and enabledPlugins for ${keys.join(", ")} to ${registration.file}.`, "unsupported", {
833
+ target,
834
+ path: registration.file,
835
+ remediation: "Re-run with --register, or apply the edit yourself.",
836
+ });
837
+ }
584
838
  function draftInstall(bundle, target, options) {
585
839
  const scope = resolveScope(options.scope, "user");
586
840
  const resolved = resolveInstallDestination(target, scope, bundle.name, options);
@@ -602,23 +856,10 @@ function draftInstall(bundle, target, options) {
602
856
  target,
603
857
  profile: location.profile,
604
858
  }));
605
- const settingsFile = location.activation
606
- ? expandInstallRoot(location.activation.file, options)
607
- : undefined;
608
- const settings = location.layout === "marketplace" && settingsFile
609
- ? {
610
- file: settingsFile,
611
- marketplaceKey: bundle.name,
612
- pluginKey: `${bundle.name}@${bundle.name}`,
613
- }
614
- : undefined;
859
+ const settings = registrationFor(location, bundle.name, [`${bundle.name}@${bundle.name}`], options, true);
615
860
  const register = Boolean(options.register) && Boolean(settings);
616
861
  if (settings && !options.register)
617
- diagnostics.push(diagnostic("AB805", `Host activation edit required but --register was not given. Add extraKnownMarketplaces.${bundle.name} (directory ${destination}) and enabledPlugins["${bundle.name}@${bundle.name}"] to ${settingsFile}.`, "unsupported", {
618
- target,
619
- path: settingsFile,
620
- remediation: "Re-run with --register, or apply the edit yourself.",
621
- }));
862
+ diagnostics.push(activationDiagnostic(settings, destination, target));
622
863
  if (mode === "link")
623
864
  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 }));
624
865
  const materialized = mode === "link" ? path.join(bundle.root, INSTALL_CACHE, target, location.profile) : undefined;
@@ -840,20 +1081,11 @@ export function planCollectionInstall(collection, options = {}) {
840
1081
  target: collection.target,
841
1082
  profile: "plugin",
842
1083
  }));
843
- const settingsFile = location.activation
844
- ? expandInstallRoot(location.activation.file, options)
845
- : undefined;
846
1084
  const pluginKeys = collection.plugins.map((plugin) => `${plugin.name}@${collection.name}`);
847
- const settings = location.layout === "marketplace" && settingsFile
848
- ? { file: settingsFile, marketplaceKey: collection.name, pluginKeys }
849
- : undefined;
1085
+ const settings = registrationFor(location, collection.name, pluginKeys, options, false);
850
1086
  const register = Boolean(options.register) && Boolean(settings);
851
1087
  if (settings && !options.register)
852
- diagnostics.push(diagnostic("AB805", `Host activation edit required but --register was not given. Add extraKnownMarketplaces.${collection.name} (directory ${destination}) and enabledPlugins for ${pluginKeys.join(", ")} to ${settingsFile}.`, "unsupported", {
853
- target: collection.target,
854
- path: settingsFile,
855
- remediation: "Re-run with --register, or apply the edit yourself.",
856
- }));
1088
+ diagnostics.push(activationDiagnostic(settings, destination, collection.target));
857
1089
  if (mode === "link")
858
1090
  diagnostics.push(diagnostic("AB807", "--link is in use; edits to the materialized tree are live and the host may not follow symlinks", "exact", { target: collection.target, profile: "plugin", path: destination }));
859
1091
  const materialized = mode === "link" ? options.materializeInto : undefined;
@@ -971,6 +1203,19 @@ function retirePrior(plan) {
971
1203
  if (previous.registration && !plan.record.registration)
972
1204
  revertRegistration(previous.registration, plan.destination);
973
1205
  }
1206
+ function restoreRegistrationRecordAfterFailure(plan) {
1207
+ const key = installKey(plan.record);
1208
+ const previous = plan.prior === "missing" || plan.prior === "malformed"
1209
+ ? undefined
1210
+ : plan.prior.installs.find((record) => installKey(record) === key)?.registration;
1211
+ const installs = plan.document.installs.map((record) => {
1212
+ if (installKey(record) !== key)
1213
+ return record;
1214
+ const { registration: _failed, ...without } = record;
1215
+ return previous ? { ...without, registration: previous } : without;
1216
+ });
1217
+ writeJsonAtomically(path.join(plan.destination, INSTALL_MANIFEST), serializeDocument({ generator: currentGenerator(), installs: sortRecords(installs) }));
1218
+ }
974
1219
  /** Writes a planned install. Caller must have already decided the run is not blocked. */
975
1220
  export function commitInstall(plan) {
976
1221
  retirePrior(plan);
@@ -979,7 +1224,15 @@ export function commitInstall(plan) {
979
1224
  else
980
1225
  writeCopy(plan);
981
1226
  if (plan.register && plan.settings)
982
- applyRegistration(plan.settings, plan.destination);
1227
+ try {
1228
+ applyRegistration(plan.settings, plan.destination);
1229
+ }
1230
+ catch (cause) {
1231
+ // The filesystem install remains useful, but its manifest must not claim
1232
+ // host activation that the driver just rolled back.
1233
+ restoreRegistrationRecordAfterFailure(plan);
1234
+ throw cause;
1235
+ }
983
1236
  }
984
1237
  export function planToEntry(plan) {
985
1238
  return toEntry(plan.record);
@@ -1086,6 +1339,10 @@ export function commitUninstall(plan) {
1086
1339
  if (!record)
1087
1340
  return;
1088
1341
  const destination = plan.destination;
1342
+ // Host state is the harder-to-recover half. If its driver fails, leave the
1343
+ // marketplace tree and manifest intact so a retry can still identify it.
1344
+ if (record.registration)
1345
+ revertRegistration(record.registration, destination);
1089
1346
  const document = readInstallDocument(destination);
1090
1347
  const key = installKey(record);
1091
1348
  const remaining = document === "missing" || document === "malformed"
@@ -1123,8 +1380,6 @@ export function commitUninstall(plan) {
1123
1380
  }
1124
1381
  if (record.materialized)
1125
1382
  removePath(record.materialized);
1126
- if (record.registration)
1127
- revertRegistration(record.registration, destination);
1128
1383
  }
1129
1384
  function scanRoot(target, scope, location, locationRoot) {
1130
1385
  const entries = [];
@@ -1164,7 +1419,7 @@ export function listInstalled(targets, options = {}) {
1164
1419
  continue;
1165
1420
  const locationRoot = options.into
1166
1421
  ? path.resolve(options.into)
1167
- : expandInstallRoot(location.root, options);
1422
+ : expandInstallLocationRoot(location, options);
1168
1423
  entries.push(...scanRoot(target, scope, location, locationRoot));
1169
1424
  }
1170
1425
  }