@detergent-software/atk 8.1.2-dev.4 → 8.1.2-dev.41
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 +261 -0
- package/build/commands/audit.d.ts.map +1 -1
- package/build/commands/audit.js +14 -9
- package/build/commands/audit.js.map +1 -1
- package/build/commands/browse.js +24 -34
- package/build/commands/browse.js.map +1 -1
- package/build/commands/install.d.ts.map +1 -1
- package/build/commands/install.js +32 -73
- package/build/commands/install.js.map +1 -1
- package/build/commands/list.js +2 -2
- package/build/commands/list.js.map +1 -1
- package/build/commands/login.js +2 -2
- package/build/commands/login.js.map +1 -1
- package/build/commands/register.js +3 -3
- package/build/commands/register.js.map +1 -1
- package/build/commands/sync.d.ts.map +1 -1
- package/build/commands/sync.js +16 -11
- package/build/commands/sync.js.map +1 -1
- package/build/commands/uninstall.d.ts +9 -1
- package/build/commands/uninstall.d.ts.map +1 -1
- package/build/commands/uninstall.js +23 -26
- package/build/commands/uninstall.js.map +1 -1
- package/build/commands/update.d.ts +9 -1
- package/build/commands/update.d.ts.map +1 -1
- package/build/commands/update.js +54 -14
- package/build/commands/update.js.map +1 -1
- package/build/components/InstallConfirmBar.d.ts.map +1 -1
- package/build/components/InstallConfirmBar.js +2 -1
- package/build/components/InstallConfirmBar.js.map +1 -1
- package/build/components/ListActionBar.d.ts.map +1 -1
- package/build/components/ListActionBar.js +3 -2
- package/build/components/ListActionBar.js.map +1 -1
- package/build/components/RunStoppedHint.d.ts +13 -0
- package/build/components/RunStoppedHint.d.ts.map +1 -0
- package/build/components/RunStoppedHint.js +12 -0
- package/build/components/RunStoppedHint.js.map +1 -0
- package/build/components/SkippedAssetSection.d.ts +2 -0
- package/build/components/SkippedAssetSection.d.ts.map +1 -1
- package/build/components/SkippedAssetSection.js +1 -1
- package/build/components/SkippedAssetSection.js.map +1 -1
- package/build/components/SyncDriftedList.d.ts +3 -2
- package/build/components/SyncDriftedList.d.ts.map +1 -1
- package/build/components/SyncDriftedList.js +11 -3
- package/build/components/SyncDriftedList.js.map +1 -1
- package/build/components/index.d.ts +1 -0
- package/build/components/index.d.ts.map +1 -1
- package/build/components/index.js +1 -0
- package/build/components/index.js.map +1 -1
- package/build/hooks/useCommand.js +2 -2
- package/build/hooks/useCommand.js.map +1 -1
- package/build/hooks/useInitState.js +5 -5
- package/build/hooks/useInitState.js.map +1 -1
- package/build/hooks/useListActions.d.ts.map +1 -1
- package/build/hooks/useListActions.js +34 -18
- package/build/hooks/useListActions.js.map +1 -1
- package/build/hooks/usePruneState.js +9 -9
- package/build/hooks/usePruneState.js.map +1 -1
- package/build/hooks/usePublishState.js +3 -3
- package/build/hooks/usePublishState.js.map +1 -1
- package/build/hooks/useRegistryChangeState.js +2 -2
- package/build/hooks/useRegistryChangeState.js.map +1 -1
- package/build/hooks/useSetupState.js +3 -3
- package/build/hooks/useSetupState.js.map +1 -1
- package/build/hooks/useUninstallState.d.ts +16 -29
- package/build/hooks/useUninstallState.d.ts.map +1 -1
- package/build/hooks/useUninstallState.js +147 -140
- package/build/hooks/useUninstallState.js.map +1 -1
- package/build/lib/adapter.d.ts.map +1 -1
- package/build/lib/adapter.js +3 -3
- package/build/lib/adapter.js.map +1 -1
- package/build/lib/asset-tools.d.ts +5 -0
- package/build/lib/asset-tools.d.ts.map +1 -1
- package/build/lib/asset-tools.js +7 -0
- package/build/lib/asset-tools.js.map +1 -1
- package/build/lib/auth.d.ts.map +1 -1
- package/build/lib/auth.js +3 -3
- package/build/lib/auth.js.map +1 -1
- package/build/lib/checksum.d.ts.map +1 -1
- package/build/lib/checksum.js +2 -1
- package/build/lib/checksum.js.map +1 -1
- package/build/lib/diagnostics.d.ts.map +1 -1
- package/build/lib/diagnostics.js +6 -5
- package/build/lib/diagnostics.js.map +1 -1
- package/build/lib/diff-command.d.ts.map +1 -1
- package/build/lib/diff-command.js +10 -6
- package/build/lib/diff-command.js.map +1 -1
- package/build/lib/enabled-list.d.ts +15 -0
- package/build/lib/enabled-list.d.ts.map +1 -0
- package/build/lib/enabled-list.js +83 -0
- package/build/lib/enabled-list.js.map +1 -0
- package/build/lib/entra.d.ts.map +1 -1
- package/build/lib/entra.js +3 -2
- package/build/lib/entra.js.map +1 -1
- package/build/lib/errors.d.ts +9 -0
- package/build/lib/errors.d.ts.map +1 -1
- package/build/lib/errors.js +29 -0
- package/build/lib/errors.js.map +1 -1
- package/build/lib/file-snapshot.d.ts +17 -0
- package/build/lib/file-snapshot.d.ts.map +1 -0
- package/build/lib/file-snapshot.js +41 -0
- package/build/lib/file-snapshot.js.map +1 -0
- package/build/lib/installed-for-tool.d.ts +8 -0
- package/build/lib/installed-for-tool.d.ts.map +1 -0
- package/build/lib/installed-for-tool.js +18 -0
- package/build/lib/installed-for-tool.js.map +1 -0
- package/build/lib/installer.d.ts +108 -4
- package/build/lib/installer.d.ts.map +1 -1
- package/build/lib/installer.js +551 -168
- package/build/lib/installer.js.map +1 -1
- package/build/lib/lockfile.d.ts +23 -4
- package/build/lib/lockfile.d.ts.map +1 -1
- package/build/lib/lockfile.js +32 -14
- package/build/lib/lockfile.js.map +1 -1
- package/build/lib/multi-tool-install.d.ts +23 -0
- package/build/lib/multi-tool-install.d.ts.map +1 -0
- package/build/lib/multi-tool-install.js +78 -0
- package/build/lib/multi-tool-install.js.map +1 -0
- package/build/lib/org.d.ts +16 -0
- package/build/lib/org.d.ts.map +1 -1
- package/build/lib/org.js +18 -0
- package/build/lib/org.js.map +1 -1
- package/build/lib/owned-paths.d.ts +68 -0
- package/build/lib/owned-paths.d.ts.map +1 -0
- package/build/lib/owned-paths.js +162 -0
- package/build/lib/owned-paths.js.map +1 -0
- package/build/lib/publisher.d.ts.map +1 -1
- package/build/lib/publisher.js +2 -1
- package/build/lib/publisher.js.map +1 -1
- package/build/lib/registry.d.ts.map +1 -1
- package/build/lib/registry.js +10 -6
- package/build/lib/registry.js.map +1 -1
- package/build/lib/setup.d.ts.map +1 -1
- package/build/lib/setup.js +4 -3
- package/build/lib/setup.js.map +1 -1
- package/build/lib/sync.d.ts +17 -4
- package/build/lib/sync.d.ts.map +1 -1
- package/build/lib/sync.js +119 -45
- package/build/lib/sync.js.map +1 -1
- package/build/lib/uninstaller.d.ts +21 -4
- package/build/lib/uninstaller.d.ts.map +1 -1
- package/build/lib/uninstaller.js +61 -91
- package/build/lib/uninstaller.js.map +1 -1
- package/build/lib/updater.d.ts +64 -3
- package/build/lib/updater.d.ts.map +1 -1
- package/build/lib/updater.js +367 -83
- package/build/lib/updater.js.map +1 -1
- package/package.json +1 -1
package/build/lib/installer.js
CHANGED
|
@@ -2,21 +2,73 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { mkdir, readFile, unlink } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { getPlacement, isEntrypoint, resolveInstalledPaths } from './adapter.js';
|
|
5
|
-
import { carryExcludedTools } from './asset-tools.js';
|
|
5
|
+
import { carryExcludedTools, isExcludedForTool } from './asset-tools.js';
|
|
6
6
|
import { computeConfigEntryChecksum, computeHookGroupChecksum, hashBytes } from './checksum.js';
|
|
7
|
-
import { mergeConfigEntriesForTool } from './config-entries.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { getConfigEntriesForTool, getOtherToolsConfigEntries, mergeConfigEntriesForTool, normalizeConfigPath, } from './config-entries.js';
|
|
8
|
+
import { normalizeForFormat, readConfigFile, resolveConfigFormat, writeConfigFile, } from './config-file.js';
|
|
9
|
+
import { addToEnabledList, removeFromEnabledList } from './enabled-list.js';
|
|
10
|
+
import { ApiCodeError, errorGuidance, errorMessage } from './errors.js';
|
|
10
11
|
import { ensureGitignoreEntries } from './gitignore.js';
|
|
11
12
|
import { buildHookGroup, deriveHookCommand, hookGroupOwnsCommand, removeHookGroup } from './hook-config.js';
|
|
12
13
|
import { hookScriptPath, makeExecutable } from './hook-script.js';
|
|
13
|
-
import {
|
|
14
|
+
import { isInstalledForTool } from './installed-for-tool.js';
|
|
15
|
+
import { addAssetToLockfile, findInstalledAsset, readLockfile, removeAssetFromLockfile, writeLockfile, } from './lockfile.js';
|
|
16
|
+
import { formatOrgName } from './org.js';
|
|
17
|
+
import { configEntriesOwnedByOthers, holdsAnotherOwnersValue, pathsOwnedByOthers } from './owned-paths.js';
|
|
14
18
|
import { toRelativePosix } from './paths.js';
|
|
15
19
|
import { fetchAssetFileBytes, fetchAssetManifest, listAssetFiles } from './registry.js';
|
|
16
20
|
import { writeBinaryFile, writeTextFile } from './write-file.js';
|
|
17
21
|
export class InstallerError extends ApiCodeError {
|
|
18
22
|
name = 'InstallerError';
|
|
19
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* A file the install would place at `path` exists and is not ATK-managed, or belongs to `owner`,
|
|
26
|
+
* another installed asset; nothing was written there. With `entry`, the conflict is that part of
|
|
27
|
+
* the file (a memory-template's block, a config entry), which `owner` wrote.
|
|
28
|
+
*/
|
|
29
|
+
export class FileConflictError extends InstallerError {
|
|
30
|
+
name = 'FileConflictError';
|
|
31
|
+
owner;
|
|
32
|
+
path;
|
|
33
|
+
/** What conflicts, quoted for a message: `"<path>"`, or `the <entry> in "<path>"`. */
|
|
34
|
+
where;
|
|
35
|
+
constructor(path, owner, entry) {
|
|
36
|
+
const where = entry ? `the ${entry} in "${path}"` : `"${path}"`;
|
|
37
|
+
super(owner
|
|
38
|
+
? `File conflict: ${where} belongs to the installed ${owner.type} "${formatOrgName(owner.org, owner.name)}".`
|
|
39
|
+
: `File conflict: ${where} already exists and was not installed by ATK. Use --force to overwrite.`, owner ? { hint: 'Uninstall it first if you want this one instead.' } : {});
|
|
40
|
+
this.owner = owner;
|
|
41
|
+
this.path = path;
|
|
42
|
+
this.where = where;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* An install plan that failed at `asset` with `cause`, after rolling back the
|
|
47
|
+
* `rolledBack` assets it had already installed. Keeps the cause's API code and
|
|
48
|
+
* guidance, so callers that name the asset themselves can quote just the cause.
|
|
49
|
+
*/
|
|
50
|
+
export class InstallPlanError extends InstallerError {
|
|
51
|
+
asset;
|
|
52
|
+
cause;
|
|
53
|
+
/** The plan kept what it installed before `asset`, because it moved an installed asset to another version. */
|
|
54
|
+
keptInstall;
|
|
55
|
+
name = 'InstallPlanError';
|
|
56
|
+
constructor(asset, cause, rolledBack, keptInstall = false) {
|
|
57
|
+
const rollback = rolledBack > 0 ? ` and ${rolledBack} previously installed asset(s) were rolled back` : '';
|
|
58
|
+
super(`Install failed at asset "${formatOrgName(asset.org, asset.name)}"${rollback}: ${errorMessage(cause)}`, errorGuidance(cause));
|
|
59
|
+
this.asset = asset;
|
|
60
|
+
this.cause = cause;
|
|
61
|
+
this.keptInstall = keptInstall;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** A plan whose files for `asset` could not be downloaded; nothing was written. */
|
|
65
|
+
export class PlanDownloadError extends InstallPlanError {
|
|
66
|
+
name = 'PlanDownloadError';
|
|
67
|
+
constructor(asset, cause) {
|
|
68
|
+
super(asset, cause, 0);
|
|
69
|
+
this.message = `Download failed for asset "${formatOrgName(asset.org, asset.name)}": ${errorMessage(cause)}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
20
72
|
/**
|
|
21
73
|
* Guard for multi-tool installs run with `skipUnsupported`. Each tool skips the asset
|
|
22
74
|
* types it cannot place, which is fine as long as some tool placed something. If every
|
|
@@ -36,9 +88,13 @@ export function assertInstalledByAnyTool(plan, toolResults) {
|
|
|
36
88
|
}
|
|
37
89
|
/**
|
|
38
90
|
* Execute a full install plan — install all assets in order.
|
|
39
|
-
* On partial failure,
|
|
40
|
-
*
|
|
41
|
-
*
|
|
91
|
+
* On partial failure, deletes the files the failing asset wrote where no file was before placing
|
|
92
|
+
* its files failed, and rolls back the assets the plan added before it (deletes placed files,
|
|
93
|
+
* reverts config entries and memory-template blocks, removes lockfile entries) so the system is not
|
|
94
|
+
* left in an inconsistent state. An asset the tool already had in place keeps what the plan placed,
|
|
95
|
+
* since rolling it back would delete its working copy, and gets its lockfile entry back. When the
|
|
96
|
+
* plan moved such an asset to another version, which may need the assets the plan added, nothing is
|
|
97
|
+
* rolled back ({@link InstallPlanError.keptInstall}).
|
|
42
98
|
*/
|
|
43
99
|
export async function executeInstallPlan(plan, options) {
|
|
44
100
|
const results = [];
|
|
@@ -52,152 +108,339 @@ export async function executeInstallPlan(plan, options) {
|
|
|
52
108
|
// Read lockfile once — used for both conflict detection and per-asset install logic
|
|
53
109
|
let lockfile = await readLockfile(options.projectDir);
|
|
54
110
|
const initialLockfile = lockfile;
|
|
55
|
-
//
|
|
56
|
-
const managedPaths =
|
|
111
|
+
// Map each ATK-managed file path to the assets that installed it, for conflict detection
|
|
112
|
+
const managedPaths = buildManagedPaths(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
|
|
113
|
+
// ...and each block or keyed config entry to the assets that wrote it
|
|
114
|
+
const managedEntries = buildManagedEntries(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
|
|
115
|
+
// ...and each keyed config entry of other tools to its owners, who may share it for this tool
|
|
116
|
+
const ownersElsewhere = buildEntryOwnersElsewhere(options.managedFrom ?? lockfile, options.adapter);
|
|
57
117
|
// In multi-tool projects the same plan runs once per tool. A tool that has no
|
|
58
118
|
// placement for an asset type (e.g. Codex CLI has no `rule` placement) skips
|
|
59
119
|
// those entries instead of aborting the whole run for that tool. When the tool
|
|
60
120
|
// supports nothing in the plan, fail loudly so a single-tool install isn't a
|
|
61
121
|
// silent no-op.
|
|
62
|
-
const supportedEntries =
|
|
122
|
+
const supportedEntries = placeableEntries(plan, options.adapter);
|
|
63
123
|
for (const entry of plan.entries) {
|
|
64
|
-
if (getPlacement(options.adapter, entry.asset.type)) {
|
|
65
|
-
supportedEntries.push(entry);
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
124
|
+
if (!getPlacement(options.adapter, entry.asset.type)) {
|
|
68
125
|
options.onProgress?.(`Skipping ${entry.asset.name}: ${options.adapter.displayName} does not support ${entry.asset.type} assets`);
|
|
69
126
|
}
|
|
70
127
|
}
|
|
71
128
|
if (supportedEntries.length === 0 && plan.entries.length > 0 && !options.skipUnsupported) {
|
|
72
129
|
throw new InstallerError(`Tool "${options.adapter.tool}" does not support asset type "${plan.entries[0].asset.type}"`);
|
|
73
130
|
}
|
|
131
|
+
// The assets this tool had in place before the plan, by plan entry, which a failure keeps.
|
|
132
|
+
const installedBefore = options.installedBefore ?? options.managedFrom ?? lockfile;
|
|
133
|
+
const inPlace = new Map();
|
|
134
|
+
for (const entry of supportedEntries) {
|
|
135
|
+
const installed = findInstalledAsset(installedBefore, entry.asset.name, entry.asset.type, entry.asset.org);
|
|
136
|
+
if (installed && isInstalledForTool(installed, options.adapter, options.projectDir))
|
|
137
|
+
inPlace.set(entry, installed);
|
|
138
|
+
}
|
|
139
|
+
// The files the asset being placed wrote where no file was, which a failure removes.
|
|
140
|
+
let created = [];
|
|
74
141
|
try {
|
|
75
142
|
for (const entry of supportedEntries) {
|
|
76
|
-
|
|
143
|
+
created = [];
|
|
144
|
+
const { lockfile: updatedLockfile, result, sharedEntries, } = await installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created);
|
|
77
145
|
results.push(result);
|
|
146
|
+
// A later entry of this plan may land on these files or entries, and must see whose they are.
|
|
147
|
+
for (const file of result.filesPlaced)
|
|
148
|
+
addOwner(managedPaths, file.destination, entry.asset);
|
|
149
|
+
for (const shared of sharedEntries)
|
|
150
|
+
addOwner(managedEntries, sharedEntryKey(shared), entry.asset);
|
|
78
151
|
lockfile = updatedLockfile;
|
|
79
152
|
}
|
|
80
153
|
}
|
|
81
154
|
catch (error) {
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
options.onProgress?.(`Rolling back ${result.name}...`);
|
|
88
|
-
// Delete placed files (best-effort)
|
|
89
|
-
for (const file of result.filesPlaced) {
|
|
90
|
-
try {
|
|
91
|
-
if (existsSync(file.destination)) {
|
|
92
|
-
await unlink(file.destination);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
options.onProgress?.(`Warning: Failed to delete file during rollback: ${file.destination} — ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// Revert config file entries (best-effort)
|
|
100
|
-
for (const entry of result.configEntriesCreated) {
|
|
101
|
-
try {
|
|
102
|
-
const configPath = join(options.projectDir, entry.file);
|
|
103
|
-
const format = resolveConfigFormat(configPath, getPlacement(options.adapter, result.type));
|
|
104
|
-
await revertConfigEntry(configPath, entry.key, entry.command, format, options.onProgress);
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
options.onProgress?.(`Warning: Failed to revert config entry ${entry.key} during rollback — ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
// Revert the lockfile entry using the in-memory lockfile (best-effort). An asset that
|
|
111
|
-
// was already recorded before this plan ran (a sync/update reinstall, or a second
|
|
112
|
-
// tool's pass in a multi-tool install) gets its previous entry back, so other tools'
|
|
113
|
-
// config entries survive; a newly added asset is removed.
|
|
114
|
-
try {
|
|
115
|
-
const rolledBack = supportedEntries[i].asset;
|
|
116
|
-
const previousEntry = (initialLockfile.assets ?? []).find((a) => a.name === rolledBack.name && a.type === rolledBack.type && a.org === rolledBack.org);
|
|
117
|
-
lockfile = previousEntry
|
|
118
|
-
? addAssetToLockfile(lockfile, previousEntry)
|
|
119
|
-
: removeAssetFromLockfile(lockfile, rolledBack.name, rolledBack.type, rolledBack.org);
|
|
120
|
-
await writeLockfile(options.projectDir, lockfile);
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
options.onProgress?.(`Warning: Failed to remove lockfile entry during rollback for ${result.name} — ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
// The failing asset isn't in `results`. Nothing else owns a path it created, so all of them go.
|
|
156
|
+
for (const path of created) {
|
|
157
|
+
try {
|
|
158
|
+
if (existsSync(path)) {
|
|
159
|
+
await unlink(path);
|
|
124
160
|
}
|
|
125
161
|
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
options.onProgress?.(`Warning: Failed to delete file during rollback: ${path} — ${errorMessage(error)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Re-throw the original error with rollback context, keeping any API error code and
|
|
167
|
+
// recovery hint/command (e.g. from an API auth failure).
|
|
168
|
+
const failed = supportedEntries[results.length].asset;
|
|
169
|
+
const placed = results.map((result, i) => ({
|
|
170
|
+
asset: supportedEntries[i].asset,
|
|
171
|
+
entry: supportedEntries[i],
|
|
172
|
+
result,
|
|
173
|
+
}));
|
|
174
|
+
if (placed.some(({ entry }) => inPlace.has(entry) && inPlace.get(entry).version !== entry.version)) {
|
|
175
|
+
options.onProgress?.(`Keeping the install for ${options.adapter.tool}: it moved an installed asset to another version.`);
|
|
176
|
+
throw new InstallPlanError(failed, error, 0, true);
|
|
177
|
+
}
|
|
178
|
+
// Roll back the assets the plan added, in reverse order
|
|
179
|
+
const added = placed.filter(({ entry }) => !inPlace.has(entry));
|
|
180
|
+
if (added.length > 0) {
|
|
181
|
+
options.onProgress?.(`Install failed, rolling back ${added.length} installed asset(s)...`);
|
|
182
|
+
lockfile = await rollBackInstalledAssets(added, initialLockfile, lockfile, options);
|
|
126
183
|
options.onProgress?.(`Rollback complete.`);
|
|
127
184
|
}
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
185
|
+
// The assets that were in place keep their files, of the same version, and get their entries back.
|
|
186
|
+
const kept = placed.filter(({ entry }) => inPlace.has(entry));
|
|
187
|
+
if (kept.length > 0) {
|
|
188
|
+
try {
|
|
189
|
+
for (const { asset } of kept) {
|
|
190
|
+
const previous = findInstalledAsset(initialLockfile, asset.name, asset.type, asset.org);
|
|
191
|
+
if (previous)
|
|
192
|
+
lockfile = addAssetToLockfile(lockfile, previous);
|
|
193
|
+
}
|
|
194
|
+
await writeLockfile(options.projectDir, lockfile);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
options.onProgress?.(`Warning: Failed to restore lockfile entries during rollback — ${errorMessage(error)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw new InstallPlanError(failed, error, added.length);
|
|
133
201
|
}
|
|
134
202
|
return results;
|
|
135
203
|
}
|
|
136
204
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
205
|
+
* Download the files of every plan entry this tool can place, without writing anything. A
|
|
206
|
+
* caller that replaces an installed asset downloads first, so a refused download leaves the
|
|
207
|
+
* installed version in place, then passes the result to {@link executeInstallPlan} as `files`.
|
|
140
208
|
*/
|
|
141
|
-
async function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
209
|
+
export async function fetchPlanFiles(plan, adapter) {
|
|
210
|
+
const files = new Map();
|
|
211
|
+
for (const entry of plan.entries) {
|
|
212
|
+
if (!getPlacement(adapter, entry.asset.type))
|
|
213
|
+
continue;
|
|
214
|
+
try {
|
|
215
|
+
files.set(planEntryKey(entry), await fetchAssetFiles(entry.asset, entry.version, entry.asset.org));
|
|
147
216
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
217
|
+
catch (error) {
|
|
218
|
+
throw new PlanDownloadError(entry.asset, error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return files;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The message of an install failure for `asset`, for a caller whose own message already names
|
|
225
|
+
* it: a plan that failed at that asset gives just its cause; one that failed at another asset
|
|
226
|
+
* keeps the installer's message, which names that asset.
|
|
227
|
+
*/
|
|
228
|
+
export function installFailureMessage(error, asset) {
|
|
229
|
+
const atAsset = error instanceof InstallPlanError &&
|
|
230
|
+
error.asset.name === asset.name &&
|
|
231
|
+
error.asset.type === asset.type &&
|
|
232
|
+
error.asset.org === asset.org;
|
|
233
|
+
return errorMessage(atAsset ? error.cause : error);
|
|
234
|
+
}
|
|
235
|
+
/** The entries of `plan` the adapter's tool has a placement for, in plan order. */
|
|
236
|
+
export function placeableEntries(plan, adapter) {
|
|
237
|
+
return plan.entries.filter((entry) => getPlacement(adapter, entry.asset.type));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Undo the installs of `installed` for the adapter's tool, in reverse order (best-effort): delete
|
|
241
|
+
* their placed files, revert their config entries (deleting a config file they created once it is
|
|
242
|
+
* empty), put back the memory-template block they replaced or take out the one they added (deleting
|
|
243
|
+
* a file they created once it is empty), take the names they added out of an enabled list, and give
|
|
244
|
+
* each asset its lockfile entry from `before`, or remove it. A file or config entry an asset of
|
|
245
|
+
* `before` also owns is kept.
|
|
246
|
+
* Returns `lockfile` with the entries reverted, as written.
|
|
247
|
+
*/
|
|
248
|
+
export async function rollBackInstalledAssets(installed, before, lockfile, options) {
|
|
249
|
+
for (const { asset: rolledBack, result } of installed.toReversed()) {
|
|
250
|
+
options.onProgress?.(`Rolling back ${result.name}...`);
|
|
251
|
+
// Delete placed files (best-effort), except one another asset installed before this plan
|
|
252
|
+
// also owns: a global and an org asset of one name and type can share a placement.
|
|
253
|
+
const ownedByOthers = pathsOwnedByOthers(before, rolledBack, options.adapter, options.projectDir);
|
|
254
|
+
for (const file of result.filesPlaced) {
|
|
255
|
+
if (ownedByOthers.has(file.destination))
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
if (existsSync(file.destination)) {
|
|
259
|
+
await unlink(file.destination);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
options.onProgress?.(`Warning: Failed to delete file during rollback: ${file.destination} — ${errorMessage(error)}`);
|
|
152
264
|
}
|
|
153
265
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
266
|
+
// Revert config file entries (best-effort), except one holding the value of an asset from
|
|
267
|
+
// before this plan that also owns it
|
|
268
|
+
const placement = getPlacement(options.adapter, result.type);
|
|
269
|
+
const entriesOwnedByOthers = configEntriesOwnedByOthers(before, rolledBack, options.adapter, options.projectDir);
|
|
270
|
+
const kept = new Set();
|
|
271
|
+
for (const entry of result.configEntriesCreated) {
|
|
272
|
+
try {
|
|
273
|
+
if (await holdsAnotherOwnersValue(entry, entriesOwnedByOthers, options.projectDir)) {
|
|
274
|
+
kept.add(entry.key);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const configPath = join(options.projectDir, entry.file);
|
|
278
|
+
const format = resolveConfigFormat(configPath, placement);
|
|
279
|
+
const created = result.configFilesCreated?.includes(entry.file) ?? false;
|
|
280
|
+
await revertConfigEntry(configPath, entry.key, entry.command, format, created, options.onProgress);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
options.onProgress?.(`Warning: Failed to revert config entry ${entry.key} during rollback — ${errorMessage(error)}`);
|
|
161
284
|
}
|
|
162
285
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await
|
|
286
|
+
// Give a memory-template's file back the block of that name it had before, or none
|
|
287
|
+
if (result.blockWritten) {
|
|
288
|
+
await revertMemoryBlock(options.projectDir, result.name, result.blockWritten, options.onProgress);
|
|
289
|
+
}
|
|
290
|
+
// Take the names the install added out of the enabled list again, except one whose entry was
|
|
291
|
+
// kept for its other owner (as uninstall does)
|
|
292
|
+
const { configKey, enabledListFile, enabledListKey } = placement ?? {};
|
|
293
|
+
const names = (result.enabledListAdded ?? []).filter((name) => !kept.has(`${configKey}.${name}`));
|
|
294
|
+
if (enabledListFile && enabledListKey && names.length > 0) {
|
|
295
|
+
await removeFromEnabledList(join(options.projectDir, enabledListFile), enabledListKey, names, result.configFilesCreated?.includes(enabledListFile) ?? false, options.onProgress);
|
|
296
|
+
}
|
|
297
|
+
// Revert the lockfile entry using the in-memory lockfile (best-effort). An asset that
|
|
298
|
+
// was already recorded before this plan ran (a sync/update reinstall, or a second
|
|
299
|
+
// tool's pass in a multi-tool install) gets its previous entry back, so other tools'
|
|
300
|
+
// config entries survive; a newly added asset is removed.
|
|
301
|
+
try {
|
|
302
|
+
const previousEntry = (before.assets ?? []).find((a) => a.name === rolledBack.name && a.type === rolledBack.type && a.org === rolledBack.org);
|
|
303
|
+
lockfile = previousEntry
|
|
304
|
+
? addAssetToLockfile(lockfile, previousEntry)
|
|
305
|
+
: removeAssetFromLockfile(lockfile, rolledBack.name, rolledBack.type, rolledBack.org);
|
|
306
|
+
await writeLockfile(options.projectDir, lockfile);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
options.onProgress?.(`Warning: Failed to remove lockfile entry during rollback for ${result.name} — ${errorMessage(error)}`);
|
|
166
310
|
}
|
|
167
311
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
312
|
+
return lockfile;
|
|
313
|
+
}
|
|
314
|
+
/** Record `owner` as one of the assets that placed the file at `key`, or wrote the shared entry. */
|
|
315
|
+
function addOwner(owned, key, owner) {
|
|
316
|
+
const owners = owned.get(key);
|
|
317
|
+
if (owners) {
|
|
318
|
+
owners.push(owner);
|
|
171
319
|
}
|
|
320
|
+
else {
|
|
321
|
+
owned.set(key, [owner]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** The regular expression source of the `<!-- ATK: {name} -->` block, delimiters included. */
|
|
325
|
+
function atkBlockPattern(assetName) {
|
|
326
|
+
const escapedName = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
327
|
+
return `<!-- ATK: ${escapedName} -->\\n[\\s\\S]*?<!-- /ATK: ${escapedName} -->`;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Map each name of a keyed config entry other tools have to the owners of each such entry: the
|
|
331
|
+
* assets that recorded it for one other tool, in one file. Assets the adapter's tool can't hold
|
|
332
|
+
* are left out, as in {@link buildManagedEntries}.
|
|
333
|
+
*/
|
|
334
|
+
function buildEntryOwnersElsewhere(lockfile, adapter) {
|
|
335
|
+
const byName = new Map();
|
|
336
|
+
for (const asset of lockfile.assets ?? []) {
|
|
337
|
+
if (isExcludedForTool(asset, adapter.tool) || !getPlacement(adapter, asset.type))
|
|
338
|
+
continue;
|
|
339
|
+
for (const entry of getOtherToolsConfigEntries(asset, adapter)) {
|
|
340
|
+
const name = configEntryName(entry.key);
|
|
341
|
+
if (entry.command !== undefined || name === undefined)
|
|
342
|
+
continue;
|
|
343
|
+
const byFile = byName.get(name) ?? new Map();
|
|
344
|
+
byName.set(name, byFile);
|
|
345
|
+
addOwner(byFile, sharedEntryKey({ file: normalizeConfigPath(entry.file), key: entry.key }), asset);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return new Map([...byName].map(([name, byFile]) => [name, [...byFile.values()]]));
|
|
172
349
|
}
|
|
173
350
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
351
|
+
* Map each memory-template block and keyed config entry the given lockfile's assets wrote for the
|
|
352
|
+
* adapter's tool to those assets. Used for conflict detection, like {@link buildManagedPaths}.
|
|
353
|
+
*/
|
|
354
|
+
function buildManagedEntries(lockfile, projectDir, adapter) {
|
|
355
|
+
const entries = new Map();
|
|
356
|
+
for (const asset of lockfile.assets ?? []) {
|
|
357
|
+
if (isExcludedForTool(asset, adapter.tool))
|
|
358
|
+
continue;
|
|
359
|
+
const placement = getPlacement(adapter, asset.type);
|
|
360
|
+
if (!placement)
|
|
361
|
+
continue;
|
|
362
|
+
if (asset.type === 'memory-template') {
|
|
363
|
+
addOwner(entries, sharedEntryKey(memoryTemplateBlock(placement, asset.name, projectDir)), asset);
|
|
364
|
+
}
|
|
365
|
+
for (const entry of getConfigEntriesForTool(asset, adapter)) {
|
|
366
|
+
// A hook's event entry is an array every hook shares; its own group is found by its command.
|
|
367
|
+
if (entry.command !== undefined)
|
|
368
|
+
continue;
|
|
369
|
+
addOwner(entries, sharedEntryKey({ file: join(projectDir, entry.file), key: entry.key }), asset);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return entries;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Map every file path managed by ATK in the given lockfile to the assets that installed it.
|
|
376
|
+
* Used for conflict detection — an asset may overwrite only the files it installed itself.
|
|
176
377
|
* Resolves installed paths dynamically via the adapter rather than reading them from the lockfile.
|
|
177
378
|
*/
|
|
178
|
-
function
|
|
179
|
-
const paths = new
|
|
379
|
+
function buildManagedPaths(lockfile, projectDir, adapter) {
|
|
380
|
+
const paths = new Map();
|
|
180
381
|
for (const asset of lockfile.assets ?? []) {
|
|
181
382
|
const resolved = resolveInstalledPaths(asset, adapter, projectDir);
|
|
182
383
|
for (const file of resolved.files) {
|
|
183
|
-
paths
|
|
384
|
+
addOwner(paths, join(projectDir, file.installedPath), asset);
|
|
184
385
|
}
|
|
185
386
|
}
|
|
186
387
|
return paths;
|
|
187
388
|
}
|
|
188
389
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
390
|
+
* Throw a FileConflictError if another installed asset wrote `entry` and `asset` didn't. An entry
|
|
391
|
+
* no installed asset wrote is left to the merge, as before. An asset that recorded the entry of
|
|
392
|
+
* that name for another tool together with all of its owners here (a forced install, or one from
|
|
393
|
+
* before installs refused a shared entry) shares it here too: otherwise the first of them to sync
|
|
394
|
+
* a newly added tool would lock the others out of it.
|
|
395
|
+
*/
|
|
396
|
+
function checkEntryConflict(entry, managedEntries, ownersElsewhere, asset) {
|
|
397
|
+
const owners = managedEntries.get(sharedEntryKey(entry));
|
|
398
|
+
if (!owners || isOwner(owners, asset)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const sharedElsewhere = (entry.entry ? ownersElsewhere.get(entry.entry.name) : undefined)?.some((coOwners) => isOwner(coOwners, asset) && owners.every((owner) => isOwner(coOwners, owner)));
|
|
402
|
+
if (sharedElsewhere) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
throw new FileConflictError(entry.file, owners[0], entry.label);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Check if a file at the destination path would conflict with a file `asset` didn't install.
|
|
409
|
+
* Throws FileConflictError if the file exists and is not tracked in the lockfile, or is tracked
|
|
410
|
+
* only for other assets (a global and an org asset of one name and type share a placement).
|
|
411
|
+
* The asset's own files can be safely overwritten (e.g., reinstall/update).
|
|
192
412
|
*/
|
|
193
|
-
async function checkFileConflict(destPath, managedPaths) {
|
|
413
|
+
async function checkFileConflict(destPath, managedPaths, asset) {
|
|
194
414
|
if (!existsSync(destPath)) {
|
|
195
415
|
return;
|
|
196
416
|
}
|
|
197
|
-
|
|
417
|
+
const owners = managedPaths.get(destPath) ?? [];
|
|
418
|
+
if (isOwner(owners, asset)) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
throw new FileConflictError(destPath, owners[0]);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Throw a FileConflictError if the config entry `entry` names already exists without ATK's
|
|
425
|
+
* `_managedBy` marker and no installed asset wrote it: the user wrote it, so only --force replaces
|
|
426
|
+
* it, as with a file. A config file that doesn't parse is left to the merge, which warns.
|
|
427
|
+
*/
|
|
428
|
+
async function checkUserEntryConflict(entry, managedEntries, format) {
|
|
429
|
+
if (!entry.entry || managedEntries.has(sharedEntryKey(entry)) || !existsSync(entry.file)) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const config = await readConfigFile(entry.file, format).catch(() => undefined);
|
|
433
|
+
const section = config?.[entry.entry.section];
|
|
434
|
+
const existing = isRecord(section) ? section[entry.entry.name] : undefined;
|
|
435
|
+
if (existing === undefined || (isRecord(existing) && existing['_managedBy'] === 'atk')) {
|
|
198
436
|
return;
|
|
199
437
|
}
|
|
200
|
-
throw new
|
|
438
|
+
throw new FileConflictError(entry.file, undefined, entry.label);
|
|
439
|
+
}
|
|
440
|
+
/** The name a keyed config entry has in its section: `neon` for `mcpServers.neon`. */
|
|
441
|
+
function configEntryName(key) {
|
|
442
|
+
const dotIndex = key.indexOf('.');
|
|
443
|
+
return dotIndex === -1 ? undefined : key.slice(dotIndex + 1);
|
|
201
444
|
}
|
|
202
445
|
/**
|
|
203
446
|
* Fetch all files for an asset version from the ATK API, plus its
|
|
@@ -225,7 +468,7 @@ async function fetchAssetFiles(asset, version, org) {
|
|
|
225
468
|
if (error instanceof InstallerError) {
|
|
226
469
|
throw error;
|
|
227
470
|
}
|
|
228
|
-
const message =
|
|
471
|
+
const message = errorMessage(error);
|
|
229
472
|
throw new InstallerError(message, errorGuidance(error));
|
|
230
473
|
}
|
|
231
474
|
}
|
|
@@ -244,11 +487,36 @@ async function fetchManifestEntrypoint(ref, version) {
|
|
|
244
487
|
return undefined;
|
|
245
488
|
}
|
|
246
489
|
}
|
|
490
|
+
/** The `<!-- ATK: {name} -->` block in `content`, delimiters included, if it has one. */
|
|
491
|
+
function findAtkBlock(content, assetName) {
|
|
492
|
+
return new RegExp(atkBlockPattern(assetName)).exec(content)?.[0];
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The named entries of a config fragment: the map at `content[configKey]`, then `content[fragmentKey]`
|
|
496
|
+
* (for tools whose target key differs from the asset content's key, e.g. `mcp_servers` vs
|
|
497
|
+
* `mcpServers`), and finally the whole content. Entries that aren't objects are skipped.
|
|
498
|
+
*/
|
|
499
|
+
function fragmentEntries(content, configKey, fragmentKey) {
|
|
500
|
+
const pickMap = (key) => {
|
|
501
|
+
if (!key)
|
|
502
|
+
return undefined;
|
|
503
|
+
const candidate = content[key];
|
|
504
|
+
return candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
505
|
+
? candidate
|
|
506
|
+
: undefined;
|
|
507
|
+
};
|
|
508
|
+
const inner = pickMap(configKey) ?? pickMap(fragmentKey) ?? content;
|
|
509
|
+
return Object.entries(inner).filter((pair) => !!pair[1] && typeof pair[1] === 'object');
|
|
510
|
+
}
|
|
247
511
|
/**
|
|
248
512
|
* Install a single asset from the install plan.
|
|
249
|
-
* Accepts the current in-memory lockfile and returns the updated lockfile alongside the result
|
|
513
|
+
* Accepts the current in-memory lockfile and returns the updated lockfile alongside the result,
|
|
514
|
+
* and the shared entries the asset wrote.
|
|
515
|
+
* While it places the asset's files, `created` holds each path it writes where no file was (added
|
|
516
|
+
* before the write), so a caller can remove them when placing fails partway. It is emptied once
|
|
517
|
+
* every file is placed.
|
|
250
518
|
*/
|
|
251
|
-
async function installAsset(entry, options, managedPaths, lockfile) {
|
|
519
|
+
async function installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created) {
|
|
252
520
|
const { asset } = entry;
|
|
253
521
|
const { adapter, force, onProgress, pin, projectDir } = options;
|
|
254
522
|
onProgress?.(`Installing ${asset.name}...`);
|
|
@@ -259,21 +527,56 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
259
527
|
}
|
|
260
528
|
// Determine base path
|
|
261
529
|
const basePath = projectDir;
|
|
262
|
-
// Fetch asset files from the ATK API (version-aware, org-aware)
|
|
263
|
-
|
|
530
|
+
// Fetch asset files from the ATK API (version-aware, org-aware), unless the caller already did.
|
|
531
|
+
// A caller that downloaded first may have removed the installed files since, so a gap in its
|
|
532
|
+
// download must not turn into a fresh request that can be refused.
|
|
533
|
+
const prefetched = options.files?.get(planEntryKey(entry));
|
|
534
|
+
if (options.files && !prefetched) {
|
|
535
|
+
throw new InstallerError(`The files of "${asset.name}"@${entry.version} were not downloaded before the install.`);
|
|
536
|
+
}
|
|
537
|
+
const { entrypoint: declaredEntrypoint, files: assetFiles } = prefetched ?? (await fetchAssetFiles(asset, entry.version, entry.asset.org));
|
|
264
538
|
// Find the entrypoint file
|
|
265
539
|
const entrypointFile = assetFiles.find((f) => isEntrypoint(f.relativePath, asset.type, declaredEntrypoint));
|
|
540
|
+
// Determine merge content for configMerge mode (a keyed merge; a hook merge doesn't use it)
|
|
541
|
+
let mergeContent;
|
|
542
|
+
if (placement.configMerge && !placement.configHookMerge && entrypointFile) {
|
|
543
|
+
try {
|
|
544
|
+
mergeContent = JSON.parse(entrypointFile.content.toString('utf-8'));
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// If content isn't valid JSON, fall back to marker-only behavior
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// The target config file's serialization format (JSON by default; TOML for tools like Codex
|
|
551
|
+
// CLI), resolved once here and threaded into the entry checks and every merge helper.
|
|
552
|
+
const configFormat = placement.configFile
|
|
553
|
+
? resolveConfigFormat(join(basePath, placement.configFile), placement)
|
|
554
|
+
: 'json';
|
|
555
|
+
// Check the block or config entries the asset writes into shared files before writing anything,
|
|
556
|
+
// so a conflict leaves another asset's entry, or one the user wrote, as it was.
|
|
557
|
+
const sharedEntries = plannedSharedEntries(asset.name, asset.type, placement, basePath, mergeContent);
|
|
558
|
+
if (!force) {
|
|
559
|
+
for (const shared of sharedEntries) {
|
|
560
|
+
checkEntryConflict(shared, managedEntries, ownersElsewhere, asset);
|
|
561
|
+
await checkUserEntryConflict(shared, managedEntries, configFormat);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
266
564
|
// Branch on placement type
|
|
267
565
|
const placedFiles = [];
|
|
268
566
|
const filesPlaced = [];
|
|
269
567
|
const configEntries = [];
|
|
568
|
+
// The shared entries written so far: the merge helpers warn and write nothing when they fail.
|
|
569
|
+
const written = [];
|
|
570
|
+
// The memory-template block written, which a rollback undoes.
|
|
571
|
+
let blockWritten;
|
|
270
572
|
if (placement.fileless) {
|
|
271
573
|
// Fileless placement (e.g., MCP configs) — no files placed on disk
|
|
272
574
|
// Config merging handled below
|
|
273
575
|
}
|
|
274
576
|
else if (asset.type === 'memory-template') {
|
|
275
577
|
// Memory templates append to a shared file with delimiters
|
|
276
|
-
const
|
|
578
|
+
const block = memoryTemplateBlock(placement, asset.name, basePath);
|
|
579
|
+
const targetPath = block.file;
|
|
277
580
|
const targetDir = dirname(targetPath);
|
|
278
581
|
if (!existsSync(targetDir)) {
|
|
279
582
|
await mkdir(targetDir, { recursive: true });
|
|
@@ -284,29 +587,36 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
284
587
|
}
|
|
285
588
|
const content = (entrypointFile?.content ?? firstFile.content).toString('utf-8');
|
|
286
589
|
const delimitedContent = `<!-- ATK: ${asset.name} -->\n${content}\n<!-- /ATK: ${asset.name} -->`;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
590
|
+
const existing = existsSync(targetPath) ? await readFile(targetPath, 'utf-8') : undefined;
|
|
591
|
+
// Strip any existing ATK block for this asset before appending the new one
|
|
592
|
+
const rest = existing === undefined ? '' : stripAtkBlock(existing, asset.name).trimEnd();
|
|
593
|
+
await writeTextFile(targetPath, (rest ? `${rest}\n\n` : '') + delimitedContent + '\n');
|
|
594
|
+
written.push(block);
|
|
595
|
+
const replaced = existing === undefined ? undefined : findAtkBlock(existing, asset.name);
|
|
596
|
+
blockWritten = {
|
|
597
|
+
created: existing === undefined,
|
|
598
|
+
file: toRelativePosix(projectDir, targetPath),
|
|
599
|
+
...(replaced !== undefined ? { replaced } : {}),
|
|
600
|
+
};
|
|
296
601
|
// Memory-template entries are tracked as placed files, not config entries.
|
|
297
602
|
// They don't go through updateConfigFile() and have no config entry checksum.
|
|
298
603
|
}
|
|
299
604
|
else if (placement.entrypointName) {
|
|
300
605
|
// Directory-based placement (e.g., skills)
|
|
301
|
-
|
|
302
|
-
|
|
606
|
+
const destinations = assetFiles.map((file) => resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, placement.entrypointName, declaredEntrypoint));
|
|
607
|
+
// Check every destination before writing any, so a conflict leaves none of the asset's files behind.
|
|
608
|
+
if (!force) {
|
|
609
|
+
for (const destPath of destinations)
|
|
610
|
+
await checkFileConflict(destPath, managedPaths, asset);
|
|
611
|
+
}
|
|
612
|
+
for (const [index, file] of assetFiles.entries()) {
|
|
613
|
+
const destPath = destinations[index];
|
|
303
614
|
const destDir = dirname(destPath);
|
|
304
|
-
if (!force) {
|
|
305
|
-
await checkFileConflict(destPath, managedPaths);
|
|
306
|
-
}
|
|
307
615
|
if (!existsSync(destDir)) {
|
|
308
616
|
await mkdir(destDir, { recursive: true });
|
|
309
617
|
}
|
|
618
|
+
if (!existsSync(destPath))
|
|
619
|
+
created.push(destPath);
|
|
310
620
|
await writeBinaryFile(destPath, file.content);
|
|
311
621
|
const checksum = hashBytes(file.content);
|
|
312
622
|
placedFiles.push({
|
|
@@ -322,15 +632,19 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
322
632
|
}
|
|
323
633
|
else {
|
|
324
634
|
// Default single-file placement
|
|
325
|
-
|
|
326
|
-
|
|
635
|
+
const destinations = assetFiles.map((file) => resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, undefined, declaredEntrypoint));
|
|
636
|
+
if (!force) {
|
|
637
|
+
for (const destPath of destinations)
|
|
638
|
+
await checkFileConflict(destPath, managedPaths, asset);
|
|
639
|
+
}
|
|
640
|
+
for (const [index, file] of assetFiles.entries()) {
|
|
641
|
+
const destPath = destinations[index];
|
|
327
642
|
const destDir = dirname(destPath);
|
|
328
|
-
if (!force) {
|
|
329
|
-
await checkFileConflict(destPath, managedPaths);
|
|
330
|
-
}
|
|
331
643
|
if (!existsSync(destDir)) {
|
|
332
644
|
await mkdir(destDir, { recursive: true });
|
|
333
645
|
}
|
|
646
|
+
if (!existsSync(destPath))
|
|
647
|
+
created.push(destPath);
|
|
334
648
|
await writeBinaryFile(destPath, file.content);
|
|
335
649
|
const checksum = hashBytes(file.content);
|
|
336
650
|
placedFiles.push({
|
|
@@ -350,12 +664,17 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
350
664
|
await makeExecutable(scriptDest);
|
|
351
665
|
}
|
|
352
666
|
}
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
667
|
+
// Every file is placed, and the config entries below can point at them (a hook runs its script),
|
|
668
|
+
// so a failure from here on leaves them in place.
|
|
669
|
+
created.length = 0;
|
|
670
|
+
// The config files the writes below can create, which a rollback then removes once empty.
|
|
671
|
+
const absentConfigFiles = [placement.configFile, placement.enabledListFile]
|
|
672
|
+
.filter((file) => !!file)
|
|
673
|
+
.map((file) => join(basePath, file))
|
|
674
|
+
.filter((path) => !existsSync(path));
|
|
675
|
+
// The names the install adds to the placement's enabled list, which a rollback takes out again.
|
|
676
|
+
let enabledListAdded = [];
|
|
677
|
+
// Handle config file updates if needed.
|
|
359
678
|
if (placement.configFile && placement.configKey && placement.configHookMerge) {
|
|
360
679
|
// Hook placement: merge the asset's hookConfig event bindings into the tool
|
|
361
680
|
// config file as valid event arrays (e.g. settings.json hooks.Stop[]), with the
|
|
@@ -365,16 +684,6 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
365
684
|
configEntries.push(...hookEntries);
|
|
366
685
|
}
|
|
367
686
|
else if (placement.configFile && placement.configKey) {
|
|
368
|
-
// Determine merge content for configMerge mode
|
|
369
|
-
let mergeContent;
|
|
370
|
-
if (placement.configMerge && entrypointFile) {
|
|
371
|
-
try {
|
|
372
|
-
mergeContent = JSON.parse(entrypointFile.content.toString('utf-8'));
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
// If content isn't valid JSON, fall back to marker-only behavior
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
687
|
if (placement.configFragment) {
|
|
379
688
|
// Fragment merge (e.g., mcp-config): the asset content is a full config fragment
|
|
380
689
|
// (e.g. a whole .mcp.json). Merge the entries inside content[configKey] into
|
|
@@ -385,7 +694,7 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
385
694
|
// Auto-register the merged entry names into an enabled-list (e.g. enabledMcpjsonServers
|
|
386
695
|
// in settings.local.json) so the tool auto-approves them.
|
|
387
696
|
if (names.length > 0 && placement.enabledListFile && placement.enabledListKey) {
|
|
388
|
-
await addToEnabledList(join(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
|
|
697
|
+
enabledListAdded = await addToEnabledList(join(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
|
|
389
698
|
}
|
|
390
699
|
}
|
|
391
700
|
else {
|
|
@@ -395,6 +704,10 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
395
704
|
}
|
|
396
705
|
}
|
|
397
706
|
}
|
|
707
|
+
written.push(...sharedEntries.filter((shared) => configEntries.some((c) => !c.command && c.key === shared.key)));
|
|
708
|
+
const configFilesCreated = absentConfigFiles
|
|
709
|
+
.filter((path) => existsSync(path))
|
|
710
|
+
.map((path) => toRelativePosix(projectDir, path));
|
|
398
711
|
// Config entries are tool-specific but the lockfile is shared across tools: tag this
|
|
399
712
|
// tool's entries and carry over whatever other tools already registered for this asset,
|
|
400
713
|
// so a multi-tool install/sync/update never drops another tool's registration record.
|
|
@@ -431,14 +744,33 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
431
744
|
return {
|
|
432
745
|
lockfile: updatedLockfile,
|
|
433
746
|
result: {
|
|
747
|
+
...(blockWritten ? { blockWritten } : {}),
|
|
434
748
|
...(options.bundleOrigin ? { bundleOrigin: options.bundleOrigin } : {}),
|
|
435
749
|
configEntriesCreated: configEntries,
|
|
750
|
+
...(configFilesCreated.length > 0 ? { configFilesCreated } : {}),
|
|
751
|
+
...(enabledListAdded.length > 0 ? { enabledListAdded } : {}),
|
|
436
752
|
filesPlaced,
|
|
437
753
|
installReason: entry.reason,
|
|
438
754
|
name: asset.name,
|
|
439
755
|
type: asset.type,
|
|
440
756
|
version: entry.version,
|
|
441
757
|
},
|
|
758
|
+
sharedEntries: written,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/** Whether `asset` is one of `owners`. */
|
|
762
|
+
function isOwner(owners, asset) {
|
|
763
|
+
return owners.some((o) => o.name === asset.name && o.type === asset.type && o.org === asset.org);
|
|
764
|
+
}
|
|
765
|
+
function isRecord(value) {
|
|
766
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
767
|
+
}
|
|
768
|
+
/** The `<!-- ATK: {name} -->` block a memory-template asset writes into its placement's file. */
|
|
769
|
+
function memoryTemplateBlock(placement, name, basePath) {
|
|
770
|
+
return {
|
|
771
|
+
file: join(basePath, placement.path.replace(/\{name\}/g, name)),
|
|
772
|
+
key: `ATK:${name}`,
|
|
773
|
+
label: `"${name}" block`,
|
|
442
774
|
};
|
|
443
775
|
}
|
|
444
776
|
/**
|
|
@@ -450,9 +782,7 @@ async function installAsset(entry, options, managedPaths, lockfile) {
|
|
|
450
782
|
* with the `_managedBy: 'atk'` marker. Returns one config entry per merged name (for lockfile
|
|
451
783
|
* tracking / checksum verification) plus the list of names that were merged.
|
|
452
784
|
*
|
|
453
|
-
* The
|
|
454
|
-
* whose target key differs from the asset content's key, e.g. `mcp_servers` vs `mcpServers`),
|
|
455
|
-
* and finally the whole content is treated as the inner map.
|
|
785
|
+
* The entries are found by {@link fragmentEntries}.
|
|
456
786
|
*/
|
|
457
787
|
async function mergeFragmentConfig(configFilePath, configKey, format, projectDir, mergeContent, fragmentKey, onProgress) {
|
|
458
788
|
const empty = { entries: [], names: [] };
|
|
@@ -460,17 +790,8 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
|
|
|
460
790
|
return empty;
|
|
461
791
|
}
|
|
462
792
|
try {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
return undefined;
|
|
466
|
-
const candidate = mergeContent[key];
|
|
467
|
-
return candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
468
|
-
? candidate
|
|
469
|
-
: undefined;
|
|
470
|
-
};
|
|
471
|
-
const inner = pickMap(configKey) ?? pickMap(fragmentKey) ?? mergeContent;
|
|
472
|
-
const innerNames = Object.keys(inner).filter((name) => inner[name] && typeof inner[name] === 'object');
|
|
473
|
-
if (innerNames.length === 0) {
|
|
793
|
+
const inner = fragmentEntries(mergeContent, configKey, fragmentKey);
|
|
794
|
+
if (inner.length === 0) {
|
|
474
795
|
return empty;
|
|
475
796
|
}
|
|
476
797
|
let config = {};
|
|
@@ -488,8 +809,8 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
|
|
|
488
809
|
}
|
|
489
810
|
const section = config[configKey];
|
|
490
811
|
const entries = [];
|
|
491
|
-
for (const name of
|
|
492
|
-
const value = normalizeForFormat({ ...
|
|
812
|
+
for (const [name, fragment] of inner) {
|
|
813
|
+
const value = normalizeForFormat({ ...fragment, _managedBy: 'atk' }, format);
|
|
493
814
|
section[name] = value;
|
|
494
815
|
entries.push({
|
|
495
816
|
checksum: computeConfigEntryChecksum(value),
|
|
@@ -498,11 +819,11 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
|
|
|
498
819
|
});
|
|
499
820
|
}
|
|
500
821
|
await writeConfigFile(configFilePath, config, format, onProgress);
|
|
501
|
-
return { entries, names:
|
|
822
|
+
return { entries, names: inner.map(([name]) => name) };
|
|
502
823
|
}
|
|
503
824
|
catch (error) {
|
|
504
825
|
// Non-fatal — log but don't fail the install
|
|
505
|
-
onProgress?.(`Warning: Failed to merge config fragment into ${configFilePath} — ${
|
|
826
|
+
onProgress?.(`Warning: Failed to merge config fragment into ${configFilePath} — ${errorMessage(error)}`);
|
|
506
827
|
return empty;
|
|
507
828
|
}
|
|
508
829
|
}
|
|
@@ -557,10 +878,40 @@ async function mergeHookConfig(configFilePath, configKey, format, hookConfig, co
|
|
|
557
878
|
}
|
|
558
879
|
catch (error) {
|
|
559
880
|
// Non-fatal — log but don't fail the install
|
|
560
|
-
onProgress?.(`Warning: Failed to merge hook config into ${configFilePath} — ${
|
|
881
|
+
onProgress?.(`Warning: Failed to merge hook config into ${configFilePath} — ${errorMessage(error)}`);
|
|
561
882
|
return [];
|
|
562
883
|
}
|
|
563
884
|
}
|
|
885
|
+
/** The {@link PlanFiles} key of a plan entry: one asset version. */
|
|
886
|
+
function planEntryKey(entry) {
|
|
887
|
+
return `${entry.asset.type}:${entry.asset.org ?? ''}:${entry.asset.name}@${entry.version}`;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* The memory-template block or keyed config entries an asset of `name` and `type` will write: the
|
|
891
|
+
* fragment's own entry names for a fragment merge, the asset name for any other keyed merge. A
|
|
892
|
+
* hook's event entries are shared by every hook, so they aren't among them.
|
|
893
|
+
*/
|
|
894
|
+
function plannedSharedEntries(name, type, placement, basePath, mergeContent) {
|
|
895
|
+
// The same routing as installAsset: a file-placing memory-template writes its block, then any
|
|
896
|
+
// keyed (not hook) config merge writes its entries.
|
|
897
|
+
const entries = !placement.fileless && type === 'memory-template' ? [memoryTemplateBlock(placement, name, basePath)] : [];
|
|
898
|
+
const { configFile, configKey } = placement;
|
|
899
|
+
if (!configFile || !configKey || placement.configHookMerge) {
|
|
900
|
+
return entries;
|
|
901
|
+
}
|
|
902
|
+
const names = placement.configFragment
|
|
903
|
+
? fragmentEntries(mergeContent ?? {}, configKey, placement.configFragmentKey).map(([entryName]) => entryName)
|
|
904
|
+
: [name];
|
|
905
|
+
return [
|
|
906
|
+
...entries,
|
|
907
|
+
...names.map((entryName) => ({
|
|
908
|
+
entry: { name: entryName, section: configKey },
|
|
909
|
+
file: join(basePath, configFile),
|
|
910
|
+
key: `${configKey}.${entryName}`,
|
|
911
|
+
label: `"${configKey}.${entryName}" entry`,
|
|
912
|
+
})),
|
|
913
|
+
];
|
|
914
|
+
}
|
|
564
915
|
/**
|
|
565
916
|
* Resolve the destination file path for an asset file.
|
|
566
917
|
* For directory-based placement (entrypointName set), the path is a directory template.
|
|
@@ -587,9 +938,10 @@ function resolveFilePath(pathTemplate, assetName, basePath, relativePath, assetT
|
|
|
587
938
|
/**
|
|
588
939
|
* Revert a config file entry created during installation.
|
|
589
940
|
* Reads the config file, removes the entry at the given key path (e.g., "mcpServers.my-mcp"),
|
|
590
|
-
* and writes it back. If the parent section becomes empty, it is also removed.
|
|
941
|
+
* and writes it back. If the parent section becomes empty, it is also removed. A file the
|
|
942
|
+
* install created (`deleteWhenEmpty`) is deleted instead once nothing is left in it.
|
|
591
943
|
*/
|
|
592
|
-
async function revertConfigEntry(configFilePath, keyPath, command, format, onProgress) {
|
|
944
|
+
async function revertConfigEntry(configFilePath, keyPath, command, format, deleteWhenEmpty, onProgress) {
|
|
593
945
|
if (!existsSync(configFilePath)) {
|
|
594
946
|
return;
|
|
595
947
|
}
|
|
@@ -619,12 +971,45 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, onPro
|
|
|
619
971
|
delete config[sectionKey];
|
|
620
972
|
}
|
|
621
973
|
}
|
|
974
|
+
if (deleteWhenEmpty && Object.keys(config).length === 0) {
|
|
975
|
+
await unlink(configFilePath);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
622
978
|
await writeConfigFile(configFilePath, config, format, onProgress);
|
|
623
979
|
}
|
|
624
980
|
catch (error) {
|
|
625
|
-
onProgress?.(`Warning: Failed to revert config entry ${keyPath} in ${configFilePath} — ${
|
|
981
|
+
onProgress?.(`Warning: Failed to revert config entry ${keyPath} in ${configFilePath} — ${errorMessage(error)}`);
|
|
626
982
|
}
|
|
627
983
|
}
|
|
984
|
+
/**
|
|
985
|
+
* Undo the write of a memory-template block (best-effort): put back the block of that name the
|
|
986
|
+
* install replaced, or take the block out, deleting the file once it is empty if the install
|
|
987
|
+
* created it.
|
|
988
|
+
*/
|
|
989
|
+
async function revertMemoryBlock(projectDir, assetName, { created, file, replaced }, onProgress) {
|
|
990
|
+
const path = join(projectDir, file);
|
|
991
|
+
try {
|
|
992
|
+
if (!existsSync(path))
|
|
993
|
+
return;
|
|
994
|
+
const content = await readFile(path, 'utf-8');
|
|
995
|
+
const reverted = (replaced === undefined
|
|
996
|
+
? stripAtkBlock(content, assetName)
|
|
997
|
+
: content.replace(new RegExp(atkBlockPattern(assetName)), () => replaced)).trimEnd();
|
|
998
|
+
if (created && reverted.trim() === '') {
|
|
999
|
+
await unlink(path);
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
await writeTextFile(path, `${reverted}\n`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
onProgress?.(`Warning: Failed to revert the "${assetName}" block during rollback — ${errorMessage(error)}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/** The {@link ManagedEntries} key of a shared entry. */
|
|
1010
|
+
function sharedEntryKey(entry) {
|
|
1011
|
+
return `${entry.file}#${entry.key}`;
|
|
1012
|
+
}
|
|
628
1013
|
/**
|
|
629
1014
|
* Remove an existing ATK-delimited block for the given asset name from file content.
|
|
630
1015
|
* Matches blocks of the form:
|
|
@@ -634,9 +1019,7 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, onPro
|
|
|
634
1019
|
* along with surrounding blank lines to keep formatting clean.
|
|
635
1020
|
*/
|
|
636
1021
|
function stripAtkBlock(content, assetName) {
|
|
637
|
-
|
|
638
|
-
const pattern = new RegExp(`(\n{0,2})<!-- ATK: ${escapedName} -->\\n[\\s\\S]*?<!-- /ATK: ${escapedName} -->\\n?`, 'g');
|
|
639
|
-
return content.replace(pattern, '');
|
|
1022
|
+
return content.replace(new RegExp(`(\n{0,2})${atkBlockPattern(assetName)}\\n?`, 'g'), '');
|
|
640
1023
|
}
|
|
641
1024
|
/**
|
|
642
1025
|
* Update a tool config file (e.g., settings.json) with an entry for the installed asset.
|
|
@@ -680,7 +1063,7 @@ async function updateConfigFile(configFilePath, configKey, format, assetName, as
|
|
|
680
1063
|
}
|
|
681
1064
|
catch (error) {
|
|
682
1065
|
// Non-fatal — log but don't fail the install
|
|
683
|
-
onProgress?.(`Warning: Failed to update config file ${configFilePath} — ${
|
|
1066
|
+
onProgress?.(`Warning: Failed to update config file ${configFilePath} — ${errorMessage(error)}`);
|
|
684
1067
|
return null;
|
|
685
1068
|
}
|
|
686
1069
|
}
|