@detergent-software/atk 8.4.2 → 8.4.3-dev.2

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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/build/commands/audit.js +2 -2
  3. package/build/commands/audit.js.map +1 -1
  4. package/build/commands/install.d.ts.map +1 -1
  5. package/build/commands/install.js +39 -20
  6. package/build/commands/install.js.map +1 -1
  7. package/build/components/ExcludedSkippedHint.d.ts +3 -2
  8. package/build/components/ExcludedSkippedHint.d.ts.map +1 -1
  9. package/build/components/ExcludedSkippedHint.js +3 -2
  10. package/build/components/ExcludedSkippedHint.js.map +1 -1
  11. package/build/components/InstallSummary.d.ts +16 -2
  12. package/build/components/InstallSummary.d.ts.map +1 -1
  13. package/build/components/InstallSummary.js +3 -3
  14. package/build/components/InstallSummary.js.map +1 -1
  15. package/build/hooks/useListActions.d.ts.map +1 -1
  16. package/build/hooks/useListActions.js +10 -2
  17. package/build/hooks/useListActions.js.map +1 -1
  18. package/build/lib/asset-tools.d.ts +22 -0
  19. package/build/lib/asset-tools.d.ts.map +1 -1
  20. package/build/lib/asset-tools.js +32 -0
  21. package/build/lib/asset-tools.js.map +1 -1
  22. package/build/lib/installer.d.ts +4 -1
  23. package/build/lib/installer.d.ts.map +1 -1
  24. package/build/lib/installer.js +32 -7
  25. package/build/lib/installer.js.map +1 -1
  26. package/build/lib/multi-tool-install.d.ts +34 -0
  27. package/build/lib/multi-tool-install.d.ts.map +1 -1
  28. package/build/lib/multi-tool-install.js +72 -23
  29. package/build/lib/multi-tool-install.js.map +1 -1
  30. package/build/lib/schemas/lockfile.js +1 -1
  31. package/build/lib/schemas/lockfile.js.map +1 -1
  32. package/build/lib/sync.d.ts +4 -0
  33. package/build/lib/sync.d.ts.map +1 -1
  34. package/build/lib/sync.js +30 -3
  35. package/build/lib/sync.js.map +1 -1
  36. package/build/lib/updater.d.ts +10 -0
  37. package/build/lib/updater.d.ts.map +1 -1
  38. package/build/lib/updater.js +256 -197
  39. package/build/lib/updater.js.map +1 -1
  40. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  import { join } from 'node:path';
2
2
  import { compare, valid } from 'semver';
3
3
  import { getPlacement, resolveInstalledPaths } from './adapter.js';
4
- import { isExcludedForTool, supportsType } from './asset-tools.js';
4
+ import { declarationAllows, isExcludedForTool, supportsType } from './asset-tools.js';
5
5
  import { getCredential } from './auth.js';
6
6
  import { getConfigEntriesForTool } from './config-entries.js';
7
7
  import { ApiCodeError, asSentence, errorGuidance, errorMessage, isRunWideFailure, } from './errors.js';
8
8
  import { restoreFiles, snapshotFiles } from './file-snapshot.js';
9
- import { executeInstallPlan, fetchPlanFiles, FileConflictError, installFailureMessage, InstallPlanError, } from './installer.js';
9
+ import { executeInstallPlan, fetchPlanFiles, FileConflictError, InstallerError, installFailureMessage, InstallPlanError, } from './installer.js';
10
10
  import { addAssetToLockfile, findDependencyAsset, findDependents, findInstalledAsset, getLockfilePath, readLockfile, writeLockfile, } from './lockfile.js';
11
+ import { withoutKeptDependencies } from './multi-tool-install.js';
11
12
  import { formatOrgName, parseOrgFromName, scopeSwitchGuidance } from './org.js';
12
13
  import { findAsset } from './registry.js';
13
14
  import { resolveInstallPlan } from './resolver.js';
@@ -64,6 +65,18 @@ export function checkPinMismatches(lockfile) {
64
65
  ]
65
66
  : []);
66
67
  }
68
+ /**
69
+ * Whether the manifest of `version` of the asset lets the adapter's tool take it (see
70
+ * {@link declarationAllows}); only a hook's declaration decides that. A version the registry lacks
71
+ * in the asset's scope counts as allowed, so the update itself reports it.
72
+ */
73
+ export function declarationAllowsVersion(registry, asset, version, adapter) {
74
+ if (asset.type !== 'hook')
75
+ return true;
76
+ const found = findAsset(registry, asset.name, asset.type, version, asset.org);
77
+ const tools = found?.asset.org === asset.org ? found?.versionData.tools : undefined;
78
+ return declarationAllows(asset.type, tools, adapter.tool);
79
+ }
67
80
  /**
68
81
  * The installed asset `atk update <spec>` names. `@org/name` names that org's entry only. A
69
82
  * bare name resolves the way `install` resolved it: the lockfile org's entry, else the global
@@ -98,186 +111,7 @@ export function findUpdateTarget(lockfile, spec, { type, version } = {}) {
98
111
  * download, so the rollback needs no second download.
99
112
  */
100
113
  export async function updateAsset(assetName, installed, registry, options) {
101
- const { adapter, onProgress, projectDir, targetVersion } = options;
102
- // Resolve the target version — defaults to latest when targetVersion is undefined
103
- const resolved = findAsset(registry, assetName, installed.type, targetVersion, installed.org);
104
- if (!resolved) {
105
- if (targetVersion) {
106
- throw new UpdaterError(`Asset "${assetName}" version "${targetVersion}" not found in registry.`);
107
- }
108
- throw new UpdaterError(`Asset "${assetName}" not found in registry.`);
109
- }
110
- if (resolved.asset.org !== installed.org) {
111
- throw scopeSwitchError(installed, resolved.asset);
112
- }
113
- const toVersion = resolved.version;
114
- if (!isNewer(toVersion, installed.version)) {
115
- if (targetVersion) {
116
- throw new UpdaterError(`Asset "${assetName}" is already at or ahead of the requested version (installed: v${installed.version}, requested: v${toVersion}).`);
117
- }
118
- throw new UpdaterError(`Asset "${assetName}" is already at the latest version (v${installed.version}).`);
119
- }
120
- const fromVersion = installed.version;
121
- // Capture old dependencies before uninstalling
122
- const oldDeps = installed.dependencies ?? [];
123
- // Download the target version before removing anything, so a refused download (an outdated
124
- // CLI, an expired sign-in, the API down) leaves the installed version in place.
125
- const plan = planFor(registry, installed, toVersion);
126
- onProgress?.(`Downloading ${assetName}@${toVersion}...`);
127
- let files;
128
- try {
129
- files = await fetchPlanFiles(plan, adapter);
130
- }
131
- catch (downloadError) {
132
- throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${failureReason(downloadError, installed)} ` +
133
- `v${fromVersion} is still installed.`, errorGuidance(downloadError));
134
- }
135
- // Capture what the update can change, so a failed uninstall or install restores it without the network.
136
- let snapshot;
137
- // The version the lockfile records now. In a multi-tool update an earlier tool may already
138
- // have moved it past `fromVersion`, which a restore then puts back.
139
- let lockedVersion;
140
- // The asset's files as this tool has them, which the uninstall removes.
141
- let toolEntry;
142
- try {
143
- const lockfile = await readLockfile(projectDir);
144
- lockedVersion = findInstalledAsset(lockfile, assetName, installed.type, installed.org)?.version;
145
- const toolLockfile = options.toolLockfile ?? lockfile;
146
- toolEntry = findInstalledAsset(toolLockfile, assetName, installed.type, installed.org) ?? installed;
147
- snapshot = await snapshotFiles(updateTouchedPaths([lockfile, toolLockfile], plan, [installed, toolEntry], adapter, projectDir));
148
- }
149
- catch (snapshotError) {
150
- throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${failureReason(snapshotError, installed)} ` +
151
- `v${fromVersion} is still installed.`, errorGuidance(snapshotError));
152
- }
153
- let results;
154
- try {
155
- // Uninstall current version without cascade — new version may still need the same deps.
156
- // The lockfile entry is kept so the reinstall below can carry over the config entries
157
- // other tools registered for this asset (the install replaces the entry either way).
158
- onProgress?.(`Removing ${assetName}@${fromVersion}...`);
159
- await uninstallAsset(assetName, {
160
- adapter,
161
- cascade: false,
162
- keepLockfileEntry: true,
163
- ...(options.toolLockfile ? { managedFrom: options.toolLockfile } : {}),
164
- onProgress,
165
- org: installed.org,
166
- plan: { dependents: [], orphanedDependencies: [], primary: toolEntry },
167
- projectDir,
168
- type: installed.type,
169
- });
170
- // Install target version (pass explicit version to resolver)
171
- onProgress?.(`Installing ${assetName}@${toVersion}...`);
172
- // Not forced: the paths this tool has are ATK-managed, so only an unmanaged file or another
173
- // installed asset's file conflicts.
174
- results = await executeInstallPlan(plan, {
175
- adapter,
176
- files,
177
- ...(options.toolLockfile ? { managedFrom: options.toolLockfile } : {}),
178
- onProgress,
179
- projectDir,
180
- });
181
- }
182
- catch (installError) {
183
- // `atk update --force` overrides pins, not file conflicts, so the conflict gets its own advice.
184
- const conflict = installError instanceof InstallPlanError && installError.cause instanceof FileConflictError
185
- ? { asset: installError.asset, owner: installError.cause.owner, where: installError.cause.where }
186
- : undefined;
187
- const owner = conflict?.owner && formatOrgName(conflict.owner.org, conflict.owner.name);
188
- const installFailure = conflict
189
- ? `${conflict.where} ` +
190
- (conflict.owner
191
- ? `belongs to the installed ${conflict.owner.type} "${owner}"`
192
- : 'already exists and was not installed by ATK') +
193
- (isSameAsset(conflict.asset, installed)
194
- ? '.'
195
- : `, and the dependency "${formatOrgName(conflict.asset.org, conflict.asset.name)}" goes there.`)
196
- : failureReason(installError, installed);
197
- const conflictRemedy = owner ? `Uninstall "${owner}" first` : 'Move or rename it';
198
- onProgress?.(`Update of ${assetName} to ${toVersion} failed, attempting to restore ${assetName}@${fromVersion}...`);
199
- try {
200
- await restoreFiles(snapshot);
201
- }
202
- catch (rollbackError) {
203
- const rollbackFailure = failureReason(rollbackError, installed);
204
- throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${installFailure} ` +
205
- `Rollback to v${fromVersion} also failed` +
206
- (rollbackFailure === installFailure ? ' for the same reason. ' : `: ${rollbackFailure} `) +
207
- `Run "atk sync" to restore the asset.`, { command: 'atk sync', ...errorGuidance(installError) });
208
- }
209
- if (lockedVersion !== undefined && lockedVersion !== fromVersion) {
210
- // The lockfile has one entry for every tool, so this tool's files now trail it.
211
- const tool = adapter.tool;
212
- const command = `atk sync --tool ${tool}`;
213
- throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion} for ${tool}: ${installFailure} ` +
214
- `Its ${tool} files were restored to v${fromVersion}, but the lockfile records v${lockedVersion}, ` +
215
- `which the other tools have. Run "${command}" to install v${lockedVersion} for ${tool}.`, conflict
216
- ? { command, hint: `${conflictRemedy}, then run the command.` }
217
- : { ...errorGuidance(installError), command });
218
- }
219
- throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${installFailure} ` +
220
- `The previous version (v${fromVersion}) has been restored.`, conflict ? { hint: `${conflictRemedy}, then run the update again.` } : errorGuidance(installError));
221
- }
222
- // After the restore path: the new version is installed, and restoring the old lockfile would
223
- // orphan the files of the dependencies it added.
224
- await restoreBundleOrigin(projectDir, installed);
225
- // Match org and version too: the plan can hold a same-named dependency from another org.
226
- const directResult = results.find((r) => r.name === assetName && r.type === installed.type && r.org === installed.org && r.version === toVersion);
227
- if (!directResult) {
228
- throw new UpdaterError(`Install did not produce a result for "${assetName}".`);
229
- }
230
- // Clean up dropped dependencies: deps in old version but not in new version. One asset's
231
- // dependency refs carry no org, so type and name identify each.
232
- const newDeps = resolved.versionData.dependencies ?? [];
233
- const newDepKeys = new Set(newDeps.map((d) => `${d.type}:${d.name}`));
234
- const droppedDeps = oldDeps.filter((d) => !newDepKeys.has(`${d.type}:${d.name}`));
235
- // The new version is installed, so a cleanup failure is a warning, not a failed update.
236
- const warnings = [];
237
- if (droppedDeps.length > 0) {
238
- // Read after the install: before it, the old entry still lists the dropped deps as its own.
239
- const updatedLockfile = await readLockfile(projectDir);
240
- for (const dep of droppedDeps) {
241
- const depAsset = findDependencyAsset(updatedLockfile, dep, installed.org);
242
- if (!depAsset)
243
- continue;
244
- // Only remove if it was a dependency and no other asset depends on it for this tool
245
- if ((depAsset.installReason ?? 'direct') !== 'dependency')
246
- continue;
247
- const dependents = findDependents(updatedLockfile, depAsset.name, depAsset.type, depAsset.org).filter((d) => !isExcludedForTool(d, adapter.tool));
248
- if (dependents.length === 0) {
249
- onProgress?.(`Removing dropped dependency ${dep.name}...`);
250
- try {
251
- await uninstallAsset(dep.name, {
252
- adapter,
253
- cascade: true,
254
- onProgress,
255
- org: depAsset.org,
256
- projectDir,
257
- toolScope: { configuredAdapters: options.configuredAdapters ?? [adapter] },
258
- type: depAsset.type,
259
- });
260
- }
261
- catch (error) {
262
- const message = `Could not remove dropped dependency "${dep.name}": ${asSentence(errorMessage(error))}`;
263
- onProgress?.(`Warning: ${message}`);
264
- // Other tools may still need the dependency, so the command removes it for this tool only.
265
- const toolFlag = (options.configuredAdapters?.length ?? 1) > 1 ? ` --tool ${adapter.tool}` : '';
266
- const spec = `${depAsset.org ? `@${depAsset.org}/` : ''}${dep.name}`;
267
- warnings.push({ command: `atk uninstall ${spec}${toolFlag}`, message });
268
- }
269
- }
270
- }
271
- }
272
- return {
273
- fromVersion,
274
- installResult: directResult,
275
- name: assetName,
276
- ...(installed.org ? { org: installed.org } : {}),
277
- toVersion,
278
- type: installed.type,
279
- ...(warnings.length > 0 ? { warnings } : {}),
280
- };
114
+ return (await updateAssetPlanned(assetName, installed, registry, options)).result;
281
115
  }
282
116
  /**
283
117
  * Update multiple assets. If no names provided, updates all outdated assets.
@@ -323,14 +157,17 @@ export async function updateAssets(assetNames, lockfile, registry, options) {
323
157
  reportUnsupported(installed, options);
324
158
  return { failures: [], results: [] };
325
159
  }
326
- const dependencySkip = await failedDependencySkipFor(installed, registry, options);
160
+ if (!declarationAllowsVersion(registry, installed, options.targetVersion, options.adapter)) {
161
+ reportUndeclared(installed, options);
162
+ return { failures: [], results: [] };
163
+ }
164
+ // The skip check and the update plan from the same view of what this tool has.
165
+ const toolOptions = { ...options, toolLockfile: options.toolLockfile ?? lockfile };
166
+ const dependencySkip = await failedDependencySkipFor(installed, registry, toolOptions);
327
167
  if (dependencySkip) {
328
168
  return { failedSkipped: [dependencySkip], failures: [], results: [] };
329
169
  }
330
- const result = await updateAsset(name, installed, registry, {
331
- ...options,
332
- toolLockfile: options.toolLockfile ?? lockfile,
333
- });
170
+ const result = await updateAsset(name, installed, registry, toolOptions);
334
171
  return { failures: [], results: [result] };
335
172
  }
336
173
  const outdated = checkOutdated(lockfile, registry, options.frozenAssets);
@@ -408,6 +245,11 @@ export async function updateAssets(assetNames, lockfile, registry, options) {
408
245
  reportUnsupported(asset, options);
409
246
  return false;
410
247
  }
248
+ // A hook whose new version doesn't declare the tool: the installer would refuse it.
249
+ if (!declarationAllowsVersion(registry, asset, asset.latestVersion, options.adapter)) {
250
+ reportUndeclared(asset, options);
251
+ return false;
252
+ }
411
253
  return true;
412
254
  });
413
255
  if (toUpdate.length === 0) {
@@ -434,14 +276,14 @@ export async function updateAssets(assetNames, lockfile, registry, options) {
434
276
  if (!installed) {
435
277
  throw new UpdaterError(`Asset "${asset.name}" is installed but not found in lockfile.`);
436
278
  }
437
- let result;
279
+ let updated;
438
280
  try {
439
- const dependencySkip = await failedDependencySkipFor(installed, registry, options);
281
+ const dependencySkip = await failedDependencySkipFor(installed, registry, { ...options, toolLockfile });
440
282
  if (dependencySkip) {
441
283
  failedSkipped.push(dependencySkip);
442
284
  continue;
443
285
  }
444
- result = await updateAsset(asset.name, installed, registry, { ...options, toolLockfile });
286
+ updated = await updateAssetPlanned(asset.name, installed, registry, { ...options, toolLockfile });
445
287
  }
446
288
  catch (error) {
447
289
  // Most errors from updateAsset already name the asset; only frame the ones that don't.
@@ -465,9 +307,10 @@ export async function updateAssets(assetNames, lockfile, registry, options) {
465
307
  }
466
308
  continue;
467
309
  }
468
- results.push(result);
469
- // The update succeeded either way; a failed read leaves the next update to report it.
470
- toolLockfile = await withInstalledEntries(toolLockfile, planFor(registry, installed, result.toVersion), options).catch(() => toolLockfile);
310
+ results.push(updated.result);
311
+ // The update succeeded either way; a failed read leaves the next update to report it. Only the
312
+ // assets it installed: a dependency it kept may hold another tool's entry on disk by now.
313
+ toolLockfile = await withInstalledEntries(toolLockfile, updated.plan, options).catch(() => toolLockfile);
471
314
  }
472
315
  return {
473
316
  ...(excludedSkipped.length > 0 ? { excludedSkipped } : {}),
@@ -487,7 +330,8 @@ function assetSpec(asset, type) {
487
330
  * The skip record for an asset whose update would move a dependency whose own update failed for
488
331
  * an earlier tool in the run: that tool kept the version the lockfile records, so moving it here
489
332
  * would leave the tools on different versions. A dependency the lockfile already records at the
490
- * plan's version, or one this tool has no placement for, is not moved, so it does not count.
333
+ * plan's version, one this tool has no placement for, or one the update keeps (see
334
+ * {@link withoutKeptDependencies}) is not moved, so it does not count.
491
335
  */
492
336
  async function failedDependencySkipFor(installed, registry, options) {
493
337
  if (!options.failedFor?.size)
@@ -497,7 +341,18 @@ async function failedDependencySkipFor(installed, registry, options) {
497
341
  if (!resolved || resolved.asset.org !== installed.org)
498
342
  return undefined;
499
343
  const lockfile = await readLockfile(options.projectDir);
500
- for (const { asset, version } of planFor(registry, installed, resolved.version).entries) {
344
+ let plan;
345
+ try {
346
+ const { adapter, projectDir, toolLockfile } = options;
347
+ plan = planFor(registry, installed, resolved.version, { adapter, lockfile: toolLockfile ?? lockfile, projectDir });
348
+ }
349
+ catch (error) {
350
+ // It would move a pinned dependency, which the update itself refuses and reports.
351
+ if (error instanceof InstallerError)
352
+ return undefined;
353
+ throw error;
354
+ }
355
+ for (const { asset, version } of plan.entries) {
501
356
  if (isSameAsset(asset, installed) || !supportsType(options.adapter, asset.type))
502
357
  continue;
503
358
  const failedFor = options.failedFor.get(`${asset.type}:${asset.org ?? ''}:${asset.name}`);
@@ -559,9 +414,14 @@ function lockfileEntryFor(lockfile, asset) {
559
414
  * dependency must not turn it into a direct install. Only the requested asset's entry is
560
415
  * `direct`, so a same-named entry, such as a dependency of another type or org, keeps its
561
416
  * own reason.
417
+ *
418
+ * With `kept`, the dependencies that lockfile has in place for the adapter's tool at a version the
419
+ * plan accepts are left out (see {@link withoutKeptDependencies}). That runs before the reason is
420
+ * carried over, while the asset being updated is still the plan's one direct entry.
562
421
  */
563
- function planFor(registry, installed, version) {
564
- const plan = resolveInstallPlan(registry, installed.name, installed.type, version, installed.org);
422
+ function planFor(registry, installed, version, kept) {
423
+ const resolved = resolveInstallPlan(registry, installed.name, installed.type, version, installed.org);
424
+ const plan = kept ? withoutKeptDependencies(resolved, kept.lockfile, [{ adapter: kept.adapter }], kept) : resolved;
565
425
  const reason = installed.installReason ?? 'direct';
566
426
  return {
567
427
  ...plan,
@@ -572,6 +432,9 @@ function planFor(registry, installed, version) {
572
432
  async function recordedLockfile(options) {
573
433
  return options.failedFor?.size ? readLockfile(options.projectDir) : undefined;
574
434
  }
435
+ function reportUndeclared(asset, options) {
436
+ options.onProgress?.(`Skipping ${asset.name}: the hook does not declare ${options.adapter.tool}`);
437
+ }
575
438
  function reportUnsupported(asset, options) {
576
439
  options.onProgress?.(`Skipping ${asset.name}: ${options.adapter.tool} has no placement for ${asset.type} assets`);
577
440
  }
@@ -595,6 +458,202 @@ function scopeSwitchError(installed, fallback) {
595
458
  function toExcludedSkipped(installed) {
596
459
  return { name: installed.name, ...(installed.org ? { org: installed.org } : {}), type: installed.type };
597
460
  }
461
+ /** {@link updateAsset}, with the plan it installed: kept dependencies are not in it. */
462
+ async function updateAssetPlanned(assetName, installed, registry, options) {
463
+ const { adapter, onProgress, projectDir, targetVersion } = options;
464
+ // Resolve the target version — defaults to latest when targetVersion is undefined
465
+ const resolved = findAsset(registry, assetName, installed.type, targetVersion, installed.org);
466
+ if (!resolved) {
467
+ if (targetVersion) {
468
+ throw new UpdaterError(`Asset "${assetName}" version "${targetVersion}" not found in registry.`);
469
+ }
470
+ throw new UpdaterError(`Asset "${assetName}" not found in registry.`);
471
+ }
472
+ if (resolved.asset.org !== installed.org) {
473
+ throw scopeSwitchError(installed, resolved.asset);
474
+ }
475
+ const toVersion = resolved.version;
476
+ if (!isNewer(toVersion, installed.version)) {
477
+ if (targetVersion) {
478
+ throw new UpdaterError(`Asset "${assetName}" is already at or ahead of the requested version (installed: v${installed.version}, requested: v${toVersion}).`);
479
+ }
480
+ throw new UpdaterError(`Asset "${assetName}" is already at the latest version (v${installed.version}).`);
481
+ }
482
+ const fromVersion = installed.version;
483
+ // Capture old dependencies before uninstalling
484
+ const oldDeps = installed.dependencies ?? [];
485
+ // Dependencies this tool already has at a version the new one accepts stay as they are, as in
486
+ // `atk install`; moving a pinned one refuses the update before anything changes.
487
+ let plan;
488
+ try {
489
+ const kept = { ...options, lockfile: options.toolLockfile ?? (await readLockfile(projectDir)) };
490
+ plan = planFor(registry, installed, toVersion, kept);
491
+ }
492
+ catch (planError) {
493
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${failureReason(planError, installed)} ` +
494
+ `v${fromVersion} is still installed.`, errorGuidance(planError));
495
+ }
496
+ // Download the target version before removing anything, so a refused download (an outdated
497
+ // CLI, an expired sign-in, the API down) leaves the installed version in place.
498
+ onProgress?.(`Downloading ${assetName}@${toVersion}...`);
499
+ let files;
500
+ try {
501
+ files = await fetchPlanFiles(plan, adapter);
502
+ }
503
+ catch (downloadError) {
504
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${failureReason(downloadError, installed)} ` +
505
+ `v${fromVersion} is still installed.`, errorGuidance(downloadError));
506
+ }
507
+ // Capture what the update can change, so a failed uninstall or install restores it without the network.
508
+ let snapshot;
509
+ // The version the lockfile records now. In a multi-tool update an earlier tool may already
510
+ // have moved it past `fromVersion`, which a restore then puts back.
511
+ let lockedVersion;
512
+ // The asset's files as this tool has them, which the uninstall removes.
513
+ let toolEntry;
514
+ try {
515
+ const lockfile = await readLockfile(projectDir);
516
+ lockedVersion = findInstalledAsset(lockfile, assetName, installed.type, installed.org)?.version;
517
+ const toolLockfile = options.toolLockfile ?? lockfile;
518
+ toolEntry = findInstalledAsset(toolLockfile, assetName, installed.type, installed.org) ?? installed;
519
+ snapshot = await snapshotFiles(updateTouchedPaths([lockfile, toolLockfile], plan, [installed, toolEntry], adapter, projectDir));
520
+ }
521
+ catch (snapshotError) {
522
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${failureReason(snapshotError, installed)} ` +
523
+ `v${fromVersion} is still installed.`, errorGuidance(snapshotError));
524
+ }
525
+ let results;
526
+ try {
527
+ // Uninstall current version without cascade — new version may still need the same deps.
528
+ // The lockfile entry is kept so the reinstall below can carry over the config entries
529
+ // other tools registered for this asset (the install replaces the entry either way).
530
+ onProgress?.(`Removing ${assetName}@${fromVersion}...`);
531
+ await uninstallAsset(assetName, {
532
+ adapter,
533
+ cascade: false,
534
+ keepLockfileEntry: true,
535
+ ...(options.toolLockfile ? { managedFrom: options.toolLockfile } : {}),
536
+ onProgress,
537
+ org: installed.org,
538
+ plan: { dependents: [], orphanedDependencies: [], primary: toolEntry },
539
+ projectDir,
540
+ type: installed.type,
541
+ });
542
+ // Install target version (pass explicit version to resolver)
543
+ onProgress?.(`Installing ${assetName}@${toVersion}...`);
544
+ // Not forced: the paths this tool has are ATK-managed, so only an unmanaged file or another
545
+ // installed asset's file conflicts.
546
+ results = await executeInstallPlan(plan, {
547
+ adapter,
548
+ files,
549
+ ...(options.toolLockfile ? { managedFrom: options.toolLockfile } : {}),
550
+ onProgress,
551
+ projectDir,
552
+ });
553
+ }
554
+ catch (installError) {
555
+ // `atk update --force` overrides pins, not file conflicts, so the conflict gets its own advice.
556
+ const conflict = installError instanceof InstallPlanError && installError.cause instanceof FileConflictError
557
+ ? { asset: installError.asset, owner: installError.cause.owner, where: installError.cause.where }
558
+ : undefined;
559
+ const owner = conflict?.owner && formatOrgName(conflict.owner.org, conflict.owner.name);
560
+ const installFailure = conflict
561
+ ? `${conflict.where} ` +
562
+ (conflict.owner
563
+ ? `belongs to the installed ${conflict.owner.type} "${owner}"`
564
+ : 'already exists and was not installed by ATK') +
565
+ (isSameAsset(conflict.asset, installed)
566
+ ? '.'
567
+ : `, and the dependency "${formatOrgName(conflict.asset.org, conflict.asset.name)}" goes there.`)
568
+ : failureReason(installError, installed);
569
+ const conflictRemedy = owner ? `Uninstall "${owner}" first` : 'Move or rename it';
570
+ onProgress?.(`Update of ${assetName} to ${toVersion} failed, attempting to restore ${assetName}@${fromVersion}...`);
571
+ try {
572
+ await restoreFiles(snapshot);
573
+ }
574
+ catch (rollbackError) {
575
+ const rollbackFailure = failureReason(rollbackError, installed);
576
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${installFailure} ` +
577
+ `Rollback to v${fromVersion} also failed` +
578
+ (rollbackFailure === installFailure ? ' for the same reason. ' : `: ${rollbackFailure} `) +
579
+ `Run "atk sync" to restore the asset.`, { command: 'atk sync', ...errorGuidance(installError) });
580
+ }
581
+ if (lockedVersion !== undefined && lockedVersion !== fromVersion) {
582
+ // The lockfile has one entry for every tool, so this tool's files now trail it.
583
+ const tool = adapter.tool;
584
+ const command = `atk sync --tool ${tool}`;
585
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion} for ${tool}: ${installFailure} ` +
586
+ `Its ${tool} files were restored to v${fromVersion}, but the lockfile records v${lockedVersion}, ` +
587
+ `which the other tools have. Run "${command}" to install v${lockedVersion} for ${tool}.`, conflict
588
+ ? { command, hint: `${conflictRemedy}, then run the command.` }
589
+ : { ...errorGuidance(installError), command });
590
+ }
591
+ throw new UpdaterError(`Failed to update "${assetName}" to v${toVersion}: ${installFailure} ` +
592
+ `The previous version (v${fromVersion}) has been restored.`, conflict ? { hint: `${conflictRemedy}, then run the update again.` } : errorGuidance(installError));
593
+ }
594
+ // After the restore path: the new version is installed, and restoring the old lockfile would
595
+ // orphan the files of the dependencies it added.
596
+ await restoreBundleOrigin(projectDir, installed);
597
+ // Match org and version too: the plan can hold a same-named dependency from another org.
598
+ const directResult = results.find((r) => r.name === assetName && r.type === installed.type && r.org === installed.org && r.version === toVersion);
599
+ if (!directResult) {
600
+ throw new UpdaterError(`Install did not produce a result for "${assetName}".`);
601
+ }
602
+ // Clean up dropped dependencies: deps in old version but not in new version. One asset's
603
+ // dependency refs carry no org, so type and name identify each.
604
+ const newDeps = resolved.versionData.dependencies ?? [];
605
+ const newDepKeys = new Set(newDeps.map((d) => `${d.type}:${d.name}`));
606
+ const droppedDeps = oldDeps.filter((d) => !newDepKeys.has(`${d.type}:${d.name}`));
607
+ // The new version is installed, so a cleanup failure is a warning, not a failed update.
608
+ const warnings = [];
609
+ if (droppedDeps.length > 0) {
610
+ // Read after the install: before it, the old entry still lists the dropped deps as its own.
611
+ const updatedLockfile = await readLockfile(projectDir);
612
+ for (const dep of droppedDeps) {
613
+ const depAsset = findDependencyAsset(updatedLockfile, dep, installed.org);
614
+ if (!depAsset)
615
+ continue;
616
+ // Only remove if it was a dependency and no other asset depends on it for this tool
617
+ if ((depAsset.installReason ?? 'direct') !== 'dependency')
618
+ continue;
619
+ const dependents = findDependents(updatedLockfile, depAsset.name, depAsset.type, depAsset.org).filter((d) => !isExcludedForTool(d, adapter.tool));
620
+ if (dependents.length === 0) {
621
+ onProgress?.(`Removing dropped dependency ${dep.name}...`);
622
+ try {
623
+ await uninstallAsset(dep.name, {
624
+ adapter,
625
+ cascade: true,
626
+ onProgress,
627
+ org: depAsset.org,
628
+ projectDir,
629
+ toolScope: { configuredAdapters: options.configuredAdapters ?? [adapter] },
630
+ type: depAsset.type,
631
+ });
632
+ }
633
+ catch (error) {
634
+ const message = `Could not remove dropped dependency "${dep.name}": ${asSentence(errorMessage(error))}`;
635
+ onProgress?.(`Warning: ${message}`);
636
+ // Other tools may still need the dependency, so the command removes it for this tool only.
637
+ const toolFlag = (options.configuredAdapters?.length ?? 1) > 1 ? ` --tool ${adapter.tool}` : '';
638
+ const spec = `${depAsset.org ? `@${depAsset.org}/` : ''}${dep.name}`;
639
+ warnings.push({ command: `atk uninstall ${spec}${toolFlag}`, message });
640
+ }
641
+ }
642
+ }
643
+ }
644
+ return {
645
+ plan,
646
+ result: {
647
+ fromVersion,
648
+ installResult: directResult,
649
+ name: assetName,
650
+ ...(installed.org ? { org: installed.org } : {}),
651
+ toVersion,
652
+ type: installed.type,
653
+ ...(warnings.length > 0 ? { warnings } : {}),
654
+ },
655
+ };
656
+ }
598
657
  /**
599
658
  * The absolute paths that updating `installed` with `plan` can change for the adapter's tool:
600
659
  * the lockfile, the placed files and config files of the asset and of every installed asset the