claudeup 6.7.1 → 6.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claudeup.js +116 -12
- package/package.json +4 -4
- package/scripts/build-binaries.ts +26 -1
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/launcher-signal.test.ts +314 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/cli/upgrade.ts +7 -1
- package/src/data/models-presets.ts +62 -2
- package/src/services/binary-signing.ts +78 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
package/bin/claudeup.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* path. That escape hatch exists because the launcher PREFERS the binary: when
|
|
12
12
|
* a shipped binary cannot start on a given machine, without it claudeup is a
|
|
13
13
|
* hard block rather than a slow start.
|
|
14
|
+
*
|
|
15
|
+
* This file must stay self-contained and import only node: builtins. Its tests
|
|
16
|
+
* copy it into a throwaway tree, where any relative import would not resolve.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { spawnSync } from "node:child_process";
|
|
@@ -18,11 +21,46 @@ import { createRequire } from "node:module";
|
|
|
18
21
|
import { fileURLToPath } from "node:url";
|
|
19
22
|
import { dirname, join } from "node:path";
|
|
20
23
|
import { existsSync } from "node:fs";
|
|
24
|
+
import { constants } from "node:os";
|
|
21
25
|
|
|
22
26
|
const require = createRequire(import.meta.url);
|
|
23
27
|
const args = process.argv.slice(2);
|
|
24
28
|
const { platform, arch } = process;
|
|
25
29
|
|
|
30
|
+
const ISSUES_URL = "https://github.com/MadAppGang/magus/issues";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The four signals a person sends on purpose. Everything else is a crash.
|
|
34
|
+
*
|
|
35
|
+
* The rule is deliberately inverted from the obvious one. An allowlist of
|
|
36
|
+
* crash signals leaves holes: SIGABRT is what a Bun/JSC panic raises, which is
|
|
37
|
+
* exactly the "this binary is broken, use source" case, and it would fall
|
|
38
|
+
* through the allowlist into the old masked path.
|
|
39
|
+
*/
|
|
40
|
+
const USER_SIGNALS = new Set(["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long after exec a crash still counts as "it never started".
|
|
44
|
+
*
|
|
45
|
+
* An invalid code signature kills the binary in single-digit milliseconds, at
|
|
46
|
+
* exec, before any output. A `kill -9` against a running TUI, or an OOM kill,
|
|
47
|
+
* lands seconds later. Without this gate the fix would trade a silent exit 0
|
|
48
|
+
* for a surprise relaunch of an app the user deliberately killed.
|
|
49
|
+
*/
|
|
50
|
+
const CRASH_FALLBACK_WINDOW_MS = 2000;
|
|
51
|
+
|
|
52
|
+
/** Shell convention: a signal death is 128 + the signal number. */
|
|
53
|
+
function exitCodeForSignal(signal) {
|
|
54
|
+
const number = constants.signals[signal];
|
|
55
|
+
return typeof number === "number" ? 128 + number : 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Set when a binary was found and tried but could not deliver a result. It
|
|
59
|
+
// changes what the source path says if IT then fails: "no prebuilt binary for
|
|
60
|
+
// this platform" is true when none was installed and a flat contradiction when
|
|
61
|
+
// one was just reported as killed.
|
|
62
|
+
let binaryFailed = false;
|
|
63
|
+
|
|
26
64
|
// 1. Prefer the prebuilt platform binary, unless the source path is forced.
|
|
27
65
|
const forceSource = process.env.CLAUDEUP_NO_BINARY === "1";
|
|
28
66
|
const pkgName = `claudeup-${platform}-${arch}`;
|
|
@@ -36,15 +74,53 @@ try {
|
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
if (binaryPath && !forceSource) {
|
|
77
|
+
const startedAt = Date.now();
|
|
39
78
|
const result = spawnSync(binaryPath, args, { stdio: "inherit" });
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
79
|
+
const elapsedMs = Date.now() - startedAt;
|
|
80
|
+
|
|
81
|
+
if (!result.error) {
|
|
82
|
+
// `status === null` is the authoritative "did not exit normally" marker,
|
|
83
|
+
// NOT the truthiness of `signal`. Node writes an empty string into
|
|
84
|
+
// `signal` for a signal it has no name for (Linux real-time signals,
|
|
85
|
+
// SIGRTMIN+n), and "" is falsy — so branching on the signal would fall
|
|
86
|
+
// straight back into the `status ?? 0` this release exists to remove.
|
|
87
|
+
if (result.status !== null) process.exit(result.status);
|
|
88
|
+
|
|
89
|
+
const signalName = result.signal || "an unnamed signal";
|
|
90
|
+
const deliberate =
|
|
91
|
+
USER_SIGNALS.has(result.signal) ||
|
|
92
|
+
elapsedMs >= CRASH_FALLBACK_WINDOW_MS;
|
|
93
|
+
if (deliberate) process.exit(exitCodeForSignal(result.signal));
|
|
94
|
+
|
|
95
|
+
binaryFailed = true;
|
|
96
|
+
// The elapsed time is measured; "before producing any output" is not —
|
|
97
|
+
// the child ran under stdio: "inherit", so this process never saw its
|
|
98
|
+
// output and must not claim there was none.
|
|
99
|
+
console.error(
|
|
100
|
+
`claudeup: the prebuilt binary was killed by ${signalName} ${elapsedMs}ms after it started.\n` +
|
|
101
|
+
"\n" +
|
|
102
|
+
` binary: ${binaryPath}\n` +
|
|
103
|
+
` platform: ${platform}-${arch}\n` +
|
|
104
|
+
"\n" +
|
|
105
|
+
"On macOS this almost always means the binary's code signature is invalid and the\n" +
|
|
106
|
+
"kernel refused to run it. Confirm with:\n" +
|
|
107
|
+
"\n" +
|
|
108
|
+
` codesign --verify --strict "${binaryPath}"\n` +
|
|
109
|
+
"\n" +
|
|
110
|
+
"Falling back to running from source via Bun. To skip the binary permanently:\n" +
|
|
111
|
+
" export CLAUDEUP_NO_BINARY=1\n" +
|
|
112
|
+
"\n" +
|
|
113
|
+
`Please report this: ${ISSUES_URL}`,
|
|
114
|
+
);
|
|
115
|
+
// fall through to the source path
|
|
116
|
+
} else {
|
|
117
|
+
// A binary that could not be executed AT ALL (ENOENT, EACCES, bad arch)
|
|
118
|
+
// also leaves status null. Say so and fall through.
|
|
119
|
+
binaryFailed = true;
|
|
120
|
+
console.error(
|
|
121
|
+
`claudeup: prebuilt binary could not start (${result.error.message}); falling back to source.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
48
124
|
}
|
|
49
125
|
|
|
50
126
|
// 2. Fallback: run from source via Bun.
|
|
@@ -57,10 +133,38 @@ const bunRun = spawnSync("bun", ["--no-env-file", mainSrc, ...args], {
|
|
|
57
133
|
stdio: "inherit",
|
|
58
134
|
});
|
|
59
135
|
if (bunRun.error) {
|
|
136
|
+
// Two different states reach here, and one message for both is a lie in one
|
|
137
|
+
// of them. A user who installed claudeup from npm on macOS 27 and has no
|
|
138
|
+
// Bun hits the FIRST branch — telling them no binary exists for their
|
|
139
|
+
// platform contradicts the diagnostic printed seconds earlier, and
|
|
140
|
+
// "use a supported platform" is advice for a problem they do not have.
|
|
141
|
+
if (binaryFailed) {
|
|
142
|
+
console.error(
|
|
143
|
+
`claudeup: the prebuilt binary for ${platform}-${arch} is unusable on this machine, and Bun is not installed to fall back to.\n` +
|
|
144
|
+
"\n" +
|
|
145
|
+
"Reinstall to pick up a working binary:\n" +
|
|
146
|
+
" npm install -g claudeup@latest\n" +
|
|
147
|
+
"\n" +
|
|
148
|
+
"Or install Bun (https://bun.sh) so the source path can run.\n" +
|
|
149
|
+
`Please report this: ${ISSUES_URL}`,
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
console.error(
|
|
153
|
+
`claudeup: no prebuilt binary for ${platform}-${arch}, and Bun is not installed.`,
|
|
154
|
+
);
|
|
155
|
+
console.error("Install Bun (https://bun.sh) or use a supported platform.");
|
|
156
|
+
}
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
// Same masking bug, second site, keyed the same way: `status === null` decides,
|
|
160
|
+
// not the signal. There is no binary here and nowhere left to fall back to, so
|
|
161
|
+
// this reports and exits — no codesign hint, which would be nonsense for a Bun
|
|
162
|
+
// crash.
|
|
163
|
+
if (bunRun.status !== null) process.exit(bunRun.status);
|
|
164
|
+
if (!USER_SIGNALS.has(bunRun.signal)) {
|
|
60
165
|
console.error(
|
|
61
|
-
`claudeup:
|
|
166
|
+
`claudeup: running from source was killed by ${bunRun.signal || "an unnamed signal"}.\n` +
|
|
167
|
+
`Please report this: ${ISSUES_URL}`,
|
|
62
168
|
);
|
|
63
|
-
console.error("Install Bun (https://bun.sh) or use a supported platform.");
|
|
64
|
-
process.exit(1);
|
|
65
169
|
}
|
|
66
|
-
process.exit(bunRun.
|
|
170
|
+
process.exit(exitCodeForSignal(bunRun.signal));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "6.
|
|
68
|
-
"claudeup-darwin-x64": "6.
|
|
69
|
-
"claudeup-linux-x64": "6.
|
|
67
|
+
"claudeup-darwin-arm64": "6.8.1",
|
|
68
|
+
"claudeup-darwin-x64": "6.8.1",
|
|
69
|
+
"claudeup-linux-x64": "6.8.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Hard constraint: claudeup pins @opentui 0.1.x — 0.4.x breaks --compile.
|
|
17
17
|
*
|
|
18
|
+
* Hard constraint: every darwin binary is re-signed and verified here. The
|
|
19
|
+
* signature --compile leaves behind does not cover the appended bundle, and
|
|
20
|
+
* macOS 27 kills such a binary at exec. See src/services/binary-signing.ts.
|
|
21
|
+
*
|
|
18
22
|
* Hard constraint: the two --no-compile-autoload flags below must stay. A Bun
|
|
19
23
|
* standalone executable autoloads .env and bunfig.toml from its CURRENT
|
|
20
24
|
* DIRECTORY by default, and claudeup reads neither on purpose. Bun 1.4.0 dies
|
|
@@ -30,6 +34,7 @@ import { $ } from "bun";
|
|
|
30
34
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
31
35
|
import path from "node:path";
|
|
32
36
|
import pkg from "../package.json";
|
|
37
|
+
import { signAndVerify } from "../src/services/binary-signing.js";
|
|
33
38
|
|
|
34
39
|
interface Target {
|
|
35
40
|
os: "darwin" | "linux";
|
|
@@ -51,7 +56,11 @@ const entry = path.join(root, "src", "main.tsx");
|
|
|
51
56
|
// Optional filter: `bun scripts/build-binaries.ts darwin-arm64`
|
|
52
57
|
const only = process.argv[2];
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
// Only a full build purges the whole tree. Scoping a filtered build to its own
|
|
60
|
+
// target means `build:binaries linux-x64` on a Linux host does not first wipe
|
|
61
|
+
// dist-binaries/ and then throw on the darwin guard below, leaving the user
|
|
62
|
+
// with neither the old output nor the one target they could actually build.
|
|
63
|
+
if (!only) await rm(distRoot, { recursive: true, force: true });
|
|
55
64
|
|
|
56
65
|
for (const t of TARGETS) {
|
|
57
66
|
const id = `${t.os}-${t.cpu}`;
|
|
@@ -60,12 +69,28 @@ for (const t of TARGETS) {
|
|
|
60
69
|
const pkgName = `claudeup-${id}`;
|
|
61
70
|
const outDir = path.join(distRoot, pkgName);
|
|
62
71
|
const binPath = path.join(outDir, "bin", "claudeup");
|
|
72
|
+
|
|
73
|
+
// `codesign` is macOS-only, and an unsigned darwin binary is killed at exec
|
|
74
|
+
// by macOS 27. Building one anywhere else produces an artifact that cannot
|
|
75
|
+
// be gated, which is precisely how the broken binaries shipped.
|
|
76
|
+
if (t.os === "darwin" && process.platform !== "darwin") {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Cannot build ${pkgName} on ${process.platform}: darwin binaries must be signed, and codesign exists only on macOS. Build this target on a macOS runner, or name a buildable one: bun run build:binaries linux-x64`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await rm(outDir, { recursive: true, force: true });
|
|
63
83
|
await mkdir(path.join(outDir, "bin"), { recursive: true });
|
|
64
84
|
|
|
65
85
|
console.log(`Building ${pkgName} (${t.bunTarget})…`);
|
|
66
86
|
// The --no-compile-autoload flags are load-bearing; see the header.
|
|
67
87
|
await $`bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
|
|
68
88
|
|
|
89
|
+
// --compile signs the Mach-O, then appends the bundle after it, leaving a
|
|
90
|
+
// signature that does not cover the file. Re-sign and refuse to continue if
|
|
91
|
+
// the result does not verify.
|
|
92
|
+
signAndVerify(t, binPath);
|
|
93
|
+
|
|
69
94
|
// Platform package: os/cpu-restricted, ships only the binary, declares NO
|
|
70
95
|
// `bin` (the main package's launcher resolves and execs bin/claudeup).
|
|
71
96
|
const platformPkg = {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `services/binary-signing.ts` — the build-time code-signing gate.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists at all: `bun build --compile` emits an ad-hoc,
|
|
5
|
+
* linker-signed Mach-O and then appends the JavaScript bundle to it. The
|
|
6
|
+
* signature therefore does not cover the file it ships in. `codesign -v`
|
|
7
|
+
* reported "code or signature have been modified" on every published claudeup
|
|
8
|
+
* back to 4.22.0. macOS 27 kills such a binary at exec, with no output, which
|
|
9
|
+
* is what broke 4.40.0 onward.
|
|
10
|
+
*
|
|
11
|
+
* The decision logic lives in `src/` rather than in `scripts/build-binaries.ts`
|
|
12
|
+
* for one blunt reason: `scripts/test-isolated.ts` only walks `src/`, so a test
|
|
13
|
+
* placed beside the build script would never be collected and would report
|
|
14
|
+
* nothing at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { appendFileSync, copyFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
type Run,
|
|
24
|
+
needsSigning,
|
|
25
|
+
signAndVerify,
|
|
26
|
+
} from "../services/binary-signing.js";
|
|
27
|
+
|
|
28
|
+
/** Records every command instead of running one. */
|
|
29
|
+
function recorder(status = 0) {
|
|
30
|
+
const calls: Array<[string, string[]]> = [];
|
|
31
|
+
const run: Run = (cmd, args) => {
|
|
32
|
+
calls.push([cmd, args]);
|
|
33
|
+
return { status, stderr: "" };
|
|
34
|
+
};
|
|
35
|
+
return { calls, run };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("binary-signing", () => {
|
|
39
|
+
test("T-sign-1: a darwin target is signed ad-hoc, then verified", () => {
|
|
40
|
+
const { calls, run } = recorder();
|
|
41
|
+
signAndVerify({ os: "darwin" }, "/out/bin/claudeup", run);
|
|
42
|
+
|
|
43
|
+
// Ad-hoc (`--sign -`), not Developer ID: npm-installed files are not
|
|
44
|
+
// quarantined, so the kernel signature check is the only failing gate.
|
|
45
|
+
// No Apple account, no CI secrets, no notarization wait.
|
|
46
|
+
expect(calls).toEqual([
|
|
47
|
+
["codesign", ["--force", "--sign", "-", "/out/bin/claudeup"]],
|
|
48
|
+
["codesign", ["--verify", "--strict", "/out/bin/claudeup"]],
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("T-sign-2: a failing codesign is a hard gate, not a warning", () => {
|
|
53
|
+
// A release that ships an unverifiable binary is the whole defect. The
|
|
54
|
+
// build must stop, not print and continue.
|
|
55
|
+
const signFails: Run = (_cmd, args) =>
|
|
56
|
+
args[0] === "--force"
|
|
57
|
+
? { status: 1, stderr: "sign failed" }
|
|
58
|
+
: { status: 0, stderr: "" };
|
|
59
|
+
expect(() =>
|
|
60
|
+
signAndVerify({ os: "darwin" }, "/out/bin/claudeup", signFails),
|
|
61
|
+
).toThrow();
|
|
62
|
+
|
|
63
|
+
const verifyFails: Run = (_cmd, args) =>
|
|
64
|
+
args[0] === "--verify"
|
|
65
|
+
? { status: 1, stderr: "verify failed" }
|
|
66
|
+
: { status: 0, stderr: "" };
|
|
67
|
+
expect(() =>
|
|
68
|
+
signAndVerify({ os: "darwin" }, "/out/bin/claudeup", verifyFails),
|
|
69
|
+
).toThrow();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("T-sign-3: a linux target runs no codesign at all", () => {
|
|
73
|
+
// `codesign` does not exist off macOS. Running it would fail the linux
|
|
74
|
+
// leg of the release matrix for no reason.
|
|
75
|
+
expect(needsSigning({ os: "linux" })).toBe(false);
|
|
76
|
+
expect(needsSigning({ os: "darwin" })).toBe(true);
|
|
77
|
+
|
|
78
|
+
const { calls, run } = recorder();
|
|
79
|
+
signAndVerify({ os: "linux" }, "/out/bin/claudeup", run);
|
|
80
|
+
expect(calls).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// darwin-only: the subject is the macOS signature format itself. skipIf, not
|
|
84
|
+
// a bare early return — both CI jobs run on ubuntu, where a return would
|
|
85
|
+
// execute zero assertions and report green. The macOS coverage of this
|
|
86
|
+
// invariant in CI comes from the release matrix, which runs the real
|
|
87
|
+
// signAndVerify during `build:binaries` on both darwin legs.
|
|
88
|
+
test.skipIf(process.platform !== "darwin")(
|
|
89
|
+
"T-sign-4: real codesign rejects a signed file with bytes appended",
|
|
90
|
+
() => {
|
|
91
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "claudeup-signing-"));
|
|
92
|
+
try {
|
|
93
|
+
const subject = path.join(dir, "subject");
|
|
94
|
+
copyFileSync("/bin/echo", subject);
|
|
95
|
+
|
|
96
|
+
// Positive control. Without it a passing T-sign-4 would prove only
|
|
97
|
+
// that codesign fails on this machine for some unrelated reason.
|
|
98
|
+
expect(() => signAndVerify({ os: "darwin" }, subject)).not.toThrow();
|
|
99
|
+
|
|
100
|
+
// Exactly what `bun build --compile` does: append the bundle after
|
|
101
|
+
// the signature has been written.
|
|
102
|
+
appendFileSync(subject, "appended-bundle-bytes");
|
|
103
|
+
|
|
104
|
+
// Skip the re-sign so the REAL verify judges the corrupted file.
|
|
105
|
+
const verifyForReal: Run = (cmd, args) => {
|
|
106
|
+
if (args.includes("--sign")) return { status: 0, stderr: "" };
|
|
107
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
108
|
+
return { status: r.status, stderr: r.stderr ?? "" };
|
|
109
|
+
};
|
|
110
|
+
// Assert on throwing only. codesign's wording moves between macOS
|
|
111
|
+
// releases; the verdict does not.
|
|
112
|
+
expect(() =>
|
|
113
|
+
signAndVerify({ os: "darwin" }, subject, verifyForReal),
|
|
114
|
+
).toThrow();
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bin/claudeup.js` — what the launcher does when the prebuilt binary dies.
|
|
3
|
+
*
|
|
4
|
+
* The defect these tests pin: a process killed by a signal leaves
|
|
5
|
+
* `{status: null, signal: "SIGKILL", error: undefined}`. The launcher's guard
|
|
6
|
+
* read `error` only, so `status ?? 0` turned every signal death into exit 0.
|
|
7
|
+
* On macOS 27 an invalid code signature kills the binary at exec, so claudeup
|
|
8
|
+
* 4.40.0 through 6.8.0 printed nothing and reported success.
|
|
9
|
+
*
|
|
10
|
+
* The tests drive the real launcher inside a throwaway tree that mirrors the
|
|
11
|
+
* npm install layout:
|
|
12
|
+
*
|
|
13
|
+
* <tmp>/package.json ({"type":"module"})
|
|
14
|
+
* <tmp>/bin/claudeup.js (a copy of the real file)
|
|
15
|
+
* <tmp>/src/main.tsx (stub source path)
|
|
16
|
+
* <tmp>/node_modules/claudeup-<plat>-<arch>/bin/claudeup (stub binary)
|
|
17
|
+
*
|
|
18
|
+
* `createRequire` inside the copy walks up from <tmp>/bin, so it can never
|
|
19
|
+
* reach this repo's own installed claudeup-darwin-arm64. That is why the tree
|
|
20
|
+
* is built rather than NODE_PATH set: NODE_PATH is consulted only AFTER the
|
|
21
|
+
* node_modules walk fails, so a dev with the real optionalDependency installed
|
|
22
|
+
* would silently test the shipped binary instead of the stub.
|
|
23
|
+
*
|
|
24
|
+
* Each stub appends one line to $CLAUDEUP_TEST_LEDGER. A ledger file gives
|
|
25
|
+
* ordering and argv forwarding, which an inherited stdio stream cannot. The
|
|
26
|
+
* source stub's exit code 7 is a value the masked path can never produce, so
|
|
27
|
+
* `status === 7` alone separates "fell back" from "pretended to succeed".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import {
|
|
33
|
+
chmodSync,
|
|
34
|
+
cpSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
realpathSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import os from "node:os";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
|
|
46
|
+
const REAL_LAUNCHER = path.join(
|
|
47
|
+
import.meta.dir,
|
|
48
|
+
"..",
|
|
49
|
+
"..",
|
|
50
|
+
"bin",
|
|
51
|
+
"claudeup.js",
|
|
52
|
+
);
|
|
53
|
+
const PKG_SUFFIX = `${process.platform}-${process.arch}`;
|
|
54
|
+
const PKG_NAME = `claudeup-${PKG_SUFFIX}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Absolute path to node, resolved once. The "Bun is not installed" tests hand
|
|
58
|
+
* the launcher a PATH with neither node nor bun on it, so the interpreter
|
|
59
|
+
* cannot be found by name.
|
|
60
|
+
*/
|
|
61
|
+
const NODE_BIN = spawnSync("sh", ["-c", "command -v node"], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
}).stdout.trim();
|
|
64
|
+
|
|
65
|
+
const dirs: string[] = [];
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const dir of dirs.splice(0))
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function sourceStub(action: string): string {
|
|
73
|
+
return `import { appendFileSync } from "node:fs";
|
|
74
|
+
appendFileSync(
|
|
75
|
+
process.env.CLAUDEUP_TEST_LEDGER as string,
|
|
76
|
+
\`SOURCE \${process.argv.slice(2).join(" ")}\\n\`,
|
|
77
|
+
);
|
|
78
|
+
${action}
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeTree(
|
|
83
|
+
opts: {
|
|
84
|
+
/** Shell the binary stub runs after logging. Omit = package absent. */
|
|
85
|
+
binaryAction?: string;
|
|
86
|
+
binaryMode?: number;
|
|
87
|
+
/** Statement the source stub runs after logging. */
|
|
88
|
+
sourceAction?: string;
|
|
89
|
+
} = {},
|
|
90
|
+
): string {
|
|
91
|
+
const dir = realpathSync(
|
|
92
|
+
mkdtempSync(path.join(os.tmpdir(), "claudeup-launcher-")),
|
|
93
|
+
);
|
|
94
|
+
dirs.push(dir);
|
|
95
|
+
|
|
96
|
+
// The launcher is ESM. Without a `type: module` package.json above it, node
|
|
97
|
+
// parses the copy as CJS and dies on the first `import`.
|
|
98
|
+
writeFileSync(
|
|
99
|
+
path.join(dir, "package.json"),
|
|
100
|
+
JSON.stringify({ name: "claudeup", version: "0.0.0", type: "module" }),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
mkdirSync(path.join(dir, "bin"));
|
|
104
|
+
cpSync(REAL_LAUNCHER, path.join(dir, "bin", "claudeup.js"));
|
|
105
|
+
|
|
106
|
+
mkdirSync(path.join(dir, "src"));
|
|
107
|
+
writeFileSync(
|
|
108
|
+
path.join(dir, "src", "main.tsx"),
|
|
109
|
+
sourceStub(opts.sourceAction ?? "process.exit(7);"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (opts.binaryAction !== undefined) {
|
|
113
|
+
const pkgDir = path.join(dir, "node_modules", PKG_NAME);
|
|
114
|
+
mkdirSync(path.join(pkgDir, "bin"), { recursive: true });
|
|
115
|
+
// No "exports" field: the launcher resolves `<pkg>/package.json`, which
|
|
116
|
+
// an exports map would refuse.
|
|
117
|
+
writeFileSync(
|
|
118
|
+
path.join(pkgDir, "package.json"),
|
|
119
|
+
JSON.stringify({ name: PKG_NAME, version: "0.0.0" }),
|
|
120
|
+
);
|
|
121
|
+
const bin = path.join(pkgDir, "bin", "claudeup");
|
|
122
|
+
writeFileSync(
|
|
123
|
+
bin,
|
|
124
|
+
`#!/bin/sh\necho "BINARY $*" >> "$CLAUDEUP_TEST_LEDGER"\n${opts.binaryAction}\n`,
|
|
125
|
+
);
|
|
126
|
+
chmodSync(bin, opts.binaryMode ?? 0o755);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return dir;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function binaryPathIn(dir: string): string {
|
|
133
|
+
return path.join(dir, "node_modules", PKG_NAME, "bin", "claudeup");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Launch {
|
|
137
|
+
status: number | null;
|
|
138
|
+
signal: NodeJS.Signals | null;
|
|
139
|
+
stderr: string;
|
|
140
|
+
ledger: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function launch(
|
|
144
|
+
dir: string,
|
|
145
|
+
args: string[] = [],
|
|
146
|
+
env: Record<string, string> = {},
|
|
147
|
+
): Launch {
|
|
148
|
+
const ledgerPath = path.join(dir, "ledger.txt");
|
|
149
|
+
const result = spawnSync(
|
|
150
|
+
NODE_BIN,
|
|
151
|
+
[path.join(dir, "bin", "claudeup.js"), ...args],
|
|
152
|
+
{
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
// Nothing may reach a TUI renderer: hand the child an empty stdin.
|
|
155
|
+
input: "",
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
CLAUDEUP_NO_BINARY: "",
|
|
159
|
+
CLAUDEUP_TEST_LEDGER: ledgerPath,
|
|
160
|
+
...env,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
const ledger = existsSync(ledgerPath)
|
|
165
|
+
? readFileSync(ledgerPath, "utf8")
|
|
166
|
+
.split("\n")
|
|
167
|
+
.map((line) => line.trim())
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
: [];
|
|
170
|
+
return {
|
|
171
|
+
status: result.status,
|
|
172
|
+
signal: result.signal,
|
|
173
|
+
stderr: result.stderr ?? "",
|
|
174
|
+
ledger,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe("launcher: binary killed by a signal", () => {
|
|
179
|
+
test("T-sig-1: SIGKILL at exec falls back to source and explains why", () => {
|
|
180
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
181
|
+
const r = launch(dir, ["--version"]);
|
|
182
|
+
|
|
183
|
+
// The shipped bug in one line: this was 0.
|
|
184
|
+
expect(r.status).not.toBe(0);
|
|
185
|
+
// 7 is the stub source path's code — nothing else can produce it.
|
|
186
|
+
expect(r.status).toBe(7);
|
|
187
|
+
expect(r.ledger).toEqual(["BINARY --version", "SOURCE --version"]);
|
|
188
|
+
|
|
189
|
+
// A stranded user needs the signal, the file, and a way out.
|
|
190
|
+
expect(r.stderr).toContain("SIGKILL");
|
|
191
|
+
expect(r.stderr).toContain(binaryPathIn(dir));
|
|
192
|
+
expect(r.stderr).toContain("signature");
|
|
193
|
+
expect(r.stderr).toContain("codesign --verify");
|
|
194
|
+
expect(r.stderr).toContain("CLAUDEUP_NO_BINARY=1");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("T-sig-2: SIGINT is the user's own Ctrl-C — exit 130, no fallback", () => {
|
|
198
|
+
const dir = makeTree({ binaryAction: "kill -s INT $$" });
|
|
199
|
+
const r = launch(dir);
|
|
200
|
+
|
|
201
|
+
expect(r.status).toBe(130);
|
|
202
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
203
|
+
expect(r.stderr).toBe("");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("T-sig-3: SIGTERM is exit 143, no fallback", () => {
|
|
207
|
+
const dir = makeTree({ binaryAction: "kill -s TERM $$" });
|
|
208
|
+
const r = launch(dir);
|
|
209
|
+
|
|
210
|
+
expect(r.status).toBe(143);
|
|
211
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("T-sig-4: SIGABRT is a crash, not a user signal — it must fall back", () => {
|
|
215
|
+
// The hole an allowlist of crash signals leaves: a Bun/JSC panic aborts,
|
|
216
|
+
// which is precisely the "binary is broken, use source" case.
|
|
217
|
+
const dir = makeTree({ binaryAction: "kill -s ABRT $$" });
|
|
218
|
+
const r = launch(dir);
|
|
219
|
+
|
|
220
|
+
expect(r.status).toBe(7);
|
|
221
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("T-sig-5: a kill seconds in is deliberate — report it, do not relaunch", () => {
|
|
225
|
+
// A signature kill lands in single-digit milliseconds at exec. A user
|
|
226
|
+
// running `kill -9` on a live TUI, or an OOM kill, lands much later.
|
|
227
|
+
// Without the time gate the fix trades a silent exit 0 for a surprise
|
|
228
|
+
// relaunch of an app the user deliberately killed.
|
|
229
|
+
const dir = makeTree({ binaryAction: "sleep 3\nkill -s KILL $$" });
|
|
230
|
+
const r = launch(dir);
|
|
231
|
+
|
|
232
|
+
expect(r.status).toBe(137);
|
|
233
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
234
|
+
}, 20000);
|
|
235
|
+
|
|
236
|
+
test("T-sig-6: an ordinary non-zero exit passes straight through", () => {
|
|
237
|
+
const dir = makeTree({ binaryAction: "exit 3" });
|
|
238
|
+
const r = launch(dir);
|
|
239
|
+
|
|
240
|
+
expect(r.status).toBe(3);
|
|
241
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("T-sig-7: no platform package — straight to source, silently", () => {
|
|
245
|
+
const dir = makeTree();
|
|
246
|
+
const r = launch(dir);
|
|
247
|
+
|
|
248
|
+
expect(r.status).toBe(7);
|
|
249
|
+
expect(r.ledger).toEqual(["SOURCE"]);
|
|
250
|
+
expect(r.stderr).not.toContain("could not start");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("T-sig-8: an unexecutable binary still falls back and says so", () => {
|
|
254
|
+
const dir = makeTree({ binaryAction: "exit 0", binaryMode: 0o644 });
|
|
255
|
+
const r = launch(dir);
|
|
256
|
+
|
|
257
|
+
expect(r.status).toBe(7);
|
|
258
|
+
expect(r.stderr).toContain("could not start");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("T-sig-9: CLAUDEUP_NO_BINARY=1 skips the binary entirely", () => {
|
|
262
|
+
// The escape hatch the SIGKILL diagnostic advertises has to work.
|
|
263
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
264
|
+
const r = launch(dir, [], { CLAUDEUP_NO_BINARY: "1" });
|
|
265
|
+
|
|
266
|
+
expect(r.ledger.some((line) => line.startsWith("BINARY"))).toBe(false);
|
|
267
|
+
expect(r.status).toBe(7);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("T-sig-10: the source path has the same bug, and nowhere to fall back to", () => {
|
|
271
|
+
const dir = makeTree({
|
|
272
|
+
sourceAction: 'process.kill(process.pid, "SIGKILL");',
|
|
273
|
+
});
|
|
274
|
+
const r = launch(dir);
|
|
275
|
+
|
|
276
|
+
expect(r.status).toBe(137);
|
|
277
|
+
// No binary was involved, so a codesign hint here would be nonsense.
|
|
278
|
+
expect(r.stderr).not.toContain("codesign");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("T-sig-11: argv reaches both the binary and the fallback unchanged", () => {
|
|
282
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
283
|
+
const r = launch(dir, ["--version", "--x"]);
|
|
284
|
+
|
|
285
|
+
expect(r.ledger).toContain("BINARY --version --x");
|
|
286
|
+
expect(r.ledger).toContain("SOURCE --version --x");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// An empty PATH removes bun, so the source fallback cannot start either.
|
|
290
|
+
// This is the terminal state for the population the 6.8.1 fix exists for:
|
|
291
|
+
// claudeup installed from npm on macOS 27, no Bun on the machine.
|
|
292
|
+
const NO_BUN = { PATH: "" };
|
|
293
|
+
|
|
294
|
+
test("T-sig-12: binary crashed AND no Bun — the message must not deny the binary", () => {
|
|
295
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
296
|
+
const r = launch(dir, ["--version"], NO_BUN);
|
|
297
|
+
|
|
298
|
+
expect(r.status).toBe(1);
|
|
299
|
+
// The contradiction: a binary was found, run, and reported killed
|
|
300
|
+
// moments earlier. Saying none exists sends the user down a dead end.
|
|
301
|
+
expect(r.stderr).not.toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
|
|
302
|
+
expect(r.stderr).toContain("unusable on this machine");
|
|
303
|
+
expect(r.stderr).toContain("npm install -g claudeup@latest");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("T-sig-13: no binary AND no Bun — that message is still correct, and kept", () => {
|
|
307
|
+
const dir = makeTree();
|
|
308
|
+
const r = launch(dir, ["--version"], NO_BUN);
|
|
309
|
+
|
|
310
|
+
expect(r.status).toBe(1);
|
|
311
|
+
expect(r.stderr).toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
|
|
312
|
+
expect(r.stderr).not.toContain("unusable on this machine");
|
|
313
|
+
});
|
|
314
|
+
});
|