@openparachute/vault 0.6.5-rc.2 → 0.6.5

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.
Files changed (70) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +1 -1
  6. package/core/src/notes.ts +33 -15
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/schema.ts +5 -5
  9. package/core/src/seed-packs.test.ts +357 -0
  10. package/core/src/seed-packs.ts +823 -0
  11. package/core/src/sql-in.test.ts +58 -0
  12. package/core/src/sql-in.ts +76 -0
  13. package/core/src/tag-hierarchy.ts +1 -1
  14. package/core/src/tag-schemas.ts +2 -2
  15. package/core/src/transcription/provider.ts +141 -0
  16. package/core/src/types.ts +1 -1
  17. package/core/src/vault-projection.ts +1 -1
  18. package/core/src/wikilinks.ts +10 -4
  19. package/package.json +1 -1
  20. package/src/add-pack.test.ts +142 -0
  21. package/src/admin-spa.ts +2 -1
  22. package/src/auth.ts +1 -1
  23. package/src/cli.ts +806 -7
  24. package/src/export-watch.ts +1 -1
  25. package/src/live-frame-parity.test.ts +201 -0
  26. package/src/module-manifest.ts +8 -0
  27. package/src/onboarding-seed.test.ts +188 -40
  28. package/src/onboarding-seed.ts +41 -46
  29. package/src/routes.ts +20 -3
  30. package/src/routing.test.ts +2 -2
  31. package/src/routing.ts +18 -6
  32. package/src/self-register.test.ts +19 -0
  33. package/src/self-register.ts +6 -1
  34. package/src/server.ts +133 -31
  35. package/src/services-manifest.ts +8 -0
  36. package/src/subscriptions.ts +100 -42
  37. package/src/tag-scope.ts +3 -3
  38. package/src/test-support/live-frame-corpus.ts +72 -0
  39. package/src/token-store.ts +2 -2
  40. package/src/transcription/build.test.ts +305 -0
  41. package/src/transcription/build.ts +332 -0
  42. package/src/transcription/capability.test.ts +118 -0
  43. package/src/transcription/capability.ts +96 -0
  44. package/src/transcription/download.test.ts +101 -0
  45. package/src/transcription/download.ts +71 -0
  46. package/src/transcription/install-python.test.ts +366 -0
  47. package/src/transcription/install-python.ts +471 -0
  48. package/src/transcription/install.test.ts +167 -0
  49. package/src/transcription/install.ts +296 -0
  50. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  51. package/src/transcription/providers/onnx-asr.ts +239 -0
  52. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  53. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  54. package/src/transcription/providers/scribe-http.test.ts +195 -0
  55. package/src/transcription/providers/scribe-http.ts +144 -0
  56. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  57. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  58. package/src/transcription/select.test.ts +299 -0
  59. package/src/transcription/select.ts +397 -0
  60. package/src/transcription/tiers.test.ts +197 -0
  61. package/src/transcription/tiers.ts +184 -0
  62. package/src/transcription-worker.test.ts +44 -0
  63. package/src/transcription-worker.ts +57 -122
  64. package/src/vault-create.test.ts +43 -10
  65. package/src/vault.test.ts +49 -1
  66. package/src/ws-server.ts +408 -0
  67. package/src/ws-subscribe.test.ts +474 -0
  68. package/src/ws-subscribe.ts +242 -0
  69. package/src/subscribe.test.ts +0 -609
  70. package/src/subscribe.ts +0 -248
package/src/tag-scope.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Tag-scope enforcement for tag-scoped tokens (patterns/tag-scoped-tokens.md).
2
+ * Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
3
3
  *
4
4
  * A token's `scoped_tags` allowlist narrows its effective access to notes
5
5
  * carrying one of the allowlisted tags or a sub-tag thereof. The expansion
6
6
  * to descendants happens via the per-vault `_tags/<name>` config-note
7
7
  * hierarchy (see core/src/tag-hierarchy.ts).
8
8
  *
9
- * Auth check pseudocode (from patterns/tag-scoped-tokens.md):
9
+ * Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
10
10
  *
11
11
  * if (!hasScope(token, ...)) return forbidden();
12
12
  * if (token.scoped_tags === null) return ok(); // unscoped
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
38
38
 
39
39
  /**
40
40
  * Return true iff the note's tag set intersects the expanded allowlist OR
41
- * — fail-open per patterns/tag-scoped-tokens.md §Storage — any of the
41
+ * — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
42
42
  * note's tags has a string-form root inside `rawRoots`. The string-form
43
43
  * fallback covers the orphan-sub-tag case: a token allowlisted for
44
44
  * `health` should still see `#health/food` even when no `_tags/health/food`
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared live-query frame corpus — MIRRORED VERBATIM from the cloud door's
3
+ * `parachute-cloud/workers/vault/test/fixtures/live-frame-corpus.ts` (the SINGLE
4
+ * source of truth). Kept byte-identical here so the self-host WS/SSE parity test
5
+ * asserts against the EXACT same fixture the cloud door pins — the load-bearing
6
+ * cross-door conformance check (WS-hibernation migration, 2026-07-04). If the
7
+ * canonical corpus changes upstream, re-mirror this file.
8
+ *
9
+ * Each case is a transport-neutral `(event, data)` tuple. The invariant under
10
+ * test: for a given case, the SSE `data:` body and the WS message carry a
11
+ * byte-IDENTICAL serialization of the inner payload (`notes` / `note` / `id`) —
12
+ * the ONLY difference is that the WS message folds the SSE `event:` NAME into a
13
+ * `type` discriminator (`{ type, ...data }`), because a WebSocket message has no
14
+ * separate event-name framing. The snapshot case additionally chunks with a
15
+ * `done` flag on the WS side (transport framing under the ~1 MiB message cap);
16
+ * its note OBJECTS still serialize identically.
17
+ *
18
+ * The notes deliberately include the drift-prone bits: unicode, nested metadata,
19
+ * a null value, an empty array, quotes/newlines in content, and stable key
20
+ * ordering — anything a careless re-serialization would reorder or mangle.
21
+ */
22
+
23
+ /** A note-shaped payload (loosely typed — the parity test cares about bytes, not
24
+ * the full Note interface; the wire carries whatever the store returns). */
25
+ export interface CorpusNote {
26
+ id: string;
27
+ path: string;
28
+ content: string;
29
+ tags: string[];
30
+ metadata: Record<string, unknown>;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ extension: string;
34
+ }
35
+
36
+ export const NOTE_A: CorpusNote = {
37
+ id: "11111111-1111-4111-8111-111111111111",
38
+ path: "greetings/héllo",
39
+ content: 'line one\nline "two" with quotes\n#greeting [[world]]',
40
+ tags: ["greeting", "watch"],
41
+ metadata: { mood: "warm", priority: 5, pinned: true, note: null, aliases: [] },
42
+ createdAt: "2026-07-04T00:00:00.000Z",
43
+ updatedAt: "2026-07-04T00:00:01.000Z",
44
+ extension: "md",
45
+ };
46
+
47
+ export const NOTE_B: CorpusNote = {
48
+ id: "22222222-2222-4222-8222-222222222222",
49
+ path: "meetings/2026-07-04",
50
+ content: "standup — ☂️ parachute cloud",
51
+ tags: ["meeting"],
52
+ metadata: { attendees: ["aaron", "uni"], count: 2 },
53
+ createdAt: "2026-07-04T09:00:00.000Z",
54
+ updatedAt: "2026-07-04T09:30:00.000Z",
55
+ extension: "md",
56
+ };
57
+
58
+ export interface FrameCase {
59
+ name: string;
60
+ event: "snapshot" | "upsert" | "remove";
61
+ /** The inner payload — the SSE `data:` body is exactly `JSON.stringify(data)`;
62
+ * the WS message is `JSON.stringify({ type: event, ...data })`. */
63
+ data: Record<string, unknown>;
64
+ }
65
+
66
+ export const FRAME_CORPUS: FrameCase[] = [
67
+ { name: "snapshot (two notes)", event: "snapshot", data: { notes: [NOTE_A, NOTE_B] } },
68
+ { name: "snapshot (empty set)", event: "snapshot", data: { notes: [] } },
69
+ { name: "upsert (rich note)", event: "upsert", data: { note: NOTE_A } },
70
+ { name: "upsert (unicode note)", event: "upsert", data: { note: NOTE_B } },
71
+ { name: "remove (id)", event: "remove", data: { id: NOTE_A.id } },
72
+ ];
@@ -54,7 +54,7 @@ export interface Token {
54
54
  * access is the intersection of `scopes` and notes carrying one of these
55
55
  * tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
56
56
  * NULL = unscoped, full vault access per `scopes`. See
57
- * patterns/tag-scoped-tokens.md.
57
+ * docs/contracts/tag-scoped-tokens.md.
58
58
  */
59
59
  scoped_tags: string[] | null;
60
60
  /**
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
301
301
  * Returns display ID + label pairs (no token-hash exposure) so error
302
302
  * envelopes can name the offending tokens for the operator. The match is
303
303
  * exact on the root name — `scoped_tags` only ever stores roots per
304
- * patterns/tag-scoped-tokens.md.
304
+ * docs/contracts/tag-scoped-tokens.md.
305
305
  */
306
306
  export function findTokensReferencingTag(
307
307
  db: Database,
@@ -0,0 +1,305 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { join } from "path";
3
+ import {
4
+ buildTranscribeCli,
5
+ buildCompileCommand,
6
+ detectCompiler,
7
+ compilerInstallHint,
8
+ cliSourceUrl,
9
+ patchMainCppBackendInit,
10
+ BACKEND_INIT_PATCH_ANCHOR,
11
+ BACKEND_INIT_PATCH_MARKER,
12
+ CLI_SOURCE_FILES,
13
+ CXX_CANDIDATES,
14
+ TRANSCRIBE_CPP_SOURCE_REF,
15
+ type BuildCliDeps,
16
+ type CompileResult,
17
+ } from "./build.ts";
18
+
19
+ /**
20
+ * transcribe-cli build tests (scribe-fold Phase 2b). Pure recipe shape +
21
+ * fully-mocked orchestration — NO network, NO real compiler (the real compile
22
+ * is exercised only by the install-time live verify). Covers the toolchain-
23
+ * absent, fetch-failure, patch-failure, compile-failure, and success branches,
24
+ * and the "never leave a half-built binary" invariant.
25
+ */
26
+
27
+ /** A minimal stand-in for the pinned main.cpp: log-sink install + the batch-
28
+ * mode comment the vault#534 patch anchors on. */
29
+ const MAIN_CPP_FIXTURE = [
30
+ "int main(int argc, char ** argv) {",
31
+ " if (!args.quiet) {",
32
+ " transcribe_log_set(log_cb, nullptr);",
33
+ " }",
34
+ "",
35
+ BACKEND_INIT_PATCH_ANCHOR,
36
+ " // the model ONCE and reuses the context across all files.",
37
+ " if (!args.batch_file.empty()) {",
38
+ " }",
39
+ "}",
40
+ "",
41
+ ].join("\n");
42
+
43
+ describe("detectCompiler", () => {
44
+ test("returns the first candidate found (c++ preferred)", () => {
45
+ const which = (c: string) => (c === "c++" ? "/usr/bin/c++" : null);
46
+ expect(detectCompiler(which)).toBe("/usr/bin/c++");
47
+ });
48
+ test("falls through to a later candidate when earlier ones are absent", () => {
49
+ const which = (c: string) => (c === "g++" ? "/usr/bin/g++" : null);
50
+ expect(detectCompiler(which)).toBe("/usr/bin/g++");
51
+ });
52
+ test("returns null when NO compiler is on PATH", () => {
53
+ expect(detectCompiler(() => null)).toBeNull();
54
+ expect(detectCompiler(() => undefined)).toBeNull();
55
+ });
56
+ test("probes the documented candidate order", () => {
57
+ expect([...CXX_CANDIDATES]).toEqual(["c++", "clang++", "g++"]);
58
+ });
59
+ });
60
+
61
+ describe("compilerInstallHint", () => {
62
+ test("macOS → xcode-select", () => {
63
+ expect(compilerInstallHint("darwin")).toContain("xcode-select --install");
64
+ });
65
+ test("linux → build-essential / gcc-c++", () => {
66
+ const h = compilerInstallHint("linux");
67
+ expect(h).toContain("build-essential");
68
+ expect(h).toContain("gcc-c++");
69
+ });
70
+ test("other → generic compiler hint", () => {
71
+ expect(compilerInstallHint("win32")).toContain("c++/clang++/g++");
72
+ });
73
+ });
74
+
75
+ describe("cliSourceUrl + CLI_SOURCE_FILES", () => {
76
+ test("URL pins the exact v0.1.1 source ref", () => {
77
+ const url = cliSourceUrl("examples/cli/main.cpp");
78
+ expect(url).toContain("raw.githubusercontent.com/handy-computer/transcribe.cpp");
79
+ expect(url).toContain(TRANSCRIBE_CPP_SOURCE_REF);
80
+ expect(url.endsWith("/examples/cli/main.cpp")).toBe(true);
81
+ });
82
+ test("source set covers the CLI + WAV loader + public header", () => {
83
+ expect(CLI_SOURCE_FILES).toContain("examples/cli/main.cpp");
84
+ expect(CLI_SOURCE_FILES).toContain("examples/common/wav.cpp");
85
+ expect(CLI_SOURCE_FILES).toContain("examples/common/wav.h");
86
+ expect(CLI_SOURCE_FILES).toContain("examples/common/dr_wav.h");
87
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe.h");
88
+ // headers main.cpp includes directly
89
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/parakeet.h");
90
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/whisper.h");
91
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/voxtral_realtime.h");
92
+ });
93
+ });
94
+
95
+ describe("buildCompileCommand — the proven recipe shape", () => {
96
+ const base = {
97
+ compiler: "/usr/bin/c++",
98
+ srcDir: "/root/.parachute/transcription/.src",
99
+ libsDir: "/root/.parachute/transcription/libs",
100
+ outPath: "/root/.parachute/transcription/bin/transcribe-cli",
101
+ };
102
+
103
+ test("macOS → @loader_path/../libs rpath", () => {
104
+ const cmd = buildCompileCommand({ ...base, platform: "darwin" });
105
+ expect(cmd[0]).toBe("/usr/bin/c++");
106
+ expect(cmd).toContain("-std=c++17");
107
+ expect(cmd).toContain(`-I${join(base.srcDir, "include")}`);
108
+ expect(cmd).toContain(`-I${join(base.srcDir, "examples", "common")}`);
109
+ expect(cmd).toContain(join(base.srcDir, "examples", "cli", "main.cpp"));
110
+ expect(cmd).toContain(join(base.srcDir, "examples", "common", "wav.cpp"));
111
+ expect(cmd).toContain(`-L${base.libsDir}`);
112
+ expect(cmd).toContain("-ltranscribe");
113
+ expect(cmd).toContain("-Wl,-rpath,@loader_path/../libs");
114
+ // output is the final -o <path>
115
+ expect(cmd[cmd.length - 2]).toBe("-o");
116
+ expect(cmd[cmd.length - 1]).toBe(base.outPath);
117
+ });
118
+
119
+ test("Linux → $ORIGIN/../libs rpath (literal — passed to argv, not a shell)", () => {
120
+ const cmd = buildCompileCommand({ ...base, platform: "linux" });
121
+ expect(cmd).toContain("-Wl,-rpath,$ORIGIN/../libs");
122
+ expect(cmd).not.toContain("-Wl,-rpath,@loader_path/../libs");
123
+ });
124
+ });
125
+
126
+ /** Build a deps object with call-recording mocks; overrides win. */
127
+ function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
128
+ calls: {
129
+ fetch: number;
130
+ compile: number;
131
+ removed: string[];
132
+ renamed: Array<[string, string]>;
133
+ written: Array<[string, string]>;
134
+ };
135
+ } {
136
+ const calls = {
137
+ fetch: 0,
138
+ compile: 0,
139
+ removed: [] as string[],
140
+ renamed: [] as Array<[string, string]>,
141
+ written: [] as Array<[string, string]>,
142
+ };
143
+ const deps: BuildCliDeps = {
144
+ platform: "darwin",
145
+ which: (c) => (c === "c++" ? "/usr/bin/c++" : null),
146
+ fetchSource: async () => {
147
+ calls.fetch++;
148
+ },
149
+ readFile: () => MAIN_CPP_FIXTURE,
150
+ writeFile: (p, content) => {
151
+ calls.written.push([p, content]);
152
+ },
153
+ compile: async (): Promise<CompileResult> => {
154
+ calls.compile++;
155
+ return { exitCode: 0, stderr: "" };
156
+ },
157
+ exists: () => true,
158
+ removeBin: (p) => {
159
+ calls.removed.push(p);
160
+ },
161
+ rename: (from, to) => {
162
+ calls.renamed.push([from, to]);
163
+ },
164
+ ...over,
165
+ };
166
+ return Object.assign(deps, { calls });
167
+ }
168
+
169
+ const INPUT = { srcDir: "/t/.src", libsDir: "/t/libs", binPath: "/t/bin/transcribe-cli" };
170
+ const TMP = `${INPUT.binPath}.building`; // the temp output the build compiles to
171
+
172
+ describe("patchMainCppBackendInit — the vault#534 backend-init source patch", () => {
173
+ test("injects transcribe_init_backends_default() before the batch-mode anchor", () => {
174
+ const patched = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
175
+ expect(patched).toContain("transcribe_init_backends_default();");
176
+ expect(patched).toContain(BACKEND_INIT_PATCH_MARKER);
177
+ // The init call lands BEFORE the inference paths (the anchor) and AFTER
178
+ // the log-sink install, so backend plugin-load logs route through the sink.
179
+ const initAt = patched.indexOf("transcribe_init_backends_default();");
180
+ expect(initAt).toBeGreaterThan(patched.indexOf("transcribe_log_set(log_cb, nullptr);"));
181
+ expect(initAt).toBeLessThan(patched.indexOf(BACKEND_INIT_PATCH_ANCHOR));
182
+ // Everything else is preserved verbatim: removing the injected block
183
+ // restores the original source exactly.
184
+ const [before, after] = patched.split(/ \/\/ \[parachute-vault[\s\S]*?transcribe_init_backends_default\(\);\n\n/);
185
+ expect(`${before}${after}`).toBe(MAIN_CPP_FIXTURE);
186
+ });
187
+
188
+ test("idempotent: an already-patched source is returned unchanged", () => {
189
+ const once = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
190
+ expect(patchMainCppBackendInit(once)).toBe(once);
191
+ });
192
+
193
+ test("anchor missing ⇒ throws a loud, actionable error naming the pin + vault#534", () => {
194
+ expect(() => patchMainCppBackendInit("int main() { return 0; }\n")).toThrow(
195
+ /anchor not found[\s\S]*vault#534/,
196
+ );
197
+ });
198
+ });
199
+
200
+ describe("buildTranscribeCli — orchestration branches", () => {
201
+ test("SUCCESS: fetch → patch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
202
+ const deps = mkDeps();
203
+ const r = await buildTranscribeCli(INPUT, deps);
204
+ expect(r.ok).toBe(true);
205
+ if (r.ok) {
206
+ expect(r.binPath).toBe(INPUT.binPath);
207
+ expect(r.compiler).toBe("/usr/bin/c++");
208
+ // reported command targets the FINAL binPath (reproducible by hand), not the temp
209
+ expect(r.command).toContain("-ltranscribe");
210
+ expect(r.command).toContain(`-o ${INPUT.binPath}`);
211
+ expect(r.command).not.toContain(TMP);
212
+ }
213
+ expect(deps.calls.fetch).toBe(1);
214
+ expect(deps.calls.compile).toBe(1);
215
+ // the vault#534 backend-init patch was written into the fetched main.cpp
216
+ expect(deps.calls.written).toHaveLength(1);
217
+ const [patchedPath, patchedContent] = deps.calls.written[0]!;
218
+ expect(patchedPath).toBe(join(INPUT.srcDir, "examples", "cli", "main.cpp"));
219
+ expect(patchedContent).toContain("transcribe_init_backends_default();");
220
+ // only the TEMP is cleared pre-compile; the fresh temp is renamed into place
221
+ expect(deps.calls.removed).toEqual([TMP]);
222
+ expect(deps.calls.renamed).toEqual([[TMP, INPUT.binPath]]);
223
+ });
224
+
225
+ test("PATCH FAILURE: anchor missing in fetched source ⇒ { ok:false, stage:'patch' }, no write, no compile", async () => {
226
+ const deps = mkDeps({ readFile: () => "int main() { return 0; }\n" });
227
+ const r = await buildTranscribeCli(INPUT, deps);
228
+ expect(r.ok).toBe(false);
229
+ if (!r.ok) {
230
+ expect(r.stage).toBe("patch");
231
+ expect(r.message).toContain("anchor not found");
232
+ expect(r.message).toContain("vault#534");
233
+ }
234
+ expect(deps.calls.written).toEqual([]);
235
+ expect(deps.calls.compile).toBe(0);
236
+ });
237
+
238
+ test("TOOLCHAIN ABSENT: no compiler ⇒ { ok:false, stage:'toolchain' }, no fetch/compile", async () => {
239
+ const deps = mkDeps({ which: () => null });
240
+ const r = await buildTranscribeCli(INPUT, deps);
241
+ expect(r.ok).toBe(false);
242
+ if (!r.ok) {
243
+ expect(r.stage).toBe("toolchain");
244
+ expect(r.message).toContain("no C++ compiler");
245
+ expect(r.message).toContain("xcode-select"); // darwin hint embedded
246
+ }
247
+ expect(deps.calls.fetch).toBe(0);
248
+ expect(deps.calls.compile).toBe(0);
249
+ });
250
+
251
+ test("FETCH FAILURE: fetchSource throws ⇒ { ok:false, stage:'fetch' }, no compile", async () => {
252
+ const deps = mkDeps({
253
+ fetchSource: async () => {
254
+ throw new Error("network down");
255
+ },
256
+ });
257
+ const r = await buildTranscribeCli(INPUT, deps);
258
+ expect(r.ok).toBe(false);
259
+ if (!r.ok) {
260
+ expect(r.stage).toBe("fetch");
261
+ expect(r.message).toContain("network down");
262
+ expect(r.message).toContain(TRANSCRIBE_CPP_SOURCE_REF);
263
+ }
264
+ expect(deps.calls.compile).toBe(0);
265
+ });
266
+
267
+ test("COMPILE FAILURE (nonzero exit): keeps provider + surfaces stderr + command; only the temp is cleaned", async () => {
268
+ const deps = mkDeps({
269
+ compile: async () => ({ exitCode: 1, stderr: "main.cpp:9: fatal error: transcribe.h not found" }),
270
+ });
271
+ const r = await buildTranscribeCli(INPUT, deps);
272
+ expect(r.ok).toBe(false);
273
+ if (!r.ok) {
274
+ expect(r.stage).toBe("compile");
275
+ expect(r.message).toContain("transcribe.h not found");
276
+ expect(r.command).toContain("-ltranscribe");
277
+ }
278
+ // ONLY the temp is touched (pre-clear + post-cleanup) — the real binPath is never removed
279
+ expect(deps.calls.removed).toEqual([TMP, TMP]);
280
+ expect(deps.calls.removed).not.toContain(INPUT.binPath);
281
+ expect(deps.calls.renamed).toEqual([]);
282
+ });
283
+
284
+ test("COMPILE 'succeeds' but output missing ⇒ { ok:false, stage:'compile' } + only temp cleaned", async () => {
285
+ const deps = mkDeps({ exists: () => false });
286
+ const r = await buildTranscribeCli(INPUT, deps);
287
+ expect(r.ok).toBe(false);
288
+ if (!r.ok) expect(r.stage).toBe("compile");
289
+ expect(deps.calls.removed).toEqual([TMP, TMP]);
290
+ expect(deps.calls.renamed).toEqual([]);
291
+ });
292
+
293
+ test("ATOMIC: a failed rebuild NEVER removes/renames the existing binary (--force safety)", async () => {
294
+ // Simulate an existing working binary present at binPath; the rebuild fails.
295
+ const deps = mkDeps({
296
+ exists: (p) => p === INPUT.binPath, // real binary exists; the temp never appears
297
+ compile: async () => ({ exitCode: 1, stderr: "boom" }),
298
+ });
299
+ const r = await buildTranscribeCli(INPUT, deps);
300
+ expect(r.ok).toBe(false);
301
+ // The existing binary is untouched: no removeBin/rename ever names binPath.
302
+ expect(deps.calls.removed).not.toContain(INPUT.binPath);
303
+ expect(deps.calls.renamed).toEqual([]);
304
+ });
305
+ });