@mycadet/cli 0.12.8 → 0.12.9-dev.202610131620.d833e01

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 (67) hide show
  1. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
  4. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/package.json +4 -3
  6. package/src/__tests__/client-web-config.test.ts +0 -1
  7. package/src/__tests__/cloud-releases.test.ts +23 -13
  8. package/src/__tests__/ensure-binary.test.ts +182 -0
  9. package/src/__tests__/nginx-ingress.test.ts +6 -11
  10. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  11. package/src/__tests__/pair.test.ts +149 -78
  12. package/src/__tests__/ps-cloud-status.test.ts +4 -3
  13. package/src/__tests__/ps-table.test.ts +79 -0
  14. package/src/__tests__/tailscale.test.ts +1 -1
  15. package/src/__tests__/teleport.test.ts +1 -16
  16. package/src/__tests__/tunnel.test.ts +274 -82
  17. package/src/__tests__/uninstall.test.ts +141 -9
  18. package/src/__tests__/wake.test.ts +2 -2
  19. package/src/commands/backup.ts +3 -3
  20. package/src/commands/client.ts +4 -6
  21. package/src/commands/completions.test.ts +104 -0
  22. package/src/commands/completions.ts +87 -0
  23. package/src/commands/exec.ts +7 -7
  24. package/src/commands/flags.ts +2 -2
  25. package/src/commands/hatch.ts +5 -2
  26. package/src/commands/logs.ts +7 -7
  27. package/src/commands/pair.ts +140 -37
  28. package/src/commands/ps.ts +63 -26
  29. package/src/commands/restore.ts +2 -2
  30. package/src/commands/retire.ts +2 -2
  31. package/src/commands/rollback.ts +2 -2
  32. package/src/commands/sleep.ts +2 -2
  33. package/src/commands/teleport.ts +18 -37
  34. package/src/commands/terminal.ts +1 -1
  35. package/src/commands/tunnel.ts +101 -78
  36. package/src/commands/uninstall.ts +108 -73
  37. package/src/commands/upgrade.ts +8 -8
  38. package/src/commands/use.ts +1 -1
  39. package/src/commands/wake.ts +6 -6
  40. package/src/index.ts +15 -0
  41. package/src/lib/__tests__/docker.test.ts +168 -20
  42. package/src/lib/__tests__/job-polling.test.ts +1 -1
  43. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  44. package/src/lib/cloud-client.ts +0 -3
  45. package/src/lib/cloud-login.ts +0 -1
  46. package/src/lib/cloud-releases.ts +6 -4
  47. package/src/lib/color.ts +4 -0
  48. package/src/lib/docker.ts +190 -102
  49. package/src/lib/ensure-binary.ts +171 -0
  50. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  51. package/src/lib/environments/paths.ts +16 -8
  52. package/src/lib/environments/resolve.ts +2 -4
  53. package/src/lib/flag-args.test.ts +1 -1
  54. package/src/lib/flag-args.ts +1 -1
  55. package/src/lib/guardian-token.ts +1 -1
  56. package/src/lib/hatch-local.ts +15 -8
  57. package/src/lib/hatch-next-steps.ts +5 -9
  58. package/src/lib/job-polling.ts +1 -1
  59. package/src/lib/local.ts +37 -4
  60. package/src/lib/nginx-ingress.ts +3 -5
  61. package/src/lib/pair.ts +1 -1
  62. package/src/lib/step-runner.ts +6 -1
  63. package/src/lib/sync-cloud-assistants.ts +2 -2
  64. package/src/lib/tunnels/cloudflare.ts +21 -11
  65. package/src/lib/tunnels/ngrok.ts +198 -108
  66. package/src/lib/tunnels/tailscale.ts +34 -4
  67. package/src/lib/upgrade-lifecycle.ts +5 -4
@@ -4,6 +4,7 @@ import {
4
4
  beforeEach,
5
5
  describe,
6
6
  expect,
7
+ mock,
7
8
  spyOn,
8
9
  test,
9
10
  } from "bun:test";
@@ -19,9 +20,36 @@ process.env.CADET_LOCKFILE_DIR = testDir;
19
20
  const originalEnvironment = process.env.CADET_ENVIRONMENT;
20
21
  process.env.CADET_ENVIRONMENT = "production";
21
22
 
22
- import { buildAppConnectUrl, pair } from "../commands/pair.js";
23
23
  import { withPreconnect } from "./helpers/http.js";
24
24
 
25
+ // The gateway-CLI mint runs in a SUBPROCESS for local and docker assistants;
26
+ // answer it in-process and record the invocations (the fetch stubs below
27
+ // still serve the loopback HTTP mint the other topologies keep using).
28
+ const mintCalls: Array<{ command: string; args: readonly string[] }> = [];
29
+ const actualStepRunner = await import("../lib/step-runner.js");
30
+ const mockExecOutput = mock(
31
+ (command: string, args: readonly string[]): Promise<string> => {
32
+ mintCalls.push({ command, args });
33
+ const mintedUrl = args[args.length - 1] ?? "";
34
+ return Promise.resolve(
35
+ JSON.stringify({
36
+ deviceCode: "device-code",
37
+ userCode: "ABCD-EFGH",
38
+ verificationUri: `${mintedUrl}/assistant/pair`,
39
+ expiresAt: "2026-06-04T00:10:00.000Z",
40
+ expiresInSeconds: 600,
41
+ intervalSeconds: 5,
42
+ }),
43
+ );
44
+ },
45
+ );
46
+ mock.module("../lib/step-runner.js", () => ({
47
+ ...actualStepRunner,
48
+ execOutput: mockExecOutput,
49
+ }));
50
+
51
+ const { buildAppConnectUrl, pair } = await import("../commands/pair.js");
52
+
25
53
  // Distinct loopback (mint) vs reachable (advertised) URLs to verify the split.
26
54
  const LOCAL_URL = "http://127.0.0.1:7830";
27
55
  const RUNTIME_URL = "http://192.168.1.50:7830";
@@ -93,6 +121,7 @@ const ORIGINAL_ARGV = [...process.argv];
93
121
 
94
122
  describe("pair command", () => {
95
123
  beforeEach(() => {
124
+ mintCalls.length = 0;
96
125
  writeLockfile();
97
126
  });
98
127
 
@@ -130,18 +159,16 @@ describe("pair command", () => {
130
159
  globalThis.fetch = origFetch;
131
160
  }
132
161
 
133
- // Mint over the loopback gateway, then approve: running this command on
134
- // the host IS the approval, so the link alone completes the pairing.
135
- expect(calls.map((c) => c[0])).toEqual([
136
- `${LOCAL_URL}/v1/remote-web/pairing-challenge`,
137
- `${LOCAL_URL}/v1/remote-web/pairing-verification`,
162
+ // Mint through the gateway CLI subprocess: process execution IS the
163
+ // local-presence proof and the approval, so the link alone completes
164
+ // the pairing — no HTTP at all for a local assistant.
165
+ expect(mintCalls).toHaveLength(1);
166
+ expect(mintCalls[0]?.args.slice(-3)).toEqual([
167
+ "pairing",
168
+ "mint",
169
+ PUBLIC_URL,
138
170
  ]);
139
- expect(JSON.parse(String(calls[0][1]?.body))).toEqual({
140
- publicBaseUrl: PUBLIC_URL,
141
- });
142
- expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
143
- userCode: "ABCD-EFGH",
144
- });
171
+ expect(calls).toHaveLength(0);
145
172
 
146
173
  const output = logs.join("\n");
147
174
  // One artifact, two renderings: the link and the same link as a QR.
@@ -391,10 +418,9 @@ describe("pair command", () => {
391
418
  }
392
419
 
393
420
  // Resolution succeeded (no exit), so the challenge was minted.
394
- expect(calls.map((c) => c[0])).toEqual([
395
- `${LOCAL_URL}/v1/remote-web/pairing-challenge`,
396
- `${LOCAL_URL}/v1/remote-web/pairing-verification`,
397
- ]);
421
+ expect(mintCalls).toHaveLength(1);
422
+ expect(mintCalls[0]?.args.at(-1)).toBe(PUBLIC_URL);
423
+ expect(calls).toHaveLength(0);
398
424
  });
399
425
 
400
426
  test("rejects an unknown --flag before any network call", async () => {
@@ -587,11 +613,8 @@ describe("pair command", () => {
587
613
  globalThis.fetch = origFetch;
588
614
  }
589
615
 
590
- // Mint still over loopback; the link advertises the override.
591
- expect(calls[0][0]).toBe(`${LOCAL_URL}/v1/remote-web/pairing-challenge`);
592
- expect(JSON.parse(String(calls[0][1]?.body))).toEqual({
593
- publicBaseUrl: OVERRIDE,
594
- });
616
+ // Mint still through the gateway CLI; the link advertises the override.
617
+ expect(mintCalls[0]?.args.at(-1)).toBe(OVERRIDE);
595
618
  expect(JSON.parse(logs.join("\n")).pairUrl).toBe(
596
619
  `${OVERRIDE}/assistant/pair#device_code=device-code`,
597
620
  );
@@ -1069,35 +1092,8 @@ describe("pair command", () => {
1069
1092
 
1070
1093
  const calls: string[] = [];
1071
1094
  const origFetch = globalThis.fetch;
1072
- globalThis.fetch = withPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
1073
- const url = String(input);
1074
- calls.push(url);
1075
- if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
1076
- // The minted challenge must advertise the entry's recorded URL.
1077
- expect(JSON.parse(String(init?.body))).toEqual({
1078
- publicBaseUrl: "https://saved.example.ts.net",
1079
- });
1080
- return new Response(
1081
- JSON.stringify({
1082
- deviceCode: "device-code",
1083
- userCode: "ABCD-EFGH",
1084
- verificationUri: "https://saved.example.ts.net/assistant/pair",
1085
- expiresAt: "2026-06-04T00:10:00.000Z",
1086
- expiresInSeconds: 600,
1087
- }),
1088
- { status: 200, headers: { "content-type": "application/json" } },
1089
- );
1090
- }
1091
- if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
1092
- return new Response(
1093
- JSON.stringify({
1094
- status: "approved",
1095
- verificationUri: "https://saved.example.ts.net/assistant/pair",
1096
- expiresAt: "2026-06-04T00:10:00.000Z",
1097
- }),
1098
- { status: 200, headers: { "content-type": "application/json" } },
1099
- );
1100
- }
1095
+ globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
1096
+ calls.push(String(input));
1101
1097
  return new Response("not found", { status: 404 });
1102
1098
  });
1103
1099
 
@@ -1116,7 +1112,9 @@ describe("pair command", () => {
1116
1112
  globalThis.fetch = origFetch;
1117
1113
  }
1118
1114
 
1119
- expect(calls).toContain(`${LOCAL_URL}/v1/remote-web/pairing-challenge`);
1115
+ // The minted challenge must advertise the entry's recorded URL.
1116
+ expect(mintCalls[0]?.args.at(-1)).toBe("https://saved.example.ts.net");
1117
+ expect(calls).toHaveLength(0);
1120
1118
  const output = logs.join("\n");
1121
1119
  expect(output).toContain(
1122
1120
  "Using saved ingress URL https://saved.example.ts.net",
@@ -1130,33 +1128,7 @@ describe("pair command", () => {
1130
1128
  writeLockfileWithIngress("https://saved.example.ts.net");
1131
1129
 
1132
1130
  const origFetch = globalThis.fetch;
1133
- globalThis.fetch = withPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
1134
- const url = String(input);
1135
- if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
1136
- expect(JSON.parse(String(init?.body))).toEqual({
1137
- publicBaseUrl: "https://explicit.example.ts.net",
1138
- });
1139
- return new Response(
1140
- JSON.stringify({
1141
- deviceCode: "device-code",
1142
- userCode: "ABCD-EFGH",
1143
- verificationUri: "https://explicit.example.ts.net/assistant/pair",
1144
- expiresAt: "2026-06-04T00:10:00.000Z",
1145
- expiresInSeconds: 600,
1146
- }),
1147
- { status: 200, headers: { "content-type": "application/json" } },
1148
- );
1149
- }
1150
- if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
1151
- return new Response(
1152
- JSON.stringify({
1153
- status: "approved",
1154
- verificationUri: "https://explicit.example.ts.net/assistant/pair",
1155
- expiresAt: "2026-06-04T00:10:00.000Z",
1156
- }),
1157
- { status: 200, headers: { "content-type": "application/json" } },
1158
- );
1159
- }
1131
+ globalThis.fetch = withPreconnect(async () => {
1160
1132
  return new Response("not found", { status: 404 });
1161
1133
  });
1162
1134
 
@@ -1182,6 +1154,8 @@ describe("pair command", () => {
1182
1154
  }
1183
1155
 
1184
1156
  expect(logs.join("\n")).not.toContain("Using saved ingress URL");
1157
+ // The override reached the mint.
1158
+ expect(mintCalls[0]?.args.at(-1)).toBe("https://explicit.example.ts.net");
1185
1159
  });
1186
1160
 
1187
1161
  test("a non-https recorded ingress URL is ignored", async () => {
@@ -1263,4 +1237,101 @@ describe("pair command", () => {
1263
1237
  expect(errors.join("\n")).toContain(RUNTIME_URL);
1264
1238
  expect(minted).toBe(false);
1265
1239
  });
1240
+
1241
+ test("refuses a paired (connect-import) assistant before minting", async () => {
1242
+ writeFileSync(
1243
+ join(testDir, "lockfile.json"),
1244
+ JSON.stringify({
1245
+ assistants: [
1246
+ {
1247
+ assistantId: "pair-test",
1248
+ runtimeUrl: RUNTIME_URL,
1249
+ cloud: "paired",
1250
+ },
1251
+ ],
1252
+ activeAssistant: "pair-test",
1253
+ }),
1254
+ );
1255
+
1256
+ const errors: string[] = [];
1257
+ const errSpy = spyOn(console, "error").mockImplementation(
1258
+ (...a: unknown[]) => {
1259
+ errors.push(a.join(" "));
1260
+ },
1261
+ );
1262
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1263
+ (code?: number | string | undefined): never => {
1264
+ throw new Error(`exit:${code}`);
1265
+ },
1266
+ );
1267
+
1268
+ process.argv = ["bun", "cadet", "pair", "--url", PUBLIC_URL];
1269
+ let exited = false;
1270
+ try {
1271
+ await pair();
1272
+ } catch (e) {
1273
+ exited = e instanceof Error && e.message === "exit:1";
1274
+ } finally {
1275
+ errSpy.mockRestore();
1276
+ exitSpy.mockRestore();
1277
+ }
1278
+
1279
+ expect(exited).toBe(true);
1280
+ expect(errors.join("\n")).toContain("was paired as a remote");
1281
+ // Keyed on the entry's cloud, not on which fields happen to exist.
1282
+ expect(mintCalls).toHaveLength(0);
1283
+ });
1284
+
1285
+ test("a custom (ssh-managed) assistant keeps the loopback HTTP mint", async () => {
1286
+ writeFileSync(
1287
+ join(testDir, "lockfile.json"),
1288
+ JSON.stringify({
1289
+ assistants: [
1290
+ {
1291
+ assistantId: "pair-test",
1292
+ runtimeUrl: LOCAL_URL,
1293
+ cloud: "custom",
1294
+ },
1295
+ ],
1296
+ activeAssistant: "pair-test",
1297
+ }),
1298
+ );
1299
+
1300
+ const calls: Array<[string, RequestInit | undefined]> = [];
1301
+ const origFetch = globalThis.fetch;
1302
+ globalThis.fetch = withPreconnect(
1303
+ async (input: RequestInfo | URL, init?: RequestInit) => {
1304
+ const url = String(input);
1305
+ calls.push([url, init]);
1306
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
1307
+ return challengeResponse(PUBLIC_URL);
1308
+ }
1309
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
1310
+ return approvalResponse(PUBLIC_URL);
1311
+ }
1312
+ return new Response("not found", { status: 404 });
1313
+ },
1314
+ );
1315
+
1316
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
1317
+
1318
+ process.argv = ["bun", "cadet", "pair", "--url", PUBLIC_URL];
1319
+ try {
1320
+ await pair();
1321
+ } finally {
1322
+ logSpy.mockRestore();
1323
+ globalThis.fetch = origFetch;
1324
+ }
1325
+
1326
+ // Not local/docker: no CLI execution — the mint rides the loopback HTTP
1327
+ // route, keyed on the entry's cloud, not on containerInfo.
1328
+ expect(mintCalls).toHaveLength(0);
1329
+ expect(calls.map((c) => c[0])).toEqual([
1330
+ `${LOCAL_URL}/v1/remote-web/pairing-challenge`,
1331
+ `${LOCAL_URL}/v1/remote-web/pairing-verification`,
1332
+ ]);
1333
+ expect(JSON.parse(String(calls[0]?.[1]?.body))).toEqual({
1334
+ publicBaseUrl: PUBLIC_URL,
1335
+ });
1336
+ });
1266
1337
  });
@@ -258,9 +258,10 @@ describe("cadet ps — platform status line", () => {
258
258
  await listAllAssistants(false);
259
259
 
260
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…
261
+ // The tunnel and the edge render machine-level — root resources, never
262
+ // children of an assistant row…
263
+ expect(output).toContain("ngrok");
264
+ expect(output).not.toContain("└ ngrok");
264
265
  expect(output).toContain("nginx edge");
265
266
  // …the type column distinguishes the three process kinds…
266
267
  expect(output).toContain("🧑‍🚀 Assistant");
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { displayWidth, formatRow } from "../commands/ps.js";
4
+
5
+ describe("displayWidth", () => {
6
+ test("ascii strings count one cell per character", () => {
7
+ expect(displayWidth("healthy")).toBe(7);
8
+ expect(displayWidth("")).toBe(0);
9
+ });
10
+
11
+ test("emoji count two cells", () => {
12
+ expect(displayWidth("🟢")).toBe(2);
13
+ expect(displayWidth("🚀")).toBe(2);
14
+ });
15
+
16
+ test("the ZWJ astronaut sequence counts two cells, not its UTF-16 length", () => {
17
+ // "🧑‍🚀" is 5 UTF-16 units but renders 2 cells wide.
18
+ expect(displayWidth("🧑‍🚀 Assistant")).toBe(12);
19
+ });
20
+
21
+ test("a variation selector widens a narrow symbol to two cells", () => {
22
+ expect(displayWidth("⚠️ warn")).toBe(7);
23
+ });
24
+ });
25
+
26
+ describe("ps table alignment", () => {
27
+ const colWidths = { name: 15, type: 14, status: 10, info: 0 };
28
+
29
+ /** The terminal column where `marker` starts (string indices shift under
30
+ * emoji, which render 2 cells but span 2 UTF-16 units or more). */
31
+ const columnOf = (s: string, marker: string): number =>
32
+ displayWidth(s.slice(0, s.indexOf(marker)));
33
+
34
+ test("emoji cells keep every later column aligned", () => {
35
+ const emojiRow = formatRow(
36
+ {
37
+ name: "slim-yak",
38
+ type: "🧑‍🚀 Assistant",
39
+ status: "🟢 healthy",
40
+ info: "http://x",
41
+ },
42
+ colWidths,
43
+ );
44
+ const asciiRow = formatRow(
45
+ {
46
+ name: "plain",
47
+ type: "Assistant",
48
+ status: "healthy",
49
+ info: "http://y",
50
+ },
51
+ colWidths,
52
+ );
53
+ expect(columnOf(emojiRow, "http://x")).toBe(columnOf(asciiRow, "http://y"));
54
+ });
55
+
56
+ test("the header separator matches the emoji row's column starts", () => {
57
+ const widths = {
58
+ name: displayWidth("slim-yak-yhpdq6"),
59
+ type: displayWidth("🧑‍🚀 Assistant"),
60
+ status: displayWidth("🟢 healthy"),
61
+ info: 0,
62
+ };
63
+ const header = formatRow(
64
+ { name: "NAME", type: "TYPE", status: "STATUS", info: "INFO" },
65
+ widths,
66
+ );
67
+ const row = formatRow(
68
+ {
69
+ name: "slim-yak-yhpdq6",
70
+ type: "🧑‍🚀 Assistant",
71
+ status: "🟢 healthy",
72
+ info: "http://127.0.0.1:7830",
73
+ },
74
+ widths,
75
+ );
76
+ expect(columnOf(header, "TYPE")).toBe(columnOf(row, "🧑‍🚀"));
77
+ expect(columnOf(header, "STATUS")).toBe(columnOf(row, "🟢"));
78
+ });
79
+ });
@@ -79,7 +79,7 @@ function makeDeps(opts: {
79
79
  const calls: string[][] = [];
80
80
  const binary = opts.binary === undefined ? "tailscale" : opts.binary;
81
81
  const deps: TailscaleDeps = {
82
- findBinary: () => binary,
82
+ findBinary: async () => binary,
83
83
  run: (_bin, args) => {
84
84
  calls.push(args);
85
85
  return opts.responses?.[args.join(" ")] ?? ok();
@@ -2567,7 +2567,6 @@ describe("platform credential injection", () => {
2567
2567
  bearerToken: "local-bearer",
2568
2568
  assistantApiKey: "api-key-123",
2569
2569
  cloudAssistantId: "platform-assistant-1",
2570
- cloudBaseUrl: "https://cloud.mycadet.ai",
2571
2570
  organizationId: "org-1",
2572
2571
  userId: "user-1",
2573
2572
  webhookSecret: "webhook-secret-123",
@@ -2821,7 +2820,7 @@ describe("auth + transient-error resilience", () => {
2821
2820
  await teleport();
2822
2821
  expect(pollCalls).toBeGreaterThanOrEqual(2);
2823
2822
  expect(warnSpy).toHaveBeenCalledWith(
2824
- expect.stringContaining("polling failed, retrying"),
2823
+ expect.stringContaining("polling failed:"),
2825
2824
  );
2826
2825
  } finally {
2827
2826
  restoreFetch();
@@ -3008,17 +3007,3 @@ describe("upload signed-URL records source runtime version (not CLI version)", (
3008
3007
  });
3009
3008
  });
3010
3009
 
3011
- // ---------------------------------------------------------------------------
3012
- // Misc: legacy --to deprecation
3013
- // ---------------------------------------------------------------------------
3014
-
3015
- describe("misc", () => {
3016
- test("legacy --to flag shows deprecation message", async () => {
3017
- setArgv("--from", "source", "--to", "target");
3018
-
3019
- await expect(teleport()).rejects.toThrow("process.exit:1");
3020
- expect(consoleErrorSpy).toHaveBeenCalledWith(
3021
- expect.stringContaining("--to is deprecated"),
3022
- );
3023
- });
3024
- });