claudeup 6.8.0 → 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 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
- // A binary that could not be executed AT ALL (ENOENT, EACCES, bad arch)
41
- // leaves status null, and `status ?? 0` then reported SUCCESS for a run that
42
- // never happened — the worst answer available. Only trust the status when the
43
- // process actually ran; otherwise say so and fall through to the source path.
44
- if (!result.error) process.exit(result.status ?? 0);
45
- console.error(
46
- `claudeup: prebuilt binary could not start (${result.error.message}); falling back to source.`,
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: no prebuilt binary for ${platform}-${arch}, and Bun is not installed.`,
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.status ?? 0);
170
+ process.exit(exitCodeForSignal(bunRun.signal));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "6.8.0",
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.8.0",
68
- "claudeup-darwin-x64": "6.8.0",
69
- "claudeup-linux-x64": "6.8.0"
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
- await rm(distRoot, { recursive: true, force: true });
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
+ });
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { spawn } from "node:child_process";
14
+ import { constants } from "node:os";
14
15
 
15
16
  export async function runUpgradeCommand(): Promise<number> {
16
17
  const { execSync } = await import("node:child_process");
@@ -85,7 +86,12 @@ export async function runUpgradeCommand(): Promise<number> {
85
86
  stdio: "inherit",
86
87
  shell: false, // Avoid shell for security (fixes DEP0190 warning)
87
88
  });
88
- proc.on("exit", (code) => resolve(code ?? 0));
89
+ // `code ?? 0` is the same defect 6.8.1 removed from bin/claudeup.js: a
90
+ // package manager killed by a signal delivers code null, and reporting
91
+ // that upgrade as a success is the worst answer available.
92
+ proc.on("exit", (code, signal) =>
93
+ resolve(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)),
94
+ );
89
95
  proc.on("error", () => resolve(1));
90
96
  });
91
97
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Code-sign a compiled claudeup binary, and refuse to ship one that does not
3
+ * verify.
4
+ *
5
+ * `bun build --compile` produces a Mach-O carrying an ad-hoc, linker-signed
6
+ * signature (`flags=0x20002(adhoc,linker-signed)`) and then appends the
7
+ * JavaScript bundle to the end of the file. The signature therefore describes
8
+ * a shorter file than the one that ships. `codesign -v` has reported
9
+ * "invalid signature (code or signature have been modified)" on every
10
+ * published claudeup binary back to 4.22.0.
11
+ *
12
+ * macOS 27 refuses to exec such a binary: SIGKILL at startup, no output. The
13
+ * build never signed anything, so nothing caught it — `codesign` appeared
14
+ * nowhere in this package.
15
+ *
16
+ * Signing is ad-hoc (`--sign -`) rather than Developer ID on purpose: files
17
+ * installed by npm are not quarantined, so Gatekeeper never evaluates them.
18
+ * The kernel's signature check is the only gate that was failing, and an
19
+ * ad-hoc signature satisfies it — with no Apple account, no CI secrets and no
20
+ * notarization wait.
21
+ */
22
+
23
+ import { spawnSync } from "node:child_process";
24
+
25
+ /** Result of one external command. Injected so the logic is testable. */
26
+ export type Run = (
27
+ cmd: string,
28
+ args: string[],
29
+ ) => { status: number | null; stderr: string };
30
+
31
+ const realRun: Run = (cmd, args) => {
32
+ const result = spawnSync(cmd, args, { encoding: "utf8" });
33
+ // Fail closed and say why. A missing `codesign` must stop the build, not
34
+ // quietly skip signing — and the raw ENOENT names neither the binary being
35
+ // signed nor the reason signing is mandatory.
36
+ if (result.error) {
37
+ throw new Error(
38
+ `Could not run \`${cmd}\` while signing ${args[args.length - 1]}: ${result.error.message}\nSigning is mandatory for darwin binaries — macOS kills an unsigned one at exec.`,
39
+ );
40
+ }
41
+ return { status: result.status, stderr: result.stderr ?? "" };
42
+ };
43
+
44
+ /**
45
+ * Only darwin. `codesign` does not exist elsewhere, and no other platform
46
+ * validates a signature at exec.
47
+ */
48
+ export const needsSigning = (target: { os: string }): boolean =>
49
+ target.os === "darwin";
50
+
51
+ /**
52
+ * Sign the binary, then verify it. A failing verify THROWS.
53
+ *
54
+ * This is a gate, not an advisory. The whole defect being fixed is a release
55
+ * that shipped a binary whose signature did not match it; a warning here would
56
+ * reproduce that exactly, one log line louder.
57
+ */
58
+ export function signAndVerify(
59
+ target: { os: string },
60
+ binPath: string,
61
+ run: Run = realRun,
62
+ ): void {
63
+ if (!needsSigning(target)) return;
64
+
65
+ const signed = run("codesign", ["--force", "--sign", "-", binPath]);
66
+ if (signed.status !== 0) {
67
+ throw new Error(
68
+ `codesign --sign failed for ${binPath} (exit ${signed.status})\n${signed.stderr}`,
69
+ );
70
+ }
71
+
72
+ const verified = run("codesign", ["--verify", "--strict", binPath]);
73
+ if (verified.status !== 0) {
74
+ throw new Error(
75
+ `codesign --verify --strict rejected ${binPath} (exit ${verified.status}).\nRefusing to ship a binary macOS will kill at exec.\n${verified.stderr}`,
76
+ );
77
+ }
78
+ }