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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The agent tier table is keyed by agent id, and nothing else checks those ids.
2
+ * The agent tier table and the workflow list are keyed by agent id, and nothing else
3
+ * checks those ids.
3
4
  *
4
5
  * `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
5
6
  * agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
@@ -17,7 +18,11 @@
17
18
  import { describe, expect, test } from "bun:test";
18
19
  import { existsSync, readFileSync, readdirSync } from "node:fs";
19
20
  import { join, resolve } from "node:path";
20
- import { DEFAULT_AGENT_GRADES, VISION_AGENTS } from "../data/models-presets.js";
21
+ import {
22
+ DEFAULT_AGENT_GRADES,
23
+ VISION_AGENTS,
24
+ WORKFLOWS,
25
+ } from "../data/models-presets.js";
21
26
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
22
27
 
23
28
  const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
@@ -100,6 +105,28 @@ describe.skipIf(!inMagusSrc)(
100
105
  expect(dead).toEqual([]);
101
106
  });
102
107
 
108
+ /**
109
+ * (c) `WORKFLOWS` names agent ids too, and until now nothing checked them.
110
+ *
111
+ * These ids drive the per-workflow spread graphic. A stale one matches no agent, so
112
+ * its segment silently vanishes and the chart goes on drawing a confident answer
113
+ * about a workflow it has mis-measured — the same silent-drift failure (a) exists to
114
+ * catch, one array over. Measured: the 13.0.0 `code-analysis` → `code-search` rename
115
+ * reached this array through a rebase conflict rather than through this gate.
116
+ *
117
+ * Deliberately NOT folded into `presetKeys`. Being named in a workflow is not a
118
+ * routing classification, so counting it in (b) would let a workflow mention excuse
119
+ * an agent from ever being given a tier.
120
+ */
121
+ test("(c) every workflow agent names an existing agent or a built-in", () => {
122
+ const workflowKeys = WORKFLOWS.flatMap((w) => [
123
+ ...w.agents,
124
+ ...w.external.map((step) => step.agent),
125
+ ]);
126
+ const dead = workflowKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
127
+ expect(dead).toEqual([]);
128
+ });
129
+
103
130
  test("(b) every marketplace agent is classified somewhere", () => {
104
131
  const classified = new Set<string>([
105
132
  ...presetKeys,
@@ -24,6 +24,11 @@ function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
24
24
  };
25
25
  }
26
26
 
27
+ /** Drop `//` line comments and block comments, so a source guard matches code only. */
28
+ function stripComments(source: string): string {
29
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
30
+ }
31
+
27
32
  /** Navigating away and back is what unmounts the screen. */
28
33
  const roundTrip: AppAction[] = [
29
34
  { type: "NAVIGATE", route: { screen: "plugins" } },
@@ -36,6 +41,9 @@ describe("models screen initial state", () => {
36
41
  expect(initialState.models.searchQuery).toBe("");
37
42
  expect(initialState.models.status).toBeNull();
38
43
  expect(initialState.models.isApplying).toBe(false);
44
+ // The mate slots are not drawn until something says they can be served. Starting
45
+ // true would put three rows on screen before anything had been asked.
46
+ expect(initialState.models.matesAvailable).toBe(false);
39
47
  });
40
48
 
41
49
  test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
@@ -194,6 +202,44 @@ describe("apply-in-flight state", () => {
194
202
  });
195
203
  });
196
204
 
205
+ describe("the mate slots' availability", () => {
206
+ test("survives the screen unmounting, so the lookup happens once a session", () => {
207
+ // Unlike the two file reads this screen does on mount, this answer costs a
208
+ // marketplace resolution with network timeouts behind it. Held locally it would die
209
+ // on every tab switch and be paid for again on every return — for something that
210
+ // cannot change while the TUI is open.
211
+ const available = run([
212
+ { type: "MODELS_MATES_AVAILABLE", available: true },
213
+ ]);
214
+ expect(available.models.matesAvailable).toBe(true);
215
+ expect(run(roundTrip, available).models.matesAvailable).toBe(true);
216
+ });
217
+
218
+ test("a false answer is recorded, not treated as no answer", () => {
219
+ // "Not installed" and "never asked" render the same, and that is deliberate — but
220
+ // the reducer still has to hold the difference, or a later dispatch could not undo it.
221
+ const state = run([
222
+ { type: "MODELS_MATES_AVAILABLE", available: true },
223
+ { type: "MODELS_MATES_AVAILABLE", available: false },
224
+ ]);
225
+ expect(state.models.matesAvailable).toBe(false);
226
+ });
227
+
228
+ test("it touches no other part of the models state", () => {
229
+ const before = run([
230
+ { type: "MODELS_SELECT", index: 2 },
231
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
232
+ ]);
233
+ const after = run(
234
+ [{ type: "MODELS_MATES_AVAILABLE", available: true }],
235
+ before,
236
+ );
237
+ expect(after.models.selectedIndex).toBe(2);
238
+ expect(after.models.data).toBe(before.models.data);
239
+ expect(after.styles).toBe(before.styles);
240
+ });
241
+ });
242
+
197
243
  describe("selection state", () => {
198
244
  test("select records the index verbatim", () => {
199
245
  expect(
@@ -253,7 +299,17 @@ describe("ModelsScreen keybindings", () => {
253
299
  test("screen state is held in the reducer, never in useState", async () => {
254
300
  // The rule the two "survives unmounting" tests above enforce at runtime,
255
301
  // pinned at the source so a new piece of state cannot quietly opt out.
256
- const src = await fs.readFile(SCREEN, "utf8");
302
+ //
303
+ // Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
304
+ // is about CODE, and this screen documents why each piece of its state lives in the
305
+ // reducer — naming the hook it is deliberately not using is the clearest way to say
306
+ // that, and a guard reading the prose would forbid the explanation along with the
307
+ // thing explained.
308
+ const src = stripComments(await fs.readFile(SCREEN, "utf8"));
257
309
  expect(src).not.toContain("useState");
310
+ // Negative control: stripping must not have eaten the code being guarded. A guard
311
+ // over an empty string passes forever.
312
+ expect(src).toContain("useEffect");
313
+ expect(src).toContain("useCallback");
258
314
  });
259
315
  });
package/src/cli/doctor.ts CHANGED
@@ -21,7 +21,6 @@ import { readManifest } from "../services/manifest.js";
21
21
  import {
22
22
  readModelsConfig,
23
23
  readModelsStatus,
24
- reapplyModels,
25
24
  } from "../services/models-manager.js";
26
25
  import { resolveAllProfiles } from "../services/resolver.js";
27
26
  import { activeProfile, profileDir } from "../services/symlink-manager.js";
@@ -172,19 +171,15 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
172
171
  );
173
172
  }
174
173
 
174
+ // No drift check. Settings differing from the config is what a CUSTOM config is, and
175
+ // `doctor` reports problems — a project running models no shipped preset names has none.
176
+ // So `doctor --fix` no longer reapplies anything: `invalid` returns early above (those
177
+ // are choices, not damage) and `unhooked` is repaired by registering the hook. Writing
178
+ // settings back from the config is `claudeup models use <preset>`, which is a decision
179
+ // the user makes, not a repair.
175
180
  const status = await readModelsStatus(projectPath);
176
- if (status.state === "stale") {
177
- console.log(" settings have drifted from the config:");
178
- for (const line of status.drift) console.log(` ${line}`);
179
- if (fix) {
180
- await reapplyModels(projectPath);
181
- console.log(" Re-applied the config (fixed).");
182
- } else {
183
- console.log(" Fix: claudeup doctor --fix");
184
- problems++;
185
- }
186
- } else if (status.state === "on") {
187
- console.log(` ✓ preset "${status.preset}" applied, no drift.`);
181
+ if (status.state === "on") {
182
+ console.log(" routing is applied.");
188
183
  }
189
184
 
190
185
  for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
package/src/cli/models.ts CHANGED
@@ -18,10 +18,20 @@ import {
18
18
  presetLabel,
19
19
  presetNames,
20
20
  } from "../data/models-presets.js";
21
+ import {
22
+ loadMateCatalog,
23
+ stalenessNote,
24
+ unknownBindings,
25
+ } from "../services/mate-catalog.js";
21
26
  import {
22
27
  GRADES,
23
28
  type ModelsConfig,
24
29
  type ModelsState,
30
+ boundModel,
31
+ isMate,
32
+ mateEffort,
33
+ matesInUse,
34
+ sameRouting,
25
35
  } from "../services/models-core.js";
26
36
  import {
27
37
  applyModelPreset,
@@ -35,13 +45,34 @@ import { ensureManifest } from "./bootstrap.js";
35
45
  const HEADLINE: Record<ModelsState, string> = {
36
46
  off: "off — no .claude/models.json, subagents inherit the session model",
37
47
  on: "on",
38
- stale: "stale — settings no longer match the config",
39
48
  invalid: "invalid — nothing is routed",
40
49
  unhooked: "unhooked — the config is there, but nothing runs it",
41
50
  };
42
51
 
43
- /** `opus (medium)`, or `opus` when no effort is set. */
44
- function describeSpec(spec: { model: string; effort?: string }): string {
52
+ /**
53
+ * `opus (medium)`, or `opus` when no effort is set.
54
+ *
55
+ * A mate prints the SLOT and what it is bound to — `mate1 → grok-4.6` — because both are
56
+ * answers to different questions the reader has: which role this tier plays, and which model
57
+ * plays it. The slot alone was all that could be printed before the `mates` block existed,
58
+ * and a bare `kangaroo` in a column of `opus (xhigh)` read as a model id nobody recognises.
59
+ *
60
+ * An effort on a mate is qualified `(declared)`, for the reason spelled out at
61
+ * `DECLARED_SUFFIX`: claudeup writes it for claudish and nothing applies it yet, so printing
62
+ * a bare `(high)` beside a tier where `(high)` IS in force would claim they do the same
63
+ * thing.
64
+ */
65
+ function describeSpec(
66
+ config: ModelsConfig,
67
+ spec: { model: string; effort?: string },
68
+ ): string {
69
+ if (isMate(spec.model)) {
70
+ const bound = boundModel(config, spec.model);
71
+ const effort = spec.effort ?? mateEffort(config, spec.model);
72
+ const target = bound ?? "unbound";
73
+ const note = effort ? `${effort} declared, via claudish` : "via claudish";
74
+ return `${spec.model} → ${target} (${note})`;
75
+ }
45
76
  return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
46
77
  }
47
78
 
@@ -58,24 +89,82 @@ function printPreset(preset: ModelsConfig, active: boolean): void {
58
89
  console.log(
59
90
  `${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
60
91
  );
61
- console.log(` main ${describeSpec(preset.main)}`);
92
+ console.log(` main ${describeSpec(preset, preset.main)}`);
62
93
  for (const grade of GRADES) {
63
94
  console.log(
64
- ` ${grade.padEnd(10)} ${describeSpec(preset.grades[grade])}`,
95
+ ` ${grade.padEnd(10)} ${describeSpec(preset, preset.grades[grade])}`,
65
96
  );
66
97
  }
67
98
  }
68
99
 
69
100
  async function runList(projectPath: string): Promise<number> {
70
101
  const { config } = await readModelsConfig(projectPath);
102
+
103
+ // Has the project diverged from the built-in it NAMES?
104
+ //
105
+ // The test used to be "does it name a preset the built-ins do not", which misses the
106
+ // ordinary case: `claudeup models use opus-lead` writes that name, and every hand edit
107
+ // afterwards leaves it in place. So a config with a `mates` block was reported as the
108
+ // built-in `opus-lead`, and the `●` sat on a preset that is not what runs. Compared by
109
+ // CONTENT, shared with the TUI through `sameRouting` so the two cannot disagree about
110
+ // which routing is in force.
111
+ const builtInNamed = BUILT_IN_PRESETS.find(
112
+ (preset) => preset.preset === config?.preset,
113
+ );
114
+ const diverged =
115
+ config !== null && (!builtInNamed || !sameRouting(config, builtInNamed));
116
+
71
117
  console.log("\nModel tiers:\n");
72
118
  for (const preset of BUILT_IN_PRESETS) {
73
- printPreset(preset, config?.preset === preset.preset);
119
+ printPreset(preset, !diverged && config?.preset === preset.preset);
74
120
  console.log();
75
121
  }
76
- if (config && !presetNames().includes(config.preset)) {
77
- console.log(`● ${config.preset} (this project's own, not a built-in)\n`);
122
+ if (config && diverged) {
123
+ // The project's own config gets its tiers PRINTED, not just its name.
124
+ //
125
+ // It is the routing actually in force, and it is the only config here that can name a
126
+ // mate — no built-in does — so printing the name alone hid the one line a reader came
127
+ // for. The built-ins above are shown in full; showing the active one in less detail
128
+ // than the ones it replaced was backwards.
129
+ printPreset(config, true);
130
+ console.log(" (this project's own, not a built-in)\n");
78
131
  }
132
+
133
+ // Which agents leave Claude Code entirely.
134
+ //
135
+ // `models list` prints tiers, never agents, so a mate assigned per-agent — the ordinary
136
+ // way to use one — appears nowhere else in this command's output. It is also the one
137
+ // routing decision here that claudeup does not carry out itself, which makes it exactly
138
+ // the thing worth naming rather than leaving to be discovered in the file.
139
+ const mated = Object.entries(config?.agents ?? {}).filter(
140
+ ([, assignment]) =>
141
+ typeof assignment !== "string" && isMate(assignment.model),
142
+ );
143
+ if (config && mated.length > 0) {
144
+ console.log("Routed outside Claude Code, through claudish:\n");
145
+ for (const [agent, assignment] of mated) {
146
+ const slot =
147
+ typeof assignment === "string" ? assignment : assignment.model;
148
+ // The SLOT and the model it names. The slot is what the file says and what the
149
+ // reader greps for; the bound id is what actually runs. Printing only the slot was
150
+ // the CLI's version of the screen's `mate1 mate1 —` — a role where a model belongs.
151
+ const bound = isMate(slot) ? boundModel(config, slot) : null;
152
+ const target = bound ?? "unbound — add it to the mates block";
153
+ console.log(` ${agent.padEnd(28)} ${slot.padEnd(10)} ${target}`);
154
+ }
155
+ console.log();
156
+
157
+ // The staleness advisory, on the same terms as everywhere else: ADVISORY, and silent
158
+ // when the catalogue could not be read. `unknownBindings` returns nothing for an empty
159
+ // catalogue, so a machine without claudish prints no note rather than flagging every
160
+ // binding it cannot check.
161
+ const bound = matesInUse(config)
162
+ .map((mate) => boundModel(config, mate))
163
+ .filter((id): id is string => id !== null);
164
+ const note = stalenessNote(unknownBindings(bound, await loadMateCatalog()));
165
+ if (note) console.log(`⚠ ${note}\n`);
166
+ }
167
+
79
168
  console.log(
80
169
  "Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
81
170
  );
@@ -97,11 +186,6 @@ async function runStatus(projectPath: string): Promise<number> {
97
186
  if (status.drift.length > 0) {
98
187
  console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
99
188
  for (const line of status.drift) console.log(` ✗ ${line}`);
100
- if (status.state === "stale") {
101
- console.log(
102
- "\n Fix: claudeup models use <preset> (or claudeup doctor --fix)",
103
- );
104
- }
105
189
  if (status.state === "unhooked")
106
190
  console.log("\n Fix: claudeup doctor --fix");
107
191
  }
@@ -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
  }
@@ -13,7 +13,7 @@
13
13
  * `medium`) rather than a harder-working one. What holds everywhere is the other half: the
14
14
  * thread dispatching the work is never strictly the biggest spend in the preset.
15
15
  */
16
- import type { Grade, ModelsConfig } from "../services/models-core.js";
16
+ import type { Effort, Grade, ModelsConfig } from "../services/models-core.js";
17
17
 
18
18
  /**
19
19
  * Which tier each subagent gets, shared by every preset.
@@ -70,8 +70,43 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
70
70
  * Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
71
71
  * also name skills (`dev:context-detection`, `code-search:investigate`) which are not
72
72
  * dispatched through the Agent tool and so are not routed at all.
73
+ *
74
+ * ## `external` — the steps that leave Claude Code, whatever the preset says
75
+ *
76
+ * A workflow's use of an outside model is a property of the WORKFLOW, not of the routing
77
+ * config. `/dev:dev` runs a multi-model plan review at Phase 3 and a multi-model code review
78
+ * at Phase 5 because its command file says so, and switching from `Sonnet` to `Opus main`
79
+ * does not change that. So these steps are declared here beside the agents and are drawn
80
+ * under every preset, rather than being read out of `config.agents` — where they would
81
+ * appear only for a project that happened to have hand-bound a slot.
82
+ *
83
+ * Read out of the same command files:
84
+ * `plugins/dev/commands/dev.md:138` Plan review (external models via claudish) → dev:architect
85
+ * `plugins/dev/commands/dev.md:140` Code review (external models via claudish) → dev:architect
86
+ *
87
+ * `debug.md` and `investigate.md` contain no claudish reference at all, so both are empty
88
+ * here — and that absence is worth drawing, because "this flow stays inside Claude Code" is
89
+ * exactly as useful to know as the opposite.
90
+ *
91
+ * A STEP, not an agent: `dev:architect` appears in `agents` above as well, because the same
92
+ * agent does the Phase 3 planning on a Claude model. Marking the agent external would be
93
+ * wrong in both directions — it would move the planning outside and merge the two reviews
94
+ * into it.
73
95
  */
74
- export const WORKFLOWS: { name: string; agents: string[] }[] = [
96
+ export interface WorkflowStep {
97
+ /** What the phase is called in the command file, for the reader who goes looking. */
98
+ step: string;
99
+ /** The agent the step dispatches. Recorded for provenance; routing is claudish's. */
100
+ agent: string;
101
+ }
102
+
103
+ export interface Workflow {
104
+ name: string;
105
+ agents: string[];
106
+ external: WorkflowStep[];
107
+ }
108
+
109
+ export const WORKFLOWS: Workflow[] = [
75
110
  {
76
111
  name: "dev",
77
112
  agents: [
@@ -81,6 +116,10 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
81
116
  "dev:qa-engineer",
82
117
  "dev:reviewer",
83
118
  ],
119
+ external: [
120
+ { step: "plan review", agent: "dev:architect" },
121
+ { step: "code review", agent: "dev:architect" },
122
+ ],
84
123
  },
85
124
  {
86
125
  name: "debug",
@@ -90,10 +129,12 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
90
129
  "dev:debugger",
91
130
  "dev:developer",
92
131
  ],
132
+ external: [],
93
133
  },
94
134
  {
95
135
  name: "investigate",
96
136
  agents: ["code-search:analyze", "dev:researcher"],
137
+ external: [],
97
138
  },
98
139
  ];
99
140
 
@@ -113,10 +154,25 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
113
154
  */
114
155
  export const VISION_AGENTS = ["designer:review", "designer:ui"] as const;
115
156
 
157
+ /**
158
+ * The effort the external slots run at, per preset.
159
+ *
160
+ * A preset cannot say WHICH model serves a mate — a catalogue id shipped in this repo goes
161
+ * stale, which is the whole reason bindings live in a project's own `mates` block. It can say
162
+ * how hard that work should try, and that half does not depend on which model answers.
163
+ *
164
+ * `xhigh` almost everywhere: a slot is reached for the judgement calls — plan review, code
165
+ * review — and those are the calls worth spending on whichever model takes them. `Sonnet` is
166
+ * the exception because it is the economy preset, and a preset whose whole point is to spend
167
+ * less would be lying if the work it sent outside ignored that.
168
+ */
169
+ const MATE_EFFORT_DEFAULT: Effort = "xhigh";
170
+
116
171
  function preset(
117
172
  name: string,
118
173
  main: ModelsConfig["main"],
119
174
  grades: ModelsConfig["grades"],
175
+ mateEffort: Effort = MATE_EFFORT_DEFAULT,
120
176
  ): ModelsConfig {
121
177
  return {
122
178
  version: 1,
@@ -125,6 +181,7 @@ function preset(
125
181
  grades,
126
182
  agents: { ...DEFAULT_AGENT_GRADES },
127
183
  fallback: "normal",
184
+ mateEffort,
128
185
  };
129
186
  }
130
187
 
@@ -247,6 +304,8 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
247
304
  // effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
248
305
  // including `cheap` — so the hook still pins the model (a session started on opus does
249
306
  // not leak into its subagents) while the tier table itself makes no distinction.
307
+ // The one preset whose mates run at `medium` rather than `xhigh`: an economy preset that
308
+ // spent freely the moment work left Claude Code would not be one.
250
309
  preset(
251
310
  "sonnet-economy",
252
311
  { model: "sonnet", effort: "xhigh" },
@@ -255,6 +314,7 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
255
314
  normal: { model: "sonnet", effort: "xhigh" },
256
315
  cheap: { model: "sonnet", effort: "xhigh" },
257
316
  },
317
+ "medium",
258
318
  ),
259
319
  ];
260
320
 
@@ -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
+ }