claudeup 6.5.1 → 6.6.0
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__/enabled-not-installed.test.ts +1 -2
- 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__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- 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/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +1 -173
- 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
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The ONE place plugin state is decided, and the planning for `claudeup update`.
|
|
3
|
+
*
|
|
4
|
+
* `classifyPlugin` answers "what state is this plugin in, and why" for a plugin
|
|
5
|
+
* id, the version the profile pins it to, and the catalog. Every consumer reads
|
|
6
|
+
* its answer and nothing else: `claudeup update`'s plan, and in the TUI the
|
|
7
|
+
* Plugins row text, the detail panel, the header counts, the `U`/`a` actions
|
|
8
|
+
* and the scope picker. The TUI used to derive the same state from raw
|
|
9
|
+
* `PluginInfo` flags with its own rules, and the two front ends drifted — the
|
|
10
|
+
* same plugin, in the same project, could read green in one and red in the
|
|
11
|
+
* other, or carry a different reason in each.
|
|
12
|
+
*
|
|
13
|
+
* Tools follow the same rule: `planBinUpdates` plans from `classifyTool`
|
|
14
|
+
* (services/tool-probe.ts), the same classifier the TUI's CLI Tools screen
|
|
15
|
+
* renders, so a tool has one state in both front ends.
|
|
3
16
|
*
|
|
4
17
|
* `install` makes the machine match the manifest's pins. `update` advances it:
|
|
5
18
|
* anything the active profile declares but the machine lacks gets installed,
|
|
6
19
|
* and anything pinned "latest" moves to the newest version the marketplace
|
|
7
20
|
* catalog offers. Everything here is a pure function of a closure plus captured
|
|
8
|
-
* machine state, so the decisions are unit-testable and the
|
|
9
|
-
* does IO.
|
|
21
|
+
* machine state, so the decisions are unit-testable and the callers only do IO.
|
|
10
22
|
*
|
|
11
|
-
* The one rule worth stating out loud:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
23
|
+
* The one rule worth stating out loud: "cannot tell" must never be reported as
|
|
24
|
+
* "up to date". `PluginInfo.updateCheckFailed` exists because a rate-limited
|
|
25
|
+
* fetch once compared the installed version against a stale local clone, found
|
|
26
|
+
* them equal, and rendered the failure as a clean bill of health. That case, and
|
|
27
|
+
* every other one where the answer is not provable, gets its own action
|
|
28
|
+
* (`unknown`) rather than folding into `current`.
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
31
|
import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
|
|
19
32
|
import type { PluginScope } from "./claude-cli.js";
|
|
20
|
-
import
|
|
21
|
-
|
|
33
|
+
import {
|
|
34
|
+
type PluginInfo,
|
|
35
|
+
compareVersions,
|
|
36
|
+
isBehind,
|
|
37
|
+
} from "./plugin-manager.js";
|
|
38
|
+
import {
|
|
39
|
+
type ToolFacts,
|
|
40
|
+
type ToolReason,
|
|
41
|
+
type ToolState,
|
|
42
|
+
type ToolStatus,
|
|
43
|
+
classifyTool,
|
|
44
|
+
} from "./tool-probe.js";
|
|
22
45
|
|
|
23
46
|
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
24
47
|
|
|
@@ -34,6 +57,36 @@ export type PluginUpdateAction =
|
|
|
34
57
|
/** The catalog check failed; we cannot claim either way. */
|
|
35
58
|
| "unknown";
|
|
36
59
|
|
|
60
|
+
/**
|
|
61
|
+
* WHY a plugin is in its state — a machine code, so tests and styling can key
|
|
62
|
+
* on it without parsing the human `note`.
|
|
63
|
+
*/
|
|
64
|
+
export type PluginReason =
|
|
65
|
+
/** install: declared by the profile, installed nowhere. */
|
|
66
|
+
| "absent"
|
|
67
|
+
/** install: enabled in a settings file, but no registry row in any scope. */
|
|
68
|
+
| "enabled-not-installed"
|
|
69
|
+
/** unknown: a marketplace we READ does not list it. */
|
|
70
|
+
| "not-offered"
|
|
71
|
+
/** unknown: installed, unlisted, and another marketplace publishes the name. */
|
|
72
|
+
| "moved"
|
|
73
|
+
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
74
|
+
| "marketplace-unread"
|
|
75
|
+
/** unknown: the catalog fetch failed ("latest" pins only). */
|
|
76
|
+
| "catalog-unverified"
|
|
77
|
+
/** unknown: listed, but its marketplace publishes no version for it. */
|
|
78
|
+
| "no-published-version"
|
|
79
|
+
/** unknown: an installed scope cannot be ordered against the target. */
|
|
80
|
+
| "unorderable"
|
|
81
|
+
/** repair: files changed without a version bump. */
|
|
82
|
+
| "content-stale"
|
|
83
|
+
/** update: a scope is provably behind. */
|
|
84
|
+
| "behind"
|
|
85
|
+
/** current|update: an exact pin differs from what the marketplace offers. */
|
|
86
|
+
| "pin-undeliverable"
|
|
87
|
+
/** current: every installed scope is at (or ahead of) the target. */
|
|
88
|
+
| "up-to-date";
|
|
89
|
+
|
|
37
90
|
export interface PluginUpdateItem {
|
|
38
91
|
pluginId: string;
|
|
39
92
|
/** What the profile pins: an exact version, or "latest". */
|
|
@@ -45,9 +98,19 @@ export interface PluginUpdateItem {
|
|
|
45
98
|
/** Version this item would move to. Null for `current`/`unknown`. */
|
|
46
99
|
target: string | null;
|
|
47
100
|
action: PluginUpdateAction;
|
|
101
|
+
/** Why it is in this state. */
|
|
102
|
+
reason: PluginReason;
|
|
48
103
|
/** Scopes holding an install this action must touch. Empty for a fresh install. */
|
|
49
104
|
scopes: PluginScope[];
|
|
50
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The version installed in each scope BEFORE any work, so an apply can tell
|
|
107
|
+
* by reading back whether anything actually moved.
|
|
108
|
+
*/
|
|
109
|
+
beforeVersions: Partial<Record<PluginScope, string>>;
|
|
110
|
+
/**
|
|
111
|
+
* The exact human reason. The CLI prints it and the TUI shows it verbatim, so
|
|
112
|
+
* it is the SAME string in both front ends.
|
|
113
|
+
*/
|
|
51
114
|
note?: string;
|
|
52
115
|
}
|
|
53
116
|
|
|
@@ -67,21 +130,30 @@ function scopeVersions(
|
|
|
67
130
|
return out;
|
|
68
131
|
}
|
|
69
132
|
|
|
70
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* The highest version installed across scopes, or null.
|
|
135
|
+
*
|
|
136
|
+
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
137
|
+
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
138
|
+
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
139
|
+
* deterministically is, and every caller only needs *an* installed version.
|
|
140
|
+
*/
|
|
71
141
|
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
72
142
|
const versions = scopeVersions(info).map(([, v]) => v);
|
|
73
143
|
if (versions.length === 0) return null;
|
|
74
|
-
return versions.reduce((a, b) =>
|
|
144
|
+
return versions.reduce((a, b) =>
|
|
145
|
+
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
146
|
+
);
|
|
75
147
|
}
|
|
76
148
|
|
|
77
149
|
/**
|
|
78
150
|
* Scopes that are strictly BEHIND `target`.
|
|
79
151
|
*
|
|
80
|
-
* Every scope that holds an outdated install is returned, not just "user".
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
152
|
+
* Every scope that holds an outdated install is returned, not just "user".
|
|
153
|
+
* Updating one hardcoded scope does not converge: drift is detected from the
|
|
154
|
+
* CURRENT PROJECT's resolved version, so "fixing" user scope reports success
|
|
155
|
+
* and leaves the project row untouched, and the same drift reappears on the
|
|
156
|
+
* next run.
|
|
85
157
|
*
|
|
86
158
|
* This only ever moves FORWARD, and there is deliberately no option to do
|
|
87
159
|
* otherwise. Nothing here can perform a downgrade: `claude plugin install`
|
|
@@ -100,182 +172,567 @@ export function scopesNeeding(
|
|
|
100
172
|
info: PluginInfo | undefined,
|
|
101
173
|
target: string,
|
|
102
174
|
): PluginScope[] {
|
|
175
|
+
// `isBehind`, not a raw compare: a scope holding a commit sha that differs
|
|
176
|
+
// from the published one IS behind, even though the two carry no ordering.
|
|
177
|
+
// A raw `compareVersions(...) > 0` reported false for that pair and the
|
|
178
|
+
// scope was silently left alone. An unknown (null) is still left alone —
|
|
179
|
+
// moving a scope we cannot rank would be a guess.
|
|
103
180
|
return scopeVersions(info)
|
|
104
|
-
.filter(([, version]) =>
|
|
181
|
+
.filter(([, version]) => isBehind(target, version) === true)
|
|
105
182
|
.map(([scope]) => scope);
|
|
106
183
|
}
|
|
107
184
|
|
|
185
|
+
/** True when some installed scope cannot be ordered against `target` at all. */
|
|
186
|
+
function hasUnorderableScope(
|
|
187
|
+
info: PluginInfo | undefined,
|
|
188
|
+
target: string,
|
|
189
|
+
): boolean {
|
|
190
|
+
return scopeVersions(info).some(
|
|
191
|
+
([, version]) => isBehind(target, version) === null,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The "@marketplace" suffix of an id, or null when it has none to reason about. */
|
|
196
|
+
function marketplaceSuffix(id: string): string | null {
|
|
197
|
+
const at = id.indexOf("@");
|
|
198
|
+
if (at < 0) return null;
|
|
199
|
+
const suffix = id.slice(at);
|
|
200
|
+
return suffix.length < 2 ? null : suffix;
|
|
201
|
+
}
|
|
202
|
+
|
|
108
203
|
/**
|
|
109
|
-
*
|
|
204
|
+
* The catalog, indexed for the questions the classifier asks of it.
|
|
110
205
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
206
|
+
* "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
|
|
207
|
+
* installed or enabled, but published by no marketplace we read — into the same
|
|
208
|
+
* list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
|
|
209
|
+
* nothing publishes. Treating an orphan as listed is what let an orphan plan as
|
|
210
|
+
* `current`: its `version` is filled in from its own installed version, so the
|
|
211
|
+
* compare found them equal.
|
|
113
212
|
*/
|
|
114
|
-
export
|
|
115
|
-
|
|
116
|
-
catalog:
|
|
117
|
-
):
|
|
118
|
-
|
|
213
|
+
export interface CatalogIndex {
|
|
214
|
+
get(pluginId: string): PluginInfo | undefined;
|
|
215
|
+
/** In the catalog AND not an orphan: a marketplace we read publishes it. */
|
|
216
|
+
isListed(pluginId: string): boolean;
|
|
217
|
+
/** Some OTHER listed id shares this id's "@marketplace" suffix. False for ids without "@". */
|
|
218
|
+
marketplaceIsKnown(pluginId: string): boolean;
|
|
219
|
+
}
|
|
119
220
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
221
|
+
export function indexCatalog(
|
|
222
|
+
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
223
|
+
): CatalogIndex {
|
|
224
|
+
const byId: ReadonlyMap<string, PluginInfo> =
|
|
225
|
+
catalog instanceof Map
|
|
226
|
+
? catalog
|
|
227
|
+
: new Map((catalog as readonly PluginInfo[]).map((p) => [p.id, p]));
|
|
124
228
|
|
|
125
|
-
|
|
229
|
+
// Listed ids per marketplace suffix, counted once. The previous inline check
|
|
230
|
+
// scanned every catalog key for every plugin, which is quadratic in a catalog
|
|
231
|
+
// of ~300 official plugins.
|
|
232
|
+
const listedPerSuffix = new Map<string, number>();
|
|
233
|
+
for (const [id, info] of byId) {
|
|
234
|
+
if (info.isOrphaned) continue;
|
|
235
|
+
const suffix = marketplaceSuffix(id);
|
|
236
|
+
if (suffix)
|
|
237
|
+
listedPerSuffix.set(suffix, (listedPerSuffix.get(suffix) ?? 0) + 1);
|
|
238
|
+
}
|
|
126
239
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
target: pin === "latest" ? available : pin,
|
|
132
|
-
action: "install",
|
|
133
|
-
scopes: [],
|
|
134
|
-
});
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
240
|
+
const isListed = (id: string): boolean => {
|
|
241
|
+
const info = byId.get(id);
|
|
242
|
+
return !!info && !info.isOrphaned;
|
|
243
|
+
};
|
|
137
244
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// remains the place that GATES on pin drift; this command reports it.
|
|
151
|
-
//
|
|
152
|
-
// `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
|
|
153
|
-
// deliverable version is left alone rather than downgraded — the same rule
|
|
154
|
-
// as the "latest" path below, because in both cases the target is what the
|
|
155
|
-
// marketplace currently publishes.
|
|
156
|
-
//
|
|
157
|
-
// The decision comes from the PER-SCOPE versions, never from `installed`.
|
|
158
|
-
// `installed` is the highest across scopes, so comparing it to the target
|
|
159
|
-
// hides the case that matters most: user scope at the target and project
|
|
160
|
-
// scope behind it reads as "current" while the project — the scope that
|
|
161
|
-
// actually loads for this repo — stays stale.
|
|
162
|
-
if (pin !== "latest") {
|
|
163
|
-
const deliverable = available ?? pin;
|
|
164
|
-
const needing = scopesNeeding(info, deliverable);
|
|
165
|
-
items.push({
|
|
166
|
-
...base,
|
|
167
|
-
target: deliverable,
|
|
168
|
-
action: needing.length === 0 ? "current" : "update",
|
|
169
|
-
scopes: needing,
|
|
170
|
-
...(available !== null && available !== pin
|
|
171
|
-
? {
|
|
172
|
-
note: `manifest pins ${pin}; the marketplace offers only ${available}`,
|
|
173
|
-
}
|
|
174
|
-
: {}),
|
|
175
|
-
});
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
245
|
+
return {
|
|
246
|
+
get: (id) => byId.get(id),
|
|
247
|
+
isListed,
|
|
248
|
+
marketplaceIsKnown: (id) => {
|
|
249
|
+
const suffix = marketplaceSuffix(id);
|
|
250
|
+
if (!suffix) return false;
|
|
251
|
+
const others =
|
|
252
|
+
(listedPerSuffix.get(suffix) ?? 0) - (isListed(id) ? 1 : 0);
|
|
253
|
+
return others > 0;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
178
257
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
info.updateCheckFailure?.detail ??
|
|
188
|
-
"catalog fetch failed — cannot tell whether a newer version exists",
|
|
189
|
-
});
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
258
|
+
/** True when a settings file enables the plugin in at least one scope. */
|
|
259
|
+
function enabledAnywhere(info: PluginInfo | undefined): boolean {
|
|
260
|
+
return (
|
|
261
|
+
!!info?.userScope?.enabled ||
|
|
262
|
+
!!info?.projectScope?.enabled ||
|
|
263
|
+
!!info?.localScope?.enabled
|
|
264
|
+
);
|
|
265
|
+
}
|
|
192
266
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
267
|
+
/**
|
|
268
|
+
* THE plugin classifier. Pure.
|
|
269
|
+
*
|
|
270
|
+
* The rules are evaluated in order and the first match wins. Each branch below
|
|
271
|
+
* returns, so reading top to bottom is reading the precedence.
|
|
272
|
+
*
|
|
273
|
+
* @param pluginId the plugin
|
|
274
|
+
* @param pin what it is judged against: an exact version, or "latest"
|
|
275
|
+
* @param index the catalog — see {@link indexCatalog}
|
|
276
|
+
*/
|
|
277
|
+
export function classifyPlugin(
|
|
278
|
+
pluginId: string,
|
|
279
|
+
pin: string,
|
|
280
|
+
index: CatalogIndex,
|
|
281
|
+
): PluginUpdateItem {
|
|
282
|
+
const info = index.get(pluginId);
|
|
283
|
+
const listed = index.isListed(pluginId);
|
|
284
|
+
const installed = highestInstalled(info);
|
|
285
|
+
// An orphan's `version` is its own installed version (or a stale local
|
|
286
|
+
// clone's), not something a marketplace we read offers — so it is not
|
|
287
|
+
// "available" from anywhere.
|
|
288
|
+
const available = listed ? (info?.version ?? null) : null;
|
|
289
|
+
|
|
290
|
+
const base = {
|
|
291
|
+
pluginId,
|
|
292
|
+
pinned: pin,
|
|
293
|
+
installed,
|
|
294
|
+
available,
|
|
295
|
+
beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
|
|
296
|
+
Record<PluginScope, string>
|
|
297
|
+
>,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Declared by the profile, installed nowhere, and its marketplace IS
|
|
301
|
+
// known to us but does not list it. That pin names a plugin that cannot
|
|
302
|
+
// be installed, and saying so is the whole point of this branch.
|
|
303
|
+
//
|
|
304
|
+
// Measured on the reporter's machine: mnemex's profile pinned
|
|
305
|
+
// `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing),
|
|
306
|
+
// and every run reported "3 install" while two of the three could never
|
|
307
|
+
// succeed — `claude plugin install` had nothing to fetch. Applied, failed,
|
|
308
|
+
// replanned identically, forever, with no diagnostic.
|
|
309
|
+
//
|
|
310
|
+
// "Not offered" is CATALOG MEMBERSHIP, not a missing version. Keying this
|
|
311
|
+
// on `available === null` was wrong twice over, and the second way was
|
|
312
|
+
// worse than the bug it replaced:
|
|
313
|
+
//
|
|
314
|
+
// - 278 of the 292 plugins in `claude-plugins-official` declare no
|
|
315
|
+
// version at all — `feature-dev` among them. They are listed, they are
|
|
316
|
+
// installable, and `available` is null for every one. Treating that as
|
|
317
|
+
// "not offered" made a profile pin for any of them plan `unknown` and
|
|
318
|
+
// never install. Measured on the reporting machine.
|
|
319
|
+
// - An INSTALLED plugin whose catalog version is unreadable is handled
|
|
320
|
+
// further down against what is on disk. Hence `!installed` here.
|
|
321
|
+
//
|
|
322
|
+
// Listing answers the real question directly. The remaining trap is that a
|
|
323
|
+
// marketplace this machine has not registered yet contributes NO entries, so
|
|
324
|
+
// its plugins are also absent — but installing one is exactly what registers
|
|
325
|
+
// the marketplace. `marketplaceIsKnown` separates the two: holding other
|
|
326
|
+
// LISTED ids from that marketplace proves we genuinely read its catalog, so
|
|
327
|
+
// absence is an answer; holding none proves nothing.
|
|
328
|
+
if (!listed && !installed && index.marketplaceIsKnown(pluginId)) {
|
|
329
|
+
return {
|
|
330
|
+
...base,
|
|
331
|
+
target: null,
|
|
332
|
+
action: "unknown",
|
|
333
|
+
reason: "not-offered",
|
|
334
|
+
scopes: [],
|
|
335
|
+
note: "not offered by any configured marketplace",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Not installed anywhere — the "checked but not installed" case.
|
|
340
|
+
//
|
|
341
|
+
// Nothing is installed in any scope here, so "enabled somewhere" is exactly
|
|
342
|
+
// "enabled but not installed". It is tested directly rather than through
|
|
343
|
+
// `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
|
|
344
|
+
// a settings key would otherwise read as `absent`, and the TUI renders
|
|
345
|
+
// `absent` as "the profile declares it", which an undeclared orphan does not.
|
|
346
|
+
if (!installed) {
|
|
347
|
+
const brokenEnable = enabledAnywhere(info);
|
|
348
|
+
return {
|
|
349
|
+
...base,
|
|
350
|
+
target: pin === "latest" ? available : pin,
|
|
351
|
+
action: "install",
|
|
352
|
+
reason: brokenEnable ? "enabled-not-installed" : "absent",
|
|
353
|
+
scopes: [],
|
|
354
|
+
...(brokenEnable
|
|
355
|
+
? { note: "enabled in settings, but no files on disk" }
|
|
356
|
+
: {}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Installed, but no marketplace we read lists it — an orphan.
|
|
361
|
+
//
|
|
362
|
+
// Planning this against its own installed version (which is what an
|
|
363
|
+
// orphan's `version` holds) found the two equal and reported `current`,
|
|
364
|
+
// while the TUI rendered the same plugin red as "deprecated". Neither is
|
|
365
|
+
// the fact. What is known is one of three things, and each gets its own
|
|
366
|
+
// text: it moved to a marketplace that does publish it; a marketplace we
|
|
367
|
+
// did read no longer lists it; or its marketplace contributed nothing we
|
|
368
|
+
// could read, so its absence proves nothing either way.
|
|
369
|
+
if (!listed) {
|
|
370
|
+
const orphan = {
|
|
371
|
+
...base,
|
|
372
|
+
target: null,
|
|
373
|
+
action: "unknown" as const,
|
|
374
|
+
scopes: [],
|
|
375
|
+
};
|
|
376
|
+
if (info?.movedTo) {
|
|
377
|
+
return {
|
|
378
|
+
...orphan,
|
|
379
|
+
reason: "moved",
|
|
380
|
+
note: `moved to ${info.movedTo}`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (index.marketplaceIsKnown(pluginId)) {
|
|
384
|
+
return {
|
|
385
|
+
...orphan,
|
|
386
|
+
reason: "not-offered",
|
|
387
|
+
note: "not offered by any configured marketplace",
|
|
388
|
+
};
|
|
205
389
|
}
|
|
390
|
+
const mp =
|
|
391
|
+
info?.marketplace || marketplaceSuffix(pluginId)?.slice(1) || pluginId;
|
|
392
|
+
return {
|
|
393
|
+
...orphan,
|
|
394
|
+
reason: "marketplace-unread",
|
|
395
|
+
note: `its marketplace ${mp} was not read, so whether it still offers this plugin is unknown`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
206
398
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
399
|
+
// An exact pin is planned against what can ACTUALLY be delivered.
|
|
400
|
+
//
|
|
401
|
+
// This is dependency management, not a package manager: there is no
|
|
402
|
+
// resolver and no version negotiation. `claude plugin install` takes no
|
|
403
|
+
// version — it fetches whatever the marketplace publishes now — so a pin
|
|
404
|
+
// naming anything else is a request nothing can satisfy.
|
|
405
|
+
//
|
|
406
|
+
// Planning toward the pin regardless is an infinite loop with a progress
|
|
407
|
+
// bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
|
|
408
|
+
// next run, and fail `--check` every time in between. Targeting the
|
|
409
|
+
// deliverable version converges, and the note carries the shortfall so it
|
|
410
|
+
// is recorded rather than hidden. `install --check` + `strictVersions`
|
|
411
|
+
// remains the place that GATES on pin drift; this command reports it.
|
|
412
|
+
//
|
|
413
|
+
// `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
|
|
414
|
+
// deliverable version is left alone rather than downgraded — the same rule
|
|
415
|
+
// as the "latest" path below, because in both cases the target is what the
|
|
416
|
+
// marketplace currently publishes.
|
|
417
|
+
//
|
|
418
|
+
// The decision comes from the PER-SCOPE versions, never from `installed`.
|
|
419
|
+
// `installed` is the highest across scopes, so comparing it to the target
|
|
420
|
+
// hides the case that matters most: user scope at the target and project
|
|
421
|
+
// scope behind it reads as "current" while the project — the scope that
|
|
422
|
+
// actually loads for this repo — stays stale.
|
|
423
|
+
if (pin !== "latest") {
|
|
424
|
+
const deliverable = available ?? pin;
|
|
425
|
+
const needing = scopesNeeding(info, deliverable);
|
|
426
|
+
|
|
427
|
+
// The same "cannot tell is not current" rule as the "latest" path below.
|
|
428
|
+
// A scope that cannot be ordered against the deliverable contributes
|
|
429
|
+
// nothing to `needing`, and used to fall through to `current` here.
|
|
430
|
+
if (needing.length === 0 && hasUnorderableScope(info, deliverable)) {
|
|
431
|
+
return {
|
|
211
432
|
...base,
|
|
212
433
|
target: null,
|
|
213
434
|
action: "unknown",
|
|
435
|
+
reason: "unorderable",
|
|
214
436
|
scopes: [],
|
|
215
|
-
note:
|
|
216
|
-
|
|
217
|
-
|
|
437
|
+
note:
|
|
438
|
+
available !== null
|
|
439
|
+
? `installed version cannot be compared with the published ${available}`
|
|
440
|
+
: `installed version cannot be compared with the pin ${pin}`,
|
|
441
|
+
};
|
|
218
442
|
}
|
|
219
443
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
items.push({
|
|
444
|
+
const undeliverable = available !== null && available !== pin;
|
|
445
|
+
return {
|
|
223
446
|
...base,
|
|
224
|
-
target:
|
|
447
|
+
target: deliverable,
|
|
225
448
|
action: needing.length === 0 ? "current" : "update",
|
|
449
|
+
reason: undeliverable
|
|
450
|
+
? "pin-undeliverable"
|
|
451
|
+
: needing.length === 0
|
|
452
|
+
? "up-to-date"
|
|
453
|
+
: "behind",
|
|
226
454
|
scopes: needing,
|
|
227
|
-
|
|
455
|
+
...(undeliverable
|
|
456
|
+
? {
|
|
457
|
+
note: `manifest pins ${pin}; the marketplace offers only ${available}`,
|
|
458
|
+
}
|
|
459
|
+
: {}),
|
|
460
|
+
};
|
|
228
461
|
}
|
|
229
462
|
|
|
230
|
-
|
|
463
|
+
// "latest" — and the catalog is the only source for what that means.
|
|
464
|
+
if (info?.updateCheckFailed) {
|
|
465
|
+
return {
|
|
466
|
+
...base,
|
|
467
|
+
target: null,
|
|
468
|
+
action: "unknown",
|
|
469
|
+
reason: "catalog-unverified",
|
|
470
|
+
scopes: [],
|
|
471
|
+
note:
|
|
472
|
+
info.updateCheckFailure?.detail ??
|
|
473
|
+
"catalog fetch failed — cannot tell whether a newer version exists",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Same version, changed files. There is no version bump to detect, so a
|
|
478
|
+
// plain install is a no-op ("Plugin is already installed") and only
|
|
479
|
+
// uninstall+install refreshes the content.
|
|
480
|
+
if (info?.contentStale) {
|
|
481
|
+
return {
|
|
482
|
+
...base,
|
|
483
|
+
target: installed,
|
|
484
|
+
action: "repair",
|
|
485
|
+
reason: "content-stale",
|
|
486
|
+
scopes: scopeVersions(info).map(([scope]) => scope),
|
|
487
|
+
note: "files changed without a version bump",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Installed and listed, with nothing to compare against. The answer is
|
|
492
|
+
// `unknown` — "current" is a claim we cannot support — and the note must not
|
|
493
|
+
// confuse this with the withdrawn case (handled above as an orphan): it is
|
|
494
|
+
// still published, still installable; we simply have no number to compare.
|
|
495
|
+
// 278 of the 292 plugins in `claude-plugins-official` are in this state, so
|
|
496
|
+
// reporting them as "not offered by any configured marketplace" told the
|
|
497
|
+
// user their installed, working, currently-published plugin had been
|
|
498
|
+
// withdrawn.
|
|
499
|
+
if (!available) {
|
|
500
|
+
return {
|
|
501
|
+
...base,
|
|
502
|
+
target: null,
|
|
503
|
+
action: "unknown",
|
|
504
|
+
reason: "no-published-version",
|
|
505
|
+
scopes: [],
|
|
506
|
+
note: "its marketplace publishes no version for this plugin, so there is nothing to compare",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Again per-scope, and only ever forward.
|
|
511
|
+
const needing = scopesNeeding(info, available);
|
|
512
|
+
|
|
513
|
+
// No scope is provably behind — but that is not the same as being current.
|
|
514
|
+
// `scopesNeeding` filters on `isBehind(...) === true`, so a scope whose
|
|
515
|
+
// version cannot be ordered against the catalog contributes nothing and
|
|
516
|
+
// would fall through to `current`, which is precisely the "cannot tell
|
|
517
|
+
// rendered as nothing wrong" this whole change removes.
|
|
518
|
+
//
|
|
519
|
+
// Real and measured: 21 registry rows carry the literal string "unknown"
|
|
520
|
+
// as their version, across 8 `@claude-plugins-official` ids. Against a
|
|
521
|
+
// semver catalog they are unorderable, so they land here every run.
|
|
522
|
+
if (needing.length === 0 && hasUnorderableScope(info, available)) {
|
|
523
|
+
return {
|
|
524
|
+
...base,
|
|
525
|
+
target: null,
|
|
526
|
+
action: "unknown",
|
|
527
|
+
reason: "unorderable",
|
|
528
|
+
scopes: [],
|
|
529
|
+
note: `installed version cannot be compared with the published ${available}`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
...base,
|
|
535
|
+
target: needing.length === 0 ? installed : available,
|
|
536
|
+
action: needing.length === 0 ? "current" : "update",
|
|
537
|
+
reason: needing.length === 0 ? "up-to-date" : "behind",
|
|
538
|
+
scopes: needing,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Decide what to do with every plugin the profile declares.
|
|
544
|
+
*
|
|
545
|
+
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
546
|
+
* @param catalog getAvailablePlugins() output, keyed by plugin id
|
|
547
|
+
*/
|
|
548
|
+
export function planPluginUpdates(
|
|
549
|
+
pinned: Record<string, string>,
|
|
550
|
+
catalog: Map<string, PluginInfo>,
|
|
551
|
+
): PluginUpdateItem[] {
|
|
552
|
+
const index = indexCatalog(catalog);
|
|
553
|
+
return Object.entries(pinned)
|
|
554
|
+
.map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
|
|
555
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The pin a TUI row is judged against.
|
|
560
|
+
*
|
|
561
|
+
* 1. The active profile's pin, when it declares the plugin — the SAME value
|
|
562
|
+
* `claudeup update` plans with, which is what makes the two agree.
|
|
563
|
+
* 2. Otherwise "latest", when the plugin is installed in any scope or enabled
|
|
564
|
+
* in one — what the TUI's own update actions have always planned with, so
|
|
565
|
+
* the row and the action agree.
|
|
566
|
+
* 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
|
|
567
|
+
* gets no status at all. `install` means "the profile wants it", and an
|
|
568
|
+
* undeclared plugin carries no such claim.
|
|
569
|
+
*
|
|
570
|
+
* A consequence the renderer relies on: reason `absent` can only come from
|
|
571
|
+
* rule 1, because rule 2 never assigns a pin to a row that is neither installed
|
|
572
|
+
* nor enabled.
|
|
573
|
+
*/
|
|
574
|
+
export function pinForRow(
|
|
575
|
+
info: PluginInfo,
|
|
576
|
+
pins: Readonly<Record<string, string>>,
|
|
577
|
+
): string | null {
|
|
578
|
+
if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
|
|
579
|
+
if (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* One status per row that has a pin. Rows whose {@link pinForRow} is null are
|
|
585
|
+
* absent from the map.
|
|
586
|
+
*
|
|
587
|
+
* Computed once per data/pins change, and the same map feeds every consumer —
|
|
588
|
+
* so the row, the detail panel, the header and the scope picker cannot disagree.
|
|
589
|
+
*/
|
|
590
|
+
export function classifyPluginRows(
|
|
591
|
+
plugins: readonly PluginInfo[],
|
|
592
|
+
pins: Readonly<Record<string, string>>,
|
|
593
|
+
): Map<string, PluginUpdateItem> {
|
|
594
|
+
const index = indexCatalog(plugins);
|
|
595
|
+
const out = new Map<string, PluginUpdateItem>();
|
|
596
|
+
for (const info of plugins) {
|
|
597
|
+
const pin = pinForRow(info, pins);
|
|
598
|
+
if (pin === null) continue;
|
|
599
|
+
out.set(info.id, classifyPlugin(info.id, pin, index));
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export interface PluginStateCounts {
|
|
605
|
+
install: number;
|
|
606
|
+
update: number;
|
|
607
|
+
repair: number;
|
|
608
|
+
current: number;
|
|
609
|
+
unknown: number;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Counts per action. One implementation, so a header cannot recount differently. */
|
|
613
|
+
export function countPluginStates(
|
|
614
|
+
items: Iterable<PluginUpdateItem>,
|
|
615
|
+
): PluginStateCounts {
|
|
616
|
+
const counts: PluginStateCounts = {
|
|
617
|
+
install: 0,
|
|
618
|
+
update: 0,
|
|
619
|
+
repair: 0,
|
|
620
|
+
current: 0,
|
|
621
|
+
unknown: 0,
|
|
622
|
+
};
|
|
623
|
+
for (const item of items) counts[item.action]++;
|
|
624
|
+
return counts;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The scope picker's one question: would advancing THIS scope be an update?
|
|
629
|
+
*
|
|
630
|
+
* Only a provable `update` that touches the scope says yes. A scope ahead of the
|
|
631
|
+
* catalog, one that cannot be ordered, and a `repair` all say no — `claude
|
|
632
|
+
* plugin install` on the same version is a no-op, so offering "update" there
|
|
633
|
+
* runs something that changes nothing.
|
|
634
|
+
*/
|
|
635
|
+
export function scopeIsBehind(
|
|
636
|
+
status: PluginUpdateItem | null | undefined,
|
|
637
|
+
scope: PluginScope,
|
|
638
|
+
): boolean {
|
|
639
|
+
return status?.action === "update" && status.scopes.includes(scope);
|
|
231
640
|
}
|
|
232
641
|
|
|
233
642
|
// ── Binaries ─────────────────────────────────────────────────────────────────
|
|
234
643
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
/** Present and pinned to an exact version; nothing to advance to. */
|
|
241
|
-
| "current";
|
|
644
|
+
/**
|
|
645
|
+
* A tool's action is exactly its state from `classifyTool` — the same word the
|
|
646
|
+
* TUI's CLI Tools screen shows for the same tool.
|
|
647
|
+
*/
|
|
648
|
+
export type BinUpdateAction = ToolState;
|
|
242
649
|
|
|
243
650
|
export interface BinUpdateItem {
|
|
244
651
|
name: string;
|
|
245
652
|
action: BinUpdateAction;
|
|
653
|
+
/** Why it is in this state. */
|
|
654
|
+
reason: ToolReason;
|
|
655
|
+
/** The exact human reason; the same string the TUI shows. */
|
|
656
|
+
note?: string;
|
|
657
|
+
/**
|
|
658
|
+
* Set when the upgrade will take the tool over from another installer —
|
|
659
|
+
* {@link ToolStatus.adopted}. The row must say so: it is an ordinary
|
|
660
|
+
* `upgrade` otherwise, and the owner changing is not something to discover
|
|
661
|
+
* afterwards.
|
|
662
|
+
*/
|
|
663
|
+
adopted?: string;
|
|
246
664
|
/** The exact pin, when the profile declares one. */
|
|
247
665
|
version?: string;
|
|
666
|
+
/** The version PATH runs, or null when absent / unreadable. */
|
|
667
|
+
installed: string | null;
|
|
668
|
+
/** The newest version its installer offers, when known. */
|
|
669
|
+
latest: string | null;
|
|
670
|
+
/** The version an install/upgrade aims at, when known. */
|
|
671
|
+
target: string | null;
|
|
248
672
|
sources: string[];
|
|
249
673
|
}
|
|
250
674
|
|
|
251
675
|
/**
|
|
252
|
-
* Plan binary work from
|
|
676
|
+
* Plan binary work from probe facts, through {@link classifyTool}.
|
|
677
|
+
*
|
|
678
|
+
* This used to plan from a presence check alone: present-and-unpinned was an
|
|
679
|
+
* UPGRADE, every run, because nothing could say whether the tool was behind.
|
|
680
|
+
* `brew upgrade tmux` on a current tmux, `uv tool install --force --upgrade`
|
|
681
|
+
* on a current browser-use — each "succeeded", changed nothing, and was
|
|
682
|
+
* planned identically the next time. Now an upgrade is planned only when the
|
|
683
|
+
* installer that owns the running binary says it is behind (or its registry
|
|
684
|
+
* does), and a tool whose state cannot be established is `unknown`, which is
|
|
685
|
+
* shown and never applied.
|
|
253
686
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* than reinstalled on a guess. An unpinned one is re-run through its installer,
|
|
257
|
-
* which is what "update" means for a dependency with no declared version.
|
|
687
|
+
* Facts are matched by name. A bin with no facts entry was never probed, and
|
|
688
|
+
* that is `unknown` too — "not checked" is not "fine".
|
|
258
689
|
*/
|
|
259
690
|
export function planBinUpdates(
|
|
260
691
|
bins: ResolvedBin[],
|
|
261
|
-
|
|
692
|
+
facts: readonly ToolFacts[],
|
|
262
693
|
): BinUpdateItem[] {
|
|
263
|
-
const
|
|
694
|
+
const byName = new Map(facts.map((f) => [f.name, f]));
|
|
264
695
|
return bins.map((bin) => {
|
|
696
|
+
const f = byName.get(bin.name);
|
|
697
|
+
const status: ToolStatus = f
|
|
698
|
+
? classifyTool(bin, f)
|
|
699
|
+
: {
|
|
700
|
+
name: bin.name,
|
|
701
|
+
state: "unknown",
|
|
702
|
+
reason: "not-probed",
|
|
703
|
+
note: "not probed",
|
|
704
|
+
installed: null,
|
|
705
|
+
latest: null,
|
|
706
|
+
target: null,
|
|
707
|
+
};
|
|
265
708
|
const item: BinUpdateItem = {
|
|
266
709
|
name: bin.name,
|
|
710
|
+
action: status.state,
|
|
711
|
+
reason: status.reason,
|
|
712
|
+
installed: status.installed,
|
|
713
|
+
latest: status.latest,
|
|
714
|
+
target: status.target,
|
|
267
715
|
sources: bin.sources,
|
|
268
|
-
action: !present.get(bin.name)
|
|
269
|
-
? "install"
|
|
270
|
-
: bin.version
|
|
271
|
-
? "current"
|
|
272
|
-
: "upgrade",
|
|
273
716
|
};
|
|
717
|
+
if (status.note) item.note = status.note;
|
|
718
|
+
if (status.adopted) item.adopted = status.adopted;
|
|
274
719
|
if (bin.version) item.version = bin.version;
|
|
275
720
|
return item;
|
|
276
721
|
});
|
|
277
722
|
}
|
|
278
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Whether a bin item is WORK: an install or an upgrade.
|
|
726
|
+
*
|
|
727
|
+
* The one predicate `applyBins`, `planWorkItems` and `planHasWork` share. The
|
|
728
|
+
* apply loop used to skip only `current` and run everything else, so the day an
|
|
729
|
+
* `unknown` existed it would have been applied — "cannot tell" executed as an
|
|
730
|
+
* upgrade. One predicate means the three cannot disagree about that.
|
|
731
|
+
*/
|
|
732
|
+
export function binNeedsWork(item: BinUpdateItem): boolean {
|
|
733
|
+
return item.action === "install" || item.action === "upgrade";
|
|
734
|
+
}
|
|
735
|
+
|
|
279
736
|
// ── Skills ───────────────────────────────────────────────────────────────────
|
|
280
737
|
|
|
281
738
|
export interface SkillUpdateItem {
|
|
@@ -329,7 +786,7 @@ export interface UpdatePlan {
|
|
|
329
786
|
export function planHasWork(plan: UpdatePlan): boolean {
|
|
330
787
|
return (
|
|
331
788
|
plan.plugins.some((p) => p.action !== "current") ||
|
|
332
|
-
plan.bins.some(
|
|
789
|
+
plan.bins.some(binNeedsWork) ||
|
|
333
790
|
plan.skills.length > 0
|
|
334
791
|
);
|
|
335
792
|
}
|
|
@@ -338,24 +795,41 @@ export function planHasWork(plan: UpdatePlan): boolean {
|
|
|
338
795
|
* True when something is actually MISSING or KNOWN TO BE BEHIND. This is what
|
|
339
796
|
* `--check` gates on, and it is deliberately narrower than {@link planHasWork}.
|
|
340
797
|
*
|
|
341
|
-
* The distinction exists because
|
|
342
|
-
*
|
|
343
|
-
* -
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* present/absent.
|
|
798
|
+
* The distinction exists because a skill is re-run every time without that
|
|
799
|
+
* meaning anything is wrong: its manifest ref carries no content hash, so it is
|
|
800
|
+
* re-fetched rather than compared. Counting that as drift made `--check` exit 1
|
|
801
|
+
* forever for any profile that declared a single skill — a gate that can never
|
|
802
|
+
* pass tells you nothing, and would have been switched off in CI within a week.
|
|
347
803
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
804
|
+
* A bin `upgrade` DOES count now. It used to be excluded because an unpinned
|
|
805
|
+
* binary had no version probe and "upgrade" meant "re-run the installer"; it
|
|
806
|
+
* now means the owning installer says the tool is behind, which is drift.
|
|
351
807
|
*
|
|
352
|
-
* `unknown`
|
|
353
|
-
*
|
|
808
|
+
* NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
|
|
809
|
+
* this" is not evidence of drift, and most unknowns are structural and
|
|
810
|
+
* permanent rather than transient: a plugin whose marketplace publishes no
|
|
811
|
+
* version (278 of the official ones), a tool on PATH but installed by a
|
|
812
|
+
* different npm, `go` absent, a private registry. A gate that exits 1 on those
|
|
813
|
+
* can never pass, so it protects nothing and is switched off. The cost is that
|
|
814
|
+
* an item whose check merely FAILED this run — a rate-limited catalog fetch, a
|
|
815
|
+
* timed-out probe — also passes, so the exit code alone must never be read as
|
|
816
|
+
* "everything verified": the caller prints how many items went unchecked
|
|
817
|
+
* (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
|
|
818
|
+
* `cli/update-view.ts`), and every unknown row carries its own reason above.
|
|
819
|
+
*
|
|
820
|
+
* Both unknowns are listed positively rather than as `!== "current"`, so a new
|
|
821
|
+
* action has to be classified here deliberately instead of defaulting into the
|
|
822
|
+
* gate.
|
|
354
823
|
*/
|
|
355
824
|
export function planIsBehind(plan: UpdatePlan): boolean {
|
|
356
825
|
return (
|
|
357
|
-
plan.plugins.some(
|
|
358
|
-
|
|
826
|
+
plan.plugins.some(
|
|
827
|
+
(p) =>
|
|
828
|
+
p.action === "install" ||
|
|
829
|
+
p.action === "update" ||
|
|
830
|
+
p.action === "repair",
|
|
831
|
+
) ||
|
|
832
|
+
plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
|
|
359
833
|
plan.skills.some((s) => s.action === "install")
|
|
360
834
|
);
|
|
361
835
|
}
|
|
@@ -366,18 +840,24 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
366
840
|
updated: number;
|
|
367
841
|
repaired: number;
|
|
368
842
|
current: number;
|
|
843
|
+
/** unknownPlugins + unknownTools. */
|
|
369
844
|
unknown: number;
|
|
845
|
+
unknownPlugins: number;
|
|
846
|
+
unknownTools: number;
|
|
370
847
|
} {
|
|
371
848
|
const count = (a: PluginUpdateAction) =>
|
|
372
849
|
plan.plugins.filter((p) => p.action === a).length;
|
|
850
|
+
const bins = (a: BinUpdateAction) =>
|
|
851
|
+
plan.bins.filter((b) => b.action === a).length;
|
|
852
|
+
const unknownPlugins = count("unknown");
|
|
853
|
+
const unknownTools = bins("unknown");
|
|
373
854
|
return {
|
|
374
|
-
installed:
|
|
375
|
-
|
|
376
|
-
updated:
|
|
377
|
-
count("update") + plan.bins.filter((b) => b.action === "upgrade").length,
|
|
855
|
+
installed: count("install") + bins("install"),
|
|
856
|
+
updated: count("update") + bins("upgrade"),
|
|
378
857
|
repaired: count("repair"),
|
|
379
|
-
current:
|
|
380
|
-
|
|
381
|
-
|
|
858
|
+
current: count("current") + bins("current"),
|
|
859
|
+
unknown: unknownPlugins + unknownTools,
|
|
860
|
+
unknownPlugins,
|
|
861
|
+
unknownTools,
|
|
382
862
|
};
|
|
383
863
|
}
|