claudeup 6.5.1 → 6.7.1
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/package.json +4 -4
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +2 -3
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/models-presets-marketplace.test.ts +141 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/models-presets.ts +10 -11
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +2 -174
- package/src/services/community-styles.ts +1 -1
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
|
@@ -58,6 +58,15 @@ export interface PythonCliInstaller {
|
|
|
58
58
|
* frequently is not an executable at all.
|
|
59
59
|
*/
|
|
60
60
|
uninstall(pkg: string): Command;
|
|
61
|
+
/**
|
|
62
|
+
* Move an ALREADY-INSTALLED package to exactly `version`.
|
|
63
|
+
*
|
|
64
|
+
* Distinct from `install(pkg, version)`, which each of these treats as a
|
|
65
|
+
* no-op once the package is present — `uv tool install pkg==v` answers
|
|
66
|
+
* "already installed" when any version is there. `--force` is what makes
|
|
67
|
+
* the pin land.
|
|
68
|
+
*/
|
|
69
|
+
pin(pkg: string, version: string): Command;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
|
|
@@ -81,6 +90,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
|
|
|
81
90
|
args: ["tool", "install", "--force", "--upgrade", pkg],
|
|
82
91
|
}),
|
|
83
92
|
uninstall: (pkg) => ({ cmd: "uv", args: ["tool", "uninstall", pkg] }),
|
|
93
|
+
pin: (pkg, version) => ({
|
|
94
|
+
cmd: "uv",
|
|
95
|
+
args: ["tool", "install", "--force", `${pkg}==${version}`],
|
|
96
|
+
}),
|
|
84
97
|
},
|
|
85
98
|
{
|
|
86
99
|
probe: "pipx",
|
|
@@ -92,6 +105,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
|
|
|
92
105
|
// manage the package.
|
|
93
106
|
upgrade: (pkg) => ({ cmd: "pipx", args: ["install", "--force", pkg] }),
|
|
94
107
|
uninstall: (pkg) => ({ cmd: "pipx", args: ["uninstall", pkg] }),
|
|
108
|
+
pin: (pkg, version) => ({
|
|
109
|
+
cmd: "pipx",
|
|
110
|
+
args: ["install", "--force", `${pkg}==${version}`],
|
|
111
|
+
}),
|
|
95
112
|
},
|
|
96
113
|
{
|
|
97
114
|
probe: "python3",
|
|
@@ -114,6 +131,10 @@ export const PYTHON_CLI_INSTALLERS: readonly PythonCliInstaller[] = [
|
|
|
114
131
|
cmd: "python3",
|
|
115
132
|
args: ["-m", "pip", "uninstall", "-y", pkg],
|
|
116
133
|
}),
|
|
134
|
+
pin: (pkg, version) => ({
|
|
135
|
+
cmd: "python3",
|
|
136
|
+
args: ["-m", "pip", "install", "--user", `${pkg}==${version}`],
|
|
137
|
+
}),
|
|
117
138
|
},
|
|
118
139
|
];
|
|
119
140
|
|
|
@@ -295,6 +316,82 @@ export function binUpgradeCommand(
|
|
|
295
316
|
}
|
|
296
317
|
}
|
|
297
318
|
|
|
319
|
+
/**
|
|
320
|
+
* The command that moves an ALREADY-INSTALLED binary to exactly its pin.
|
|
321
|
+
*
|
|
322
|
+
* `claudeup update` plans a pinned tool that sits below its pin as an upgrade
|
|
323
|
+
* TO the pin, and neither of the other two builders delivers that:
|
|
324
|
+
* {@link binUpgradeCommand} goes to the newest version, past the pin, and a
|
|
325
|
+
* plain `uv tool install pkg==v` is a no-op once any version is installed.
|
|
326
|
+
*
|
|
327
|
+
* bun, npm and go take the version in the same token `binInstallCommand` builds
|
|
328
|
+
* (`pkg@v`), and each replaces an installed version with it. pip goes through
|
|
329
|
+
* the resolved installer's own `pin`. brew installs only its newest version, so
|
|
330
|
+
* there is nothing to return — the planner never asks for it (a brew pin below
|
|
331
|
+
* the installed version is reported, not planned).
|
|
332
|
+
*
|
|
333
|
+
* Null also when the requirement carries no pin.
|
|
334
|
+
*/
|
|
335
|
+
export function binPinCommand(
|
|
336
|
+
bin: {
|
|
337
|
+
name: string;
|
|
338
|
+
via: BinInstaller;
|
|
339
|
+
package?: string;
|
|
340
|
+
formula?: string;
|
|
341
|
+
module?: string;
|
|
342
|
+
version?: string;
|
|
343
|
+
},
|
|
344
|
+
python: PythonCliInstaller = resolvePythonCliInstaller(),
|
|
345
|
+
): Command | null {
|
|
346
|
+
if (!bin.version) return null;
|
|
347
|
+
switch (bin.via) {
|
|
348
|
+
case "bun":
|
|
349
|
+
case "npm":
|
|
350
|
+
case "go":
|
|
351
|
+
return binInstallCommand(bin, python);
|
|
352
|
+
case "pip":
|
|
353
|
+
return python.pin(bin.package ?? bin.name, bin.version);
|
|
354
|
+
case "brew":
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Which of the three builders a requirement in a given state needs.
|
|
361
|
+
*
|
|
362
|
+
* THE one rule that turns a state into a command, so a front end cannot show
|
|
363
|
+
* one target and run another. It was written twice before: `binCommandFor`
|
|
364
|
+
* (`update-engine.ts`) for `claudeup update`, and the CLI Tools screen's row,
|
|
365
|
+
* which always built `@latest` — so a row reading `→ v9.0.0` (the pin) ran
|
|
366
|
+
* `bun install -g pkg@latest` and left the tool permanently ahead of its pin,
|
|
367
|
+
* a state claudeup cannot leave. Both now come through here.
|
|
368
|
+
*
|
|
369
|
+
* `install` → {@link binInstallCommand} (which appends the pin, when there is
|
|
370
|
+
* one). An upgrade whose target IS the pin → {@link binPinCommand}, because
|
|
371
|
+
* {@link binUpgradeCommand} would overshoot it. Any other upgrade → the newest
|
|
372
|
+
* version.
|
|
373
|
+
*
|
|
374
|
+
* Null only when no sound command exists: a pinned brew formula, where brew
|
|
375
|
+
* installs its newest version and nothing else (the pin is advisory).
|
|
376
|
+
*/
|
|
377
|
+
export function binCommandForState(
|
|
378
|
+
state: "install" | "upgrade",
|
|
379
|
+
target: string | null,
|
|
380
|
+
bin: {
|
|
381
|
+
name: string;
|
|
382
|
+
via: BinInstaller;
|
|
383
|
+
package?: string;
|
|
384
|
+
formula?: string;
|
|
385
|
+
module?: string;
|
|
386
|
+
version?: string;
|
|
387
|
+
},
|
|
388
|
+
python: PythonCliInstaller = resolvePythonCliInstaller(),
|
|
389
|
+
): Command | null {
|
|
390
|
+
if (state === "install") return binInstallCommand(bin, python);
|
|
391
|
+
if (bin.version && target === bin.version) return binPinCommand(bin, python);
|
|
392
|
+
return binUpgradeCommand(bin, python);
|
|
393
|
+
}
|
|
394
|
+
|
|
298
395
|
/**
|
|
299
396
|
* The command that REMOVES an installed binary.
|
|
300
397
|
*
|
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import path from "node:path";
|
|
33
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
ProfileSkillRef,
|
|
35
|
+
ResolvedBin,
|
|
36
|
+
SkillInfo,
|
|
37
|
+
} from "../types/index.js";
|
|
34
38
|
import type { Command } from "../utils/command-utils.js";
|
|
35
39
|
import { formatCommand } from "../utils/command-utils.js";
|
|
36
40
|
import { runCommand } from "../utils/run.js";
|
|
@@ -41,14 +45,34 @@ import {
|
|
|
41
45
|
saveInstalledPluginVersionForScope,
|
|
42
46
|
} from "./plugin-manager.js";
|
|
43
47
|
import { installSkill } from "./skills-manager.js";
|
|
44
|
-
import {
|
|
45
|
-
|
|
48
|
+
import {
|
|
49
|
+
type ToolFacts,
|
|
50
|
+
compareToolVersions,
|
|
51
|
+
probeTool,
|
|
52
|
+
} from "./tool-probe.js";
|
|
53
|
+
import { binCommandForState } from "./toolchain.js";
|
|
54
|
+
import {
|
|
55
|
+
type BinUpdateItem,
|
|
56
|
+
type PluginUpdateItem,
|
|
57
|
+
type UpdatePlan,
|
|
58
|
+
binNeedsWork,
|
|
59
|
+
} from "./update-plan.js";
|
|
46
60
|
|
|
47
61
|
// -- results -----------------------------------------------------------------
|
|
48
62
|
|
|
49
63
|
export interface ApplyResult {
|
|
64
|
+
/** Items that CHANGED — the installer exited 0 and read-back shows movement. */
|
|
50
65
|
ok: number;
|
|
51
66
|
failed: string[];
|
|
67
|
+
/**
|
|
68
|
+
* Items whose installer exited 0 but whose read-back shows nothing moved.
|
|
69
|
+
*
|
|
70
|
+
* Not a failure, and not a change. A stale tap, a PATH that still resolves
|
|
71
|
+
* an older copy, a marketplace clone behind its catalog — each "succeeds",
|
|
72
|
+
* lands on the same version, and is planned identically next run. Counting
|
|
73
|
+
* those as changed is how "3 changed" was printed on every run forever.
|
|
74
|
+
*/
|
|
75
|
+
unchanged: string[];
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
/** An exact pin the installer could not deliver, discovered by reading back. */
|
|
@@ -70,6 +94,8 @@ export interface UpdateOutcome {
|
|
|
70
94
|
skills: ApplyResult;
|
|
71
95
|
/** Items that changed, across all three. */
|
|
72
96
|
changed: number;
|
|
97
|
+
/** Names whose installer exited 0 but read-back shows no movement. */
|
|
98
|
+
unchanged: string[];
|
|
73
99
|
/** Names that failed, across all three. */
|
|
74
100
|
failed: string[];
|
|
75
101
|
}
|
|
@@ -158,10 +184,9 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
158
184
|
* scope, matching `install`: a profile is a property of one repo, so its
|
|
159
185
|
* plugins must not be enabled machine-wide.
|
|
160
186
|
*
|
|
161
|
-
* Every scope holding an outdated install is returned, not just one.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* same drift reappeared on every run.
|
|
187
|
+
* Every scope holding an outdated install is returned, not just one. Fixing a
|
|
188
|
+
* single hardcoded scope does not converge: it reports success while the
|
|
189
|
+
* project row stays untouched, so the same drift reappears on the next run.
|
|
165
190
|
*/
|
|
166
191
|
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
167
192
|
if (item.action === "current" || item.action === "unknown") return [];
|
|
@@ -219,13 +244,51 @@ async function advanceInScope(
|
|
|
219
244
|
else await deps.install(pluginId, scope);
|
|
220
245
|
}
|
|
221
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Why nothing moved for `item`, judged by READ-BACK; null when something did.
|
|
249
|
+
*
|
|
250
|
+
* No installer output is parsed — "already at the latest version" is
|
|
251
|
+
* locale- and version-fragile. The registry row the apply loop already reads
|
|
252
|
+
* back is compared with the before-version the plan carries:
|
|
253
|
+
* - update: every touched scope reads back exactly what it held before;
|
|
254
|
+
* - install: no touched scope reads back any version at all;
|
|
255
|
+
* - repair: never — same version is its definition; the files moved.
|
|
256
|
+
*/
|
|
257
|
+
function pluginNoMove(
|
|
258
|
+
item: PluginUpdateItem,
|
|
259
|
+
landed: ReadonlyArray<[PluginScope, string | null]>,
|
|
260
|
+
): string | null {
|
|
261
|
+
if (landed.length === 0) return null;
|
|
262
|
+
if (item.action === "update") {
|
|
263
|
+
const same = landed.every(
|
|
264
|
+
([scope, actual]) => (actual ?? undefined) === item.beforeVersions[scope],
|
|
265
|
+
);
|
|
266
|
+
if (!same) return null;
|
|
267
|
+
const v = landed.find(([, a]) => a)?.[1];
|
|
268
|
+
return v
|
|
269
|
+
? `the installer exited 0 but ${item.pluginId} is still ${v}`
|
|
270
|
+
: `the installer exited 0 but no version of ${item.pluginId} was recorded`;
|
|
271
|
+
}
|
|
272
|
+
if (item.action === "install") {
|
|
273
|
+
return landed.every(([, actual]) => actual === null)
|
|
274
|
+
? `the installer exited 0 but no version of ${item.pluginId} was recorded`
|
|
275
|
+
: null;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
222
280
|
export async function applyPlugins(
|
|
223
281
|
items: PluginUpdateItem[],
|
|
224
282
|
projectPath: string,
|
|
225
283
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
226
284
|
report: ApplyReporter = SILENT_REPORTER,
|
|
227
285
|
): Promise<PluginApplyResult> {
|
|
228
|
-
const result: PluginApplyResult = {
|
|
286
|
+
const result: PluginApplyResult = {
|
|
287
|
+
ok: 0,
|
|
288
|
+
failed: [],
|
|
289
|
+
unchanged: [],
|
|
290
|
+
mismatches: [],
|
|
291
|
+
};
|
|
229
292
|
|
|
230
293
|
for (const item of items) {
|
|
231
294
|
const scopes = scopesToApply(item);
|
|
@@ -233,6 +296,7 @@ export async function applyPlugins(
|
|
|
233
296
|
|
|
234
297
|
report.begin(item.pluginId);
|
|
235
298
|
const startedAt = Date.now();
|
|
299
|
+
const landed: Array<[PluginScope, string | null]> = [];
|
|
236
300
|
|
|
237
301
|
try {
|
|
238
302
|
for (const scope of scopes) {
|
|
@@ -268,6 +332,7 @@ export async function applyPlugins(
|
|
|
268
332
|
scope,
|
|
269
333
|
projectPath,
|
|
270
334
|
);
|
|
335
|
+
landed.push([scope, actual]);
|
|
271
336
|
if (actual) {
|
|
272
337
|
await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
|
|
273
338
|
}
|
|
@@ -280,13 +345,24 @@ export async function applyPlugins(
|
|
|
280
345
|
});
|
|
281
346
|
}
|
|
282
347
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
348
|
+
const noMove = pluginNoMove(item, landed);
|
|
349
|
+
if (noMove) {
|
|
350
|
+
report.finish(
|
|
351
|
+
true,
|
|
352
|
+
item.pluginId,
|
|
353
|
+
`no change — ${noMove}`,
|
|
354
|
+
Date.now() - startedAt,
|
|
355
|
+
);
|
|
356
|
+
result.unchanged.push(item.pluginId);
|
|
357
|
+
} else {
|
|
358
|
+
report.finish(
|
|
359
|
+
true,
|
|
360
|
+
item.pluginId,
|
|
361
|
+
`${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
|
|
362
|
+
Date.now() - startedAt,
|
|
363
|
+
);
|
|
364
|
+
result.ok++;
|
|
365
|
+
}
|
|
290
366
|
} catch (error) {
|
|
291
367
|
const msg = error instanceof Error ? error.message : String(error);
|
|
292
368
|
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
@@ -369,6 +445,74 @@ export async function updatePluginInScope(
|
|
|
369
445
|
|
|
370
446
|
// -- binaries ----------------------------------------------------------------
|
|
371
447
|
|
|
448
|
+
/**
|
|
449
|
+
* The command a bin item runs, or null when it is not work.
|
|
450
|
+
*
|
|
451
|
+
* The state→command rule itself is {@link binCommandForState}, shared with the
|
|
452
|
+
* TUI's CLI Tools row so the two front ends cannot run different commands for
|
|
453
|
+
* the same row. The CLI's plan report renders this same function, so what it
|
|
454
|
+
* shows is what runs.
|
|
455
|
+
*/
|
|
456
|
+
export function binCommandFor(
|
|
457
|
+
item: BinUpdateItem,
|
|
458
|
+
spec: ResolvedBin,
|
|
459
|
+
): Command | null {
|
|
460
|
+
if (!binNeedsWork(item)) return null;
|
|
461
|
+
return binCommandForState(
|
|
462
|
+
item.action === "install" ? "install" : "upgrade",
|
|
463
|
+
item.target,
|
|
464
|
+
spec,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Why nothing moved for a bin, judged by RE-PROBING it; null when it moved.
|
|
470
|
+
*
|
|
471
|
+
* The re-probe resolves PATH again, so it reads the binary that runs NOW —
|
|
472
|
+
* which is what catches the no-ops planning cannot see: a stale brew tap, an
|
|
473
|
+
* installer that wrote a copy PATH does not resolve, `~/.bun/bin` missing from
|
|
474
|
+
* PATH. Versions are compared with the same comparator the planner used,
|
|
475
|
+
* because before and after can come from different sources ("v0.13.10" from
|
|
476
|
+
* `uv tool list` against "0.13.10").
|
|
477
|
+
*
|
|
478
|
+
* A read-back that cannot read a version is NOT a no-op: failing to read is not
|
|
479
|
+
* evidence nothing changed, and saying so would turn every go-less machine's
|
|
480
|
+
* upgrade into a warning.
|
|
481
|
+
*/
|
|
482
|
+
function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
|
|
483
|
+
if (item.action === "install" && !after.present) {
|
|
484
|
+
return `the installer exited 0 but ${item.name} is still not on PATH`;
|
|
485
|
+
}
|
|
486
|
+
if (after.managed === false) {
|
|
487
|
+
return `installed, but PATH still runs ${after.resolvedPath ?? "another copy"}`;
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
item.action === "upgrade" &&
|
|
491
|
+
after.installed !== null &&
|
|
492
|
+
item.installed !== null &&
|
|
493
|
+
compareToolVersions(after.installed, item.installed) === 0
|
|
494
|
+
) {
|
|
495
|
+
return `the installer exited 0 but ${item.name} is still ${after.installed}`;
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The read-back probe, and the ONLY caller that lets the probe run a tool's
|
|
502
|
+
* `check` command.
|
|
503
|
+
*
|
|
504
|
+
* Planning never does: `check` comes from a marketplace `plugin.json`, and
|
|
505
|
+
* `--dry-run` / `--check` must execute nothing a catalogue supplied. Here the
|
|
506
|
+
* installer for this very tool has just run with the user's consent, and the
|
|
507
|
+
* probe's own narrowing means the only argv that can run is the tool asking
|
|
508
|
+
* itself its version (`tool-probe.ts`, `versionFromCheck`). Without it a tool
|
|
509
|
+
* whose installer keeps no readable record reads back as "version unknown",
|
|
510
|
+
* which R6 counts as changed — the safe answer, but never "no change — still
|
|
511
|
+
* 3.7c", the one the user needs.
|
|
512
|
+
*/
|
|
513
|
+
const REAL_READ_BACK = (spec: ResolvedBin): Promise<ToolFacts> =>
|
|
514
|
+
probeTool(spec, undefined, { allowCheckCommand: true });
|
|
515
|
+
|
|
372
516
|
export async function applyBins(
|
|
373
517
|
plan: UpdatePlan,
|
|
374
518
|
report: ApplyReporter = SILENT_REPORTER,
|
|
@@ -377,30 +521,69 @@ export async function applyBins(
|
|
|
377
521
|
// as an invitation to hand it a string. It takes argv, and nothing here
|
|
378
522
|
// builds a shell line.
|
|
379
523
|
run: (command: Command) => Promise<boolean> = runCommand,
|
|
524
|
+
/** Read-back seam: re-probe ONE tool after its installer ran. */
|
|
525
|
+
probe: (spec: ResolvedBin) => Promise<ToolFacts> = REAL_READ_BACK,
|
|
380
526
|
): Promise<ApplyResult> {
|
|
381
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
527
|
+
const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
|
|
382
528
|
for (const item of plan.bins) {
|
|
383
|
-
|
|
529
|
+
// The SAME predicate planWorkItems and planHasWork use. Skipping only
|
|
530
|
+
// `current` here would apply an `unknown` — "cannot tell" run as an
|
|
531
|
+
// upgrade.
|
|
532
|
+
if (!binNeedsWork(item)) continue;
|
|
384
533
|
const bin = plan.binSpecs.get(item.name);
|
|
385
534
|
if (!bin) continue;
|
|
386
|
-
const cmd =
|
|
387
|
-
item.action === "install"
|
|
388
|
-
? binInstallCommand(bin)
|
|
389
|
-
: binUpgradeCommand(bin);
|
|
390
535
|
report.begin(item.name);
|
|
391
536
|
const startedAt = Date.now();
|
|
537
|
+
const cmd = binCommandFor(item, bin);
|
|
538
|
+
if (!cmd) {
|
|
539
|
+
// Unreachable from the planner (a brew pin is never planned as work),
|
|
540
|
+
// but the meter counted this item, so it must finish.
|
|
541
|
+
report.finish(
|
|
542
|
+
false,
|
|
543
|
+
item.name,
|
|
544
|
+
`no command can move ${bin.via} to ${item.target ?? "the pin"}`,
|
|
545
|
+
Date.now() - startedAt,
|
|
546
|
+
);
|
|
547
|
+
result.failed.push(item.name);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
392
550
|
// The installer owns the terminal for the duration: it streams its own
|
|
393
551
|
// output through inherited stdio.
|
|
394
552
|
const succeeded = await report.detach(() => run(cmd));
|
|
553
|
+
if (!succeeded) {
|
|
554
|
+
report.finish(
|
|
555
|
+
false,
|
|
556
|
+
item.name,
|
|
557
|
+
formatCommand(cmd),
|
|
558
|
+
Date.now() - startedAt,
|
|
559
|
+
);
|
|
560
|
+
result.failed.push(item.name);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
let noMove: string | null = null;
|
|
564
|
+
try {
|
|
565
|
+
noMove = binNoMove(item, await probe(bin));
|
|
566
|
+
} catch {
|
|
567
|
+
// Could not read back: not evidence of a no-op.
|
|
568
|
+
}
|
|
395
569
|
// The reporter renders; only the argv above is executed.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
570
|
+
if (noMove) {
|
|
571
|
+
report.finish(
|
|
572
|
+
true,
|
|
573
|
+
item.name,
|
|
574
|
+
`no change — ${noMove}`,
|
|
575
|
+
Date.now() - startedAt,
|
|
576
|
+
);
|
|
577
|
+
result.unchanged.push(item.name);
|
|
578
|
+
} else {
|
|
579
|
+
report.finish(
|
|
580
|
+
true,
|
|
581
|
+
item.name,
|
|
582
|
+
formatCommand(cmd),
|
|
583
|
+
Date.now() - startedAt,
|
|
584
|
+
);
|
|
585
|
+
result.ok++;
|
|
586
|
+
}
|
|
404
587
|
}
|
|
405
588
|
return result;
|
|
406
589
|
}
|
|
@@ -432,7 +615,7 @@ export async function applySkills(
|
|
|
432
615
|
report: ApplyReporter = SILENT_REPORTER,
|
|
433
616
|
install: typeof installSkill = installSkill,
|
|
434
617
|
): Promise<ApplyResult> {
|
|
435
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
618
|
+
const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
|
|
436
619
|
for (const item of plan.skills) {
|
|
437
620
|
report.begin(item.name);
|
|
438
621
|
const startedAt = Date.now();
|
|
@@ -456,6 +639,8 @@ export interface ApplyPlanOptions {
|
|
|
456
639
|
report?: ApplyReporter;
|
|
457
640
|
/** Installer execution seam — see the note on {@link applyBins}. */
|
|
458
641
|
run?: (command: Command) => Promise<boolean>;
|
|
642
|
+
/** Read-back seam — see {@link applyBins}. Default: `probeTool(spec)`. */
|
|
643
|
+
probe?: (spec: ResolvedBin) => Promise<ToolFacts>;
|
|
459
644
|
}
|
|
460
645
|
|
|
461
646
|
/**
|
|
@@ -478,14 +663,17 @@ export async function applyPlan(
|
|
|
478
663
|
opts.deps ?? REAL_PLUGIN_DEPS,
|
|
479
664
|
report,
|
|
480
665
|
);
|
|
481
|
-
const bins = await applyBins(plan, report, opts.run);
|
|
666
|
+
const bins = await applyBins(plan, report, opts.run, opts.probe);
|
|
482
667
|
const skills = await applySkills(plan, projectPath, report);
|
|
483
668
|
|
|
484
669
|
return {
|
|
485
670
|
plugins,
|
|
486
671
|
bins,
|
|
487
672
|
skills,
|
|
673
|
+
// `ok` counts only items read-back shows moved, so a no-op installer
|
|
674
|
+
// run is never "changed".
|
|
488
675
|
changed: plugins.ok + bins.ok + skills.ok,
|
|
676
|
+
unchanged: [...plugins.unchanged, ...bins.unchanged, ...skills.unchanged],
|
|
489
677
|
failed: [...plugins.failed, ...bins.failed, ...skills.failed],
|
|
490
678
|
};
|
|
491
679
|
}
|
|
@@ -497,7 +685,7 @@ export async function applyPlan(
|
|
|
497
685
|
export function planWorkItems(plan: UpdatePlan): string[] {
|
|
498
686
|
return [
|
|
499
687
|
...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
|
|
500
|
-
...plan.bins.filter(
|
|
688
|
+
...plan.bins.filter(binNeedsWork).map((b) => b.name),
|
|
501
689
|
...plan.skills.map((s) => s.name),
|
|
502
690
|
];
|
|
503
691
|
}
|