@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.
- package/CHANGELOG.md +16 -0
- package/build/commands/audit.js +2 -2
- package/build/commands/audit.js.map +1 -1
- package/build/commands/install.d.ts.map +1 -1
- package/build/commands/install.js +39 -20
- package/build/commands/install.js.map +1 -1
- package/build/components/ExcludedSkippedHint.d.ts +3 -2
- package/build/components/ExcludedSkippedHint.d.ts.map +1 -1
- package/build/components/ExcludedSkippedHint.js +3 -2
- package/build/components/ExcludedSkippedHint.js.map +1 -1
- package/build/components/InstallSummary.d.ts +16 -2
- package/build/components/InstallSummary.d.ts.map +1 -1
- package/build/components/InstallSummary.js +3 -3
- package/build/components/InstallSummary.js.map +1 -1
- package/build/hooks/useListActions.d.ts.map +1 -1
- package/build/hooks/useListActions.js +10 -2
- package/build/hooks/useListActions.js.map +1 -1
- package/build/lib/asset-tools.d.ts +22 -0
- package/build/lib/asset-tools.d.ts.map +1 -1
- package/build/lib/asset-tools.js +32 -0
- package/build/lib/asset-tools.js.map +1 -1
- package/build/lib/installer.d.ts +4 -1
- package/build/lib/installer.d.ts.map +1 -1
- package/build/lib/installer.js +32 -7
- package/build/lib/installer.js.map +1 -1
- package/build/lib/multi-tool-install.d.ts +34 -0
- package/build/lib/multi-tool-install.d.ts.map +1 -1
- package/build/lib/multi-tool-install.js +72 -23
- package/build/lib/multi-tool-install.js.map +1 -1
- package/build/lib/schemas/lockfile.js +1 -1
- package/build/lib/schemas/lockfile.js.map +1 -1
- package/build/lib/sync.d.ts +4 -0
- package/build/lib/sync.d.ts.map +1 -1
- package/build/lib/sync.js +30 -3
- package/build/lib/sync.js.map +1 -1
- package/build/lib/updater.d.ts +10 -0
- package/build/lib/updater.d.ts.map +1 -1
- package/build/lib/updater.js +256 -197
- package/build/lib/updater.js.map +1 -1
- package/package.json +1 -1
package/build/lib/updater.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|