@mycadet/cli 0.12.6-dev.202610082233.54244ee → 0.12.7

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 (89) hide show
  1. package/eslint.config.mjs +7 -0
  2. package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
  3. package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
  4. package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
  5. package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
  6. package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
  7. package/package.json +4 -3
  8. package/src/__tests__/assistant-config.test.ts +30 -28
  9. package/src/__tests__/debug.test.ts +8 -8
  10. package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
  11. package/src/__tests__/flags.test.ts +2 -2
  12. package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
  13. package/src/__tests__/ingress-config.test.ts +2 -2
  14. package/src/__tests__/login-loopback.test.ts +1 -1
  15. package/src/__tests__/nginx-ingress.test.ts +68 -76
  16. package/src/__tests__/pair.test.ts +135 -121
  17. package/src/__tests__/platform-client.test.ts +2 -2
  18. package/src/__tests__/recover.test.ts +96 -3
  19. package/src/__tests__/retire.test.ts +5 -3
  20. package/src/__tests__/signing-key-continuity.test.ts +261 -0
  21. package/src/__tests__/sleep.test.ts +189 -6
  22. package/src/__tests__/tailscale-tunnel.test.ts +1 -1
  23. package/src/__tests__/uninstall.test.ts +171 -0
  24. package/src/__tests__/upgrade-local.test.ts +16 -3
  25. package/src/__tests__/use.test.ts +2 -2
  26. package/src/__tests__/wake-error.test.ts +76 -0
  27. package/src/__tests__/wake.test.ts +309 -36
  28. package/src/commands/backup.ts +6 -2
  29. package/src/commands/clean.ts +6 -2
  30. package/src/commands/client.ts +91 -41
  31. package/src/commands/confirm.ts +6 -2
  32. package/src/commands/connect.ts +6 -2
  33. package/src/commands/debug.ts +6 -2
  34. package/src/commands/devices.ts +6 -2
  35. package/src/commands/events.ts +6 -2
  36. package/src/commands/exec.ts +50 -12
  37. package/src/commands/flags.ts +6 -2
  38. package/src/commands/gateway.ts +6 -2
  39. package/src/commands/hatch.ts +6 -2
  40. package/src/commands/login.ts +26 -356
  41. package/src/commands/logs.ts +6 -2
  42. package/src/commands/message.ts +6 -2
  43. package/src/commands/pair.ts +6 -2
  44. package/src/commands/ps.ts +7 -2
  45. package/src/commands/recover.ts +21 -16
  46. package/src/commands/restore.ts +6 -2
  47. package/src/commands/retire.ts +6 -2
  48. package/src/commands/roadmap.ts +6 -2
  49. package/src/commands/rollback.ts +6 -2
  50. package/src/commands/setup.ts +76 -9
  51. package/src/commands/sleep.ts +131 -31
  52. package/src/commands/ssh.ts +6 -2
  53. package/src/commands/teleport.ts +6 -2
  54. package/src/commands/terminal.ts +6 -2
  55. package/src/commands/tunnel.ts +6 -2
  56. package/src/commands/uninstall.ts +228 -0
  57. package/src/commands/unpair.ts +6 -2
  58. package/src/commands/upgrade.ts +13 -8
  59. package/src/commands/use.ts +6 -2
  60. package/src/commands/voice.ts +6 -2
  61. package/src/commands/wake.ts +69 -37
  62. package/src/commands/workflows.ts +6 -2
  63. package/src/components/DefaultMainScreen.tsx +250 -36
  64. package/src/index.ts +23 -55
  65. package/src/lib/assistant-config.ts +27 -13
  66. package/src/lib/backup-ops.ts +3 -4
  67. package/src/lib/cli-error.ts +5 -0
  68. package/src/lib/client-identity.ts +8 -10
  69. package/src/lib/cloud-login.ts +422 -0
  70. package/src/lib/color.ts +16 -0
  71. package/src/lib/default-main-screen-formatting.ts +32 -8
  72. package/src/lib/environments/__tests__/paths.test.ts +9 -11
  73. package/src/lib/environments/paths.ts +2 -20
  74. package/src/lib/guardian-token.ts +72 -18
  75. package/src/lib/hatch-local.ts +52 -1
  76. package/src/lib/health-check.ts +24 -15
  77. package/src/lib/lifecycle-reporter.ts +9 -1
  78. package/src/lib/local-hatch-lock.ts +19 -27
  79. package/src/lib/local-runtime-client.ts +25 -14
  80. package/src/lib/local.ts +17 -43
  81. package/src/lib/ngrok.ts +48 -16
  82. package/src/lib/platform-client.ts +141 -72
  83. package/src/lib/retire-archive.ts +4 -4
  84. package/src/lib/retire-local.ts +2 -25
  85. package/src/lib/teleport-backup.ts +23 -9
  86. package/src/lib/terminal-client.ts +21 -9
  87. package/src/lib/tunnel-edge.ts +11 -13
  88. package/src/lib/upgrade-lifecycle.ts +16 -4
  89. package/src/lib/workos-pkce.ts +52 -21
@@ -1,3 +1,4 @@
1
+ import { resolveLockfilePaths } from "@mycadet/local-mode";
1
2
  import {
2
3
  afterAll,
3
4
  afterEach,
@@ -14,9 +15,11 @@ import {
14
15
  mkdirSync,
15
16
  mkdtempSync,
16
17
  rmSync,
18
+ readFileSync,
17
19
  writeFileSync,
18
20
  } from "node:fs";
19
- import { tmpdir } from "node:os";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import * as osModule from "node:os";
20
23
  import { basename, join } from "node:path";
21
24
 
22
25
  import type { AssistantEntry } from "../lib/assistant-config.js";
@@ -35,7 +38,9 @@ const realExec = stepRunnerModule.exec;
35
38
 
36
39
  // Prevent real daemon / gateway / CES from starting
37
40
  const startLocalDaemonMock = mock(async () => {});
38
- const startGatewayMock = mock(async () => {});
41
+ const startGatewayMock = mock<typeof localModule.startGateway>(
42
+ async () => "http://127.0.0.1:7831",
43
+ );
39
44
  const startCesMock = mock(async () => {});
40
45
 
41
46
  // Capture exec calls without running real tar
@@ -73,12 +78,14 @@ function makeEntry(assistantId: string, instanceDir: string): AssistantEntry {
73
78
  assistantId,
74
79
  runtimeUrl: "http://127.0.0.1:7831",
75
80
  cloud: "local",
81
+ guardianBootstrapSecret: "existing-bootstrap",
76
82
  resources: {
77
83
  instanceDir,
78
84
  daemonPort: 7801,
79
85
  gatewayPort: 7831,
80
86
  qdrantPort: 6334,
81
87
  cesPort: 7790,
88
+ signingKey: "ab".repeat(32),
82
89
  },
83
90
  };
84
91
  }
@@ -106,18 +113,21 @@ function writeArchiveFixtures(
106
113
  return { archivePath, metadataPath, extractedPath };
107
114
  }
108
115
 
116
+ let homeSpy: ReturnType<typeof spyOn>;
109
117
  let consoleLogSpy: ReturnType<typeof spyOn>;
110
118
  let consoleErrorSpy: ReturnType<typeof spyOn>;
111
119
  let exitSpy: ReturnType<typeof spyOn>;
112
120
 
113
121
  beforeEach(() => {
122
+ homeSpy = spyOn(osModule, "homedir").mockReturnValue(join(testDir, "home"));
123
+ mkdirSync(homedir(), { recursive: true });
114
124
  // Route lockfile and retired archives to the temp directory
115
125
  process.env.CADET_LOCKFILE_DIR = testDir;
116
126
  process.env.XDG_DATA_HOME = testDir;
117
127
  // Write an empty lockfile so saveAssistantEntry has a dir to write to
118
128
  mkdirSync(testDir, { recursive: true });
119
129
  writeFileSync(
120
- join(testDir, ".cadet.lock.json"),
130
+ resolveLockfilePaths(process.env)[0]!,
121
131
  JSON.stringify({ assistants: [] }) + "\n",
122
132
  );
123
133
 
@@ -134,6 +144,7 @@ beforeEach(() => {
134
144
  });
135
145
 
136
146
  afterEach(() => {
147
+ homeSpy.mockRestore();
137
148
  process.argv = [...originalArgv];
138
149
  process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
139
150
  process.env.XDG_DATA_HOME = originalXdgData;
@@ -176,6 +187,83 @@ describe("recover --help", () => {
176
187
  });
177
188
 
178
189
  describe("recover error cases", () => {
190
+ for (const failure of ["missing", "conflict"] as const) {
191
+ test(`recovery ${failure} leaves destination and registry untouched and permits retry`, async () => {
192
+ const name = `retry-${failure}`;
193
+ const instanceDir = join(testDir, "cadet", "assistants", name);
194
+ const entry = makeEntry(name, instanceDir);
195
+ delete entry.resources!.signingKey;
196
+ const { archivePath, metadataPath, extractedPath } = writeArchiveFixtures(
197
+ name,
198
+ entry,
199
+ );
200
+ if (failure === "conflict") {
201
+ for (const [dir, hex] of [
202
+ ["gateway-security", "ab"],
203
+ ["workspace/deprecated", "cd"],
204
+ ]) {
205
+ mkdirSync(join(extractedPath, dir!), { recursive: true });
206
+ writeFileSync(
207
+ join(extractedPath, dir!, "actor-token-signing-key"),
208
+ Buffer.from(hex!.repeat(32), "hex"),
209
+ );
210
+ }
211
+ }
212
+ const registry = resolveLockfilePaths(process.env)[0]!;
213
+ const before = readFileSync(registry, "utf8");
214
+ process.argv = ["bun", "cadet", "recover", name];
215
+ await expect(recover()).rejects.toThrow(`signing key ${failure}`);
216
+ expect(existsSync(instanceDir)).toBe(false);
217
+ expect(readFileSync(registry, "utf8")).toBe(before);
218
+ expect(existsSync(archivePath)).toBe(true);
219
+ expect(existsSync(metadataPath)).toBe(true);
220
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
221
+ expect(startGatewayMock).not.toHaveBeenCalled();
222
+ // Restore the authoritative identity in the archive metadata and retry.
223
+ entry.resources!.signingKey = "ab".repeat(32);
224
+ writeFileSync(metadataPath, JSON.stringify(entry));
225
+ await recover();
226
+ expect(existsSync(instanceDir)).toBe(true);
227
+ expect(existsSync(archivePath)).toBe(false);
228
+ expect(startGatewayMock).toHaveBeenCalledTimes(1);
229
+ rmSync(instanceDir, { recursive: true, force: true });
230
+ });
231
+ }
232
+
233
+ test("recovers an archived legacy identity from staging", async () => {
234
+ const name = "legacy-staged";
235
+ const entry = makeEntry(name, join(testDir, "cadet", "assistants", name));
236
+ delete entry.resources!.signingKey;
237
+ const { extractedPath } = writeArchiveFixtures(name, entry);
238
+ const dir = join(extractedPath, "workspace", "deprecated");
239
+ mkdirSync(dir, { recursive: true });
240
+ writeFileSync(
241
+ join(dir, "actor-token-signing-key"),
242
+ Buffer.from("cd".repeat(32), "hex"),
243
+ );
244
+ process.argv = ["bun", "cadet", "recover", name];
245
+ await recover();
246
+ expect(startGatewayMock).toHaveBeenCalledWith(
247
+ false,
248
+ expect.objectContaining({ signingKey: "cd".repeat(32) }),
249
+ { signingKey: "cd".repeat(32), bootstrapSecret: "existing-bootstrap" },
250
+ );
251
+ rmSync(entry.resources!.instanceDir, { recursive: true, force: true });
252
+ });
253
+
254
+ test("missing identity preserves the archive and starts no processes", async () => {
255
+ const name = "missing-key";
256
+ const entry = makeEntry(name, join(testDir, "cadet", "assistants", name));
257
+ delete entry.resources!.signingKey;
258
+ const { archivePath, metadataPath } = writeArchiveFixtures(name, entry);
259
+ process.argv = ["bun", "cadet", "recover", name];
260
+ await expect(recover()).rejects.toThrow("signing key missing");
261
+ expect(existsSync(archivePath)).toBe(true);
262
+ expect(existsSync(metadataPath)).toBe(true);
263
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
264
+ expect(startGatewayMock).not.toHaveBeenCalled();
265
+ });
266
+
179
267
  test("exits 1 when no name is given", async () => {
180
268
  process.argv = ["bun", "cadet", "recover"];
181
269
  await expect(recover()).rejects.toThrow("process.exit(1)");
@@ -253,6 +341,11 @@ describe("recover extraction path — named instance (instanceDir !== homedir())
253
341
  // Daemon and gateway were started
254
342
  expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
255
343
  expect(startGatewayMock).toHaveBeenCalledTimes(1);
344
+ expect(startGatewayMock.mock.calls[0]).toEqual([
345
+ false,
346
+ expect.objectContaining({ signingKey: "ab".repeat(32) }),
347
+ { signingKey: "ab".repeat(32), bootstrapSecret: "existing-bootstrap" },
348
+ ]);
256
349
  });
257
350
 
258
351
  test("creates parent directories of instanceDir when they do not exist", async () => {
@@ -111,13 +111,13 @@ function makeEntry(
111
111
  function writeLockfile(entries: AssistantEntry[]): void {
112
112
  mkdirSync(testDir, { recursive: true });
113
113
  writeFileSync(
114
- join(testDir, ".cadet.lock.json"),
114
+ join(testDir, "lockfile.json"),
115
115
  JSON.stringify({ assistants: entries }, null, 2) + "\n",
116
116
  );
117
117
  }
118
118
 
119
119
  function readLockfile(): string {
120
- return readFileSync(join(testDir, ".cadet.lock.json"), "utf-8");
120
+ return readFileSync(join(testDir, "lockfile.json"), "utf-8");
121
121
  }
122
122
 
123
123
  function setTerminalMode(isTTY: boolean): void {
@@ -168,7 +168,9 @@ describe("cadet retire", () => {
168
168
  beforeEach(() => {
169
169
  process.env.CADET_LOCKFILE_DIR = testDir;
170
170
  delete process.env.CADET_PLATFORM_TOKEN;
171
- rmSync(join(testDir, ".cadet.lock.json"), { force: true });
171
+ for (const name of ["lockfile.json", ".cadet.lock.json", ".cadet.lockfile.json"]) {
172
+ rmSync(join(testDir, name), { force: true });
173
+ }
172
174
  process.argv = ["bun", "cadet", "retire"];
173
175
  process.exit = ((code?: number) => {
174
176
  throw new Error(`process.exit:${code}`);
@@ -0,0 +1,261 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createHmac } from "node:crypto";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+
14
+ import {
15
+ getLockfileData,
16
+ resolveExistingLocalSigningKey,
17
+ upsertRendererLockfileAssistant,
18
+ } from "@mycadet/local-mode";
19
+
20
+ // wake, upgrade and recover resolve the existing signing identity through
21
+ // @mycadet/local-mode before touching any process. These tests exercise that
22
+ // contract the way the CLI consumes it: host lockfile entries, legacy on-disk
23
+ // key files, and a renderer save in between.
24
+ describe("local signing identity continuity", () => {
25
+ let instanceDir: string;
26
+ const key = "ab".repeat(32);
27
+ const other = "cd".repeat(32);
28
+ const legacyPaths = () => [
29
+ join(instanceDir, "gateway-security", "actor-token-signing-key"),
30
+ join(instanceDir, "workspace", "deprecated", "actor-token-signing-key"),
31
+ ];
32
+ function put(file: string, hex: string) {
33
+ mkdirSync(dirname(file), { recursive: true });
34
+ writeFileSync(file, Buffer.from(hex, "hex"), { mode: 0o600 });
35
+ }
36
+ beforeEach(() => {
37
+ instanceDir = mkdtempSync(join(tmpdir(), "signing-continuity-"));
38
+ });
39
+ afterEach(() => {
40
+ rmSync(instanceDir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("resolveExistingLocalSigningKey", () => {
44
+ test("loads the persisted key without creating disk copies", () => {
45
+ expect(
46
+ resolveExistingLocalSigningKey({
47
+ instanceDir,
48
+ signingKey: key.toUpperCase(),
49
+ }),
50
+ ).toBe(key);
51
+ for (const file of legacyPaths()) {
52
+ expect(existsSync(file)).toBe(false);
53
+ }
54
+ });
55
+ for (const index of [0, 1]) {
56
+ test(`recovers legacy source ${index} without rewriting it`, () => {
57
+ put(legacyPaths()[index]!, key);
58
+ expect(resolveExistingLocalSigningKey({ instanceDir })).toBe(key);
59
+ expect(readFileSync(legacyPaths()[index]!)).toEqual(
60
+ Buffer.from(key, "hex"),
61
+ );
62
+ });
63
+ }
64
+ test("matching legacy sources converge", () => {
65
+ for (const file of legacyPaths()) {
66
+ put(file, key);
67
+ }
68
+ expect(
69
+ resolveExistingLocalSigningKey({ instanceDir, signingKey: key }),
70
+ ).toBe(key);
71
+ });
72
+ test("missing state refuses to create an identity", () => {
73
+ expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
74
+ "signing key missing",
75
+ );
76
+ });
77
+ test("malformed persisted state cannot fall back to a different source", () => {
78
+ put(legacyPaths()[1]!, key);
79
+ for (const invalid of ["", "short", null, 123, "z".repeat(64)]) {
80
+ expect(() =>
81
+ resolveExistingLocalSigningKey({ instanceDir, signingKey: invalid }),
82
+ ).toThrow("signing key invalid");
83
+ }
84
+ });
85
+ test("the host key remains authoritative over stale legacy copies after repair", () => {
86
+ put(legacyPaths()[1]!, other);
87
+ expect(
88
+ resolveExistingLocalSigningKey({ instanceDir, signingKey: key }),
89
+ ).toBe(key);
90
+ expect(readFileSync(legacyPaths()[1]!)).toEqual(
91
+ Buffer.from(other, "hex"),
92
+ );
93
+ });
94
+ test("conflicting legacy copies refuse even without a persisted key", () => {
95
+ put(legacyPaths()[0]!, key);
96
+ put(legacyPaths()[1]!, other);
97
+ expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
98
+ "signing key conflict",
99
+ );
100
+ });
101
+ test("malformed or unreadable legacy storage is not absence", () => {
102
+ put(legacyPaths()[0]!, "ab");
103
+ expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
104
+ "signing key invalid",
105
+ );
106
+ rmSync(legacyPaths()[0]!);
107
+ mkdirSync(legacyPaths()[0]!);
108
+ expect(() => resolveExistingLocalSigningKey({ instanceDir })).toThrow(
109
+ /signing key (invalid|unreadable)/,
110
+ );
111
+ });
112
+ test("a foreign instance cannot supply a key", () => {
113
+ put(legacyPaths()[1]!, key);
114
+ expect(() =>
115
+ resolveExistingLocalSigningKey({
116
+ instanceDir: join(instanceDir, "other"),
117
+ }),
118
+ ).toThrow("signing key missing");
119
+ });
120
+ });
121
+
122
+ describe("renderer saves preserve host identity", () => {
123
+ const lockfilePath = () => join(instanceDir, "lockfile.json");
124
+ function writeOnDisk(value: unknown): void {
125
+ writeFileSync(lockfilePath(), JSON.stringify(value, null, 2));
126
+ }
127
+ function readOnDisk(): Record<string, unknown> {
128
+ return JSON.parse(readFileSync(lockfilePath(), "utf-8")) as Record<
129
+ string,
130
+ unknown
131
+ >;
132
+ }
133
+
134
+ test("a renderer read/select/save preserves host resources and credentials", () => {
135
+ const entry = {
136
+ assistantId: "local-1",
137
+ cloud: "local",
138
+ name: "Example Assistant",
139
+ runtimeUrl: "http://127.0.0.1:7830",
140
+ guardianBootstrapSecret: "bootstrap-secret",
141
+ resources: {
142
+ instanceDir: "/example/instance",
143
+ gatewayPort: 7830,
144
+ daemonPort: 7831,
145
+ signingKey: key,
146
+ cesPort: 7832,
147
+ futureHostField: "keep",
148
+ },
149
+ };
150
+ writeOnDisk({ assistants: [entry], activeAssistant: null });
151
+ const read = getLockfileData([lockfilePath()]);
152
+ expect(read.ok).toBe(true);
153
+ if (!read.ok) {
154
+ throw new Error("fixture read failed");
155
+ }
156
+ const view = read.data.assistants[0]!;
157
+ expect(view.resources).not.toHaveProperty("signingKey");
158
+ const result = upsertRendererLockfileAssistant(
159
+ [lockfilePath()],
160
+ { ...view },
161
+ "local-1",
162
+ );
163
+ expect(result.ok).toBe(true);
164
+ expect(readOnDisk()).toEqual({
165
+ assistants: [entry],
166
+ activeAssistant: "local-1",
167
+ });
168
+ });
169
+
170
+ test("stale or forged renderer resources cannot replace host state", () => {
171
+ const entry = {
172
+ assistantId: "local-1",
173
+ cloud: "local",
174
+ guardianBootstrapSecret: "keep",
175
+ resources: {
176
+ instanceDir: "/example/current",
177
+ signingKey: key,
178
+ gatewayPort: 7830,
179
+ daemonPort: 7831,
180
+ },
181
+ };
182
+ writeOnDisk({ assistants: [entry], activeAssistant: null });
183
+ const result = upsertRendererLockfileAssistant(
184
+ [lockfilePath()],
185
+ {
186
+ assistantId: "local-1",
187
+ name: "Updated",
188
+ guardianBootstrapSecret: "replace",
189
+ resources: {
190
+ instanceDir: "/example/stale",
191
+ signingKey: other,
192
+ },
193
+ },
194
+ undefined,
195
+ );
196
+ expect(result.ok).toBe(true);
197
+ expect(readOnDisk().assistants).toEqual([{ ...entry, name: "Updated" }]);
198
+ });
199
+
200
+ test("a renderer save refuses an unreadable registry without replacing it", () => {
201
+ writeFileSync(lockfilePath(), "{ invalid");
202
+ expect(
203
+ upsertRendererLockfileAssistant(
204
+ [lockfilePath()],
205
+ { assistantId: "local-1" },
206
+ "local-1",
207
+ ),
208
+ ).toMatchObject({ ok: false, status: 409 });
209
+ expect(readFileSync(lockfilePath(), "utf-8")).toBe("{ invalid");
210
+ });
211
+ });
212
+
213
+ describe("across desktop saves and wake cycles", () => {
214
+ test("read/select/save and repeated key loads preserve a retained credential", () => {
215
+ const registry = join(instanceDir, "registry.json");
216
+ const payload = "synthetic-retained-credential";
217
+ const signature = createHmac("sha256", Buffer.from(key, "hex"))
218
+ .update(payload)
219
+ .digest("hex");
220
+ writeFileSync(
221
+ registry,
222
+ JSON.stringify({
223
+ assistants: [
224
+ {
225
+ assistantId: "example",
226
+ cloud: "local",
227
+ resources: {
228
+ instanceDir,
229
+ gatewayPort: 7830,
230
+ daemonPort: 7831,
231
+ signingKey: key,
232
+ },
233
+ },
234
+ ],
235
+ }),
236
+ );
237
+ for (let cycle = 0; cycle < 3; cycle++) {
238
+ const view = getLockfileData([registry]);
239
+ if (!view.ok) {
240
+ throw new Error("fixture read failed");
241
+ }
242
+ expect(
243
+ upsertRendererLockfileAssistant(
244
+ [registry],
245
+ { ...view.data.assistants[0]! },
246
+ "example",
247
+ ).ok,
248
+ ).toBe(true);
249
+ const stored = JSON.parse(readFileSync(registry, "utf8"));
250
+ const restored = resolveExistingLocalSigningKey(
251
+ stored.assistants[0].resources,
252
+ );
253
+ expect(
254
+ createHmac("sha256", Buffer.from(restored, "hex"))
255
+ .update(payload)
256
+ .digest("hex"),
257
+ ).toBe(signature);
258
+ }
259
+ });
260
+ });
261
+ });
@@ -18,10 +18,12 @@ const assistantRootDir = testDir;
18
18
  process.env.CADET_LOCKFILE_DIR = testDir;
19
19
 
20
20
  const stopProcessByPidFileMock = mock(async () => true);
21
- const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
22
- alive: false,
23
- pid: null,
24
- }));
21
+ const isProcessAliveMock = mock(
22
+ (_pidFile?: string): { alive: boolean; pid: number | null } => ({
23
+ alive: false,
24
+ pid: null,
25
+ }),
26
+ );
25
27
 
26
28
  const realProcess = { ...(await import("../lib/process.js")) };
27
29
 
@@ -95,7 +97,7 @@ import {
95
97
  // assistant without needing to mock the entire assistant-config module.
96
98
  function writeLockfile(extraEntryFields: Record<string, unknown> = {}): void {
97
99
  writeFileSync(
98
- join(testDir, ".cadet.lock.json"),
100
+ join(testDir, "lockfile.json"),
99
101
  JSON.stringify(
100
102
  {
101
103
  assistants: [
@@ -161,7 +163,7 @@ describe("sleep command", () => {
161
163
  // Reset per-test state: drop the lockfile and any lease file left behind.
162
164
  // assistantRootDir IS testDir now (state is flattened into the instance
163
165
  // dir), so wipe files rather than the directory itself.
164
- rmSync(join(testDir, ".cadet.lock.json"), { force: true });
166
+ rmSync(join(testDir, "lockfile.json"), { force: true });
165
167
  rmSync(join(assistantRootDir, "active-call-leases.json"), { force: true });
166
168
  writeLockfile();
167
169
  });
@@ -366,4 +368,185 @@ describe("sleep command", () => {
366
368
  expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
367
369
  });
368
370
  });
371
+
372
+ describe("--all", () => {
373
+ function writeMultiAssistantLockfile(): void {
374
+ writeFileSync(
375
+ join(testDir, "lockfile.json"),
376
+ JSON.stringify(
377
+ {
378
+ assistants: [
379
+ {
380
+ assistantId: "sleep-all-1",
381
+ runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
382
+ cloud: "local",
383
+ resources: {
384
+ instanceDir: join(testDir, "instance-1"),
385
+ daemonPort: DEFAULT_DAEMON_PORT,
386
+ gatewayPort: DEFAULT_GATEWAY_PORT,
387
+ qdrantPort: DEFAULT_QDRANT_PORT,
388
+ },
389
+ },
390
+ {
391
+ assistantId: "sleep-all-2",
392
+ runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
393
+ cloud: "local",
394
+ resources: {
395
+ instanceDir: join(testDir, "instance-2"),
396
+ daemonPort: DEFAULT_DAEMON_PORT,
397
+ gatewayPort: DEFAULT_GATEWAY_PORT,
398
+ qdrantPort: DEFAULT_QDRANT_PORT,
399
+ },
400
+ },
401
+ ],
402
+ activeAssistant: "sleep-all-1",
403
+ },
404
+ null,
405
+ 2,
406
+ ),
407
+ );
408
+ }
409
+
410
+ test("stops every assistant in the lockfile", async () => {
411
+ writeMultiAssistantLockfile();
412
+ process.argv = ["bun", "cadet", "sleep", "--all"];
413
+
414
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
415
+ try {
416
+ await sleep();
417
+ } finally {
418
+ consoleLog.mockRestore();
419
+ }
420
+
421
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(6); // assistant+gateway+CES × 2
422
+ });
423
+
424
+ test("skips assistants whose lifecycle is managed elsewhere", async () => {
425
+ writeMultiAssistantLockfile();
426
+ // Rewrite the lockfile with the second entry paired.
427
+ writeFileSync(
428
+ join(testDir, "lockfile.json"),
429
+ JSON.stringify({
430
+ assistants: [
431
+ {
432
+ assistantId: "sleep-all-1",
433
+ runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
434
+ cloud: "local",
435
+ resources: {
436
+ instanceDir: join(testDir, "instance-1"),
437
+ daemonPort: DEFAULT_DAEMON_PORT,
438
+ gatewayPort: DEFAULT_GATEWAY_PORT,
439
+ qdrantPort: DEFAULT_QDRANT_PORT,
440
+ },
441
+ },
442
+ {
443
+ assistantId: "paired-one",
444
+ runtimeUrl: "http://127.0.0.1:1",
445
+ cloud: "paired",
446
+ },
447
+ ],
448
+ activeAssistant: "sleep-all-1",
449
+ }),
450
+ );
451
+ process.argv = ["bun", "cadet", "sleep", "--all"];
452
+
453
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
454
+ try {
455
+ await sleep();
456
+ } finally {
457
+ consoleLog.mockRestore();
458
+ }
459
+
460
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3); // assistant+gateway+CES, one local entry
461
+ });
462
+
463
+ test("continues past a failing assistant and exits 1", async () => {
464
+ writeMultiAssistantLockfile();
465
+ // instance-1 hits an active-call lease refusal; instance-2 is fine.
466
+ isProcessAliveMock.mockImplementation((pidFile?: string) => ({
467
+ alive: pidFile?.includes("instance-1") ?? false,
468
+ pid: pidFile?.includes("instance-1") ? 12345 : null,
469
+ }));
470
+ mkdirSync(join(testDir, "instance-1"), { recursive: true });
471
+ writeFileSync(
472
+ join(testDir, "instance-1", "active-call-leases.json"),
473
+ JSON.stringify({
474
+ version: 1,
475
+ leases: [
476
+ {
477
+ callSessionId: "call-all-1",
478
+ providerCallSid: null,
479
+ updatedAt: Date.now(),
480
+ },
481
+ ],
482
+ }),
483
+ );
484
+ process.argv = ["bun", "cadet", "sleep", "--all"];
485
+
486
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
487
+ const exitMock = mock((code?: number) => {
488
+ throw new Error(`process.exit:${code}`);
489
+ });
490
+ const originalExit = process.exit;
491
+ process.exit = exitMock as unknown as typeof process.exit;
492
+
493
+ try {
494
+ await expect(sleep()).rejects.toThrow("process.exit:1");
495
+ expect(
496
+ consoleError.mock.calls.some(([line]) =>
497
+ String(line).includes("failed to stop 'sleep-all-1'"),
498
+ ),
499
+ ).toBe(true);
500
+ } finally {
501
+ process.exit = originalExit;
502
+ consoleError.mockRestore();
503
+ }
504
+
505
+ // The healthy assistant was still stopped.
506
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3); // assistant+gateway+CES of instance-2
507
+ });
508
+
509
+ test("exits 1 when there is nothing to stop", async () => {
510
+ rmSync(join(testDir, "lockfile.json"), { force: true });
511
+ process.argv = ["bun", "cadet", "sleep", "--all"];
512
+
513
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
514
+ const exitMock = mock((code?: number) => {
515
+ throw new Error(`process.exit:${code}`);
516
+ });
517
+ const originalExit = process.exit;
518
+ process.exit = exitMock as unknown as typeof process.exit;
519
+
520
+ try {
521
+ await expect(sleep()).rejects.toThrow("process.exit:1");
522
+ expect(consoleError).toHaveBeenCalledWith(
523
+ expect.stringContaining("no assistants found"),
524
+ );
525
+ } finally {
526
+ process.exit = originalExit;
527
+ consoleError.mockRestore();
528
+ }
529
+ });
530
+
531
+ test("refuses --all combined with a name", async () => {
532
+ process.argv = ["bun", "cadet", "sleep", "sleep-test", "--all"];
533
+
534
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
535
+ const exitMock = mock((code?: number) => {
536
+ throw new Error(`process.exit:${code}`);
537
+ });
538
+ const originalExit = process.exit;
539
+ process.exit = exitMock as unknown as typeof process.exit;
540
+
541
+ try {
542
+ await expect(sleep()).rejects.toThrow("process.exit:1");
543
+ expect(consoleError).toHaveBeenCalledWith(
544
+ expect.stringContaining("--all cannot be combined"),
545
+ );
546
+ } finally {
547
+ process.exit = originalExit;
548
+ consoleError.mockRestore();
549
+ }
550
+ });
551
+ });
369
552
  });
@@ -92,7 +92,7 @@ function makeDeps(opts: {
92
92
  function useTempLockfile(assistantId: string, ingressUrl?: string): string {
93
93
  const lockfileDir = makeWorkspace();
94
94
  process.env.CADET_LOCKFILE_DIR = lockfileDir;
95
- const lockfilePath = join(lockfileDir, ".cadet.lock.json");
95
+ const lockfilePath = join(lockfileDir, "lockfile.json");
96
96
  writeFileSync(
97
97
  lockfilePath,
98
98
  JSON.stringify({