@mycadet/cli 0.12.7-dev.202610111049.d2b52a8 → 0.12.8

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 (30) hide show
  1. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  4. package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  5. package/package.json +4 -3
  6. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  7. package/src/__tests__/ps-cloud-status.test.ts +53 -0
  8. package/src/__tests__/retire.test.ts +37 -178
  9. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  10. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
  11. package/src/__tests__/tunnel.test.ts +127 -80
  12. package/src/__tests__/upgrade-local.test.ts +3 -3
  13. package/src/__tests__/wake.test.ts +16 -9
  14. package/src/commands/client.ts +1 -1
  15. package/src/commands/ps.ts +181 -72
  16. package/src/commands/retire.ts +28 -137
  17. package/src/commands/tunnel.ts +76 -118
  18. package/src/commands/upgrade.ts +2 -9
  19. package/src/commands/wake.ts +5 -9
  20. package/src/lib/assistant-config.ts +53 -0
  21. package/src/lib/detached-process.ts +1 -1
  22. package/src/lib/ingress-config.ts +7 -34
  23. package/src/lib/local.ts +6 -27
  24. package/src/lib/nginx-ingress.ts +29 -8
  25. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  26. package/src/lib/tunnels/cadet.ts +29 -0
  27. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
  28. package/src/lib/tunnels/gateway-notify.ts +61 -0
  29. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
  30. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `platform_disconnected` SSE event.
2
+ * `cloud_disconnected` SSE event.
3
3
  *
4
4
  * Emitted by the cloud-disconnect route after stored platform
5
5
  * credentials are deleted, notifying connected clients that the cloud
@@ -13,7 +13,7 @@
13
13
  import { z } from "zod";
14
14
 
15
15
  export const CloudDisconnectedEventSchema = z.object({
16
- type: z.literal("platform_disconnected"),
16
+ type: z.literal("cloud_disconnected"),
17
17
  });
18
18
 
19
19
  export type CloudDisconnectedEvent = z.infer<
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
100
100
  export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
101
 
102
102
  /**
103
- * The local tunnel providers the CLI can start, in the order they are offered.
104
- * `cadet tunnel`'s accepted `--provider` values and the persisted-record
105
- * validation below both derive from it.
103
+ * The tunnel providers the CLI offers, in the order they are presented —
104
+ * the managed `cadet` tunnel first (listed, not yet implemented), then the
105
+ * local ones. `cadet tunnel`'s accepted `--provider` values and the
106
+ * persisted-record validation below both derive from it.
106
107
  */
107
- export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+ export const TUNNEL_PROVIDERS = [
109
+ "cadet",
110
+ "ngrok",
111
+ "cloudflare",
112
+ "tailscale",
113
+ ] as const;
108
114
 
109
115
  export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
116
 
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
100
100
  export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
101
 
102
102
  /**
103
- * The local tunnel providers the CLI can start, in the order they are offered.
104
- * `cadet tunnel`'s accepted `--provider` values and the persisted-record
105
- * validation below both derive from it.
103
+ * The tunnel providers the CLI offers, in the order they are presented —
104
+ * the managed `cadet` tunnel first (listed, not yet implemented), then the
105
+ * local ones. `cadet tunnel`'s accepted `--provider` values and the
106
+ * persisted-record validation below both derive from it.
106
107
  */
107
- export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+ export const TUNNEL_PROVIDERS = [
109
+ "cadet",
110
+ "ngrok",
111
+ "cloudflare",
112
+ "tailscale",
113
+ ] as const;
108
114
 
109
115
  export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
116
 
@@ -100,11 +100,17 @@ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
100
100
  export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
101
 
102
102
  /**
103
- * The local tunnel providers the CLI can start, in the order they are offered.
104
- * `cadet tunnel`'s accepted `--provider` values and the persisted-record
105
- * validation below both derive from it.
103
+ * The tunnel providers the CLI offers, in the order they are presented —
104
+ * the managed `cadet` tunnel first (listed, not yet implemented), then the
105
+ * local ones. `cadet tunnel`'s accepted `--provider` values and the
106
+ * persisted-record validation below both derive from it.
106
107
  */
107
- export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+ export const TUNNEL_PROVIDERS = [
109
+ "cadet",
110
+ "ngrok",
111
+ "cloudflare",
112
+ "tailscale",
113
+ ] as const;
108
114
 
109
115
  export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/cli",
3
- "version": "0.12.7-dev.202610111049.d2b52a8",
3
+ "version": "0.12.8",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,12 +15,12 @@
15
15
  "./src/commands/*": "./src/commands/*.ts"
16
16
  },
17
17
  "bin": {
18
- "cadet-dev": "./src/index.ts"
18
+ "cadet": "./src/index.ts"
19
19
  },
20
20
  "scripts": {
21
21
  "format": "prettier --write .",
22
22
  "format:check": "prettier --check .",
23
- "lint": "eslint --max-warnings 326",
23
+ "lint": "eslint --max-warnings 323",
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
25
  "compile:check": "bun build --compile src/index.ts --outfile /dev/null",
26
26
  "test": "bun run scripts/run-tests.ts",
@@ -41,6 +41,7 @@
41
41
  "qrcode-terminal": "0.12.0",
42
42
  "react": "19.2.6",
43
43
  "react-devtools-core": "7.0.1",
44
+ "yaml": "2.8.2",
44
45
  "zod": "4.3.6"
45
46
  },
46
47
  "bundledDependencies": [
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { EventEmitter } from "node:events";
3
3
  import type { ChildProcess } from "node:child_process";
4
4
 
5
- import { waitForCloudflareTunnelUrl } from "../lib/cloudflare-tunnel.js";
5
+ import { waitForCloudflareTunnelUrl } from "../lib/tunnels/cloudflare.js";
6
6
 
7
7
  // ── Helpers ───────────────────────────────────────────────────────────────────
8
8
 
@@ -32,6 +32,10 @@ const loadAllAssistantsMock = spyOn(
32
32
  assistantConfig,
33
33
  "loadAllAssistants",
34
34
  ).mockReturnValue([]);
35
+ const readRootProcessesMock = spyOn(
36
+ assistantConfig,
37
+ "readRootProcesses",
38
+ ).mockReturnValue({});
35
39
  const getActiveAssistantMock = spyOn(
36
40
  assistantConfig,
37
41
  "getActiveAssistant",
@@ -99,6 +103,8 @@ beforeEach(() => {
99
103
  }) as typeof process.stdout.write;
100
104
  });
101
105
 
106
+ const NL = String.fromCharCode(10);
107
+
102
108
  afterEach(() => {
103
109
  console.log = originalLog;
104
110
  console.error = originalError;
@@ -116,6 +122,7 @@ afterEach(() => {
116
122
  fetchCloudAssistantsMock.mockResolvedValue([]);
117
123
  checkManagedHealthMock.mockReset();
118
124
  checkManagedHealthMock.mockResolvedValue({ status: "healthy", detail: null });
125
+ readRootProcessesMock.mockReturnValue({});
119
126
  });
120
127
 
121
128
  afterAll(() => {
@@ -217,6 +224,52 @@ describe("cadet ps — platform status line", () => {
217
224
  }
218
225
  });
219
226
 
227
+ test("CLI-managed root processes nest under the assistant they front", async () => {
228
+ loadAllAssistantsMock.mockReturnValue([
229
+ {
230
+ assistantId: "assistant-123",
231
+ name: "Alice",
232
+ runtimeUrl: "https://platform.example",
233
+ cloud: "cadet",
234
+ },
235
+ {
236
+ assistantId: "assistant-456",
237
+ name: "Bob",
238
+ runtimeUrl: "https://platform.example/b",
239
+ cloud: "cadet",
240
+ },
241
+ ]);
242
+ getActiveAssistantMock.mockReturnValue(null);
243
+ checkManagedHealthMock.mockImplementation(async () => ({
244
+ status: "healthy",
245
+ detail: null,
246
+ }));
247
+ // The tunnel fronts Alice (recorded id); the edge records no assistant.
248
+ readRootProcessesMock.mockReturnValue({
249
+ tunnel: {
250
+ pid: 4242,
251
+ provider: "ngrok",
252
+ startedAt: "2026-10-11T00:00:00.000Z",
253
+ assistantId: "assistant-123",
254
+ },
255
+ app: { pid: 77, startedAt: "2026-10-11T00:00:00.000Z" },
256
+ });
257
+
258
+ await listAllAssistants(false);
259
+
260
+ const output = stdout.join(NL);
261
+ // The tunnel nests under Alice — its name is just the provider now…
262
+ expect(output).toContain("└ ngrok");
263
+ // …the unattributed edge renders machine-level…
264
+ expect(output).toContain("nginx edge");
265
+ // …the type column distinguishes the three process kinds…
266
+ expect(output).toContain("🧑‍🚀 Assistant");
267
+ expect(output).toContain("🚇 Tunnel");
268
+ expect(output).toContain("🌐 App");
269
+ // …and the second table is gone: one header row only.
270
+ expect(output.match(/NAME/g)?.length).toBe(1);
271
+ });
272
+
220
273
  test("assistant rows use display name as primary label and keep id visible", async () => {
221
274
  loadAllAssistantsMock.mockReturnValue([
222
275
  {
@@ -168,7 +168,11 @@ describe("cadet retire", () => {
168
168
  beforeEach(() => {
169
169
  process.env.CADET_LOCKFILE_DIR = testDir;
170
170
  delete process.env.CADET_PLATFORM_TOKEN;
171
- for (const name of ["lockfile.json", ".cadet.lock.json", ".cadet.lockfile.json"]) {
171
+ for (const name of [
172
+ "lockfile.json",
173
+ ".cadet.lock.json",
174
+ ".cadet.lockfile.json",
175
+ ]) {
172
176
  rmSync(join(testDir, name), { force: true });
173
177
  }
174
178
  process.argv = ["bun", "cadet", "retire"];
@@ -252,35 +256,25 @@ describe("cadet retire", () => {
252
256
  );
253
257
  });
254
258
 
255
- test("local retire unregisters the self-hosted platform record first", async () => {
256
- const entry = makeEntry("assistant-1", { name: "Example Assistant" });
259
+ test("local retire unregisters the cloud record straight from the lockfile entry", async () => {
260
+ const entry = makeEntry("assistant-1", {
261
+ name: "Example Assistant",
262
+ localUrl: "http://127.0.0.1:7831",
263
+ cloudAssistantId: "cloud-asst-1",
264
+ cloudOrganizationId: "org-123",
265
+ });
257
266
  writeLockfile([entry]);
258
267
  readCloudTokenMock.mockReturnValue("session-token");
259
- getCloudUrlMock.mockReturnValue("https://platform.example.test/api");
260
- readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
261
- if (name === "cadet:platform_assistant_id") {
262
- return { value: "platform-assistant-1", unreachable: false };
263
- }
264
- if (name === "cadet:platform_base_url") {
265
- return { value: "https://platform.example.test", unreachable: false };
266
- }
267
- if (name === "cadet:platform_organization_id") {
268
- return { value: "org-123", unreachable: false };
269
- }
270
- return { value: null, unreachable: false };
271
- });
268
+ getCloudUrlMock.mockReturnValue("https://platform.example.test");
272
269
  process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
273
270
 
274
271
  await retire();
275
272
 
276
- expect(readGatewayCredentialMock).toHaveBeenCalledWith(
277
- entry.localUrl ?? entry.runtimeUrl,
278
- "cadet:platform_assistant_id",
279
- entry.bearerToken,
280
- );
281
- expect(authHeadersMock).not.toHaveBeenCalled();
273
+ // The cloud record resolves from the entry + env — the local gateway
274
+ // credential store is never consulted.
275
+ expect(readGatewayCredentialMock).not.toHaveBeenCalled();
282
276
  expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
283
- "https://platform.example.test/api/v1/assistants/platform-assistant-1/retire/",
277
+ "https://platform.example.test/v1/assistants/cloud-asst-1/retire/",
284
278
  {
285
279
  method: "DELETE",
286
280
  headers: {
@@ -294,194 +288,67 @@ describe("cadet retire", () => {
294
288
  expect(loadAllAssistants()).toEqual([]);
295
289
  });
296
290
 
297
- test("local retire reads platform registration through the loopback gateway URL", async () => {
298
- const localGatewayUrl = "http://127.0.0.1:7831";
291
+ test("local retire resolves the organization via the cloud when the entry omits it", async () => {
299
292
  const entry = makeEntry("assistant-1", {
300
293
  name: "Example Assistant",
301
- runtimeUrl: "http://assistant-1.local:7831",
302
- localUrl: localGatewayUrl,
294
+ cloudAssistantId: "cloud-asst-1",
303
295
  });
304
296
  writeLockfile([entry]);
305
297
  readCloudTokenMock.mockReturnValue("session-token");
306
298
  getCloudUrlMock.mockReturnValue("https://platform.example.test");
307
- readGatewayCredentialMock.mockImplementation(async (gatewayUrl, name) => {
308
- expect(gatewayUrl).toBe(localGatewayUrl);
309
- if (name === "cadet:platform_assistant_id") {
310
- return { value: "platform-assistant-1", unreachable: false };
311
- }
312
- if (name === "cadet:platform_base_url") {
313
- return { value: "https://platform.example.test", unreachable: false };
314
- }
315
- if (name === "cadet:platform_organization_id") {
316
- return { value: "org-123", unreachable: false };
317
- }
318
- return { value: null, unreachable: false };
319
- });
320
299
  process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
321
300
 
322
301
  await retire();
323
302
 
324
- expect(readGatewayCredentialMock).toHaveBeenCalledWith(
325
- localGatewayUrl,
326
- "cadet:platform_assistant_id",
327
- entry.bearerToken,
328
- );
329
- expect(readGatewayCredentialMock).toHaveBeenCalledWith(
330
- localGatewayUrl,
331
- "cadet:platform_base_url",
332
- entry.bearerToken,
333
- );
334
- expect(readGatewayCredentialMock).toHaveBeenCalledWith(
335
- localGatewayUrl,
336
- "cadet:platform_organization_id",
337
- entry.bearerToken,
303
+ expect(authHeadersMock).toHaveBeenCalledWith(
304
+ "session-token",
305
+ "https://platform.example.test",
338
306
  );
307
+ expect(authHeadersForKnownOrganizationMock).not.toHaveBeenCalled();
339
308
  expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
340
- "https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
341
- expect.any(Object),
342
- );
343
- expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
344
- });
345
-
346
- test("local retire uses saved platform registration when local credentials are unreachable", async () => {
347
- const entry = makeEntry("assistant-1", {
348
- name: "Example Assistant",
349
- cloudAssistantId: "platform-assistant-1",
350
- cloudBaseUrl: "https://platform.example.test",
351
- cloudOrganizationId: "org-123",
352
- });
353
- writeLockfile([entry]);
354
- readCloudTokenMock.mockReturnValue("session-token");
355
- getCloudUrlMock.mockReturnValue("https://platform.example.test");
356
- readGatewayCredentialMock.mockResolvedValue({
357
- value: null,
358
- unreachable: true,
359
- });
360
- process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
361
-
362
- await retire();
363
-
364
- expect(readGatewayCredentialMock).toHaveBeenCalledWith(
365
- entry.localUrl ?? entry.runtimeUrl,
366
- "cadet:platform_assistant_id",
367
- entry.bearerToken,
368
- );
369
- expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
370
- "https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
309
+ "https://platform.example.test/v1/assistants/cloud-asst-1/retire/",
371
310
  {
372
311
  method: "DELETE",
373
312
  headers: {
374
313
  "Content-Type": "application/json",
375
314
  "X-Session-Token": "session-token",
376
- "Cadet-Organization-Id": "org-123",
377
315
  },
378
316
  },
379
317
  );
380
- expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
381
- "using saved platform registration",
382
- );
383
318
  expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
384
319
  expect(loadAllAssistants()).toEqual([]);
385
320
  });
386
321
 
387
- test("local retire continues when local credentials are unreachable and no saved cloud registration exists", async () => {
388
- const entry = makeEntry("assistant-1", { name: "Example Assistant" });
389
- writeLockfile([entry]);
390
- readCloudTokenMock.mockReturnValue("session-token");
391
- readGatewayCredentialMock.mockResolvedValue({
392
- value: null,
393
- unreachable: true,
394
- });
395
- process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
396
-
397
- await retire();
398
-
399
- expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
400
- expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
401
- "no saved cloud registration is available",
402
- );
403
- expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
404
- expect(loadAllAssistants()).toEqual([]);
405
- });
406
-
407
- test("local retire skips platform unregister when stored platform URL origin mismatches", async () => {
408
- const entry = makeEntry("assistant-1", { name: "Example Assistant" });
409
- writeLockfile([entry]);
410
- readCloudTokenMock.mockReturnValue("session-token");
411
- getCloudUrlMock.mockReturnValue("https://platform.example.com");
412
- readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
413
- if (name === "cadet:platform_assistant_id") {
414
- return { value: "platform-assistant-1", unreachable: false };
415
- }
416
- if (name === "cadet:platform_base_url") {
417
- return { value: "https://malicious.example.test", unreachable: false };
418
- }
419
- if (name === "cadet:platform_organization_id") {
420
- return { value: "org-123", unreachable: false };
421
- }
422
- return { value: null, unreachable: false };
423
- });
424
- process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
425
-
426
- await retire();
427
-
428
- expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
429
- expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
430
- "Stored platform URL does not match the configured platform URL",
431
- );
432
- expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
433
- });
434
-
435
- test("local retire skips platform unregister when stored organization ID is missing", async () => {
322
+ test("local retire skips the cloud unregister when no assistant id is bound", async () => {
436
323
  const entry = makeEntry("assistant-1", { name: "Example Assistant" });
437
324
  writeLockfile([entry]);
438
325
  readCloudTokenMock.mockReturnValue("session-token");
439
326
  getCloudUrlMock.mockReturnValue("https://platform.example.test");
440
- readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
441
- if (name === "cadet:platform_assistant_id") {
442
- return { value: "platform-assistant-1", unreachable: false };
443
- }
444
- if (name === "cadet:platform_base_url") {
445
- return { value: "https://platform.example.test", unreachable: false };
446
- }
447
- return { value: null, unreachable: false };
448
- });
449
327
  process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
450
328
 
451
329
  await retire();
452
330
 
453
331
  expect(loopbackSafeFetchMock).not.toHaveBeenCalled();
454
- expect(consoleWarnSpy.mock.calls.flat().join("\n")).toContain(
455
- "Could not read platform organization ID",
456
- );
457
332
  expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
458
333
  expect(loadAllAssistants()).toEqual([]);
459
334
  });
460
335
 
461
336
  test("local retire uses injected platform token from the app host", async () => {
462
- const entry = makeEntry("assistant-1", { name: "Example Assistant" });
337
+ const entry = makeEntry("assistant-1", {
338
+ name: "Example Assistant",
339
+ cloudAssistantId: "cloud-asst-1",
340
+ cloudOrganizationId: "org-123",
341
+ });
463
342
  writeLockfile([entry]);
464
343
  process.env.CADET_PLATFORM_TOKEN = "host-session-token";
465
344
  readCloudTokenMock.mockReturnValue(null);
466
345
  getCloudUrlMock.mockReturnValue("https://platform.example.test");
467
- readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
468
- if (name === "cadet:platform_assistant_id") {
469
- return { value: "platform-assistant-1", unreachable: false };
470
- }
471
- if (name === "cadet:platform_base_url") {
472
- return { value: "https://platform.example.test", unreachable: false };
473
- }
474
- if (name === "cadet:platform_organization_id") {
475
- return { value: "org-123", unreachable: false };
476
- }
477
- return { value: null, unreachable: false };
478
- });
479
346
  process.argv = ["bun", "cadet", "retire", "assistant-1", "--yes"];
480
347
 
481
348
  await retire();
482
349
 
483
350
  expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
484
- "https://platform.example.test/v1/assistants/platform-assistant-1/retire/",
351
+ "https://platform.example.test/v1/assistants/cloud-asst-1/retire/",
485
352
  {
486
353
  method: "DELETE",
487
354
  headers: {
@@ -494,23 +361,15 @@ describe("cadet retire", () => {
494
361
  expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
495
362
  });
496
363
 
497
- test("local retire continues when self-hosted platform unregister fails", async () => {
498
- const entry = makeEntry("assistant-1", { name: "Example Assistant" });
364
+ test("local retire continues when the cloud unregister fails", async () => {
365
+ const entry = makeEntry("assistant-1", {
366
+ name: "Example Assistant",
367
+ cloudAssistantId: "cloud-asst-1",
368
+ cloudOrganizationId: "org-123",
369
+ });
499
370
  writeLockfile([entry]);
500
371
  readCloudTokenMock.mockReturnValue("session-token");
501
372
  getCloudUrlMock.mockReturnValue("https://platform.example.test");
502
- readGatewayCredentialMock.mockImplementation(async (_gatewayUrl, name) => {
503
- if (name === "cadet:platform_assistant_id") {
504
- return { value: "platform-assistant-1", unreachable: false };
505
- }
506
- if (name === "cadet:platform_base_url") {
507
- return { value: "https://platform.example.test", unreachable: false };
508
- }
509
- if (name === "cadet:platform_organization_id") {
510
- return { value: "org-123", unreachable: false };
511
- }
512
- return { value: null, unreachable: false };
513
- });
514
373
  loopbackSafeFetchMock.mockResolvedValue(
515
374
  new Response("platform unavailable", { status: 503 }),
516
375
  );
@@ -0,0 +1,99 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+ import { z } from "zod";
6
+
7
+ import {
8
+ clearRootProcess,
9
+ readRootProcesses,
10
+ stampRootProcess,
11
+ } from "../lib/assistant-config.js";
12
+
13
+ // Create a temp directory and set CADET_LOCKFILE_DIR so the real lockfile
14
+ // helpers resolve inside it (the sleep.test.ts fixture pattern).
15
+ let testDir: string;
16
+
17
+ beforeEach(() => {
18
+ testDir = mkdtempSync(join(tmpdir(), "cadet-root-process-test-"));
19
+ mkdirSync(join(testDir, "config"), { recursive: true });
20
+ process.env.CADET_LOCKFILE_DIR = join(testDir, "config");
21
+ });
22
+
23
+ afterEach(() => {
24
+ delete process.env.CADET_LOCKFILE_DIR;
25
+ rmSync(testDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("root process lockfile entries", () => {
29
+ test("a tunnel record stamps at the root, reads back, and clears", () => {
30
+ stampRootProcess("tunnel", {
31
+ pid: 4242,
32
+ provider: "ngrok",
33
+ startedAt: "2026-10-11T00:00:00.000Z",
34
+ });
35
+
36
+ expect(readRootProcesses().tunnel).toMatchObject({
37
+ pid: 4242,
38
+ provider: "ngrok",
39
+ });
40
+
41
+ clearRootProcess("tunnel");
42
+ expect(readRootProcesses().tunnel).toBeUndefined();
43
+ });
44
+
45
+ test("tunnel and app entries stamp and clear independently", () => {
46
+ stampRootProcess("app", { pid: 77, startedAt: "2026-10-11T00:00:00.000Z" });
47
+ stampRootProcess("tunnel", {
48
+ pid: 78,
49
+ provider: "cloudflare",
50
+ startedAt: "2026-10-11T00:00:00.000Z",
51
+ });
52
+
53
+ expect(readRootProcesses().app?.pid).toBe(77);
54
+ expect(readRootProcesses().tunnel?.provider).toBe("cloudflare");
55
+
56
+ clearRootProcess("app");
57
+ expect(readRootProcesses().app).toBeUndefined();
58
+ // The sibling entry survives.
59
+ expect(readRootProcesses().tunnel?.pid).toBe(78);
60
+ });
61
+
62
+ test("a record can name the assistant it fronts, for `cadet ps` nesting", () => {
63
+ stampRootProcess("tunnel", {
64
+ pid: 4242,
65
+ provider: "ngrok",
66
+ startedAt: "2026-10-11T00:00:00.000Z",
67
+ assistantId: "assistant-1",
68
+ });
69
+
70
+ expect(readRootProcesses().tunnel).toMatchObject({
71
+ pid: 4242,
72
+ provider: "ngrok",
73
+ assistantId: "assistant-1",
74
+ });
75
+ });
76
+
77
+ test("the records live at the lockfile root under their kind keys", () => {
78
+ stampRootProcess("app", { pid: 1, startedAt: "t" });
79
+
80
+ const raw = z
81
+ .object({
82
+ app: z.object({ pid: z.number(), startedAt: z.string() }),
83
+ })
84
+ .parse(
85
+ JSON.parse(
86
+ readFileSync(
87
+ join(process.env.CADET_LOCKFILE_DIR!, "lockfile.json"),
88
+ "utf-8",
89
+ ),
90
+ ),
91
+ );
92
+ expect(raw.app).toEqual({ pid: 1, startedAt: "t" });
93
+ });
94
+
95
+ test("clearing an absent entry is a no-op", () => {
96
+ clearRootProcess("tunnel");
97
+ expect(readRootProcesses()).toEqual({});
98
+ });
99
+ });
@@ -20,7 +20,7 @@ import {
20
20
  stopTailscaleServe,
21
21
  type TailscaleCommandResult,
22
22
  type TailscaleDeps,
23
- } from "../lib/tailscale-tunnel.js";
23
+ } from "../lib/tunnels/tailscale.js";
24
24
  import { snapshotEnv } from "./helpers/env.js";
25
25
 
26
26
  describe("Tailscale discovery", () => {