@detergent-software/atk 8.6.2-dev.1 → 8.7.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/build/commands/audit.d.ts.map +1 -1
  3. package/build/commands/audit.js +34 -11
  4. package/build/commands/audit.js.map +1 -1
  5. package/build/commands/browse.d.ts.map +1 -1
  6. package/build/commands/browse.js +23 -15
  7. package/build/commands/browse.js.map +1 -1
  8. package/build/commands/freeze.d.ts.map +1 -1
  9. package/build/commands/freeze.js +11 -6
  10. package/build/commands/freeze.js.map +1 -1
  11. package/build/commands/index.d.ts.map +1 -1
  12. package/build/commands/index.js +1 -0
  13. package/build/commands/index.js.map +1 -1
  14. package/build/commands/install.d.ts.map +1 -1
  15. package/build/commands/install.js +91 -26
  16. package/build/commands/install.js.map +1 -1
  17. package/build/commands/list.d.ts +2 -1
  18. package/build/commands/list.d.ts.map +1 -1
  19. package/build/commands/list.js +82 -66
  20. package/build/commands/list.js.map +1 -1
  21. package/build/commands/move.d.ts +28 -0
  22. package/build/commands/move.d.ts.map +1 -0
  23. package/build/commands/move.js +180 -0
  24. package/build/commands/move.js.map +1 -0
  25. package/build/commands/outdated.d.ts +2 -1
  26. package/build/commands/outdated.d.ts.map +1 -1
  27. package/build/commands/outdated.js +67 -24
  28. package/build/commands/outdated.js.map +1 -1
  29. package/build/commands/pin.d.ts.map +1 -1
  30. package/build/commands/pin.js +12 -7
  31. package/build/commands/pin.js.map +1 -1
  32. package/build/commands/prune.d.ts.map +1 -1
  33. package/build/commands/prune.js +17 -10
  34. package/build/commands/prune.js.map +1 -1
  35. package/build/commands/schema.d.ts +1 -1
  36. package/build/commands/sync.d.ts.map +1 -1
  37. package/build/commands/sync.js +22 -13
  38. package/build/commands/sync.js.map +1 -1
  39. package/build/commands/thaw.d.ts.map +1 -1
  40. package/build/commands/thaw.js +11 -6
  41. package/build/commands/thaw.js.map +1 -1
  42. package/build/commands/uninstall.d.ts.map +1 -1
  43. package/build/commands/uninstall.js +20 -15
  44. package/build/commands/uninstall.js.map +1 -1
  45. package/build/commands/unpin.d.ts.map +1 -1
  46. package/build/commands/unpin.js +11 -6
  47. package/build/commands/unpin.js.map +1 -1
  48. package/build/commands/update.d.ts.map +1 -1
  49. package/build/commands/update.js +11 -11
  50. package/build/commands/update.js.map +1 -1
  51. package/build/components/CrossScopeWarning.d.ts +12 -0
  52. package/build/components/CrossScopeWarning.d.ts.map +1 -0
  53. package/build/components/CrossScopeWarning.js +13 -0
  54. package/build/components/CrossScopeWarning.js.map +1 -0
  55. package/build/components/DryRunBanner.d.ts +3 -1
  56. package/build/components/DryRunBanner.d.ts.map +1 -1
  57. package/build/components/DryRunBanner.js +2 -2
  58. package/build/components/DryRunBanner.js.map +1 -1
  59. package/build/components/InstallConfirmBar.d.ts.map +1 -1
  60. package/build/components/InstallConfirmBar.js +1 -3
  61. package/build/components/InstallConfirmBar.js.map +1 -1
  62. package/build/components/InstallSummary.d.ts +3 -1
  63. package/build/components/InstallSummary.d.ts.map +1 -1
  64. package/build/components/InstallSummary.js +2 -2
  65. package/build/components/InstallSummary.js.map +1 -1
  66. package/build/components/ListActionBar.d.ts +4 -2
  67. package/build/components/ListActionBar.d.ts.map +1 -1
  68. package/build/components/ListActionBar.js +5 -4
  69. package/build/components/ListActionBar.js.map +1 -1
  70. package/build/components/ListBrowseList.d.ts +7 -6
  71. package/build/components/ListBrowseList.d.ts.map +1 -1
  72. package/build/components/ListBrowseList.js +10 -6
  73. package/build/components/ListBrowseList.js.map +1 -1
  74. package/build/components/StatusBadge.d.ts +1 -1
  75. package/build/components/StatusBadge.d.ts.map +1 -1
  76. package/build/components/StatusBadge.js +1 -0
  77. package/build/components/StatusBadge.js.map +1 -1
  78. package/build/components/SyncAllInSync.d.ts +3 -1
  79. package/build/components/SyncAllInSync.d.ts.map +1 -1
  80. package/build/components/SyncAllInSync.js +2 -2
  81. package/build/components/SyncAllInSync.js.map +1 -1
  82. package/build/components/SyncDriftedList.d.ts +3 -1
  83. package/build/components/SyncDriftedList.d.ts.map +1 -1
  84. package/build/components/SyncDriftedList.js +3 -3
  85. package/build/components/SyncDriftedList.js.map +1 -1
  86. package/build/components/SyncNoAssets.d.ts +3 -1
  87. package/build/components/SyncNoAssets.d.ts.map +1 -1
  88. package/build/components/SyncNoAssets.js +2 -2
  89. package/build/components/SyncNoAssets.js.map +1 -1
  90. package/build/components/index.d.ts +1 -0
  91. package/build/components/index.d.ts.map +1 -1
  92. package/build/components/index.js +1 -0
  93. package/build/components/index.js.map +1 -1
  94. package/build/hooks/useInitState.d.ts.map +1 -1
  95. package/build/hooks/useInitState.js +4 -2
  96. package/build/hooks/useInitState.js.map +1 -1
  97. package/build/hooks/useListActions.d.ts +19 -6
  98. package/build/hooks/useListActions.d.ts.map +1 -1
  99. package/build/hooks/useListActions.js +36 -30
  100. package/build/hooks/useListActions.js.map +1 -1
  101. package/build/hooks/useListState.d.ts +29 -12
  102. package/build/hooks/useListState.d.ts.map +1 -1
  103. package/build/hooks/useListState.js +22 -6
  104. package/build/hooks/useListState.js.map +1 -1
  105. package/build/hooks/usePruneState.d.ts +6 -0
  106. package/build/hooks/usePruneState.d.ts.map +1 -1
  107. package/build/hooks/usePruneState.js +6 -1
  108. package/build/hooks/usePruneState.js.map +1 -1
  109. package/build/hooks/useResolvedScope.d.ts +21 -0
  110. package/build/hooks/useResolvedScope.d.ts.map +1 -0
  111. package/build/hooks/useResolvedScope.js +15 -0
  112. package/build/hooks/useResolvedScope.js.map +1 -0
  113. package/build/hooks/useUninstallState.d.ts +6 -0
  114. package/build/hooks/useUninstallState.d.ts.map +1 -1
  115. package/build/hooks/useUninstallState.js +6 -1
  116. package/build/hooks/useUninstallState.js.map +1 -1
  117. package/build/lib/adapter.d.ts +47 -1
  118. package/build/lib/adapter.d.ts.map +1 -1
  119. package/build/lib/adapter.js +153 -10
  120. package/build/lib/adapter.js.map +1 -1
  121. package/build/lib/checksum.d.ts.map +1 -1
  122. package/build/lib/checksum.js +2 -1
  123. package/build/lib/checksum.js.map +1 -1
  124. package/build/lib/config-entries.d.ts +5 -3
  125. package/build/lib/config-entries.d.ts.map +1 -1
  126. package/build/lib/config-entries.js +7 -4
  127. package/build/lib/config-entries.js.map +1 -1
  128. package/build/lib/config-file.d.ts +15 -4
  129. package/build/lib/config-file.d.ts.map +1 -1
  130. package/build/lib/config-file.js +35 -10
  131. package/build/lib/config-file.js.map +1 -1
  132. package/build/lib/config-lock.d.ts +22 -0
  133. package/build/lib/config-lock.d.ts.map +1 -0
  134. package/build/lib/config-lock.js +67 -0
  135. package/build/lib/config-lock.js.map +1 -0
  136. package/build/lib/cross-scope.d.ts +50 -0
  137. package/build/lib/cross-scope.d.ts.map +1 -0
  138. package/build/lib/cross-scope.js +132 -0
  139. package/build/lib/cross-scope.js.map +1 -0
  140. package/build/lib/diagnostics.d.ts +15 -2
  141. package/build/lib/diagnostics.d.ts.map +1 -1
  142. package/build/lib/diagnostics.js +152 -10
  143. package/build/lib/diagnostics.js.map +1 -1
  144. package/build/lib/diff-command.d.ts.map +1 -1
  145. package/build/lib/diff-command.js +7 -7
  146. package/build/lib/diff-command.js.map +1 -1
  147. package/build/lib/hook-config.d.ts +7 -0
  148. package/build/lib/hook-config.d.ts.map +1 -1
  149. package/build/lib/hook-config.js +11 -0
  150. package/build/lib/hook-config.js.map +1 -1
  151. package/build/lib/init.d.ts.map +1 -1
  152. package/build/lib/init.js +2 -1
  153. package/build/lib/init.js.map +1 -1
  154. package/build/lib/installed-for-tool.d.ts +2 -1
  155. package/build/lib/installed-for-tool.d.ts.map +1 -1
  156. package/build/lib/installed-for-tool.js +7 -4
  157. package/build/lib/installed-for-tool.js.map +1 -1
  158. package/build/lib/installer.d.ts +8 -4
  159. package/build/lib/installer.d.ts.map +1 -1
  160. package/build/lib/installer.js +153 -57
  161. package/build/lib/installer.js.map +1 -1
  162. package/build/lib/lockfile.d.ts +1 -1
  163. package/build/lib/lockfile.d.ts.map +1 -1
  164. package/build/lib/lockfile.js +2 -2
  165. package/build/lib/lockfile.js.map +1 -1
  166. package/build/lib/mover.d.ts +103 -0
  167. package/build/lib/mover.d.ts.map +1 -0
  168. package/build/lib/mover.js +375 -0
  169. package/build/lib/mover.js.map +1 -0
  170. package/build/lib/multi-tool-install.js +1 -1
  171. package/build/lib/multi-tool-install.js.map +1 -1
  172. package/build/lib/owned-paths.d.ts.map +1 -1
  173. package/build/lib/owned-paths.js +8 -8
  174. package/build/lib/owned-paths.js.map +1 -1
  175. package/build/lib/read-scopes.d.ts +32 -0
  176. package/build/lib/read-scopes.d.ts.map +1 -0
  177. package/build/lib/read-scopes.js +55 -0
  178. package/build/lib/read-scopes.js.map +1 -0
  179. package/build/lib/root-path.d.ts +7 -0
  180. package/build/lib/root-path.d.ts.map +1 -0
  181. package/build/lib/root-path.js +10 -0
  182. package/build/lib/root-path.js.map +1 -0
  183. package/build/lib/schemas/adapter.d.ts +80 -1
  184. package/build/lib/schemas/adapter.d.ts.map +1 -1
  185. package/build/lib/schemas/adapter.js +48 -0
  186. package/build/lib/schemas/adapter.js.map +1 -1
  187. package/build/lib/schemas/lockfile.d.ts +2 -0
  188. package/build/lib/schemas/lockfile.d.ts.map +1 -1
  189. package/build/lib/schemas/lockfile.js +8 -2
  190. package/build/lib/schemas/lockfile.js.map +1 -1
  191. package/build/lib/scope.d.ts +11 -0
  192. package/build/lib/scope.d.ts.map +1 -1
  193. package/build/lib/scope.js +12 -0
  194. package/build/lib/scope.js.map +1 -1
  195. package/build/lib/sync.d.ts +4 -3
  196. package/build/lib/sync.d.ts.map +1 -1
  197. package/build/lib/sync.js +12 -5
  198. package/build/lib/sync.js.map +1 -1
  199. package/build/lib/toml-edit.d.ts +10 -0
  200. package/build/lib/toml-edit.d.ts.map +1 -0
  201. package/build/lib/toml-edit.js +311 -0
  202. package/build/lib/toml-edit.js.map +1 -0
  203. package/build/lib/tool-resolver.d.ts +5 -0
  204. package/build/lib/tool-resolver.d.ts.map +1 -1
  205. package/build/lib/tool-resolver.js +16 -12
  206. package/build/lib/tool-resolver.js.map +1 -1
  207. package/build/lib/uninstaller.d.ts +14 -1
  208. package/build/lib/uninstaller.d.ts.map +1 -1
  209. package/build/lib/uninstaller.js +73 -28
  210. package/build/lib/uninstaller.js.map +1 -1
  211. package/build/lib/updater.d.ts +5 -4
  212. package/build/lib/updater.d.ts.map +1 -1
  213. package/build/lib/updater.js +18 -12
  214. package/build/lib/updater.js.map +1 -1
  215. package/build/lib/write-file.d.ts +17 -4
  216. package/build/lib/write-file.d.ts.map +1 -1
  217. package/build/lib/write-file.js +60 -10
  218. package/build/lib/write-file.js.map +1 -1
  219. package/package.json +1 -1
  220. package/tool-adapters/claude-code.json +44 -0
  221. package/tool-adapters/codex-cli.json +28 -0
  222. package/tool-adapters/copilot-cli.json +46 -2
@@ -1,15 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, unlink } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
- import { getPlacement, isEntrypoint, resolveInstalledPaths } from './adapter.js';
4
+ import { adapterAsPlaced, configDirMove, getPlacement, isEntrypoint, recordUserConfigDir, resolveInstalledPaths, } from './adapter.js';
5
5
  import { carryExclusions, declarationAllows, declaresTool, isExcludedForTool } from './asset-tools.js';
6
- import { computeConfigEntryChecksum, computeHookGroupChecksum, hashBytes } from './checksum.js';
6
+ import { computeConfigEntryChecksum, computeHookGroupChecksum, hashBytes, verifyChecksum } from './checksum.js';
7
7
  import { getConfigEntriesForTool, getOtherToolsConfigEntries, mergeConfigEntriesForTool, normalizeConfigPath, } from './config-entries.js';
8
- import { normalizeForFormat, readConfigFile, resolveConfigFormat, writeConfigFile, } from './config-file.js';
8
+ import { normalizeForFormat, readConfigFile, removeEmptiedConfigFile, resolveConfigFormat, writeConfigFile, } from './config-file.js';
9
+ import { configLockPath, withConfigLock } from './config-lock.js';
9
10
  import { addToEnabledList, removeFromEnabledList } from './enabled-list.js';
10
11
  import { ApiCodeError, errorGuidance, errorMessage } from './errors.js';
11
12
  import { ensureGitignoreEntries } from './gitignore.js';
12
- import { buildHookGroup, deriveHookCommand, hookGroupOwnsCommand, removeHookGroup } from './hook-config.js';
13
+ import { buildHookGroup, hookCommand, hookGroupOwnsCommand, removeHookGroup } from './hook-config.js';
13
14
  import { hookScriptPath, makeExecutable } from './hook-script.js';
14
15
  import { isInstalledForTool } from './installed-for-tool.js';
15
16
  import { addAssetToLockfile, findInstalledAsset, readLockfile, removeAssetFromLockfile, writeLockfile, } from './lockfile.js';
@@ -17,10 +18,18 @@ import { formatOrgName } from './org.js';
17
18
  import { configEntriesOwnedByOthers, holdsAnotherOwnersValue, pathsOwnedByOthers } from './owned-paths.js';
18
19
  import { toRelativePosix } from './paths.js';
19
20
  import { fetchAssetFileBytes, fetchAssetManifest, listAssetFiles } from './registry.js';
21
+ import { fromRoot } from './root-path.js';
22
+ import { scopedCommand } from './scope.js';
23
+ import { removePlacedFiles } from './uninstaller.js';
20
24
  import { writeBinaryFile, writeTextFile } from './write-file.js';
21
25
  export class InstallerError extends ApiCodeError {
22
26
  name = 'InstallerError';
23
27
  }
28
+ /** The guidance for an asset type no tool of the user scope has a user-level placement for. */
29
+ const NO_USER_PLACEMENT = {
30
+ command: 'atk setup --user',
31
+ hint: 'Install it in a project (without --user), or add a tool to the user scope that has one.',
32
+ };
24
33
  /**
25
34
  * A file the install would place at `path` exists and is not ATK-managed, or belongs to `owner`,
26
35
  * another installed asset; nothing was written there. With `entry`, the conflict is that part of
@@ -73,19 +82,22 @@ export class PlanDownloadError extends InstallPlanError {
73
82
  * Guard for multi-tool installs run with `skipUnsupported`. Each tool skips the asset
74
83
  * types it cannot place, which is fine as long as some tool placed something. If every
75
84
  * tool skipped every entry, nothing was installed — surface that as an error rather
76
- * than reporting a successful no-op.
85
+ * than reporting a successful no-op. The refusal's setup command is `scope`'s.
77
86
  */
78
- export function assertInstalledByAnyTool(plan, toolResults) {
87
+ export function assertInstalledByAnyTool(plan, toolResults, scope = 'project') {
79
88
  if (plan.entries.length === 0 || toolResults.some((r) => r.results.length > 0)) {
80
89
  return;
81
90
  }
82
- const undeclared = undeclaredHookError(plan, toolResults.map((r) => r.tool));
91
+ const undeclared = undeclaredHookError(plan, toolResults.map((r) => r.tool), scope);
83
92
  if (undeclared)
84
93
  throw undeclared;
85
94
  const types = [...new Set(plan.entries.map((e) => e.asset.type))].join(', ');
86
95
  const tools = toolResults.map((r) => r.tool).join(', ');
96
+ if (scope === 'user') {
97
+ throw new InstallerError(`None of the user scope's tools (${tools}) has a user-level ${types}.`, NO_USER_PLACEMENT);
98
+ }
87
99
  throw new InstallerError(`None of the configured tools (${tools}) support asset type "${types}"`, {
88
- command: 'atk setup',
100
+ command: scopedCommand('atk setup', scope),
89
101
  hint: 'Add a tool that supports this asset type, or install it in a project configured for one.',
90
102
  });
91
103
  }
@@ -101,18 +113,21 @@ export function assertInstalledByAnyTool(plan, toolResults) {
101
113
  */
102
114
  export async function executeInstallPlan(plan, options) {
103
115
  const results = [];
104
- // Ensure .gitignore has entries for ATK-managed asset directories (non-fatal)
105
- try {
106
- await ensureGitignoreEntries(options.projectDir, options.adapter);
107
- }
108
- catch {
109
- options.onProgress?.('Warning: Failed to update .gitignore — continuing with install');
116
+ // Ensure .gitignore has entries for ATK-managed asset directories (non-fatal). The user root
117
+ // isn't a project, and its .gitignore is often git's global excludes file, so it's left alone.
118
+ if (options.scope !== 'user') {
119
+ try {
120
+ await ensureGitignoreEntries(options.projectDir, options.adapter);
121
+ }
122
+ catch {
123
+ options.onProgress?.('Warning: Failed to update .gitignore — continuing with install');
124
+ }
110
125
  }
111
126
  // Read lockfile once — used for both conflict detection and per-asset install logic
112
127
  let lockfile = await readLockfile(options.projectDir);
113
128
  const initialLockfile = lockfile;
114
129
  // Map each ATK-managed file path to the assets that installed it, for conflict detection
115
- const managedPaths = buildManagedPaths(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
130
+ const managedPaths = await buildManagedPaths(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
116
131
  // ...and each block or keyed config entry to the assets that wrote it
117
132
  const managedEntries = buildManagedEntries(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
118
133
  // ...and each keyed config entry of other tools to its owners, who may share it for this tool
@@ -125,7 +140,9 @@ export async function executeInstallPlan(plan, options) {
125
140
  const supportedEntries = placeableEntries(plan, options.adapter);
126
141
  for (const entry of plan.entries) {
127
142
  if (!getPlacement(options.adapter, entry.asset.type)) {
128
- options.onProgress?.(`Skipping ${entry.asset.name}: ${options.adapter.displayName} does not support ${entry.asset.type} assets`);
143
+ options.onProgress?.(options.scope === 'user'
144
+ ? `Skipping ${entry.asset.name}: ${options.adapter.displayName} has no user-level ${entry.asset.type}`
145
+ : `Skipping ${entry.asset.name}: ${options.adapter.displayName} does not support ${entry.asset.type} assets`);
129
146
  }
130
147
  else if (!declarationAllows(entry.asset.type, entry.versionData.tools, options.adapter.tool)) {
131
148
  const tools = entry.versionData.tools.join(', ');
@@ -133,9 +150,12 @@ export async function executeInstallPlan(plan, options) {
133
150
  }
134
151
  }
135
152
  if (supportedEntries.length === 0 && plan.entries.length > 0 && !options.skipUnsupported) {
136
- const undeclared = undeclaredHookError(plan, [options.adapter.tool]);
153
+ const undeclared = undeclaredHookError(plan, [options.adapter.tool], options.scope);
137
154
  if (undeclared)
138
155
  throw undeclared;
156
+ if (options.scope === 'user') {
157
+ throw new InstallerError(`${options.adapter.displayName} has no user-level ${plan.entries[0].asset.type}.`, NO_USER_PLACEMENT);
158
+ }
139
159
  throw new InstallerError(`Tool "${options.adapter.tool}" does not support asset type "${plan.entries[0].asset.type}"`);
140
160
  }
141
161
  // The assets this tool had in place before the plan, by plan entry, which a failure keeps.
@@ -148,11 +168,16 @@ export async function executeInstallPlan(plan, options) {
148
168
  }
149
169
  // The files the asset being placed wrote where no file was, which a failure removes.
150
170
  let created = [];
171
+ // The previous copies left in a user config directory the tool no longer reads, removed once the
172
+ // plan is in place: a failure restores their lockfile entries, which still point at them.
173
+ const movedCopies = [];
151
174
  try {
152
175
  for (const entry of supportedEntries) {
153
176
  created = [];
154
- const { lockfile: updatedLockfile, result, sharedEntries, } = await installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created);
177
+ const { lockfile: updatedLockfile, movedCopy, result, sharedEntries, } = await installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created);
155
178
  results.push(result);
179
+ if (movedCopy)
180
+ movedCopies.push({ previous: movedCopy, written: result.filesPlaced.map((f) => f.destination) });
156
181
  // A later entry of this plan may land on these files or entries, and must see whose they are.
157
182
  for (const file of result.filesPlaced)
158
183
  addOwner(managedPaths, file.destination, entry.asset);
@@ -209,6 +234,13 @@ export async function executeInstallPlan(plan, options) {
209
234
  }
210
235
  throw new InstallPlanError(failed, error, added.length);
211
236
  }
237
+ for (const { previous, written } of movedCopies) {
238
+ await removePlacedFiles(previous, adapterAsPlaced(options.adapter, previous), options.projectDir, {
239
+ keep: written,
240
+ lockfile,
241
+ onProgress: options.onProgress,
242
+ });
243
+ }
212
244
  return results;
213
245
  }
214
246
  /**
@@ -285,10 +317,13 @@ export async function rollBackInstalledAssets(installed, before, lockfile, optio
285
317
  kept.add(entry.key);
286
318
  continue;
287
319
  }
288
- const configPath = join(options.projectDir, entry.file);
320
+ const configPath = fromRoot(options.projectDir, entry.file);
289
321
  const format = resolveConfigFormat(configPath, placement);
290
322
  const created = result.configFilesCreated?.includes(entry.file) ?? false;
291
- await revertConfigEntry(configPath, entry.key, entry.command, format, created, options.onProgress);
323
+ const lockFile = configLockPath(placement, options.projectDir, entry.file);
324
+ await withConfigLock(lockFile, () => revertConfigEntry(configPath, entry.key, entry.command, format, created, options.onProgress, {
325
+ atomic: lockFile !== undefined,
326
+ }));
292
327
  }
293
328
  catch (error) {
294
329
  options.onProgress?.(`Warning: Failed to revert config entry ${entry.key} during rollback — ${errorMessage(error)}`);
@@ -303,7 +338,7 @@ export async function rollBackInstalledAssets(installed, before, lockfile, optio
303
338
  const { configKey, enabledListFile, enabledListKey } = placement ?? {};
304
339
  const names = (result.enabledListAdded ?? []).filter((name) => !kept.has(`${configKey}.${name}`));
305
340
  if (enabledListFile && enabledListKey && names.length > 0) {
306
- await removeFromEnabledList(join(options.projectDir, enabledListFile), enabledListKey, names, result.configFilesCreated?.includes(enabledListFile) ?? false, options.onProgress);
341
+ await removeFromEnabledList(fromRoot(options.projectDir, enabledListFile), enabledListKey, names, result.configFilesCreated?.includes(enabledListFile) ?? false, options.onProgress);
307
342
  }
308
343
  // Revert the lockfile entry using the in-memory lockfile (best-effort). An asset that
309
344
  // was already recorded before this plan ran (a sync/update reinstall, or a second
@@ -377,7 +412,7 @@ function buildManagedEntries(lockfile, projectDir, adapter) {
377
412
  // A hook's event entry is an array every hook shares; its own group is found by its command.
378
413
  if (entry.command !== undefined)
379
414
  continue;
380
- addOwner(entries, sharedEntryKey({ file: join(projectDir, entry.file), key: entry.key }), asset);
415
+ addOwner(entries, sharedEntryKey({ file: fromRoot(projectDir, entry.file), key: entry.key }), asset);
381
416
  }
382
417
  }
383
418
  return entries;
@@ -387,15 +422,25 @@ function buildManagedEntries(lockfile, projectDir, adapter) {
387
422
  * Used for conflict detection — an asset may overwrite only the files it installed itself.
388
423
  * Resolves installed paths dynamically via the adapter rather than reading them from the lockfile.
389
424
  */
390
- function buildManagedPaths(lockfile, projectDir, adapter) {
425
+ async function buildManagedPaths(lockfile, projectDir, adapter) {
391
426
  const paths = new Map();
392
427
  for (const asset of lockfile.assets ?? []) {
393
428
  // Uninstalled for this tool, or a hook never placed by it: a file at its path is not ATK's.
394
429
  if (isExcludedForTool(asset, adapter.tool))
395
430
  continue;
396
- const resolved = resolveInstalledPaths(asset, adapter, projectDir);
431
+ const resolved = resolveInstalledPaths(asset, adapterAsPlaced(adapter, asset), projectDir);
397
432
  for (const file of resolved.files) {
398
- addOwner(paths, join(projectDir, file.installedPath), asset);
433
+ addOwner(paths, fromRoot(projectDir, file.installedPath), asset);
434
+ }
435
+ // Its tool's user config directory moved since (the user moved or copied it): a file in the
436
+ // new one that holds what ATK placed is the asset's own copy, so moving the asset there may
437
+ // write over it. Any other file there is not ATK's.
438
+ if (configDirMove(adapter, asset)) {
439
+ for (const file of resolveInstalledPaths(asset, adapter, projectDir).files) {
440
+ const path = fromRoot(projectDir, file.installedPath);
441
+ if (await verifyChecksum(path, file.checksum).catch(() => false))
442
+ addOwner(paths, path, asset);
443
+ }
399
444
  }
400
445
  }
401
446
  return paths;
@@ -578,7 +623,7 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
578
623
  // The target config file's serialization format (JSON by default; TOML for tools like Codex
579
624
  // CLI), resolved once here and threaded into the entry checks and every merge helper.
580
625
  const configFormat = placement.configFile
581
- ? resolveConfigFormat(join(basePath, placement.configFile), placement)
626
+ ? resolveConfigFormat(fromRoot(basePath, placement.configFile), placement)
582
627
  : 'json';
583
628
  // Check the block or config entries the asset writes into shared files before writing anything,
584
629
  // so a conflict leaves another asset's entry, or one the user wrote, as it was.
@@ -687,7 +732,7 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
687
732
  }
688
733
  // The tool runs a registered hook script directly, so it needs the exec bit.
689
734
  const scriptPath = hookScriptPath(placement, asset.name);
690
- const scriptDest = scriptPath ? join(basePath, scriptPath) : undefined;
735
+ const scriptDest = scriptPath ? fromRoot(basePath, scriptPath) : undefined;
691
736
  if (scriptDest && filesPlaced.some((f) => f.destination === scriptDest)) {
692
737
  await makeExecutable(scriptDest);
693
738
  }
@@ -698,17 +743,20 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
698
743
  // The config files the writes below can create, which a rollback then removes once empty.
699
744
  const absentConfigFiles = [placement.configFile, placement.enabledListFile]
700
745
  .filter((file) => !!file)
701
- .map((file) => join(basePath, file))
746
+ .map((file) => fromRoot(basePath, file))
702
747
  .filter((path) => !existsSync(path));
703
748
  // The names the install adds to the placement's enabled list, which a rollback takes out again.
704
749
  let enabledListAdded = [];
750
+ // A config file the tool rewrites while it runs is written under its lock, through a rename.
751
+ const lockFile = placement.configFile ? configLockPath(placement, basePath, placement.configFile) : undefined;
752
+ const writeOptions = { atomic: lockFile !== undefined };
705
753
  // Handle config file updates if needed.
706
754
  if (placement.configFile && placement.configKey && placement.configHookMerge) {
707
755
  // Hook placement: merge the asset's hookConfig event bindings into the tool
708
756
  // config file as valid event arrays (e.g. settings.json hooks.Stop[]), with the
709
757
  // command derived from the installed script path.
710
- const command = deriveHookCommand(placement.configCommandPrefix, placement.path.replace(/\{name\}/g, asset.name));
711
- const hookEntries = await mergeHookConfig(join(basePath, placement.configFile), placement.configKey, configFormat, entry.versionData.hookConfig, command, projectDir, onProgress);
758
+ const { configFile, configKey } = placement;
759
+ const hookEntries = await withConfigLock(lockFile, () => mergeHookConfig(fromRoot(basePath, configFile), configKey, configFormat, entry.versionData.hookConfig, hookCommand(placement, asset.name), projectDir, onProgress, writeOptions));
712
760
  configEntries.push(...hookEntries);
713
761
  }
714
762
  else if (placement.configFile && placement.configKey) {
@@ -717,16 +765,18 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
717
765
  // (e.g. a whole .mcp.json). Merge the entries inside content[configKey] into
718
766
  // configFile[configKey] keyed by each entry's own name, instead of nesting the
719
767
  // whole content under the asset name.
720
- const { entries, names } = await mergeFragmentConfig(join(basePath, placement.configFile), placement.configKey, configFormat, projectDir, mergeContent, placement.configFragmentKey, onProgress);
768
+ const { configCommandEntryDefaults, configEntryDefaults, configFile, configFragmentKey, configKey } = placement;
769
+ const { entries, names } = await withConfigLock(lockFile, () => mergeFragmentConfig(fromRoot(basePath, configFile), configKey, configFormat, projectDir, mergeContent, configFragmentKey, onProgress, writeOptions, configEntryDefaults, configCommandEntryDefaults));
721
770
  configEntries.push(...entries);
722
771
  // Auto-register the merged entry names into an enabled-list (e.g. enabledMcpjsonServers
723
772
  // in settings.local.json) so the tool auto-approves them.
724
773
  if (names.length > 0 && placement.enabledListFile && placement.enabledListKey) {
725
- enabledListAdded = await addToEnabledList(join(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
774
+ enabledListAdded = await addToEnabledList(fromRoot(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
726
775
  }
727
776
  }
728
777
  else {
729
- const configResult = await updateConfigFile(join(basePath, placement.configFile), placement.configKey, configFormat, asset.name, asset.type, projectDir, mergeContent, onProgress);
778
+ const { configFile, configKey } = placement;
779
+ const configResult = await withConfigLock(lockFile, () => updateConfigFile(fromRoot(basePath, configFile), configKey, configFormat, asset.name, asset.type, projectDir, mergeContent, onProgress, writeOptions));
730
780
  if (configResult) {
731
781
  configEntries.push(configResult);
732
782
  }
@@ -736,10 +786,21 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
736
786
  const configFilesCreated = absentConfigFiles
737
787
  .filter((path) => existsSync(path))
738
788
  .map((path) => toRelativePosix(projectDir, path));
789
+ const previousEntry = (lockfile.assets ?? []).find((a) => a.name === asset.name && a.type === asset.type && a.org === entry.asset.org);
790
+ // Only once the asset is registered where the placement says: a merge that failed (it warns and
791
+ // writes nothing) must not take away the old entry as well.
792
+ if (configEntries.length > 0) {
793
+ await removeMovedConfigEntries(previousEntry, adapter, placement, lockfile, projectDir, onProgress);
794
+ }
795
+ // The tool's user config directory moved since the previous copy was placed (`install --force`,
796
+ // another version, a reinstall): this one is where the tool reads it now, so the old copy's files
797
+ // go too, once the whole plan is in place (see executeInstallPlan).
798
+ const movedCopy = previousEntry && !isExcludedForTool(previousEntry, adapter.tool) && configDirMove(adapter, previousEntry)
799
+ ? previousEntry
800
+ : undefined;
739
801
  // Config entries are tool-specific but the lockfile is shared across tools: tag this
740
802
  // tool's entries and carry over whatever other tools already registered for this asset,
741
803
  // so a multi-tool install/sync/update never drops another tool's registration record.
742
- const previousEntry = (lockfile.assets ?? []).find((a) => a.name === asset.name && a.type === asset.type && a.org === entry.asset.org);
743
804
  const lockfileConfigEntries = mergeConfigEntriesForTool(previousEntry, adapter, configEntries);
744
805
  // Installing for a tool re-includes it; exclusions recorded for other tools carry over.
745
806
  const exclusions = carryExclusions(previousEntry, adapter.tool);
@@ -753,6 +814,8 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
753
814
  ? previousEntry.pinnedVersion
754
815
  : undefined;
755
816
  const bundleOrigin = bundleOriginFor(previousEntry, options.bundleOrigin);
817
+ // Where this tool's user config directory is, so its copy is found there once the directory moves.
818
+ const userConfigDirs = recordUserConfigDir(previousEntry?.userConfigDirs, adapter);
756
819
  // Update lockfile — use resolved version and per-version dependency data
757
820
  const installedAsset = {
758
821
  ...(bundleOrigin ? { bundleOrigin } : {}),
@@ -772,6 +835,7 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
772
835
  ...(pinnedVersion ? { pinnedVersion } : {}),
773
836
  type: asset.type,
774
837
  ...(exclusions.undeclaredTools ? { undeclaredTools: exclusions.undeclaredTools } : {}),
838
+ ...(userConfigDirs ? { userConfigDirs } : {}),
775
839
  version: entry.version,
776
840
  };
777
841
  const updatedLockfile = addAssetToLockfile(lockfile, installedAsset);
@@ -779,6 +843,7 @@ async function installAsset(entry, options, managedPaths, managedEntries, owners
779
843
  onProgress?.(`Installed ${asset.name}@${entry.version}`);
780
844
  return {
781
845
  lockfile: updatedLockfile,
846
+ ...(movedCopy ? { movedCopy } : {}),
782
847
  result: {
783
848
  ...(blockWritten ? { blockWritten } : {}),
784
849
  ...(bundleOrigin ? { bundleOrigin } : {}),
@@ -805,7 +870,7 @@ function isRecord(value) {
805
870
  /** The `<!-- ATK: {name} -->` block a memory-template asset writes into its placement's file. */
806
871
  function memoryTemplateBlock(placement, name, basePath) {
807
872
  return {
808
- file: join(basePath, placement.path.replace(/\{name\}/g, name)),
873
+ file: fromRoot(basePath, placement.path.replace(/\{name\}/g, name)),
809
874
  key: `ATK:${name}`,
810
875
  label: `"${name}" block`,
811
876
  };
@@ -819,9 +884,12 @@ function memoryTemplateBlock(placement, name, basePath) {
819
884
  * with the `_managedBy: 'atk'` marker. Returns one config entry per merged name (for lockfile
820
885
  * tracking / checksum verification) plus the list of names that were merged.
821
886
  *
822
- * The entries are found by {@link fragmentEntries}.
887
+ * The entries are found by {@link fragmentEntries}. `entryDefaults` (the placement's
888
+ * `configEntryDefaults`) fills in the keys an entry leaves out, and `commandEntryDefaults`
889
+ * (`configCommandEntryDefaults`) those of an entry with a `command`, a local stdio server; the
890
+ * entry's own keys win.
823
891
  */
824
- async function mergeFragmentConfig(configFilePath, configKey, format, projectDir, mergeContent, fragmentKey, onProgress) {
892
+ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir, mergeContent, fragmentKey, onProgress, writeOptions = {}, entryDefaults = {}, commandEntryDefaults = {}) {
825
893
  const empty = { entries: [], names: [] };
826
894
  if (!mergeContent) {
827
895
  return empty;
@@ -846,8 +914,10 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
846
914
  }
847
915
  const section = config[configKey];
848
916
  const entries = [];
917
+ const commandDefaults = { ...entryDefaults, ...commandEntryDefaults };
849
918
  for (const [name, fragment] of inner) {
850
- const value = normalizeForFormat({ ...fragment, _managedBy: 'atk' }, format);
919
+ const defaults = 'command' in fragment ? commandDefaults : entryDefaults;
920
+ const value = normalizeForFormat({ ...defaults, ...fragment, _managedBy: 'atk' }, format);
851
921
  section[name] = value;
852
922
  entries.push({
853
923
  checksum: computeConfigEntryChecksum(value),
@@ -855,7 +925,7 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
855
925
  key: `${configKey}.${name}`,
856
926
  });
857
927
  }
858
- await writeConfigFile(configFilePath, config, format, onProgress);
928
+ await writeConfigFile(configFilePath, config, format, onProgress, writeOptions);
859
929
  return { entries, names: inner.map(([name]) => name) };
860
930
  }
861
931
  catch (error) {
@@ -874,7 +944,7 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
874
944
  * owned by this command is dropped before appending, so repeated install/sync doesn't duplicate.
875
945
  * Returns one lockfile config entry per event, checksummed over the ATK-owned group only.
876
946
  */
877
- async function mergeHookConfig(configFilePath, configKey, format, hookConfig, command, projectDir, onProgress) {
947
+ async function mergeHookConfig(configFilePath, configKey, format, hookConfig, command, projectDir, onProgress, writeOptions = {}) {
878
948
  const events = hookConfig ? Object.keys(hookConfig) : [];
879
949
  if (events.length === 0) {
880
950
  // No hookConfig (e.g. legacy asset or un-rebuilt registry index) — write nothing.
@@ -910,7 +980,7 @@ async function mergeHookConfig(configFilePath, configKey, format, hookConfig, co
910
980
  key: `${configKey}.${event}`,
911
981
  });
912
982
  }
913
- await writeConfigFile(configFilePath, config, format, onProgress);
983
+ await writeConfigFile(configFilePath, config, format, onProgress, writeOptions);
914
984
  return entries;
915
985
  }
916
986
  catch (error) {
@@ -943,12 +1013,37 @@ function plannedSharedEntries(name, type, placement, basePath, mergeContent) {
943
1013
  ...entries,
944
1014
  ...names.map((entryName) => ({
945
1015
  entry: { name: entryName, section: configKey },
946
- file: join(basePath, configFile),
1016
+ file: fromRoot(basePath, configFile),
947
1017
  key: `${configKey}.${entryName}`,
948
1018
  label: `"${configKey}.${entryName}" entry`,
949
1019
  })),
950
1020
  ];
951
1021
  }
1022
+ /**
1023
+ * Remove the config entries `previous` recorded for the adapter's tool in a file other than the
1024
+ * placement's `configFile`. An adapter that moved its config file (copilot-cli's mcp-config, from
1025
+ * `copilot/mcp-config.json` to `.vscode/mcp.json`) left them where the tool doesn't read them, and
1026
+ * this install has just registered the asset where it does. As with an uninstall, an entry holding
1027
+ * the value of another asset that owns it stays, for that asset's own sync to move. A file left
1028
+ * empty is deleted. (`atk sync` removes the asset before reinstalling it, so there it finds the
1029
+ * entries gone already; it is `install` and `update` that need it.)
1030
+ */
1031
+ async function removeMovedConfigEntries(previous, adapter, placement, lockfile, projectDir, onProgress) {
1032
+ if (!previous || !placement.configFile)
1033
+ return;
1034
+ const configFile = normalizeConfigPath(placement.configFile);
1035
+ const moved = getConfigEntriesForTool(previous, adapter).filter((e) => normalizeConfigPath(e.file) !== configFile);
1036
+ if (moved.length === 0)
1037
+ return;
1038
+ const owners = configEntriesOwnedByOthers(lockfile, previous, adapter, projectDir);
1039
+ for (const entry of moved) {
1040
+ const configPath = fromRoot(projectDir, entry.file);
1041
+ if (await holdsAnotherOwnersValue(entry, owners, projectDir))
1042
+ continue;
1043
+ // The old file's own format: the placement's describes the file it uses now.
1044
+ await revertConfigEntry(configPath, entry.key, entry.command, resolveConfigFormat(configPath), true, onProgress);
1045
+ }
1046
+ }
952
1047
  /**
953
1048
  * Resolve the destination file path for an asset file.
954
1049
  * For directory-based placement (entrypointName set), the path is a directory template.
@@ -959,18 +1054,18 @@ function resolveFilePath(pathTemplate, assetName, basePath, relativePath, assetT
959
1054
  const resolvedTemplate = pathTemplate.replace(/\{name\}/g, assetName);
960
1055
  if (entrypointName) {
961
1056
  // Directory-based placement: path is a directory, entrypoint gets the fixed name
962
- const dirPath = resolvedTemplate;
1057
+ const dirPath = fromRoot(basePath, resolvedTemplate);
963
1058
  if (isEntrypoint(relativePath, assetType, declaredEntrypoint)) {
964
- return join(basePath, dirPath, entrypointName);
1059
+ return join(dirPath, entrypointName);
965
1060
  }
966
- return join(basePath, dirPath, relativePath);
1061
+ return join(dirPath, relativePath);
967
1062
  }
968
1063
  // Default: single-file placement
969
- const templateDir = dirname(resolvedTemplate);
1064
+ const templatePath = fromRoot(basePath, resolvedTemplate);
970
1065
  if (isEntrypoint(relativePath, assetType, declaredEntrypoint)) {
971
- return join(basePath, resolvedTemplate);
1066
+ return templatePath;
972
1067
  }
973
- return join(basePath, templateDir, relativePath);
1068
+ return join(dirname(templatePath), relativePath);
974
1069
  }
975
1070
  /**
976
1071
  * Revert a config file entry created during installation.
@@ -978,7 +1073,7 @@ function resolveFilePath(pathTemplate, assetName, basePath, relativePath, assetT
978
1073
  * and writes it back. If the parent section becomes empty, it is also removed. A file the
979
1074
  * install created (`deleteWhenEmpty`) is deleted instead once nothing is left in it.
980
1075
  */
981
- async function revertConfigEntry(configFilePath, keyPath, command, format, deleteWhenEmpty, onProgress) {
1076
+ async function revertConfigEntry(configFilePath, keyPath, command, format, deleteWhenEmpty, onProgress, writeOptions = {}) {
982
1077
  if (!existsSync(configFilePath)) {
983
1078
  return;
984
1079
  }
@@ -1009,10 +1104,10 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, delet
1009
1104
  }
1010
1105
  }
1011
1106
  if (deleteWhenEmpty && Object.keys(config).length === 0) {
1012
- await unlink(configFilePath);
1107
+ await removeEmptiedConfigFile(configFilePath, format, writeOptions);
1013
1108
  return;
1014
1109
  }
1015
- await writeConfigFile(configFilePath, config, format, onProgress);
1110
+ await writeConfigFile(configFilePath, config, format, onProgress, writeOptions);
1016
1111
  }
1017
1112
  catch (error) {
1018
1113
  onProgress?.(`Warning: Failed to revert config entry ${keyPath} in ${configFilePath} — ${errorMessage(error)}`);
@@ -1024,7 +1119,7 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, delet
1024
1119
  * created it.
1025
1120
  */
1026
1121
  async function revertMemoryBlock(projectDir, assetName, { created, file, replaced }, onProgress) {
1027
- const path = join(projectDir, file);
1122
+ const path = fromRoot(projectDir, file);
1028
1123
  try {
1029
1124
  if (!existsSync(path))
1030
1125
  return;
@@ -1062,14 +1157,15 @@ function stripAtkBlock(content, assetName) {
1062
1157
  * The refusal of a plan that none of `tools` installs anything of because it holds a hook whose
1063
1158
  * manifest declares none of them; undefined when the plan has no such hook.
1064
1159
  */
1065
- function undeclaredHookError(plan, tools) {
1160
+ function undeclaredHookError(plan, tools, scope) {
1066
1161
  const hook = plan.entries.find((entry) => entry.asset.type === 'hook' && !tools.some((tool) => declaresTool(entry.versionData.tools, tool)));
1067
1162
  if (!hook)
1068
1163
  return undefined;
1164
+ const setup = scopedCommand('atk setup', scope);
1069
1165
  return new InstallerError(`Hook "${formatOrgName(hook.asset.org, hook.asset.name)}" is for ${hook.versionData.tools.join(', ')} only, ` +
1070
1166
  `so it is not installed for ${tools.join(', ')}.`, {
1071
- command: 'atk setup',
1072
- hint: 'A hook is installed only for the tools its manifest lists. Add one with `atk setup`, or install it in a project set up for one.',
1167
+ command: setup,
1168
+ hint: `A hook is installed only for the tools its manifest lists. Add one with \`${setup}\`, or install it in a project set up for one.`,
1073
1169
  });
1074
1170
  }
1075
1171
  /**
@@ -1077,7 +1173,7 @@ function undeclaredHookError(plan, tools) {
1077
1173
  * When mergeContent is provided, it is merged into the config section with _managedBy marker.
1078
1174
  * Otherwise, a simple marker is written.
1079
1175
  */
1080
- async function updateConfigFile(configFilePath, configKey, format, assetName, assetType, projectDir, mergeContent, onProgress) {
1176
+ async function updateConfigFile(configFilePath, configKey, format, assetName, assetType, projectDir, mergeContent, onProgress, writeOptions = {}) {
1081
1177
  try {
1082
1178
  let config = {};
1083
1179
  if (existsSync(configFilePath)) {
@@ -1108,7 +1204,7 @@ async function updateConfigFile(configFilePath, configKey, format, assetName, as
1108
1204
  type: assetType,
1109
1205
  };
1110
1206
  }
1111
- await writeConfigFile(configFilePath, config, format, onProgress);
1207
+ await writeConfigFile(configFilePath, config, format, onProgress, writeOptions);
1112
1208
  const checksum = computeConfigEntryChecksum(section[assetName]);
1113
1209
  return { checksum, file: toRelativePosix(projectDir, configFilePath), key: `${configKey}.${assetName}` };
1114
1210
  }