@mycadet/cli 0.12.7 → 0.12.9-dev.202610131452.9acab64

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 (185) hide show
  1. package/README.md +2 -2
  2. package/knip.json +1 -1
  3. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +21 -0
  4. package/node_modules/@mycadet/assistant-api/events/host-bash.ts +1 -1
  5. package/node_modules/@mycadet/assistant-api/events/host-cu.ts +1 -1
  6. package/node_modules/@mycadet/assistant-api/events/host-file.ts +1 -1
  7. package/node_modules/@mycadet/assistant-api/events/notification-intent.ts +1 -1
  8. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +18 -0
  9. package/node_modules/@mycadet/assistant-api/index.ts +11 -11
  10. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +2 -2
  11. package/node_modules/@mycadet/{service-contracts/src/platform-credential.ts → assistant-api/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  12. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  13. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  14. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  15. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  16. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  17. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  18. package/node_modules/@mycadet/assistant-api/responses/home.ts +1 -1
  19. package/node_modules/@mycadet/assistant-api/surfaces.ts +4 -4
  20. package/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  21. package/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  22. package/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  23. package/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  24. package/node_modules/@mycadet/environments/src/types.ts +6 -6
  25. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  26. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  27. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  28. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  29. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/types.ts +6 -6
  30. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +2 -2
  31. package/node_modules/@mycadet/{assistant-api/node_modules/@mycadet/service-contracts/src/platform-credential.ts → local-mode/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  32. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  33. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  34. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  35. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  36. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  37. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  38. package/node_modules/@mycadet/local-mode/src/__tests__/pair.test.ts +5 -5
  39. package/node_modules/@mycadet/local-mode/src/__tests__/retire.test.ts +1 -1
  40. package/node_modules/@mycadet/local-mode/src/config.ts +2 -2
  41. package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
  42. package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +3 -3
  43. package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +10 -5
  44. package/node_modules/@mycadet/local-mode/src/lockfile.test.ts +15 -15
  45. package/node_modules/@mycadet/local-mode/src/lockfile.ts +9 -9
  46. package/node_modules/@mycadet/local-mode/src/pair.ts +2 -2
  47. package/node_modules/@mycadet/local-mode/src/retire.ts +3 -3
  48. package/node_modules/@mycadet/local-mode/src/teleport-backup-policy.ts +2 -2
  49. package/node_modules/@mycadet/local-mode/src/unpair.ts +2 -2
  50. package/node_modules/@mycadet/service-contracts/package.json +2 -2
  51. package/node_modules/@mycadet/{local-mode/node_modules/@mycadet/service-contracts/src/platform-credential.ts → service-contracts/src/cloud-credential.ts} +9 -9
  52. package/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  53. package/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  54. package/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  55. package/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  56. package/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  57. package/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  58. package/package.json +6 -4
  59. package/src/__tests__/assistant-api-key-resolution.test.ts +6 -6
  60. package/src/__tests__/{assistant-client-platform-auth.test.ts → assistant-client-cloud-auth.test.ts} +10 -10
  61. package/src/__tests__/assistant-client-refresh.test.ts +2 -2
  62. package/src/__tests__/backup.test.ts +86 -91
  63. package/src/__tests__/client-token.test.ts +1 -1
  64. package/src/__tests__/client-tui-refresh.test.ts +1 -1
  65. package/src/__tests__/client-web-config.test.ts +1 -2
  66. package/src/__tests__/{platform-client.test.ts → cloud-client.test.ts} +59 -59
  67. package/src/__tests__/{platform-releases.test.ts → cloud-releases.test.ts} +28 -18
  68. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  69. package/src/__tests__/connect-import.test.ts +55 -39
  70. package/src/__tests__/debug.test.ts +38 -38
  71. package/src/__tests__/ensure-binary.test.ts +182 -0
  72. package/src/__tests__/guardian-token.test.ts +149 -56
  73. package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
  74. package/src/__tests__/helpers/guardian-token.ts +22 -0
  75. package/src/__tests__/helpers/http.ts +10 -0
  76. package/src/__tests__/nginx-ingress.test.ts +15 -20
  77. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  78. package/src/__tests__/orphan-detection.test.ts +1 -1
  79. package/src/__tests__/pair.test.ts +149 -85
  80. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +80 -26
  81. package/src/__tests__/ps-table.test.ts +79 -0
  82. package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
  83. package/src/__tests__/retire.test.ts +58 -199
  84. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  85. package/src/__tests__/sleep.test.ts +19 -19
  86. package/src/__tests__/statefulset.test.ts +8 -8
  87. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +2 -2
  88. package/src/__tests__/teleport-backup.test.ts +3 -3
  89. package/src/__tests__/teleport.test.ts +180 -211
  90. package/src/__tests__/tunnel.test.ts +490 -270
  91. package/src/__tests__/uninstall.test.ts +150 -9
  92. package/src/__tests__/upgrade-local.test.ts +3 -3
  93. package/src/__tests__/upgrade-replay-env.test.ts +12 -12
  94. package/src/__tests__/wake.test.ts +260 -43
  95. package/src/__tests__/workos-pkce.test.ts +2 -2
  96. package/src/commands/backup.ts +24 -24
  97. package/src/commands/client.ts +76 -78
  98. package/src/commands/completions.test.ts +104 -0
  99. package/src/commands/completions.ts +87 -0
  100. package/src/commands/debug.ts +20 -20
  101. package/src/commands/exec.ts +12 -12
  102. package/src/commands/flags.ts +2 -2
  103. package/src/commands/hatch.ts +27 -24
  104. package/src/commands/login.ts +22 -22
  105. package/src/commands/logs.ts +8 -8
  106. package/src/commands/pair.ts +140 -37
  107. package/src/commands/ps.ts +232 -86
  108. package/src/commands/restart.ts +30 -0
  109. package/src/commands/restore.ts +140 -22
  110. package/src/commands/retire.ts +48 -150
  111. package/src/commands/rollback.ts +20 -26
  112. package/src/commands/setup.ts +2 -2
  113. package/src/commands/sleep.ts +2 -2
  114. package/src/commands/teleport.ts +110 -129
  115. package/src/commands/terminal.ts +158 -18
  116. package/src/commands/tunnel.ts +156 -175
  117. package/src/commands/uninstall.ts +137 -54
  118. package/src/commands/unpair.ts +3 -3
  119. package/src/commands/upgrade.ts +30 -54
  120. package/src/commands/use.ts +1 -1
  121. package/src/commands/wake.ts +166 -50
  122. package/src/index.ts +18 -9
  123. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  124. package/src/lib/__tests__/docker.test.ts +168 -20
  125. package/src/lib/__tests__/job-polling.test.ts +2 -2
  126. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  127. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  128. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  129. package/src/lib/assistant-api-key-resolution.ts +5 -5
  130. package/src/lib/assistant-client.ts +8 -8
  131. package/src/lib/assistant-config.ts +58 -12
  132. package/src/lib/{platform-client.ts → cloud-client.ts} +105 -99
  133. package/src/lib/cloud-login.ts +23 -24
  134. package/src/lib/{platform-releases.ts → cloud-releases.ts} +11 -9
  135. package/src/lib/cloud-values.ts +3 -0
  136. package/src/lib/color.ts +4 -0
  137. package/src/lib/config-utils.ts +1 -1
  138. package/src/lib/detached-process.ts +1 -1
  139. package/src/lib/docker.ts +300 -111
  140. package/src/lib/ensure-binary.ts +171 -0
  141. package/src/lib/environments/__tests__/paths.test.ts +29 -3
  142. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  143. package/src/lib/environments/paths.ts +16 -8
  144. package/src/lib/environments/resolve.ts +10 -12
  145. package/src/lib/flag-args.test.ts +1 -1
  146. package/src/lib/flag-args.ts +1 -1
  147. package/src/lib/guardian-token.ts +37 -5
  148. package/src/lib/hatch-local.ts +32 -29
  149. package/src/lib/hatch-next-steps.ts +5 -9
  150. package/src/lib/health-check.ts +6 -6
  151. package/src/lib/ingress-config.ts +8 -35
  152. package/src/lib/job-polling.ts +2 -2
  153. package/src/lib/live-voice/connection.ts +12 -12
  154. package/src/lib/live-voice/tunnel.test.ts +11 -11
  155. package/src/lib/live-voice/tunnel.ts +15 -15
  156. package/src/lib/local-runtime-client.ts +20 -20
  157. package/src/lib/local.ts +46 -34
  158. package/src/lib/nginx-ingress.ts +40 -15
  159. package/src/lib/pair.ts +1 -1
  160. package/src/lib/process.ts +1 -1
  161. package/src/lib/provider-secrets.ts +3 -3
  162. package/src/lib/retire-apple-container.ts +1 -1
  163. package/src/lib/runtime-url.ts +8 -8
  164. package/src/lib/statefulset.ts +2 -2
  165. package/src/lib/step-runner.ts +6 -1
  166. package/src/lib/sync-cloud-assistants.ts +19 -19
  167. package/src/lib/teleport-backup.ts +4 -4
  168. package/src/lib/terminal-client.ts +17 -17
  169. package/src/lib/terminal-session.ts +24 -24
  170. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  171. package/src/lib/tunnels/cadet.ts +29 -0
  172. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +36 -15
  173. package/src/lib/tunnels/gateway-notify.ts +61 -0
  174. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +350 -89
  175. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +53 -9
  176. package/src/lib/upgrade-lifecycle.ts +8 -21
  177. package/src/lib/upgrade-preflight.ts +2 -2
  178. package/src/lib/workos-pkce.ts +5 -5
  179. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  180. package/node_modules/@mycadet/assistant-api/events/show-platform-login.ts +0 -20
  181. package/src/commands/recover.ts +0 -131
  182. package/src/commands/roadmap.ts +0 -459
  183. package/src/commands/ssh.ts +0 -144
  184. package/src/commands/workflows.ts +0 -305
  185. package/src/lib/managed-cloud.ts +0 -3
@@ -19,7 +19,9 @@ process.env.CADET_LOCKFILE_DIR = testDir;
19
19
  import * as assistantConfig from "../lib/assistant-config.js";
20
20
  import * as guardianToken from "../lib/guardian-token.js";
21
21
  import * as localRuntimeClient from "../lib/local-runtime-client.js";
22
- import * as platformClient from "../lib/platform-client.js";
22
+ import * as cloudClient from "../lib/cloud-client.js";
23
+ import { withPreconnect } from "./helpers/http.js";
24
+ import { makeGuardianTokenData } from "./helpers/guardian-token.js";
23
25
 
24
26
  const resolveTargetMock = spyOn(
25
27
  assistantConfig,
@@ -27,9 +29,9 @@ const resolveTargetMock = spyOn(
27
29
  ).mockImplementation(() => {
28
30
  throw new Error("process.exit:1");
29
31
  });
30
- const readPlatformTokenMock = spyOn(
31
- platformClient,
32
- "readPlatformToken",
32
+ const readCloudTokenMock = spyOn(
33
+ cloudClient,
34
+ "readCloudToken",
33
35
  ).mockReturnValue("platform-token");
34
36
  const exportMock = spyOn(
35
37
  localRuntimeClient,
@@ -46,23 +48,25 @@ const pollMock = spyOn(
46
48
  jobId: "job-1",
47
49
  type: "export",
48
50
  status: "complete",
49
- } as unknown as Awaited<
50
- ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
51
- >);
51
+ });
52
52
  const loadGuardianTokenSpy = spyOn(
53
53
  guardianToken,
54
54
  "loadGuardianToken",
55
- ).mockReturnValue({
56
- accessToken: "local-token",
57
- accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
58
- } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
55
+ ).mockReturnValue(
56
+ makeGuardianTokenData({
57
+ accessToken: "local-token",
58
+ accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
59
+ }),
60
+ );
59
61
  const leaseGuardianTokenSpy = spyOn(
60
62
  guardianToken,
61
63
  "leaseGuardianToken",
62
- ).mockResolvedValue({
63
- accessToken: "leased-token",
64
- accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
65
- } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
64
+ ).mockResolvedValue(
65
+ makeGuardianTokenData({
66
+ accessToken: "leased-token",
67
+ accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
68
+ }),
69
+ );
66
70
 
67
71
  const { debug } = await import("../commands/debug.js");
68
72
 
@@ -72,10 +76,10 @@ const LOCAL_ENTRY = {
72
76
  runtimeUrl: "http://127.0.0.1:7821",
73
77
  cloud: "local",
74
78
  hatchedAt: new Date().toISOString(),
75
- platformAssistantId: "11111111-2222-3333-4444-555555555555",
76
- platformBaseUrl: "https://cloud.mycadet.ai",
77
- platformOrganizationId: "org-1",
78
- } as unknown as assistantConfig.AssistantEntry;
79
+ cloudAssistantId: "11111111-2222-3333-4444-555555555555",
80
+ cloudBaseUrl: "https://cloud.mycadet.ai",
81
+ cloudOrganizationId: "org-1",
82
+ } satisfies assistantConfig.AssistantEntry;
79
83
 
80
84
  let originalFetch: typeof globalThis.fetch;
81
85
  let originalExit: typeof process.exit;
@@ -86,12 +90,12 @@ beforeEach(() => {
86
90
  process.argv = ["bun", "cadet", "debug"];
87
91
  originalFetch = globalThis.fetch;
88
92
  originalExit = process.exit;
89
- process.exit = mock((code?: number) => {
93
+ process.exit = mock((code?: number | string | undefined): never => {
90
94
  throw new Error(`process.exit:${code}`);
91
- }) as unknown as typeof process.exit;
95
+ });
92
96
  fetchCalls = [];
93
97
  uploadUrlStatus = 201;
94
- globalThis.fetch = mock(
98
+ globalThis.fetch = withPreconnect(
95
99
  async (input: RequestInfo | URL, init?: RequestInit) => {
96
100
  fetchCalls.push({ url: String(input), init });
97
101
  return new Response(
@@ -102,14 +106,14 @@ beforeEach(() => {
102
106
  },
103
107
  );
104
108
  },
105
- ) as unknown as typeof globalThis.fetch;
109
+ );
106
110
  resolveTargetMock.mockReset();
107
111
  resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
108
112
  identityMock.mockReset();
109
113
  identityMock.mockResolvedValue({ version: "0.12.3" });
110
114
  leaseGuardianTokenSpy.mockClear();
111
- readPlatformTokenMock.mockReset();
112
- readPlatformTokenMock.mockReturnValue("platform-token");
115
+ readCloudTokenMock.mockReset();
116
+ readCloudTokenMock.mockReturnValue("platform-token");
113
117
  exportMock.mockClear();
114
118
  pollMock.mockClear();
115
119
  });
@@ -123,7 +127,7 @@ afterAll(() => {
123
127
  resolveTargetMock.mockRestore();
124
128
  identityMock.mockRestore();
125
129
  leaseGuardianTokenSpy.mockRestore();
126
- readPlatformTokenMock.mockRestore();
130
+ readCloudTokenMock.mockRestore();
127
131
  exportMock.mockRestore();
128
132
  pollMock.mockRestore();
129
133
  loadGuardianTokenSpy.mockRestore();
@@ -131,7 +135,7 @@ afterAll(() => {
131
135
  });
132
136
 
133
137
  describe("cadet debug", () => {
134
- test("mints the URL on the platform, exports with the debug profile, and waits", async () => {
138
+ test("mints the URL on the cloud, exports with the debug profile, and waits", async () => {
135
139
  await debug();
136
140
 
137
141
  expect(fetchCalls).toHaveLength(1);
@@ -140,9 +144,7 @@ describe("cadet debug", () => {
140
144
  );
141
145
  expect(fetchCalls[0].init?.method).toBe("POST");
142
146
  expect(
143
- (fetchCalls[0].init?.headers as Record<string, string>)[
144
- "Cadet-Organization-Id"
145
- ],
147
+ new Headers(fetchCalls[0].init?.headers).get("Cadet-Organization-Id"),
146
148
  ).toBe("org-1");
147
149
 
148
150
  expect(exportMock).toHaveBeenCalledTimes(1);
@@ -155,7 +157,7 @@ describe("cadet debug", () => {
155
157
  profile: "debug",
156
158
  });
157
159
  expect(pollMock).toHaveBeenCalled();
158
- // The daemon was checked for the debug profile before the platform call.
160
+ // The daemon was checked for the debug profile before the cloud call.
159
161
  expect(identityMock).toHaveBeenCalledTimes(1);
160
162
  });
161
163
 
@@ -196,9 +198,7 @@ describe("cadet debug", () => {
196
198
  jobId: "job-1",
197
199
  type: "export",
198
200
  status: "complete",
199
- } as unknown as Awaited<
200
- ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
201
- >;
201
+ };
202
202
  });
203
203
 
204
204
  await debug();
@@ -207,7 +207,7 @@ describe("cadet debug", () => {
207
207
  expect(polls).toBe(2);
208
208
  });
209
209
 
210
- test("explains what to turn on when the platform refuses", async () => {
210
+ test("explains what to turn on when the cloud refuses", async () => {
211
211
  uploadUrlStatus = 403;
212
212
  const errors: string[] = [];
213
213
  const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
@@ -226,7 +226,7 @@ describe("cadet debug", () => {
226
226
  resolveTargetMock.mockReturnValue({
227
227
  ...LOCAL_ENTRY,
228
228
  cloud: "cadet",
229
- } as unknown as assistantConfig.AssistantEntry);
229
+ });
230
230
  const errorSpy = spyOn(console, "error").mockImplementation(() => {});
231
231
  try {
232
232
  await expect(debug()).rejects.toThrow("process.exit:1");
@@ -239,8 +239,8 @@ describe("cadet debug", () => {
239
239
  test("asks for a login when the assistant is not registered", async () => {
240
240
  resolveTargetMock.mockReturnValue({
241
241
  ...LOCAL_ENTRY,
242
- platformAssistantId: undefined,
243
- } as unknown as assistantConfig.AssistantEntry);
242
+ cloudAssistantId: undefined,
243
+ });
244
244
  const errors: string[] = [];
245
245
  const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
246
246
  errors.push(String(msg));
@@ -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
+ });
@@ -16,6 +16,8 @@ import { guardianTokenPath, resolveConfigDir } from "@mycadet/local-mode";
16
16
  import {
17
17
  getOrCreatePersistedDeviceId,
18
18
  guardianTokenDueForRenewal,
19
+ GuardianLeasePermanentError,
20
+ leaseGuardianToken,
19
21
  loadGuardianToken,
20
22
  refreshGuardianToken,
21
23
  refreshGuardianTokenResult,
@@ -25,6 +27,7 @@ import {
25
27
  } from "../lib/guardian-token.js";
26
28
  import { getConfigDir } from "../lib/environments/paths.js";
27
29
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
30
+ import { withPreconnect } from "./helpers/http.js";
28
31
 
29
32
  function makeTokenData(suffix: string): GuardianTokenData {
30
33
  const now = new Date().toISOString();
@@ -286,19 +289,21 @@ describe("refreshGuardianToken", () => {
286
289
  test("refreshes, persists the rotated token, sends an abort signal, releases the lock", async () => {
287
290
  seed(future());
288
291
  let sawSignal = false;
289
- globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
290
- sawSignal = init?.signal instanceof AbortSignal;
291
- return new Response(
292
- JSON.stringify({
293
- accessToken: "new-acc",
294
- refreshToken: "new-ref",
295
- accessTokenExpiresAt: future(),
296
- refreshTokenExpiresAt: future(),
297
- refreshAfter: "",
298
- }),
299
- { status: 200, headers: { "content-type": "application/json" } },
300
- );
301
- }) as typeof fetch;
292
+ globalThis.fetch = withPreconnect(
293
+ async (_url: unknown, init?: RequestInit) => {
294
+ sawSignal = init?.signal instanceof AbortSignal;
295
+ return new Response(
296
+ JSON.stringify({
297
+ accessToken: "new-acc",
298
+ refreshToken: "new-ref",
299
+ accessTokenExpiresAt: future(),
300
+ refreshTokenExpiresAt: future(),
301
+ refreshAfter: "",
302
+ }),
303
+ { status: 200, headers: { "content-type": "application/json" } },
304
+ );
305
+ },
306
+ );
302
307
 
303
308
  const result = await refreshGuardianToken("https://gw.example.com", "px");
304
309
 
@@ -311,13 +316,81 @@ describe("refreshGuardianToken", () => {
311
316
  expect(existsSync(lockPath("px"))).toBe(false); // lock released
312
317
  });
313
318
 
319
+ test("leases against the gateway's real init shape — refreshAfter arrives as a number", async () => {
320
+ // The gateway's GuardianBootstrapResult serializes refreshAfter as a
321
+ // NUMBER of ms (mintRefreshToken), and the strict lease check rejects a
322
+ // response missing any field. A string-only schema read that number as
323
+ // malformed, so every real hatch failed its lease while the ISO-string
324
+ // test doubles here stayed green.
325
+ globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
326
+ const url = String(input);
327
+ if (url.endsWith("/readyz")) {
328
+ return new Response("", { status: 404 }); // no starting gate — proceed
329
+ }
330
+ return new Response(
331
+ JSON.stringify({
332
+ guardianPrincipalId: "principal-lease",
333
+ accessToken: "access-lease",
334
+ accessTokenExpiresAt: Date.now() + 60 * 60 * 1000,
335
+ refreshToken: "refresh-lease",
336
+ refreshTokenExpiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
337
+ refreshAfter: Date.now() + 48 * 60 * 60 * 1000,
338
+ isNew: true,
339
+ mintedOverPriorEvidence: false,
340
+ }),
341
+ { status: 200, headers: { "content-type": "application/json" } },
342
+ );
343
+ });
344
+
345
+ const leased = await leaseGuardianToken("https://gw.example.com", "px");
346
+
347
+ expect(leased.accessToken).toBe("access-lease");
348
+ expect(leased.refreshAfter).toBeDefined();
349
+ const stored = loadGuardianToken("px");
350
+ expect(stored?.accessToken).toBe("access-lease");
351
+ expect(stored?.refreshAfter).toBe(leased.refreshAfter);
352
+ });
353
+
354
+ test("a malformed 200 names the unusable fields and is not retried", async () => {
355
+ globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
356
+ const url = String(input);
357
+ if (url.endsWith("/readyz")) {
358
+ return new Response("", { status: 404 });
359
+ }
360
+ return new Response(
361
+ JSON.stringify({
362
+ guardianPrincipalId: "principal-lease",
363
+ accessToken: 42,
364
+ refreshToken: "refresh-lease",
365
+ }),
366
+ { status: 200, headers: { "content-type": "application/json" } },
367
+ );
368
+ });
369
+
370
+ const err = await leaseGuardianToken("https://gw.example.com", "px").then(
371
+ () => null,
372
+ (e: unknown) => e,
373
+ );
374
+
375
+ if (!(err instanceof GuardianLeasePermanentError)) {
376
+ throw new Error(
377
+ `expected GuardianLeasePermanentError, got ${String(err)}`,
378
+ );
379
+ }
380
+ expect(err.message).toContain("(200)");
381
+ expect(err.message).toContain("accessToken arrived as number");
382
+ expect(err.message).toContain("refreshTokenExpiresAt arrived as absent");
383
+ });
384
+
314
385
  test("returns null without calling the gateway when the refresh token is expired", async () => {
315
386
  seed(new Date(Date.now() - 1000).toISOString());
316
387
  let called = false;
317
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
318
- called = true;
319
- return new Response("", { status: 200 });
320
- }) as typeof fetch;
388
+ globalThis.fetch = withPreconnect(
389
+ async (_url: unknown, _init?: RequestInit) => {
390
+ called = true;
391
+ return new Response("", { status: 200 });
392
+ },
393
+ );
321
394
 
322
395
  expect(
323
396
  await refreshGuardianToken("https://gw.example.com", "px"),
@@ -327,10 +400,12 @@ describe("refreshGuardianToken", () => {
327
400
 
328
401
  test("returns null when there is no stored token", async () => {
329
402
  let called = false;
330
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
331
- called = true;
332
- return new Response("", { status: 200 });
333
- }) as typeof fetch;
403
+ globalThis.fetch = withPreconnect(
404
+ async (_url: unknown, _init?: RequestInit) => {
405
+ called = true;
406
+ return new Response("", { status: 200 });
407
+ },
408
+ );
334
409
 
335
410
  expect(
336
411
  await refreshGuardianToken("https://gw.example.com", "missing"),
@@ -348,11 +423,13 @@ describe("refreshGuardianToken", () => {
348
423
  const old = new Date(Date.now() - 60_000);
349
424
  utimesSync(lp, old, old);
350
425
 
351
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
352
- new Response(JSON.stringify({ accessToken: "new-acc" }), {
353
- status: 200,
354
- headers: { "content-type": "application/json" },
355
- })) as typeof fetch;
426
+ globalThis.fetch = withPreconnect(
427
+ async (_url: unknown, _init?: RequestInit) =>
428
+ new Response(JSON.stringify({ accessToken: "new-acc" }), {
429
+ status: 200,
430
+ headers: { "content-type": "application/json" },
431
+ }),
432
+ );
356
433
 
357
434
  const result = await refreshGuardianToken("https://gw.example.com", "px");
358
435
  expect(result?.accessToken).toBe("new-acc");
@@ -371,13 +448,15 @@ describe("refreshGuardianToken", () => {
371
448
  ]) {
372
449
  seed(future());
373
450
  let called = false;
374
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
375
- called = true;
376
- return new Response(JSON.stringify({ accessToken: "new-acc" }), {
377
- status: 200,
378
- headers: { "content-type": "application/json" },
379
- });
380
- }) as typeof fetch;
451
+ globalThis.fetch = withPreconnect(
452
+ async (_url: unknown, _init?: RequestInit) => {
453
+ called = true;
454
+ return new Response(JSON.stringify({ accessToken: "new-acc" }), {
455
+ status: 200,
456
+ headers: { "content-type": "application/json" },
457
+ });
458
+ },
459
+ );
381
460
 
382
461
  expect(await refreshGuardianToken(url, "px")).not.toBeNull();
383
462
  expect(called).toBe(true); // loopback http is allowed
@@ -387,10 +466,12 @@ describe("refreshGuardianToken", () => {
387
466
  test("refuses a non-loopback plaintext http URL: no fetch, returns null, warns", async () => {
388
467
  seed(future());
389
468
  let called = false;
390
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
391
- called = true;
392
- return new Response("", { status: 200 });
393
- }) as typeof fetch;
469
+ globalThis.fetch = withPreconnect(
470
+ async (_url: unknown, _init?: RequestInit) => {
471
+ called = true;
472
+ return new Response("", { status: 200 });
473
+ },
474
+ );
394
475
 
395
476
  const origWarn = console.warn;
396
477
  let warned = false;
@@ -411,10 +492,12 @@ describe("refreshGuardianToken", () => {
411
492
  test("refuses a malformed gateway URL: no fetch, returns null", async () => {
412
493
  seed(future());
413
494
  let called = false;
414
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
415
- called = true;
416
- return new Response("", { status: 200 });
417
- }) as typeof fetch;
495
+ globalThis.fetch = withPreconnect(
496
+ async (_url: unknown, _init?: RequestInit) => {
497
+ called = true;
498
+ return new Response("", { status: 200 });
499
+ },
500
+ );
418
501
 
419
502
  const origWarn = console.warn;
420
503
  console.warn = () => {};
@@ -428,11 +511,13 @@ describe("refreshGuardianToken", () => {
428
511
 
429
512
  test("an unreachable gateway is a 503, not a 401", async () => {
430
513
  seed(future());
431
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
432
- throw Object.assign(new TypeError("fetch failed"), {
433
- cause: { code: "ECONNREFUSED" },
434
- });
435
- }) as unknown as typeof fetch;
514
+ globalThis.fetch = withPreconnect(
515
+ async (_url: unknown, _init?: RequestInit) => {
516
+ throw Object.assign(new TypeError("fetch failed"), {
517
+ cause: { code: "ECONNREFUSED" },
518
+ });
519
+ },
520
+ );
436
521
 
437
522
  const result = await refreshGuardianTokenResult(
438
523
  "http://127.0.0.1:59999",
@@ -451,8 +536,10 @@ describe("refreshGuardianToken", () => {
451
536
 
452
537
  test("an HTTP 401 from the gateway is a spent credential", async () => {
453
538
  seed(future());
454
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
455
- new Response("", { status: 401 })) as typeof fetch;
539
+ globalThis.fetch = withPreconnect(
540
+ async (_url: unknown, _init?: RequestInit) =>
541
+ new Response("", { status: 401 }),
542
+ );
456
543
 
457
544
  expect(
458
545
  await refreshGuardianTokenResult("https://gw.example.com", "px"),
@@ -465,8 +552,10 @@ describe("refreshGuardianToken", () => {
465
552
 
466
553
  test("an HTTP 403 from the refresh route is a spent credential, not a loopback refusal", async () => {
467
554
  seed(future());
468
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
469
- new Response("", { status: 403 })) as typeof fetch;
555
+ globalThis.fetch = withPreconnect(
556
+ async (_url: unknown, _init?: RequestInit) =>
557
+ new Response("", { status: 403 }),
558
+ );
470
559
 
471
560
  expect(
472
561
  await refreshGuardianTokenResult("https://gw.example.com", "px"),
@@ -480,10 +569,12 @@ describe("refreshGuardianToken", () => {
480
569
  test("an insecure gateway URL is a local 403, not a spent credential", async () => {
481
570
  seed(future());
482
571
  let called = false;
483
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
484
- called = true;
485
- return new Response("", { status: 200 });
486
- }) as typeof fetch;
572
+ globalThis.fetch = withPreconnect(
573
+ async (_url: unknown, _init?: RequestInit) => {
574
+ called = true;
575
+ return new Response("", { status: 200 });
576
+ },
577
+ );
487
578
 
488
579
  const origWarn = console.warn;
489
580
  console.warn = () => {};
@@ -503,8 +594,10 @@ describe("refreshGuardianToken", () => {
503
594
 
504
595
  test("an HTTP 503 from a still-starting gateway is retryable", async () => {
505
596
  seed(future());
506
- globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
507
- new Response("", { status: 503 })) as typeof fetch;
597
+ globalThis.fetch = withPreconnect(
598
+ async (_url: unknown, _init?: RequestInit) =>
599
+ new Response("", { status: 503 }),
600
+ );
508
601
 
509
602
  const result = await refreshGuardianTokenResult(
510
603
  "https://gw.example.com",