@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.
- package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/package.json +4 -3
- package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
- package/src/__tests__/ps-cloud-status.test.ts +53 -0
- package/src/__tests__/retire.test.ts +37 -178
- package/src/__tests__/root-process-lockfile.test.ts +99 -0
- package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
- package/src/__tests__/tunnel.test.ts +127 -80
- package/src/__tests__/upgrade-local.test.ts +3 -3
- package/src/__tests__/wake.test.ts +16 -9
- package/src/commands/client.ts +1 -1
- package/src/commands/ps.ts +181 -72
- package/src/commands/retire.ts +28 -137
- package/src/commands/tunnel.ts +76 -118
- package/src/commands/upgrade.ts +2 -9
- package/src/commands/wake.ts +5 -9
- package/src/lib/assistant-config.ts +53 -0
- package/src/lib/detached-process.ts +1 -1
- package/src/lib/ingress-config.ts +7 -34
- package/src/lib/local.ts +6 -27
- package/src/lib/nginx-ingress.ts +29 -8
- package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
- package/src/lib/tunnels/cadet.ts +29 -0
- package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
- package/src/lib/tunnels/gateway-notify.ts +61 -0
- package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
- package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
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("
|
|
16
|
+
type: z.literal("cloud_disconnected"),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
export type CloudDisconnectedEvent = z.infer<
|
package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
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 = [
|
|
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/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
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 = [
|
|
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
|
|
104
|
-
* `cadet tunnel
|
|
105
|
-
*
|
|
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 = [
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
256
|
-
const entry = makeEntry("assistant-1", {
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
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(
|
|
325
|
-
|
|
326
|
-
"
|
|
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/
|
|
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
|
|
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", {
|
|
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/
|
|
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
|
|
498
|
-
const entry = makeEntry("assistant-1", {
|
|
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
|
|
23
|
+
} from "../lib/tunnels/tailscale.js";
|
|
24
24
|
import { snapshotEnv } from "./helpers/env.js";
|
|
25
25
|
|
|
26
26
|
describe("Tailscale discovery", () => {
|