@mycadet/cli 0.12.8 → 0.12.9

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 (53) 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 +2 -2
  6. package/src/__tests__/cloud-releases.test.ts +23 -13
  7. package/src/__tests__/ensure-binary.test.ts +182 -0
  8. package/src/__tests__/nginx-ingress.test.ts +3 -2
  9. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  10. package/src/__tests__/ps-table.test.ts +79 -0
  11. package/src/__tests__/tailscale.test.ts +1 -1
  12. package/src/__tests__/teleport.test.ts +1 -1
  13. package/src/__tests__/tunnel.test.ts +21 -14
  14. package/src/__tests__/uninstall.test.ts +1 -1
  15. package/src/__tests__/wake.test.ts +2 -2
  16. package/src/commands/backup.ts +3 -3
  17. package/src/commands/completions.test.ts +104 -0
  18. package/src/commands/completions.ts +87 -0
  19. package/src/commands/exec.ts +7 -7
  20. package/src/commands/flags.ts +2 -2
  21. package/src/commands/hatch.ts +1 -1
  22. package/src/commands/logs.ts +7 -7
  23. package/src/commands/ps.ts +57 -8
  24. package/src/commands/restore.ts +2 -2
  25. package/src/commands/retire.ts +2 -2
  26. package/src/commands/rollback.ts +2 -2
  27. package/src/commands/sleep.ts +2 -2
  28. package/src/commands/teleport.ts +16 -16
  29. package/src/commands/terminal.ts +1 -1
  30. package/src/commands/tunnel.ts +78 -55
  31. package/src/commands/uninstall.ts +4 -4
  32. package/src/commands/upgrade.ts +8 -8
  33. package/src/commands/use.ts +1 -1
  34. package/src/commands/wake.ts +6 -6
  35. package/src/index.ts +15 -0
  36. package/src/lib/__tests__/docker.test.ts +168 -20
  37. package/src/lib/__tests__/job-polling.test.ts +1 -1
  38. package/src/lib/cloud-releases.ts +6 -4
  39. package/src/lib/docker.ts +150 -44
  40. package/src/lib/ensure-binary.ts +171 -0
  41. package/src/lib/flag-args.test.ts +1 -1
  42. package/src/lib/flag-args.ts +1 -1
  43. package/src/lib/guardian-token.ts +1 -1
  44. package/src/lib/hatch-local.ts +1 -1
  45. package/src/lib/job-polling.ts +1 -1
  46. package/src/lib/local.ts +1 -1
  47. package/src/lib/nginx-ingress.ts +2 -2
  48. package/src/lib/pair.ts +1 -1
  49. package/src/lib/sync-cloud-assistants.ts +2 -2
  50. package/src/lib/tunnels/cloudflare.ts +21 -11
  51. package/src/lib/tunnels/ngrok.ts +198 -108
  52. package/src/lib/tunnels/tailscale.ts +34 -4
  53. package/src/lib/upgrade-lifecycle.ts +5 -4
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `show_platform_login` SSE event.
2
+ * `show_cloud_login` SSE event.
3
3
  *
4
4
  * Emitted by the cloud-connect route when no cloud credentials are
5
5
  * stored yet, signalling connected clients to surface the cloud login UI.
@@ -12,7 +12,7 @@
12
12
  import { z } from "zod";
13
13
 
14
14
  export const ShowCloudLoginEventSchema = z.object({
15
- type: z.literal("show_platform_login"),
15
+ type: z.literal("show_cloud_login"),
16
16
  });
17
17
 
18
18
  export type ShowCloudLoginEvent = z.infer<typeof ShowCloudLoginEventSchema>;
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/cli",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "format": "prettier --write .",
22
22
  "format:check": "prettier --check .",
23
- "lint": "eslint --max-warnings 323",
23
+ "lint": "eslint --max-warnings 322",
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",
@@ -7,7 +7,11 @@ import {
7
7
  resolveOwnImageRefs,
8
8
  } from "../lib/cloud-releases.js";
9
9
  import { dockerhubImages } from "../lib/docker.js";
10
- import { isDevVersion, isLocalVersion, isStagingVersion } from "../lib/version-compat.js";
10
+ import {
11
+ isDevVersion,
12
+ isLocalVersion,
13
+ isStagingVersion,
14
+ } from "../lib/version-compat.js";
11
15
 
12
16
  const originalFetch = globalThis.fetch;
13
17
 
@@ -79,12 +83,12 @@ describe("fetchReleases", () => {
79
83
  });
80
84
 
81
85
  describe("resolveImageRefs", () => {
82
- test("released CLI resolves un-suffixed repos tagged with the target version", () => {
86
+ test("released CLI resolves un-suffixed repos tagged with the bare version", () => {
83
87
  const tags = resolveImageRefs("v0.12.6", "0.12.6");
84
- expect(tags.assistant).toBe("mycadet/mycadet-assistant:v0.12.6");
85
- expect(tags.gateway).toBe("mycadet/mycadet-gateway:v0.12.6");
88
+ expect(tags.assistant).toBe("mycadet/mycadet-assistant:0.12.6");
89
+ expect(tags.gateway).toBe("mycadet/mycadet-gateway:0.12.6");
86
90
  expect(tags["credential-executor"]).toBe(
87
- "mycadet/mycadet-credential-executor:v0.12.6",
91
+ "mycadet/mycadet-credential-executor:0.12.6",
88
92
  );
89
93
  });
90
94
 
@@ -94,31 +98,32 @@ describe("resolveImageRefs", () => {
94
98
  "0.12.6-dev.202610081023.3a27b77",
95
99
  );
96
100
  expect(tags.assistant).toBe(
97
- "mycadet/mycadet-assistant-dev:v0.12.6-dev.202610081023.3a27b77",
101
+ "mycadet/mycadet-assistant-dev:0.12.6-dev.202610081023.3a27b77",
98
102
  );
99
103
  expect(tags.gateway).toBe(
100
- "mycadet/mycadet-gateway-dev:v0.12.6-dev.202610081023.3a27b77",
104
+ "mycadet/mycadet-gateway-dev:0.12.6-dev.202610081023.3a27b77",
101
105
  );
102
106
  expect(tags["credential-executor"]).toBe(
103
- "mycadet/mycadet-credential-executor-dev:v0.12.6-dev.202610081023.3a27b77",
107
+ "mycadet/mycadet-credential-executor-dev:0.12.6-dev.202610081023.3a27b77",
104
108
  );
105
109
  });
106
110
 
107
111
  test("repo suffix follows the CLI version, tag follows the target", () => {
108
112
  const tags = resolveImageRefs("v0.12.5", "0.12.6-dev.202610081023.3a27b77");
109
- expect(tags.assistant).toBe("mycadet/mycadet-assistant-dev:v0.12.5");
113
+ expect(tags.assistant).toBe("mycadet/mycadet-assistant-dev:0.12.5");
110
114
  });
111
115
 
112
116
  test("does not consult the network", async () => {
113
117
  mockFetchError();
114
118
  const tags = resolveImageRefs("v0.12.6", "0.12.6");
115
- expect(tags.assistant).toContain(":v0.12.6");
119
+ expect(tags.assistant).toContain(":0.12.6");
116
120
  });
117
121
 
118
- test("normalizes the v-prefix on the tag", () => {
122
+ test("normalizes the v-prefix off the tag", () => {
119
123
  const withoutPrefix = resolveImageRefs("0.12.6", "0.12.6");
120
124
  const withPrefix = resolveImageRefs("v0.12.6", "0.12.6");
121
- expect(withoutPrefix.assistant).toBe(withPrefix.assistant);
125
+ expect(withoutPrefix.assistant).toBe("mycadet/mycadet-assistant:0.12.6");
126
+ expect(withPrefix.assistant).toBe(withoutPrefix.assistant);
122
127
  });
123
128
  });
124
129
 
@@ -126,9 +131,14 @@ describe("resolveOwnImageRefs", () => {
126
131
  test("uses the CLI's own version for both tag and repo suffix", () => {
127
132
  const tags = resolveOwnImageRefs("0.12.6-dev.202610081023.3a27b77");
128
133
  expect(tags.assistant).toBe(
129
- "mycadet/mycadet-assistant-dev:v0.12.6-dev.202610081023.3a27b77",
134
+ "mycadet/mycadet-assistant-dev:0.12.6-dev.202610081023.3a27b77",
130
135
  );
131
136
  });
137
+
138
+ test("falls back to the literal latest tag when the CLI version is unknown", () => {
139
+ const tags = resolveOwnImageRefs("");
140
+ expect(tags.assistant).toBe("mycadet/mycadet-assistant:latest");
141
+ });
132
142
  });
133
143
 
134
144
  describe("dockerhubImages", () => {
@@ -0,0 +1,182 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Readable, Writable } from "node:stream";
3
+
4
+ import { ensureBinaryInstalled, readLineText } from "../lib/ensure-binary.js";
5
+
6
+ /** A real Writable capturing everything written, as a test output stream. */
7
+ function makeOutput(): { writable: Writable; text(): string } {
8
+ const captured: string[] = [];
9
+ const writable = new Writable({
10
+ write(chunk, _encoding, callback) {
11
+ captured.push(chunk.toString());
12
+ callback();
13
+ },
14
+ });
15
+ return { writable, text: () => captured.join("") };
16
+ }
17
+
18
+ describe("ensureBinaryInstalled", () => {
19
+ test("returns the existing probe result without prompting", async () => {
20
+ const output = makeOutput();
21
+ const version = await ensureBinaryInstalled({
22
+ binary: "nginx",
23
+ purpose: "the tunnel edge",
24
+ packages: { brew: ["brew", "install", "nginx"] },
25
+ manualHint: "install nginx",
26
+ probe: () => "nginx version 1.2",
27
+ confirmInstall: () => {
28
+ throw new Error("must not prompt for a present binary");
29
+ },
30
+ streams: { output: output.writable },
31
+ });
32
+ expect(version).toBe("nginx version 1.2");
33
+ expect(output.text()).toBe("");
34
+ });
35
+
36
+ test("offers to install and runs the first available package manager", async () => {
37
+ const output = makeOutput();
38
+ const installed: string[][] = [];
39
+ let probeCalls = 0;
40
+ const version = await ensureBinaryInstalled({
41
+ binary: "nginx",
42
+ purpose: "the tunnel edge",
43
+ packages: {
44
+ brew: ["brew", "install", "nginx"],
45
+ "apt-get": ["sudo", "apt-get", "install", "-y", "nginx"],
46
+ },
47
+ manualHint: "install nginx yourself",
48
+ probe: () => (++probeCalls > 1 ? "nginx version 1.2" : null),
49
+ pmAvailable: (pm) => pm === "apt-get",
50
+ runInstall: (argv) => {
51
+ installed.push(argv);
52
+ return true;
53
+ },
54
+ confirmInstall: async () => true,
55
+ streams: { output: output.writable },
56
+ });
57
+ expect(version).toBe("nginx version 1.2");
58
+ expect(installed).toEqual([["sudo", "apt-get", "install", "-y", "nginx"]]);
59
+ expect(output.text()).toContain("isn't installed");
60
+ expect(output.text()).not.toContain("apt-get install");
61
+ });
62
+
63
+ test("the default confirm takes Enter as yes", async () => {
64
+ const output = makeOutput();
65
+ const input = Object.assign(new Readable({ read() {} }), {
66
+ isTTY: true,
67
+ });
68
+ const installed: string[][] = [];
69
+ let probeCalls = 0;
70
+ const pending = ensureBinaryInstalled({
71
+ binary: "nginx",
72
+ purpose: "the tunnel edge",
73
+ packages: { brew: ["brew", "install", "nginx"] },
74
+ manualHint: "install nginx yourself",
75
+ probe: () => (++probeCalls > 1 ? "nginx version 1.2" : null),
76
+ pmAvailable: () => true,
77
+ runInstall: (argv) => {
78
+ installed.push(argv);
79
+ return true;
80
+ },
81
+ streams: { input, output: output.writable },
82
+ });
83
+ input.emit("data", "\n");
84
+ expect(await pending).toBe("nginx version 1.2");
85
+ expect(installed).toEqual([["brew", "install", "nginx"]]);
86
+ expect(output.text()).toContain("Install nginx now?");
87
+ });
88
+
89
+ test("a declined offer prints the manual hint and installs nothing", async () => {
90
+ const output = makeOutput();
91
+ let ran = false;
92
+ const version = await ensureBinaryInstalled({
93
+ binary: "ngrok",
94
+ purpose: "the ngrok tunnel",
95
+ packages: { brew: ["brew", "install", "ngrok/ngrok/ngrok"] },
96
+ manualHint: "get ngrok from ngrok.com",
97
+ probe: () => null,
98
+ runInstall: () => {
99
+ ran = true;
100
+ return true;
101
+ },
102
+ confirmInstall: async () => false,
103
+ streams: { output: output.writable },
104
+ });
105
+ expect(version).toBe(null);
106
+ expect(ran).toBe(false);
107
+ expect(output.text()).toContain("get ngrok from ngrok.com");
108
+ });
109
+
110
+ test("a failed install falls back to the manual hint", async () => {
111
+ const output = makeOutput();
112
+ const version = await ensureBinaryInstalled({
113
+ binary: "nginx",
114
+ purpose: "the tunnel edge",
115
+ packages: { brew: ["brew", "install", "nginx"] },
116
+ manualHint: "install nginx yourself",
117
+ probe: () => null,
118
+ runInstall: () => false,
119
+ confirmInstall: async () => true,
120
+ streams: { output: output.writable },
121
+ });
122
+ expect(version).toBe(null);
123
+ expect(output.text()).toContain("install nginx yourself");
124
+ });
125
+
126
+ test("no matching package manager prints the hint", async () => {
127
+ const output = makeOutput();
128
+ let ran = false;
129
+ const version = await ensureBinaryInstalled({
130
+ binary: "cloudflared",
131
+ purpose: "the cloudflare tunnel",
132
+ packages: { brew: ["brew", "install", "cloudflared"] },
133
+ manualHint: "cloudflared install docs",
134
+ probe: () => null,
135
+ pmAvailable: () => false,
136
+ runInstall: () => {
137
+ ran = true;
138
+ return true;
139
+ },
140
+ confirmInstall: async () => true,
141
+ streams: { output: output.writable },
142
+ });
143
+ expect(version).toBe(null);
144
+ expect(ran).toBe(false);
145
+ expect(output.text()).toContain("cloudflared install docs");
146
+ });
147
+
148
+ test("a non-interactive terminal declines instead of hanging", async () => {
149
+ const version = await ensureBinaryInstalled({
150
+ binary: "nginx",
151
+ purpose: "the tunnel edge",
152
+ packages: { brew: ["brew", "install", "nginx"] },
153
+ manualHint: "install nginx yourself",
154
+ probe: () => null,
155
+ confirmInstall: async () => {
156
+ throw new Error("Install confirm requires an interactive terminal.");
157
+ },
158
+ });
159
+ expect(version).toBe(null);
160
+ });
161
+ });
162
+
163
+ describe("readLineText", () => {
164
+ test("resolves the first line and ignores the rest", async () => {
165
+ const output = makeOutput();
166
+ const input = new Readable({ read() {} });
167
+ const pending = readLineText("token: ", {
168
+ input,
169
+ output: output.writable,
170
+ });
171
+ input.emit("data", "abc123\nsecond line\n");
172
+ expect(await pending).toBe("abc123");
173
+ expect(output.text()).toBe("token: ");
174
+ });
175
+
176
+ test("resolves an empty line on an immediate newline", async () => {
177
+ const input = new Readable({ read() {} });
178
+ const pending = readLineText("[Y/n] ", { input });
179
+ input.emit("data", "\n");
180
+ expect(await pending).toBe("");
181
+ });
182
+ });
@@ -2095,8 +2095,9 @@ describe("ensureTunnelEdge", () => {
2095
2095
  gatewayPort: 7830,
2096
2096
  });
2097
2097
 
2098
- await expect(promise).rejects.toThrow("brew install nginx");
2099
- await expect(promise).rejects.toThrow("apt install nginx");
2098
+ await expect(promise).rejects.toThrow(
2099
+ "See https://nginx.org/en/docs/install.html for how to install",
2100
+ );
2100
2101
  await expect(promise).rejects.toThrow("NGINX_BIN");
2101
2102
  expect(spawnMock).not.toHaveBeenCalled();
2102
2103
  });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildNgrokSpawnArgs, isNgrokAuthError } from "../lib/tunnels/ngrok.js";
4
+
5
+ describe("buildNgrokSpawnArgs", () => {
6
+ test("spawns a plain http agent against the loopback edge", () => {
7
+ expect(buildNgrokSpawnArgs(7830)).toEqual([
8
+ "http",
9
+ "127.0.0.1:7830",
10
+ "--log=stdout",
11
+ ]);
12
+ });
13
+
14
+ test("never passes --config so the default (authtoken-bearing) config loads", () => {
15
+ const args = buildNgrokSpawnArgs(7830, "me.ngrok.app");
16
+ expect(args.some((arg) => arg.startsWith("--config"))).toBe(false);
17
+ });
18
+
19
+ test("binds a reserved domain when given", () => {
20
+ expect(buildNgrokSpawnArgs(7830, "my-assistant.ngrok.app")).toEqual([
21
+ "http",
22
+ "127.0.0.1:7830",
23
+ "--log=stdout",
24
+ "--domain=my-assistant.ngrok.app",
25
+ ]);
26
+ });
27
+ });
28
+
29
+ describe("isNgrokAuthError", () => {
30
+ test("recognizes the free-account authtoken requirement", () => {
31
+ expect(
32
+ isNgrokAuthError(
33
+ "FATAL: authentication failed: Usage of ngrok requires a free authtoken. ERR_NGROK_105",
34
+ ),
35
+ ).toBe(true);
36
+ });
37
+
38
+ test("recognizes an unauthorized token", () => {
39
+ expect(isNgrokAuthError("unauthorized authtoken ERR_NGROK_401")).toBe(true);
40
+ });
41
+
42
+ test("does not claim other startup failures are auth errors", () => {
43
+ expect(
44
+ isNgrokAuthError(
45
+ "creating tunnel session failed: dial tcp 3.219.x.x:443: connection refused",
46
+ ),
47
+ ).toBe(false);
48
+ });
49
+ });
@@ -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();
@@ -2821,7 +2821,7 @@ describe("auth + transient-error resilience", () => {
2821
2821
  await teleport();
2822
2822
  expect(pollCalls).toBeGreaterThanOrEqual(2);
2823
2823
  expect(warnSpy).toHaveBeenCalledWith(
2824
- expect.stringContaining("polling failed, retrying"),
2824
+ expect.stringContaining("polling failed:"),
2825
2825
  );
2826
2826
  } finally {
2827
2827
  restoreFetch();
@@ -84,9 +84,13 @@ const EDGE_PORT = 18080;
84
84
  const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
85
85
  async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
86
86
  );
87
+ const getNginxVersionMock = mock<typeof nginxIngress.getNginxVersion>(
88
+ () => "nginx version (test)",
89
+ );
87
90
  mock.module("../lib/nginx-ingress.js", () => ({
88
91
  ...realNginxIngress,
89
92
  ensureTunnelEdge: ensureTunnelEdgeMock,
93
+ getNginxVersion: getNginxVersionMock,
90
94
  }));
91
95
 
92
96
  const { tunnel } = await import("../commands/tunnel.js");
@@ -324,7 +328,6 @@ describe("tunnel edge targeting", () => {
324
328
  });
325
329
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
326
330
  expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
327
- expect(logs).toContain("serves remote web + webhooks");
328
331
  });
329
332
 
330
333
  test("the tunnel level notifies the gateway of the provider's public URL", async () => {
@@ -673,7 +676,6 @@ describe("tunnel edge targeting", () => {
673
676
  workspaceDir: join(entry.resources!.instanceDir, "workspace"),
674
677
  });
675
678
  expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
676
- expect(logs).toContain("serves webhooks only");
677
679
  });
678
680
 
679
681
  test("a bare invocation tunnels the default provider", async () => {
@@ -782,7 +784,7 @@ describe("tunnel edge targeting", () => {
782
784
  ensureTunnelEdgeMock.mockRejectedValue(
783
785
  new Error(
784
786
  "nginx is not installed, so the tunnel edge cannot start. " +
785
- "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
787
+ "See https://nginx.org/en/docs/install.html for how to install, " +
786
788
  "or point NGINX_BIN at an existing binary.",
787
789
  ),
788
790
  );
@@ -790,7 +792,9 @@ describe("tunnel edge targeting", () => {
790
792
  const { exited, errors } = await runTunnelExpectingExit1();
791
793
 
792
794
  expect(exited).toBe(true);
793
- expect(errors).toContain("brew install nginx");
795
+ expect(errors).toContain(
796
+ "See https://nginx.org/en/docs/install.html for how to install",
797
+ );
794
798
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
795
799
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
796
800
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
@@ -1139,13 +1143,14 @@ describe("ngrok --domain spawn args", () => {
1139
1143
  expect(spawnMock).toHaveBeenCalledTimes(1);
1140
1144
  const [cmd, args] = spawnMock.mock.calls[0];
1141
1145
  expect(cmd).toBe("ngrok");
1146
+ // Plain `ngrok http` so the user's default config — where their
1147
+ // authtoken lives — is what the agent loads.
1142
1148
  expect(args).toEqual([
1143
- "start",
1144
- "cadet",
1145
- `--config=${realNgrok.ngrokConfigPath()}`,
1149
+ "http",
1150
+ "127.0.0.1:7831",
1146
1151
  "--log=stdout",
1152
+ "--domain=foo.ngrok.app",
1147
1153
  ]);
1148
- // The reserved domain rides the XDG config the agent loads, not a flag.
1149
1154
  expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1150
1155
 
1151
1156
  const config = IngressTestConfigSchema.parse(
@@ -1430,11 +1435,13 @@ describe("ngrok --domain spawn args", () => {
1430
1435
  expect(spawnMock).toHaveBeenCalledTimes(1);
1431
1436
  const [cmd, args] = spawnMock.mock.calls[0];
1432
1437
  expect(cmd).toBe("ngrok");
1438
+ // Plain `ngrok http` so the user's default config — where their
1439
+ // authtoken lives — is what the agent loads.
1433
1440
  expect(args).toEqual([
1434
- "start",
1435
- "cadet",
1436
- `--config=${realNgrok.ngrokConfigPath()}`,
1441
+ "http",
1442
+ "127.0.0.1:7830",
1437
1443
  "--log=stdout",
1444
+ "--domain=foo.ngrok.app",
1438
1445
  ]);
1439
1446
 
1440
1447
  const config = IngressTestConfigSchema.parse(
@@ -1469,10 +1476,10 @@ describe("ngrok --domain spawn args", () => {
1469
1476
  expect(spawnMock).toHaveBeenCalledTimes(1);
1470
1477
  const [, args] = spawnMock.mock.calls[0];
1471
1478
  expect(args).toEqual([
1472
- "start",
1473
- "cadet",
1474
- `--config=${realNgrok.ngrokConfigPath()}`,
1479
+ "http",
1480
+ "127.0.0.1:7831",
1475
1481
  "--log=stdout",
1482
+ "--domain=foo.ngrok.app",
1476
1483
  ]);
1477
1484
 
1478
1485
  const config = IngressTestConfigSchema.parse(
@@ -102,7 +102,7 @@ describe("cadet uninstall", () => {
102
102
  // not an assistant guard.
103
103
  await expect(uninstall()).rejects.toThrow("process.exit:1");
104
104
  expect(warnSpy).toHaveBeenCalledWith(
105
- expect.stringContaining("1 assistant(s) still exist"),
105
+ expect.stringContaining("1 assistant still exists"),
106
106
  );
107
107
  expect(warnSpy).toHaveBeenCalledWith(
108
108
  expect.stringContaining("will be retired now"),
@@ -1503,14 +1503,14 @@ describe("cadet wake — tunnel edge restore", () => {
1503
1503
  loadRawConfigMock.mockReturnValue(webhookConfig);
1504
1504
  ensureTunnelEdgeMock.mockRejectedValue(
1505
1505
  new Error(
1506
- "nginx is not installed, so the tunnel edge cannot start. Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) or point NGINX_BIN at an existing binary.",
1506
+ "nginx is not installed, so the tunnel edge cannot start. See https://nginx.org/en/docs/install.html for how to install, or point NGINX_BIN at an existing binary.",
1507
1507
  ),
1508
1508
  );
1509
1509
 
1510
1510
  await wake();
1511
1511
 
1512
1512
  expect(warnSpy).toHaveBeenCalledWith(
1513
- expect.stringContaining("brew install nginx"),
1513
+ expect.stringContaining("https://nginx.org/en/docs/install.html"),
1514
1514
  );
1515
1515
  // No tunnel is recorded here, so no provider can be named and the advice
1516
1516
  // points at the help that lists them.
@@ -42,10 +42,10 @@ export const backup = Object.assign(
42
42
  console.log("Options:");
43
43
  console.log(" --output <path> Path to save the .vbundle file");
44
44
  console.log(
45
- " (default: ~/.local/share/cadet/backups/<name>-<timestamp>.vbundle)",
45
+ " defaults to ~/.local/share/cadet/backups/<name>-<timestamp>.vbundle",
46
46
  );
47
47
  console.log(
48
- ` --export-timeout <secs> Export request timeout in seconds (default: ${DEFAULT_EXPORT_TIMEOUT_MS / 1000})`,
48
+ ` --export-timeout <secs> Export request timeout in seconds, defaults to ${DEFAULT_EXPORT_TIMEOUT_MS / 1000}`,
49
49
  );
50
50
  console.log("");
51
51
  console.log("Examples:");
@@ -254,7 +254,7 @@ async function backupPlatform(
254
254
  const cloudToken = readCloudToken();
255
255
  if (!cloudToken) {
256
256
  console.error(
257
- "Not logged in. Run 'cadet login' first (required for cloud-managed backup).",
257
+ "Not logged in. Run 'cadet login' first.",
258
258
  );
259
259
  process.exit(1);
260
260
  }