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
|
@@ -1,34 +1,101 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
PYTHON_CLI_INSTALLERS,
|
|
4
|
+
type PythonCliInstaller,
|
|
3
5
|
binInstallCommand,
|
|
4
6
|
binUpgradeCommand,
|
|
5
7
|
detectToolchains,
|
|
8
|
+
resolvePythonCliInstaller,
|
|
6
9
|
} from "../services/toolchain.js";
|
|
7
10
|
import type { ResolvedBin } from "../types/index.js";
|
|
11
|
+
import { formatCommand } from "../utils/command-utils.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pin a Python installer explicitly in every `via: "pip"` assertion.
|
|
15
|
+
*
|
|
16
|
+
* The production default is resolved from PATH, so asserting a literal command
|
|
17
|
+
* would encode whatever happens to be installed on the machine running the
|
|
18
|
+
* suite — green here, red on a runner without uv.
|
|
19
|
+
*/
|
|
20
|
+
const byProbe = (probe: string): PythonCliInstaller => {
|
|
21
|
+
const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
|
|
22
|
+
if (!found) throw new Error(`no python installer with probe ${probe}`);
|
|
23
|
+
return found;
|
|
24
|
+
};
|
|
25
|
+
const UV = byProbe("uv");
|
|
26
|
+
const PIPX = byProbe("pipx");
|
|
27
|
+
const PY = byProbe("python3");
|
|
8
28
|
|
|
9
29
|
describe("binInstallCommand", () => {
|
|
10
30
|
test("bun with and without a version pin", () => {
|
|
11
|
-
expect(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
expect(
|
|
32
|
+
formatCommand(
|
|
33
|
+
binInstallCommand({
|
|
34
|
+
name: "claudish",
|
|
35
|
+
via: "bun",
|
|
36
|
+
package: "claudish",
|
|
37
|
+
version: "1.2.0",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
).toBe("bun install -g claudish@1.2.0");
|
|
41
|
+
expect(
|
|
42
|
+
formatCommand(binInstallCommand({ name: "claudish", via: "bun" })),
|
|
43
|
+
).toBe("bun install -g claudish");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the package name is ONE argv token, whatever is in it", () => {
|
|
47
|
+
// `bin.package` is hand-authored data — it arrives from
|
|
48
|
+
// `.claude/profiles.json` and from plugin `requires.bin`, committed by
|
|
49
|
+
// teammates and fetched from marketplaces, and nothing validates it.
|
|
50
|
+
// While commands were strings handed to `/bin/sh`, this string was four
|
|
51
|
+
// words and a command separator. As argv it cannot be anything but a
|
|
52
|
+
// (nonexistent) package name, so the install fails instead of running.
|
|
53
|
+
const evil = "a b; rm -rf /";
|
|
54
|
+
const cmd = binInstallCommand({ name: "x", via: "bun", package: evil });
|
|
55
|
+
expect(cmd.cmd).toBe("bun");
|
|
56
|
+
expect(cmd.args).toEqual(["install", "-g", evil]);
|
|
57
|
+
expect(cmd.args.at(-1)).toBe(evil); // unsplit: still exactly one token
|
|
58
|
+
// ...and rendering it for display quotes it rather than emitting syntax.
|
|
59
|
+
expect(formatCommand(cmd)).toBe("bun install -g 'a b; rm -rf /'");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a version pin joins the package as one token, not two", () => {
|
|
63
|
+
// Splitting `pkg@version` would ask the installer for two packages, the
|
|
64
|
+
// second of which is a version number.
|
|
65
|
+
const cmd = binInstallCommand({
|
|
66
|
+
name: "claudish",
|
|
67
|
+
via: "bun",
|
|
68
|
+
version: "1.2.0",
|
|
69
|
+
});
|
|
70
|
+
expect(cmd.args.at(-1)).toBe("claudish@1.2.0");
|
|
17
71
|
});
|
|
18
72
|
|
|
19
73
|
test("npm, pip, brew, go each render correctly", () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
// Rendered, not raw, because these literals are also the display-
|
|
75
|
+
// neutrality claim: every token here is shell-safe, so `formatCommand`
|
|
76
|
+
// quotes nothing and the printed line is byte-identical to the string
|
|
77
|
+
// these builders returned before they returned argv.
|
|
78
|
+
expect(
|
|
79
|
+
formatCommand(binInstallCommand({ name: "x", via: "npm", package: "x" })),
|
|
80
|
+
).toBe("npm install -g x");
|
|
81
|
+
expect(
|
|
82
|
+
formatCommand(
|
|
83
|
+
binInstallCommand(
|
|
84
|
+
{ name: "aider", via: "pip", package: "aider", version: "0.1" },
|
|
85
|
+
UV,
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
).toBe("uv tool install aider==0.1");
|
|
89
|
+
expect(
|
|
90
|
+
formatCommand(
|
|
91
|
+
binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
|
|
92
|
+
),
|
|
93
|
+
).toBe("brew install tmux");
|
|
94
|
+
expect(
|
|
95
|
+
formatCommand(
|
|
96
|
+
binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" }),
|
|
97
|
+
),
|
|
98
|
+
).toBe("go install github.com/x/t@latest");
|
|
32
99
|
});
|
|
33
100
|
});
|
|
34
101
|
|
|
@@ -38,26 +105,104 @@ describe("binUpgradeCommand", () => {
|
|
|
38
105
|
// installed formula prints "already installed" and changes nothing, and
|
|
39
106
|
// `pip install` without --upgrade does the same. Reusing them would let
|
|
40
107
|
// `claudeup update` report success while advancing nothing.
|
|
41
|
-
expect(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
108
|
+
expect(
|
|
109
|
+
formatCommand(
|
|
110
|
+
binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" }),
|
|
111
|
+
),
|
|
112
|
+
).toBe("brew upgrade tmux");
|
|
113
|
+
expect(
|
|
114
|
+
formatCommand(
|
|
115
|
+
binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, PY),
|
|
116
|
+
),
|
|
117
|
+
).toBe("python3 -m pip install --user --upgrade aider");
|
|
118
|
+
expect(
|
|
119
|
+
formatCommand(
|
|
120
|
+
binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, UV),
|
|
121
|
+
),
|
|
122
|
+
).toBe("uv tool install --force --upgrade aider");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("every Python installer distinguishes install from upgrade", () => {
|
|
126
|
+
// The same trap as brew: `uv tool install` on an already-installed tool
|
|
127
|
+
// is a no-op, so reusing the install command would report success while
|
|
128
|
+
// advancing nothing.
|
|
129
|
+
for (const python of PYTHON_CLI_INSTALLERS) {
|
|
130
|
+
const bin = { name: "aider", via: "pip" as const, package: "aider" };
|
|
131
|
+
// Compared RENDERED. Two `Command` objects are never `toBe`-equal —
|
|
132
|
+
// they are distinct references — so comparing them directly would be
|
|
133
|
+
// an assertion that cannot fail.
|
|
134
|
+
expect(formatCommand(binUpgradeCommand(bin, python))).not.toBe(
|
|
135
|
+
formatCommand(binInstallCommand(bin, python)),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("resolvePythonCliInstaller", () => {
|
|
142
|
+
test("never emits a bare `pip`", () => {
|
|
143
|
+
// The bug this exists for: `pip` is frequently a zsh ALIAS for pip3 and
|
|
144
|
+
// no such executable exists. Commands were spawned through /bin/sh, which
|
|
145
|
+
// sources no profile, so this died with `/bin/sh: pip: command not found`
|
|
146
|
+
// on a machine where typing `pip` at a prompt works.
|
|
147
|
+
//
|
|
148
|
+
// Now that a command is argv, `cmd` is the exact executable that gets
|
|
149
|
+
// looked up on PATH — so asserting on it is asserting on the syscall,
|
|
150
|
+
// not on a string a shell might later re-read.
|
|
151
|
+
for (const python of PYTHON_CLI_INSTALLERS) {
|
|
152
|
+
for (const cmd of [python.install("x"), python.upgrade("x")]) {
|
|
153
|
+
expect(cmd.cmd).not.toBe("pip");
|
|
154
|
+
expect(cmd.cmd).toBe(python.probe);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("prefers an isolated-venv installer over the system interpreter", () => {
|
|
160
|
+
// `pip install` into a Homebrew interpreter is refused outright by PEP
|
|
161
|
+
// 668 (`externally-managed-environment`), so uv and pipx must outrank it.
|
|
162
|
+
const order = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
|
|
163
|
+
expect(order.indexOf("uv")).toBeLessThan(order.indexOf("python3"));
|
|
164
|
+
expect(order.indexOf("pipx")).toBeLessThan(order.indexOf("python3"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("always resolves to something, and to one of the known options", () => {
|
|
168
|
+
// Returning null would force every caller to handle "no installer",
|
|
169
|
+
// which in practice means printing nothing and silently skipping.
|
|
170
|
+
const resolved = resolvePythonCliInstaller();
|
|
171
|
+
expect(PYTHON_CLI_INSTALLERS).toContain(resolved);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a version pin is expressed in the installer's own syntax", () => {
|
|
175
|
+
expect(
|
|
176
|
+
formatCommand(
|
|
177
|
+
binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV),
|
|
178
|
+
),
|
|
179
|
+
).toBe("uv tool install a==1.2");
|
|
180
|
+
expect(
|
|
181
|
+
formatCommand(
|
|
182
|
+
binInstallCommand({ name: "a", via: "pip", version: "1.2" }, PIPX),
|
|
183
|
+
),
|
|
184
|
+
).toBe("pipx install a==1.2");
|
|
185
|
+
// `a==1.2` is one token. Two would ask uv to install a package called
|
|
186
|
+
// "1.2".
|
|
187
|
+
expect(
|
|
188
|
+
binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV).args,
|
|
189
|
+
).toEqual(["tool", "install", "a==1.2"]);
|
|
47
190
|
});
|
|
48
191
|
|
|
49
192
|
test("bun and npm state @latest explicitly, so the printed command reads true", () => {
|
|
50
|
-
expect(
|
|
51
|
-
|
|
52
|
-
);
|
|
53
|
-
expect(
|
|
54
|
-
"npm
|
|
55
|
-
);
|
|
193
|
+
expect(
|
|
194
|
+
formatCommand(binUpgradeCommand({ name: "claudish", via: "bun" })),
|
|
195
|
+
).toBe("bun install -g claudish@latest");
|
|
196
|
+
expect(
|
|
197
|
+
formatCommand(binUpgradeCommand({ name: "x", via: "npm", package: "x" })),
|
|
198
|
+
).toBe("npm install -g x@latest");
|
|
56
199
|
});
|
|
57
200
|
|
|
58
201
|
test("go installs the module at latest", () => {
|
|
59
202
|
expect(
|
|
60
|
-
|
|
203
|
+
formatCommand(
|
|
204
|
+
binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
|
|
205
|
+
),
|
|
61
206
|
).toBe("go install github.com/x/t@latest");
|
|
62
207
|
});
|
|
63
208
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI records a plugin version only through a function that read it back.
|
|
3
|
+
*
|
|
4
|
+
* `installedPluginVersions` is claudeup's own bookkeeping — the Claude CLI does
|
|
5
|
+
* not maintain it — and `install --check` reads it to decide whether a machine
|
|
6
|
+
* has drifted from its profile. So a version written from an EXPECTATION rather
|
|
7
|
+
* than from disk is not a cosmetic inaccuracy: the drift gate ends up comparing
|
|
8
|
+
* a number against a copy of itself and reporting clean forever. That is what
|
|
9
|
+
* PluginsScreen's install branches did, via a local `saveVersionForScope`
|
|
10
|
+
* helper that duplicated `plugin-manager.ts`'s scope dispatcher and took the
|
|
11
|
+
* version as a parameter.
|
|
12
|
+
*
|
|
13
|
+
* Deleting the helper removed the UI's *ability* to make that mistake. This
|
|
14
|
+
* test is what keeps it removed: a screen may still install and update, but
|
|
15
|
+
* only through `installPluginInScope`, which reads the version off disk and
|
|
16
|
+
* writes nothing when it cannot.
|
|
17
|
+
*
|
|
18
|
+
* A source scan rather than a behavioural test because these are `.tsx` screens
|
|
19
|
+
* this suite cannot render. It encodes the rule directly instead of the symptom
|
|
20
|
+
* — the precedent for source-scanning guards here is
|
|
21
|
+
* `uppercase-keybindings.test.ts`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, test } from "bun:test";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import fs from "fs-extra";
|
|
27
|
+
|
|
28
|
+
const UI_DIR = path.join(import.meta.dir, "..", "ui");
|
|
29
|
+
|
|
30
|
+
/** The version writers that take the number on trust from their caller. */
|
|
31
|
+
const RAW_WRITERS = [
|
|
32
|
+
"saveGlobalInstalledPluginVersion",
|
|
33
|
+
"saveLocalInstalledPluginVersion",
|
|
34
|
+
"saveInstalledPluginVersion",
|
|
35
|
+
"saveInstalledPluginVersionForScope",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
async function uiSourceFiles(dir: string): Promise<string[]> {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
41
|
+
const full = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
|
|
43
|
+
else if (/\.tsx?$/.test(entry.name)) out.push(full);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isComment = (line: string) => {
|
|
49
|
+
const t = line.trim();
|
|
50
|
+
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("no UI file writes a plugin version it did not read back", () => {
|
|
54
|
+
test("the raw version writers are absent from src/ui", async () => {
|
|
55
|
+
const files = await uiSourceFiles(UI_DIR);
|
|
56
|
+
expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
|
|
57
|
+
|
|
58
|
+
const offenders: string[] = [];
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const text = await fs.readFile(file, "utf8");
|
|
61
|
+
text.split("\n").forEach((line, i) => {
|
|
62
|
+
if (isComment(line)) return;
|
|
63
|
+
for (const writer of RAW_WRITERS) {
|
|
64
|
+
// Word-bounded: `saveInstalledPluginVersion` is a prefix of
|
|
65
|
+
// `saveInstalledPluginVersionForScope`, and both are banned, so a
|
|
66
|
+
// substring match would just double-report the same line.
|
|
67
|
+
if (new RegExp(`\\b${writer}\\b`).test(line)) {
|
|
68
|
+
offenders.push(
|
|
69
|
+
`${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(offenders).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("the guard can actually fail", () => {
|
|
80
|
+
// Negative control. The assertion above passes trivially if the walk or
|
|
81
|
+
// the pattern is broken, and a guard that cannot fail is not evidence.
|
|
82
|
+
const line = "\t\t\tawait saveGlobalInstalledPluginVersion(id, version);";
|
|
83
|
+
expect(isComment(line)).toBe(false);
|
|
84
|
+
expect(RAW_WRITERS.some((w) => new RegExp(`\\b${w}\\b`).test(line))).toBe(
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type PluginApplyDeps,
|
|
12
|
+
applyPlugins,
|
|
13
|
+
installPluginInScope,
|
|
14
|
+
} from "../services/update-engine.js";
|
|
11
15
|
import type { PluginScope } from "../services/claude-cli.js";
|
|
12
16
|
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
13
17
|
|
|
@@ -221,3 +225,44 @@ describe("failure handling", () => {
|
|
|
221
225
|
expect(result.ok).toBe(0);
|
|
222
226
|
});
|
|
223
227
|
});
|
|
228
|
+
|
|
229
|
+
describe("installPluginInScope — a fresh install records what landed", () => {
|
|
230
|
+
// The TUI's install branches called the CLI directly and then wrote the
|
|
231
|
+
// version they had ASKED for. A marketplace serves whatever it publishes at
|
|
232
|
+
// the moment of the call, so that number was a guess — and `install --check`
|
|
233
|
+
// reads back the same field, so it compared the guess against a copy of
|
|
234
|
+
// itself and reported clean forever. Routing installs through the read-back
|
|
235
|
+
// the update branches already used is what closes it.
|
|
236
|
+
//
|
|
237
|
+
// The read-back semantics are pinned in cli-apply-seams.test.ts
|
|
238
|
+
// ("installPluginInScope — the scope-targeted path"). What these add is the
|
|
239
|
+
// property that makes the whole class of bug unrepresentable rather than
|
|
240
|
+
// merely absent right now.
|
|
241
|
+
|
|
242
|
+
test("takes no version parameter, so a caller cannot supply a wrong one", () => {
|
|
243
|
+
// Arity, not behaviour — and that is the point. Re-adding a `version`
|
|
244
|
+
// argument would reopen the defect without failing any behavioural test,
|
|
245
|
+
// because the new parameter would simply be believed.
|
|
246
|
+
// (pluginId, scope, projectPath, deps = REAL_PLUGIN_DEPS) — the defaulted
|
|
247
|
+
// parameter is not counted by Function.length.
|
|
248
|
+
expect(installPluginInScope.length).toBe(3);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("saves the version read back, not one the caller chose", async () => {
|
|
252
|
+
const { deps, saved } = stubDeps("9.9.9");
|
|
253
|
+
await installPluginInScope("dev@magus", "user", PROJECT, deps);
|
|
254
|
+
expect(saved).toEqual([
|
|
255
|
+
{ id: "dev@magus", version: "9.9.9", scope: "user" },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("an unreadable version writes nothing at all", async () => {
|
|
260
|
+
// Never guess. A missing record is recoverable; a wrong one is not,
|
|
261
|
+
// because nothing downstream can tell it from a true one.
|
|
262
|
+
const { deps, saved } = stubDeps(null);
|
|
263
|
+
expect(
|
|
264
|
+
await installPluginInScope("dev@magus", "user", PROJECT, deps),
|
|
265
|
+
).toBeNull();
|
|
266
|
+
expect(saved).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/cli/install.ts
CHANGED
|
@@ -47,9 +47,10 @@ import type {
|
|
|
47
47
|
ResolvedClosure,
|
|
48
48
|
SkillInfo,
|
|
49
49
|
} from "../types/index.js";
|
|
50
|
-
import { resolveExecutable } from "../utils/command-utils.js";
|
|
50
|
+
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
51
51
|
import { ensureManifest } from "./bootstrap.js";
|
|
52
|
-
import { confirm, promptValue
|
|
52
|
+
import { confirm, promptValue } from "./prompt.js";
|
|
53
|
+
import { runCommand, runShellScript } from "../utils/run.js";
|
|
53
54
|
|
|
54
55
|
interface InstallFlags {
|
|
55
56
|
check: boolean;
|
|
@@ -125,7 +126,10 @@ async function ensureToolchains(
|
|
|
125
126
|
));
|
|
126
127
|
if (ok) {
|
|
127
128
|
console.log(`Installing ${tc.name}…`);
|
|
128
|
-
|
|
129
|
+
// A bootstrap line IS shell text — bun's contains a pipe, Homebrew's a
|
|
130
|
+
// command substitution — and it is a constant in this repo, not
|
|
131
|
+
// profile data. This is the only legitimate caller.
|
|
132
|
+
if (!(await runShellScript(bootstrap))) {
|
|
129
133
|
console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
|
|
130
134
|
}
|
|
131
135
|
}
|
|
@@ -163,8 +167,10 @@ async function installBins(bins: ResolvedBin[]): Promise<void> {
|
|
|
163
167
|
continue; // already installed and runnable (not a dangling link)
|
|
164
168
|
}
|
|
165
169
|
const cmd = binInstallCommand(bin);
|
|
166
|
-
|
|
167
|
-
|
|
170
|
+
// Printed rendered, executed as argv. The package name in `cmd.args` came
|
|
171
|
+
// from a profile or a plugin manifest, so it must never reach a shell.
|
|
172
|
+
console.log(`+ ${bin.name} (${formatCommand(cmd)})`);
|
|
173
|
+
if (!(await runCommand(cmd))) {
|
|
168
174
|
console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
|
|
169
175
|
}
|
|
170
176
|
}
|
package/src/cli/prompt.ts
CHANGED
|
@@ -31,12 +31,3 @@ export async function promptValue(question: string): Promise<string> {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Run a shell command, streaming output; resolve true on exit 0. */
|
|
35
|
-
export async function runShell(command: string): Promise<boolean> {
|
|
36
|
-
const { spawn } = await import("node:child_process");
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
const child = spawn(command, { stdio: "inherit", shell: true });
|
|
39
|
-
child.on("exit", (code) => resolve(code === 0));
|
|
40
|
-
child.on("error", () => resolve(false));
|
|
41
|
-
});
|
|
42
|
-
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
} from "../services/update-plan.js";
|
|
33
33
|
import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
|
|
34
34
|
import { brand } from "../ui/theme.js";
|
|
35
|
+
import { formatCommand } from "../utils/command-utils.js";
|
|
35
36
|
import {
|
|
36
37
|
badge,
|
|
37
38
|
bold,
|
|
@@ -256,7 +257,9 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
256
257
|
item.action === "install"
|
|
257
258
|
? binInstallCommand(spec)
|
|
258
259
|
: binUpgradeCommand(spec);
|
|
259
|
-
|
|
260
|
+
// Rendered for the reader's eyes only — this row says what WILL run, while
|
|
261
|
+
// applyBins runs the argv itself.
|
|
262
|
+
return dim(formatCommand(cmd));
|
|
260
263
|
}
|
|
261
264
|
|
|
262
265
|
/**
|