claudeup 6.0.0 → 6.1.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/scripts/test-isolated.ts +7 -1
- package/src/__tests__/cli-apply-seams.test.ts +50 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +46 -1
- package/src/cli/install.ts +11 -5
- package/src/cli/prompt.ts +0 -9
- package/src/cli/update-view.ts +4 -1
- package/src/cli/update.ts +18 -287
- package/src/data/cli-tools.ts +20 -13
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +9 -3
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-engine.ts — applying an update plan. The ONLY implementation.
|
|
3
|
+
*
|
|
4
|
+
* This used to live inside `cli/update.ts`, which meant the TUI could not reach
|
|
5
|
+
* it and grew its own loop in `PluginsScreen.handleUpdateAll`. The two drifted
|
|
6
|
+
* exactly where you would expect:
|
|
7
|
+
*
|
|
8
|
+
* - the CLI advanced **every scope** holding an outdated install; the TUI
|
|
9
|
+
* updated one, derived from whichever scope tab was open, so drift in the
|
|
10
|
+
* others survived and reappeared on the next run;
|
|
11
|
+
* - the CLI recorded the version it **read back** after installing; the TUI
|
|
12
|
+
* recorded the version it had *expected*, which is the bug the CLI's own
|
|
13
|
+
* comments describe at length — `claude plugin install` takes no version
|
|
14
|
+
* argument, so writing the intended value made `install --check` compare a
|
|
15
|
+
* pin against a copy of itself and report clean forever;
|
|
16
|
+
* - only the CLI reported pins the installer could not honour.
|
|
17
|
+
*
|
|
18
|
+
* None of that was a decision. It was two copies of one idea, edited apart.
|
|
19
|
+
*
|
|
20
|
+
* So the engine lives here, in `services/`, and both front ends drive it.
|
|
21
|
+
* `cli/` and `ui/` are sibling presentation layers: they own how progress looks
|
|
22
|
+
* and nothing else. Everything IO-shaped is injected — `PluginApplyDeps` for
|
|
23
|
+
* the four Claude Code calls, `ApplyReporter` for progress, `runCommand` for
|
|
24
|
+
* installers — so the same code drives an animated terminal meter, a TUI modal,
|
|
25
|
+
* and a test that wants silence.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING HERE PRINTS. A `console.log` in this file is a layering violation:
|
|
28
|
+
* the CLI paints an animated region that a stray write corrupts, and the TUI
|
|
29
|
+
* has no stdout to write to at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
34
|
+
import { repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
35
|
+
import {
|
|
36
|
+
readInstalledVersionForScope,
|
|
37
|
+
saveInstalledPluginVersionForScope,
|
|
38
|
+
} from "./plugin-manager.js";
|
|
39
|
+
import { installSkill } from "./skills-manager.js";
|
|
40
|
+
import { binInstallCommand, binUpgradeCommand } from "./toolchain.js";
|
|
41
|
+
import type { PluginUpdateItem, UpdatePlan } from "./update-plan.js";
|
|
42
|
+
import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
|
|
43
|
+
import type { Command } from "../utils/command-utils.js";
|
|
44
|
+
import { formatCommand } from "../utils/command-utils.js";
|
|
45
|
+
import { runCommand } from "../utils/run.js";
|
|
46
|
+
|
|
47
|
+
// -- results -----------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface ApplyResult {
|
|
50
|
+
ok: number;
|
|
51
|
+
failed: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An exact pin the installer could not deliver, discovered by reading back. */
|
|
55
|
+
export interface PinMismatch {
|
|
56
|
+
pluginId: string;
|
|
57
|
+
pinned: string;
|
|
58
|
+
actual: string | null;
|
|
59
|
+
scope: PluginScope;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PluginApplyResult extends ApplyResult {
|
|
63
|
+
mismatches: PinMismatch[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Everything one full apply produced, for whichever front end is reporting. */
|
|
67
|
+
export interface UpdateOutcome {
|
|
68
|
+
plugins: PluginApplyResult;
|
|
69
|
+
bins: ApplyResult;
|
|
70
|
+
skills: ApplyResult;
|
|
71
|
+
/** Items that changed, across all three. */
|
|
72
|
+
changed: number;
|
|
73
|
+
/** Names that failed, across all three. */
|
|
74
|
+
failed: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -- injected edges ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How the apply loop reports itself.
|
|
81
|
+
*
|
|
82
|
+
* Injected rather than hard-coded, because the loop is the same work whether it
|
|
83
|
+
* is driving an animated meter, a TUI modal, or a test that wants silence.
|
|
84
|
+
*/
|
|
85
|
+
export interface ApplyReporter {
|
|
86
|
+
/** An item is starting. Called before any of its work. */
|
|
87
|
+
begin(name: string): void;
|
|
88
|
+
/** An item finished. `detail` is already formatted for display. */
|
|
89
|
+
finish(ok: boolean, name: string, detail: string, ms: number): void;
|
|
90
|
+
/**
|
|
91
|
+
* Run `work` with the terminal released.
|
|
92
|
+
*
|
|
93
|
+
* A binary upgrade spawns `brew`/`uv` with inherited stdio: it writes and
|
|
94
|
+
* moves the cursor itself, so any animated block must be erased first or the
|
|
95
|
+
* two interleave into garbage. A TUI implementation suspends its renderer;
|
|
96
|
+
* a silent one just awaits.
|
|
97
|
+
*/
|
|
98
|
+
detach<T>(work: () => Promise<T>): Promise<T>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Reports nothing. The right default for a caller that renders its own state. */
|
|
102
|
+
export const SILENT_REPORTER: ApplyReporter = {
|
|
103
|
+
begin: () => {},
|
|
104
|
+
finish: () => {},
|
|
105
|
+
detach: (work) => work(),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The four side-effecting calls the plugin apply path makes, injectable so the
|
|
110
|
+
* path itself can be tested.
|
|
111
|
+
*
|
|
112
|
+
* This exists because the apply half shipped with no coverage while the pure
|
|
113
|
+
* planner had forty tests — and every real defect found in review lived here,
|
|
114
|
+
* not there.
|
|
115
|
+
*/
|
|
116
|
+
export interface PluginApplyDeps {
|
|
117
|
+
update: (pluginId: string, scope: PluginScope) => Promise<void>;
|
|
118
|
+
repair: (
|
|
119
|
+
pluginId: string,
|
|
120
|
+
scope: PluginScope,
|
|
121
|
+
projectPath: string,
|
|
122
|
+
) => Promise<void>;
|
|
123
|
+
readInstalled: (
|
|
124
|
+
pluginId: string,
|
|
125
|
+
scope: PluginScope,
|
|
126
|
+
projectPath: string,
|
|
127
|
+
) => Promise<string | null>;
|
|
128
|
+
saveInstalled: (
|
|
129
|
+
pluginId: string,
|
|
130
|
+
version: string,
|
|
131
|
+
scope: PluginScope,
|
|
132
|
+
projectPath: string,
|
|
133
|
+
) => Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
137
|
+
update: updatePlugin,
|
|
138
|
+
repair: repairPlugin,
|
|
139
|
+
readInstalled: readInstalledVersionForScope,
|
|
140
|
+
saveInstalled: saveInstalledPluginVersionForScope,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// -- selection ---------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
147
|
+
*
|
|
148
|
+
* A plugin the profile declares but nothing has installed goes in at PROJECT
|
|
149
|
+
* scope, matching `install`: a profile is a property of one repo, so its
|
|
150
|
+
* plugins must not be enabled machine-wide.
|
|
151
|
+
*
|
|
152
|
+
* Every scope holding an outdated install is returned, not just one. The
|
|
153
|
+
* prerunner learned this the expensive way: it detected drift, "fixed" it at
|
|
154
|
+
* user scope and reported success, leaving the project row untouched so the
|
|
155
|
+
* same drift reappeared on every run.
|
|
156
|
+
*/
|
|
157
|
+
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
158
|
+
if (item.action === "current" || item.action === "unknown") return [];
|
|
159
|
+
return item.action === "install" ? ["project"] : item.scopes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the apply loop will do anything for `item`.
|
|
164
|
+
*
|
|
165
|
+
* Exported for ONE reason: a progress meter's denominator must be the same
|
|
166
|
+
* predicate the loop applies. Re-deriving it at the call site is how the two
|
|
167
|
+
* drift, and a bar that stops short of full at the end of a successful run
|
|
168
|
+
* looks exactly like a hang.
|
|
169
|
+
*/
|
|
170
|
+
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
171
|
+
return scopesToApply(item).length > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// -- plugins -----------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
export async function applyPlugins(
|
|
177
|
+
items: PluginUpdateItem[],
|
|
178
|
+
projectPath: string,
|
|
179
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
180
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
181
|
+
): Promise<PluginApplyResult> {
|
|
182
|
+
const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
|
|
183
|
+
|
|
184
|
+
for (const item of items) {
|
|
185
|
+
const scopes = scopesToApply(item);
|
|
186
|
+
if (scopes.length === 0) continue;
|
|
187
|
+
|
|
188
|
+
report.begin(item.pluginId);
|
|
189
|
+
const startedAt = Date.now();
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
for (const scope of scopes) {
|
|
193
|
+
if (item.action === "repair") {
|
|
194
|
+
await deps.repair(item.pluginId, scope, projectPath);
|
|
195
|
+
} else {
|
|
196
|
+
await deps.update(item.pluginId, scope);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Record what LANDED, not what we asked for.
|
|
200
|
+
//
|
|
201
|
+
// `claude plugin install` accepts no version, so an exact pin is a
|
|
202
|
+
// request the installer cannot honour — it fetches whatever the
|
|
203
|
+
// marketplace publishes now. Writing `item.target` here recorded the
|
|
204
|
+
// pin as satisfied whatever actually installed, and `install --check`
|
|
205
|
+
// reads that same field to decide whether the machine has drifted.
|
|
206
|
+
// The gate would have compared the pin against a copy of itself and
|
|
207
|
+
// reported clean forever.
|
|
208
|
+
const actual = await deps.readInstalled(
|
|
209
|
+
item.pluginId,
|
|
210
|
+
scope,
|
|
211
|
+
projectPath,
|
|
212
|
+
);
|
|
213
|
+
if (actual) {
|
|
214
|
+
await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
|
|
215
|
+
}
|
|
216
|
+
if (item.pinned !== "latest" && actual !== item.pinned) {
|
|
217
|
+
result.mismatches.push({
|
|
218
|
+
pluginId: item.pluginId,
|
|
219
|
+
pinned: item.pinned,
|
|
220
|
+
actual,
|
|
221
|
+
scope,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
report.finish(
|
|
226
|
+
true,
|
|
227
|
+
item.pluginId,
|
|
228
|
+
`${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
|
|
229
|
+
Date.now() - startedAt,
|
|
230
|
+
);
|
|
231
|
+
result.ok++;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
234
|
+
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
235
|
+
result.failed.push(item.pluginId);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Install ONE plugin into ONE explicitly chosen scope, recording what landed.
|
|
244
|
+
*
|
|
245
|
+
* "Install" covers updating too, because at the CLI layer there is no such
|
|
246
|
+
* distinction: `claude-cli.ts`'s `installPlugin` and `updatePlugin` issue the
|
|
247
|
+
* identical `claude plugin install <id> --scope <scope>`, and `install` is
|
|
248
|
+
* documented there as how an update is performed. Naming this after the command
|
|
249
|
+
* actually run is what makes it obvious that the fresh-install path belongs here
|
|
250
|
+
* too — while it was called `updatePluginInScope`, the TUI's install branches
|
|
251
|
+
* read as a different operation and grew their own record-keeping.
|
|
252
|
+
*
|
|
253
|
+
* For the scope-targeted actions — a menu where the user picked "install in user
|
|
254
|
+
* scope" — where {@link applyPlugins}'s "every scope that is behind" would
|
|
255
|
+
* override the choice they just made.
|
|
256
|
+
*
|
|
257
|
+
* It exists so those call sites still get the read-back: writing the version
|
|
258
|
+
* you EXPECTED is the bug documented at length in `applyPlugins`, and it was
|
|
259
|
+
* live in the TUI's per-scope menu long after the CLI had fixed it.
|
|
260
|
+
*
|
|
261
|
+
* Takes no version parameter, deliberately: there is nothing for a caller to
|
|
262
|
+
* pass that could be wrong.
|
|
263
|
+
*
|
|
264
|
+
* @returns the version actually on disk afterwards, or null if it could not be
|
|
265
|
+
* read — which is reported, never guessed.
|
|
266
|
+
*/
|
|
267
|
+
export async function installPluginInScope(
|
|
268
|
+
pluginId: string,
|
|
269
|
+
scope: PluginScope,
|
|
270
|
+
projectPath: string,
|
|
271
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
272
|
+
): Promise<string | null> {
|
|
273
|
+
await deps.update(pluginId, scope);
|
|
274
|
+
const actual = await deps.readInstalled(pluginId, scope, projectPath);
|
|
275
|
+
if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
|
|
276
|
+
return actual;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -- binaries ----------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export async function applyBins(
|
|
282
|
+
plan: UpdatePlan,
|
|
283
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
284
|
+
// Named `run`, not `shell`. The old name was the misnomer that let a
|
|
285
|
+
// profile-supplied package name reach `/bin/sh`: a seam called "shell" reads
|
|
286
|
+
// as an invitation to hand it a string. It takes argv, and nothing here
|
|
287
|
+
// builds a shell line.
|
|
288
|
+
run: (command: Command) => Promise<boolean> = runCommand,
|
|
289
|
+
): Promise<ApplyResult> {
|
|
290
|
+
const result: ApplyResult = { ok: 0, failed: [] };
|
|
291
|
+
for (const item of plan.bins) {
|
|
292
|
+
if (item.action === "current") continue;
|
|
293
|
+
const bin = plan.binSpecs.get(item.name);
|
|
294
|
+
if (!bin) continue;
|
|
295
|
+
const cmd =
|
|
296
|
+
item.action === "install"
|
|
297
|
+
? binInstallCommand(bin)
|
|
298
|
+
: binUpgradeCommand(bin);
|
|
299
|
+
report.begin(item.name);
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
// The installer owns the terminal for the duration: it streams its own
|
|
302
|
+
// output through inherited stdio.
|
|
303
|
+
const succeeded = await report.detach(() => run(cmd));
|
|
304
|
+
// The reporter renders; only the argv above is executed.
|
|
305
|
+
report.finish(
|
|
306
|
+
succeeded,
|
|
307
|
+
item.name,
|
|
308
|
+
formatCommand(cmd),
|
|
309
|
+
Date.now() - startedAt,
|
|
310
|
+
);
|
|
311
|
+
if (succeeded) result.ok++;
|
|
312
|
+
else result.failed.push(item.name);
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// -- skills ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
320
|
+
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
321
|
+
return {
|
|
322
|
+
id: `${ref.repo}/${ref.path}`,
|
|
323
|
+
name: ref.name,
|
|
324
|
+
source: {
|
|
325
|
+
label: ref.repo,
|
|
326
|
+
repo: ref.repo,
|
|
327
|
+
skillsPath: path.dirname(ref.path),
|
|
328
|
+
},
|
|
329
|
+
repoPath: ref.path,
|
|
330
|
+
gitBlobSha: "",
|
|
331
|
+
frontmatter: null,
|
|
332
|
+
installed: false,
|
|
333
|
+
installedScope: null,
|
|
334
|
+
hasUpdate: false,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function applySkills(
|
|
339
|
+
plan: UpdatePlan,
|
|
340
|
+
projectPath: string,
|
|
341
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
342
|
+
install: typeof installSkill = installSkill,
|
|
343
|
+
): Promise<ApplyResult> {
|
|
344
|
+
const result: ApplyResult = { ok: 0, failed: [] };
|
|
345
|
+
for (const item of plan.skills) {
|
|
346
|
+
report.begin(item.name);
|
|
347
|
+
const startedAt = Date.now();
|
|
348
|
+
try {
|
|
349
|
+
await install(toSkillInfo(item.ref), "project", projectPath);
|
|
350
|
+
report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
|
|
351
|
+
result.ok++;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
354
|
+
report.finish(false, item.name, msg, Date.now() - startedAt);
|
|
355
|
+
result.failed.push(item.name);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// -- the whole plan ----------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
export interface ApplyPlanOptions {
|
|
364
|
+
deps?: PluginApplyDeps;
|
|
365
|
+
report?: ApplyReporter;
|
|
366
|
+
/** Installer execution seam — see the note on {@link applyBins}. */
|
|
367
|
+
run?: (command: Command) => Promise<boolean>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Apply a whole plan: plugins, then binaries, then skills.
|
|
372
|
+
*
|
|
373
|
+
* Order matters. Plugins first because a binary requirement is declared BY a
|
|
374
|
+
* plugin, so installing the plugin is what makes the requirement real; skills
|
|
375
|
+
* last because they are the only re-fetched-every-time item and a failure there
|
|
376
|
+
* must not stop the two that actually advance versions.
|
|
377
|
+
*/
|
|
378
|
+
export async function applyPlan(
|
|
379
|
+
plan: UpdatePlan,
|
|
380
|
+
projectPath: string,
|
|
381
|
+
opts: ApplyPlanOptions = {},
|
|
382
|
+
): Promise<UpdateOutcome> {
|
|
383
|
+
const report = opts.report ?? SILENT_REPORTER;
|
|
384
|
+
const plugins = await applyPlugins(
|
|
385
|
+
plan.plugins,
|
|
386
|
+
projectPath,
|
|
387
|
+
opts.deps ?? REAL_PLUGIN_DEPS,
|
|
388
|
+
report,
|
|
389
|
+
);
|
|
390
|
+
const bins = await applyBins(plan, report, opts.run);
|
|
391
|
+
const skills = await applySkills(plan, projectPath, report);
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
plugins,
|
|
395
|
+
bins,
|
|
396
|
+
skills,
|
|
397
|
+
changed: plugins.ok + bins.ok + skills.ok,
|
|
398
|
+
failed: [...plugins.failed, ...bins.failed, ...skills.failed],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The items `applyPlan` will actually touch — a progress denominator that
|
|
404
|
+
* cannot drift from the loops, because it is derived from the same predicates.
|
|
405
|
+
*/
|
|
406
|
+
export function planWorkItems(plan: UpdatePlan): string[] {
|
|
407
|
+
return [
|
|
408
|
+
...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
|
|
409
|
+
...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
|
|
410
|
+
...plan.skills.map((s) => s.name),
|
|
411
|
+
];
|
|
412
|
+
}
|
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { CliTool } from "../../data/cli-tools.js";
|
|
3
|
+
import {
|
|
4
|
+
type InstallMethod,
|
|
5
|
+
cliToolInstall,
|
|
6
|
+
cliToolUninstall,
|
|
7
|
+
} from "../../services/cli-tool-commands.js";
|
|
8
|
+
import type { Command } from "../../utils/command-utils.js";
|
|
9
|
+
import { formatCommand } from "../../utils/command-utils.js";
|
|
3
10
|
import { theme, type UiColor } from "../theme.js";
|
|
4
11
|
|
|
5
|
-
// ───
|
|
12
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The uninstall command as a line the reader can copy.
|
|
16
|
+
*
|
|
17
|
+
* This used to be a second, hand-written copy of the screen's own uninstall
|
|
18
|
+
* switch — same shape, and already drifted (`pip uninstall` here,
|
|
19
|
+
* `pip uninstall -y` there). Both spelled a bare `pip`, which on many machines
|
|
20
|
+
* is not an executable. Now the renderer and the screen ask the same function,
|
|
21
|
+
* so what is shown is what would run.
|
|
22
|
+
*
|
|
23
|
+
* Empty string when there is nothing to offer, which is what the caller
|
|
24
|
+
* interpolates into the conflict list.
|
|
25
|
+
*/
|
|
26
|
+
function uninstallHint(
|
|
27
|
+
tool: CliTool,
|
|
28
|
+
method: InstallMethod,
|
|
29
|
+
brewFormula?: string,
|
|
30
|
+
): string {
|
|
31
|
+
const cmd = cliToolUninstall(tool, method, brewFormula);
|
|
32
|
+
return cmd ? formatCommand(cmd) : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Status type ───────────────────────────────────────────────────────────────
|
|
15
36
|
|
|
16
37
|
export interface CliToolStatus {
|
|
17
38
|
tool: CliTool;
|
|
@@ -22,35 +43,10 @@ export interface CliToolStatus {
|
|
|
22
43
|
checking: boolean;
|
|
23
44
|
installMethod?: InstallMethod;
|
|
24
45
|
allMethods?: InstallMethod[];
|
|
25
|
-
updateCommand?:
|
|
46
|
+
updateCommand?: Command;
|
|
26
47
|
brewFormula?: string;
|
|
27
48
|
}
|
|
28
49
|
|
|
29
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
function getUninstallHint(
|
|
32
|
-
tool: CliTool,
|
|
33
|
-
method: InstallMethod,
|
|
34
|
-
brewFormula?: string,
|
|
35
|
-
): string {
|
|
36
|
-
switch (method) {
|
|
37
|
-
case "bun":
|
|
38
|
-
return `bun remove -g ${tool.packageName}`;
|
|
39
|
-
case "npm":
|
|
40
|
-
return `npm uninstall -g ${tool.packageName}`;
|
|
41
|
-
case "pnpm":
|
|
42
|
-
return `pnpm remove -g ${tool.packageName}`;
|
|
43
|
-
case "yarn":
|
|
44
|
-
return `yarn global remove ${tool.packageName}`;
|
|
45
|
-
case "brew":
|
|
46
|
-
return `brew uninstall ${brewFormula || tool.name}`;
|
|
47
|
-
case "pip":
|
|
48
|
-
return `pip uninstall ${tool.packageName}`;
|
|
49
|
-
default:
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
50
|
// ─── Row renderer ──────────────────────────────────────────────────────────────
|
|
55
51
|
|
|
56
52
|
export function renderCliToolRow(
|
|
@@ -192,12 +188,14 @@ export function renderCliToolDetail(
|
|
|
192
188
|
{installed && updateCommand ? (
|
|
193
189
|
<box>
|
|
194
190
|
<text fg={theme.colors.muted}>{"Update "}</text>
|
|
195
|
-
<text fg={theme.colors.accent}>{updateCommand}</text>
|
|
191
|
+
<text fg={theme.colors.accent}>{formatCommand(updateCommand)}</text>
|
|
196
192
|
</box>
|
|
197
193
|
) : !installed ? (
|
|
198
194
|
<box>
|
|
199
195
|
<text fg={theme.colors.muted}>{"Install "}</text>
|
|
200
|
-
<text fg={theme.colors.accent}>
|
|
196
|
+
<text fg={theme.colors.accent}>
|
|
197
|
+
{formatCommand(cliToolInstall(tool))}
|
|
198
|
+
</text>
|
|
201
199
|
</box>
|
|
202
200
|
) : null}
|
|
203
201
|
<box>
|
|
@@ -233,7 +231,7 @@ export function renderCliToolDetail(
|
|
|
233
231
|
{i > 0 ? (
|
|
234
232
|
<span
|
|
235
233
|
fg={theme.colors.dim}
|
|
236
|
-
>{` ${
|
|
234
|
+
>{` ${uninstallHint(tool, method, brewFormula)}`}</span>
|
|
237
235
|
) : (
|
|
238
236
|
<span fg={theme.colors.dim}> (active in PATH)</span>
|
|
239
237
|
)}
|
|
@@ -259,7 +257,10 @@ export function renderCliToolDetail(
|
|
|
259
257
|
Enter{" "}
|
|
260
258
|
</text>
|
|
261
259
|
<text fg={theme.colors.muted}> Install</text>
|
|
262
|
-
<text fg={theme.colors.dim}>
|
|
260
|
+
<text fg={theme.colors.dim}>
|
|
261
|
+
{" "}
|
|
262
|
+
{formatCommand(cliToolInstall(tool))}
|
|
263
|
+
</text>
|
|
263
264
|
</box>
|
|
264
265
|
) : hasUpdate ? (
|
|
265
266
|
<box>
|