@mycadet/cli 0.12.7 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) 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-platform-login.ts → show-cloud-login.ts} +3 -5
  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 +1 -1
  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 +1 -1
  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 +1 -1
  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 +3 -2
  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 +2 -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} +5 -5
  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__/guardian-token.test.ts +149 -56
  72. package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
  73. package/src/__tests__/helpers/guardian-token.ts +22 -0
  74. package/src/__tests__/helpers/http.ts +10 -0
  75. package/src/__tests__/nginx-ingress.test.ts +18 -18
  76. package/src/__tests__/orphan-detection.test.ts +1 -1
  77. package/src/__tests__/pair.test.ts +1 -8
  78. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +79 -26
  79. package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
  80. package/src/__tests__/retire.test.ts +58 -199
  81. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  82. package/src/__tests__/sleep.test.ts +19 -19
  83. package/src/__tests__/statefulset.test.ts +8 -8
  84. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
  85. package/src/__tests__/teleport-backup.test.ts +3 -3
  86. package/src/__tests__/teleport.test.ts +180 -196
  87. package/src/__tests__/tunnel.test.ts +255 -227
  88. package/src/__tests__/uninstall.test.ts +13 -4
  89. package/src/__tests__/upgrade-local.test.ts +3 -3
  90. package/src/__tests__/upgrade-replay-env.test.ts +12 -12
  91. package/src/__tests__/wake.test.ts +258 -41
  92. package/src/__tests__/workos-pkce.test.ts +2 -2
  93. package/src/commands/backup.ts +22 -22
  94. package/src/commands/client.ts +76 -76
  95. package/src/commands/debug.ts +20 -20
  96. package/src/commands/exec.ts +5 -5
  97. package/src/commands/hatch.ts +22 -22
  98. package/src/commands/login.ts +22 -22
  99. package/src/commands/logs.ts +1 -1
  100. package/src/commands/ps.ts +188 -79
  101. package/src/commands/restart.ts +30 -0
  102. package/src/commands/restore.ts +138 -20
  103. package/src/commands/retire.ts +46 -148
  104. package/src/commands/rollback.ts +18 -24
  105. package/src/commands/setup.ts +2 -2
  106. package/src/commands/teleport.ts +93 -93
  107. package/src/commands/terminal.ts +157 -17
  108. package/src/commands/tunnel.ts +80 -122
  109. package/src/commands/uninstall.ts +55 -7
  110. package/src/commands/unpair.ts +3 -3
  111. package/src/commands/upgrade.ts +22 -46
  112. package/src/commands/wake.ts +162 -46
  113. package/src/index.ts +3 -9
  114. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  115. package/src/lib/__tests__/job-polling.test.ts +1 -1
  116. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  117. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  118. package/src/lib/assistant-api-key-resolution.ts +5 -5
  119. package/src/lib/assistant-client.ts +8 -8
  120. package/src/lib/assistant-config.ts +58 -12
  121. package/src/lib/{platform-client.ts → cloud-client.ts} +107 -98
  122. package/src/lib/cloud-login.ts +24 -24
  123. package/src/lib/{platform-releases.ts → cloud-releases.ts} +5 -5
  124. package/src/lib/cloud-values.ts +3 -0
  125. package/src/lib/config-utils.ts +1 -1
  126. package/src/lib/detached-process.ts +1 -1
  127. package/src/lib/docker.ts +111 -10
  128. package/src/lib/environments/__tests__/paths.test.ts +2 -2
  129. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  130. package/src/lib/environments/resolve.ts +8 -8
  131. package/src/lib/guardian-token.ts +36 -4
  132. package/src/lib/hatch-local.ts +19 -23
  133. package/src/lib/health-check.ts +6 -6
  134. package/src/lib/ingress-config.ts +8 -35
  135. package/src/lib/job-polling.ts +1 -1
  136. package/src/lib/live-voice/connection.ts +12 -12
  137. package/src/lib/live-voice/tunnel.test.ts +11 -11
  138. package/src/lib/live-voice/tunnel.ts +15 -15
  139. package/src/lib/local-runtime-client.ts +20 -20
  140. package/src/lib/local.ts +9 -30
  141. package/src/lib/nginx-ingress.ts +41 -14
  142. package/src/lib/process.ts +1 -1
  143. package/src/lib/provider-secrets.ts +3 -3
  144. package/src/lib/retire-apple-container.ts +1 -1
  145. package/src/lib/runtime-url.ts +8 -8
  146. package/src/lib/statefulset.ts +2 -2
  147. package/src/lib/sync-cloud-assistants.ts +18 -18
  148. package/src/lib/teleport-backup.ts +4 -4
  149. package/src/lib/terminal-client.ts +17 -17
  150. package/src/lib/terminal-session.ts +24 -24
  151. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  152. package/src/lib/tunnels/cadet.ts +29 -0
  153. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
  154. package/src/lib/tunnels/gateway-notify.ts +61 -0
  155. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +201 -30
  156. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
  157. package/src/lib/upgrade-lifecycle.ts +3 -17
  158. package/src/lib/upgrade-preflight.ts +2 -2
  159. package/src/lib/workos-pkce.ts +5 -5
  160. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  161. package/src/commands/recover.ts +0 -131
  162. package/src/commands/roadmap.ts +0 -459
  163. package/src/commands/ssh.ts +0 -144
  164. package/src/commands/workflows.ts +0 -305
  165. package/src/lib/managed-cloud.ts +0 -3
@@ -1,18 +1,31 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
3
10
  import { dirname, join } from "node:path";
4
11
 
5
- import { GATEWAY_PORT } from "./constants.js";
12
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
13
+
14
+ import { GATEWAY_PORT } from "../constants.js";
15
+ import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
6
16
  import {
7
17
  clearIngressUrl,
8
18
  getDefaultWorkspaceDir,
9
- loadNgrokDomain,
10
19
  loadRawConfig,
11
20
  saveIngressUrl,
12
- saveNgrokDomain,
13
- } from "./ingress-config.js";
14
- import { loopbackSafeFetch } from "./loopback-fetch.js";
21
+ } from "../ingress-config.js";
22
+ import { getConfigDir } from "../environments/paths.js";
23
+ import { getCurrentEnvironment } from "../environments/resolve.js";
24
+ import { loopbackSafeFetch } from "../loopback-fetch.js";
25
+ import { readProcessCommandLine } from "../process.js";
15
26
  import { z } from "zod";
27
+ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
28
+ import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
16
29
 
17
30
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
18
31
  const NGROK_POLL_INTERVAL_MS = 500;
@@ -42,6 +55,14 @@ export const IngressConfigSchema = z.object({
42
55
  publicBaseUrl: z.string().optional(),
43
56
  });
44
57
 
58
+ /** Per-provider copy `cadet tunnel`'s help renders for ngrok. */
59
+ export const NGROK_TUNNEL_META: ProviderMeta = {
60
+ reach: "Public tunnel that webhook integrations can reach",
61
+ install: "install: brew install ngrok/ngrok/ngrok",
62
+ domainExample: "my-assistant.ngrok.app",
63
+ supportsReservedDomain: true,
64
+ };
65
+
45
66
  /**
46
67
  * Check whether ngrok is installed and accessible on the PATH.
47
68
  * Returns the version string if installed, null otherwise.
@@ -118,9 +139,106 @@ function staleAgentDiagnostic(tunnels: NgrokTunnel[], port: number): string {
118
139
 
119
140
  /** Recovery copy for a saved domain whose reservation may have lapsed. */
120
141
  function savedDomainRecoveryHint(domain: string): string {
121
- return `The saved ngrok domain '${domain}' (ingress.ngrok.domain in the workspace config) may no longer be reserved. Run \`cadet tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
142
+ return `The saved ngrok domain '${domain}' (${ngrokConfigPath()}) may no longer be reserved. Run \`cadet tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
143
+ }
144
+
145
+ const NGROK_TUNNEL_NAME = "cadet";
146
+
147
+ const NgrokTunnelDefSchema = z.looseObject({
148
+ proto: z.string().optional(),
149
+ addr: z.string().optional(),
150
+ domain: z.string().optional(),
151
+ });
152
+
153
+ const NgrokConfigFileSchema = z.looseObject({
154
+ version: z.string().optional(),
155
+ tunnels: z.record(z.string(), NgrokTunnelDefSchema).optional(),
156
+ });
157
+
158
+ type NgrokConfigFile = z.infer<typeof NgrokConfigFileSchema>;
159
+ type NgrokTunnelDef = z.infer<typeof NgrokTunnelDefSchema>;
160
+
161
+ /**
162
+ * The ngrok-format config file this CLI owns in the XDG config dir, layered
163
+ * over the user's root ngrok config at spawn time (`--config` files merge in
164
+ * order, later wins; the user's authtoken keeps living in their root config).
165
+ * It is also the source of truth for the reserved domain at this layer — the
166
+ * workspace config no longer records one.
167
+ */
168
+ export function ngrokConfigPath(): string {
169
+ return join(getConfigDir(getCurrentEnvironment()), "tunnels", "ngrok.yaml");
170
+ }
171
+
172
+ function readNgrokConfigFile(): NgrokConfigFile {
173
+ const path = ngrokConfigPath();
174
+ if (!existsSync(path)) return {};
175
+ try {
176
+ const parsed = NgrokConfigFileSchema.safeParse(
177
+ parseYaml(readFileSync(path, "utf-8")),
178
+ );
179
+ return parsed.success ? parsed.data : {};
180
+ } catch {
181
+ return {};
182
+ }
183
+ }
184
+
185
+ function writeNgrokConfigFile(config: NgrokConfigFile): void {
186
+ const path = ngrokConfigPath();
187
+ mkdirSync(dirname(path), { recursive: true });
188
+ writeFileSync(path, stringifyYaml(config));
122
189
  }
123
190
 
191
+ /**
192
+ * Persist a reserved ngrok domain in the XDG ngrok config; null clears it.
193
+ *
194
+ * The reserved domain is an ngrok reservation concept (cloudflare quick
195
+ * tunnels and tailscale serve have no equivalent), which is why this lives in
196
+ * the ngrok provider module rather than the shared ingress-config helpers.
197
+ */
198
+ export function saveNgrokDomain(domain: string | null): void {
199
+ const config = readNgrokConfigFile();
200
+ const cadet: NgrokTunnelDef = {
201
+ ...config.tunnels?.[NGROK_TUNNEL_NAME],
202
+ proto: "http",
203
+ };
204
+ if (domain) {
205
+ cadet.domain = domain;
206
+ } else {
207
+ delete cadet.domain;
208
+ }
209
+ const tunnels: Record<string, NgrokTunnelDef> = { ...config.tunnels };
210
+ tunnels[NGROK_TUNNEL_NAME] = cadet;
211
+ const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
212
+ next.tunnels = tunnels;
213
+ writeNgrokConfigFile(next);
214
+ }
215
+
216
+ /** Read the reserved ngrok domain from the XDG ngrok config, if saved. */
217
+ export function loadNgrokDomain(): string | null {
218
+ const domain = readNgrokConfigFile().tunnels?.[NGROK_TUNNEL_NAME]?.domain;
219
+ return domain?.trim() ? domain : null;
220
+ }
221
+
222
+ /**
223
+ * Check whether a PID belongs to an ngrok process via its command line, so a
224
+ * recorded pid is verified before it is signaled (PID reuse guard).
225
+ */
226
+ export function isNgrokProcess(pid: number): boolean {
227
+ try {
228
+ return /ngrok/i.test(readProcessCommandLine(pid));
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Matches the adoption line the tunnel workflows print when they find an
236
+ * already-running tunnel, so the detached-tunnel supervisor can detect
237
+ * adoption without this module's implementation details leaking into the
238
+ * command layer.
239
+ */
240
+ export const ADOPTED_TUNNEL_RE = /Found existing ngrok tunnel: (\S+)/;
241
+
124
242
  /** Whether a tunnel public URL's host equals the given reserved domain. */
125
243
  function urlMatchesDomain(publicUrl: string, domain: string): boolean {
126
244
  try {
@@ -133,15 +251,42 @@ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
133
251
  }
134
252
 
135
253
  /**
136
- * Start an ngrok process tunneling HTTP traffic to the given local port.
254
+ * Write the cadet-owned XDG ngrok config for this run: the tunnel definition
255
+ * `ngrok start` reads, carrying the loopback target and — when reserved — the
256
+ * bound domain. The file is layered over the user's root ngrok config at
257
+ * spawn, so their authtoken and settings keep applying untouched.
258
+ */
259
+ function writeNgrokConfig(targetPort: number, domain?: string): void {
260
+ const config = readNgrokConfigFile();
261
+ const cadet: NgrokTunnelDef = {
262
+ ...config.tunnels?.[NGROK_TUNNEL_NAME],
263
+ proto: "http",
264
+ // Explicit over a bare port, which ngrok expands to `localhost`, whose
265
+ // ::1 answer resolves first on macOS and races the edge's IPv4 bind.
266
+ addr: `127.0.0.1:${targetPort}`,
267
+ };
268
+ if (domain) {
269
+ cadet.domain = domain;
270
+ }
271
+ const tunnels: Record<string, NgrokTunnelDef> = { ...config.tunnels };
272
+ tunnels[NGROK_TUNNEL_NAME] = cadet;
273
+ const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
274
+ next.tunnels = tunnels;
275
+ writeNgrokConfigFile(next);
276
+ }
277
+
278
+ /**
279
+ * Start an ngrok process tunneling HTTP traffic to the given local port, via
280
+ * the cadet-owned XDG config (`ngrok start cadet`) so the agent never collides
281
+ * with the host machine's root ngrok config.
137
282
  *
138
283
  * When `logFilePath` is provided, stdout/stderr are redirected to that file
139
284
  * instead of being piped. This avoids keeping pipe handles open in the
140
285
  * parent process — which would either prevent the CLI from exiting (if
141
286
  * handles are left open) or send SIGPIPE to ngrok (if destroyed).
142
287
  *
143
- * When `domain` is set, the tunnel binds that reserved ngrok domain via
144
- * `--domain=<domain>`.
288
+ * When `domain` is set, the spawned tunnel binds that reserved ngrok domain,
289
+ * recorded in the XDG config alongside the loopback target.
145
290
  *
146
291
  * Returns the spawned child process.
147
292
  */
@@ -159,12 +304,13 @@ export function startNgrokProcess(
159
304
  stdio = ["ignore", fd, fd];
160
305
  }
161
306
 
162
- // Explicit over a bare port, which ngrok expands to `localhost`, whose ::1
163
- // answer resolves first on macOS and races the edge's IPv4 bind.
164
- const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
165
- if (domain) {
166
- args.push(`--domain=${domain}`);
167
- }
307
+ writeNgrokConfig(targetPort, domain);
308
+ const args = [
309
+ "start",
310
+ NGROK_TUNNEL_NAME,
311
+ `--config=${ngrokConfigPath()}`,
312
+ "--log=stdout",
313
+ ];
168
314
 
169
315
  const child = spawn("ngrok", args, {
170
316
  detached: true,
@@ -225,10 +371,7 @@ export function hasWebhookIntegrations(
225
371
  const telegram = TelegramIntegrationSchema.safeParse(config.telegram);
226
372
  if (telegram.success && telegram.data.botUsername) return true;
227
373
  const twilio = TwilioIntegrationSchema.safeParse(config.twilio);
228
- if (
229
- twilio.success &&
230
- (twilio.data.accountSid || twilio.data.phoneNumber)
231
- ) {
374
+ if (twilio.success && (twilio.data.accountSid || twilio.data.phoneNumber)) {
232
375
  return true;
233
376
  }
234
377
  return false;
@@ -282,7 +425,7 @@ export async function maybeStartNgrokTunnel(
282
425
  workspaceDir: string,
283
426
  assistantId?: string,
284
427
  ): Promise<ChildProcess | null> {
285
- // Managed/containerized deployments route webhooks through the platform's
428
+ // Managed/containerized deployments route webhooks through the cloud's
286
429
  // callback proxy. ngrok is not needed and would not be reachable from the
287
430
  // platform anyway — skip it entirely.
288
431
  const isContainerized =
@@ -295,7 +438,7 @@ export async function maybeStartNgrokTunnel(
295
438
  const version = getNgrokVersion();
296
439
  if (!version) return null;
297
440
 
298
- const savedDomain = loadNgrokDomain(workspaceDir) ?? undefined;
441
+ const savedDomain = loadNgrokDomain() ?? undefined;
299
442
 
300
443
  // Reuse an existing tunnel if one is already running
301
444
  const runningTunnels = (await queryNgrokTunnels()) ?? [];
@@ -312,6 +455,10 @@ export async function maybeStartNgrokTunnel(
312
455
  }
313
456
  console.log(` Found existing ngrok tunnel: ${existingUrl}`);
314
457
  saveIngressUrl(workspaceDir, existingUrl, assistantId, "ngrok");
458
+ await notifyGatewayOfPublicUrl(existingUrl, assistantId);
459
+ // The adopted tunnel belongs to some other agent we do not own a pid for,
460
+ // so any recorded tunnel process entry would be stale from here on.
461
+ clearRootProcess("tunnel");
315
462
  return null;
316
463
  }
317
464
  if (runningTunnels.length > 0) {
@@ -339,6 +486,15 @@ export async function maybeStartNgrokTunnel(
339
486
  try {
340
487
  const publicUrl = await waitForNgrokUrl(targetPort, savedDomain);
341
488
  saveIngressUrl(workspaceDir, publicUrl, assistantId, "ngrok");
489
+ await notifyGatewayOfPublicUrl(publicUrl, assistantId);
490
+ if (ngrokProcess.pid) {
491
+ stampRootProcess("tunnel", {
492
+ pid: ngrokProcess.pid,
493
+ provider: "ngrok",
494
+ startedAt: new Date().toISOString(),
495
+ assistantId,
496
+ });
497
+ }
342
498
  console.log(` Tunnel established: ${publicUrl}`);
343
499
 
344
500
  return ngrokProcess;
@@ -350,6 +506,7 @@ export async function maybeStartNgrokTunnel(
350
506
  console.warn(` ⚠ ${savedDomainRecoveryHint(savedDomain)}`);
351
507
  }
352
508
  if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
509
+ clearRootProcess("tunnel");
353
510
  return null;
354
511
  }
355
512
  }
@@ -370,11 +527,13 @@ export interface RunNgrokTunnelOptions {
370
527
 
371
528
  /**
372
529
  * Run the ngrok tunnel workflow: check installation, find or start a tunnel,
373
- * save the public URL to config, and block until exit or signal.
530
+ * and save the public URL to config. Hands the URL back so the tunnel level
531
+ * can notify the gateway of it; `waitForExit` settles when the run ends
532
+ * (process exit, signal, or detach).
374
533
  */
375
534
  export async function runNgrokTunnel(
376
535
  opts: RunNgrokTunnelOptions = {},
377
- ): Promise<void> {
536
+ ): Promise<ProviderTunnelResult> {
378
537
  const version = getNgrokVersion();
379
538
  if (!version) {
380
539
  console.error("Error: ngrok is not installed.");
@@ -397,7 +556,7 @@ export async function runNgrokTunnel(
397
556
 
398
557
  // The saved domain is standing intent: a run without --domain reuses it,
399
558
  // and only an explicit --domain rewrites it.
400
- const domain = opts.domain ?? loadNgrokDomain(workspaceDir) ?? undefined;
559
+ const domain = opts.domain ?? loadNgrokDomain() ?? undefined;
401
560
  if (domain && !opts.domain) {
402
561
  console.log(`Using saved ngrok domain: ${domain}`);
403
562
  }
@@ -426,8 +585,10 @@ export async function runNgrokTunnel(
426
585
  }
427
586
  console.log(`Found existing ngrok tunnel: ${existingUrl}`);
428
587
  saveIngressUrl(workspaceDir, existingUrl, opts.assistantId, "ngrok");
588
+ // Not our child process: a recorded entry would be stale or foreign.
589
+ clearRootProcess("tunnel");
429
590
  if (opts.domain) {
430
- saveNgrokDomain(workspaceDir, opts.domain);
591
+ saveNgrokDomain(opts.domain);
431
592
  }
432
593
  console.log("Ingress URL saved to config.");
433
594
  console.log("");
@@ -436,11 +597,11 @@ export async function runNgrokTunnel(
436
597
  );
437
598
 
438
599
  // Block until SIGINT/SIGTERM
439
- await new Promise<void>((resolve) => {
600
+ const waitForExit = new Promise<void>((resolve) => {
440
601
  process.on("SIGINT", () => resolve());
441
602
  process.on("SIGTERM", () => resolve());
442
603
  });
443
- return;
604
+ return { publicUrl: existingUrl, waitForExit };
444
605
  }
445
606
 
446
607
  console.log(`Starting ngrok tunnel to localhost:${port}...`);
@@ -453,6 +614,7 @@ export async function runNgrokTunnel(
453
614
  if (!ngrokProcess.killed) {
454
615
  ngrokProcess.kill("SIGTERM");
455
616
  }
617
+ clearRootProcess("tunnel");
456
618
  if (publicUrl) {
457
619
  console.log("\nClearing ingress URL from config...");
458
620
  clearIngressUrl(workspaceDir, opts.assistantId);
@@ -510,15 +672,24 @@ export async function runNgrokTunnel(
510
672
  // The domain is standing intent, not tunnel state: cleanup clears the
511
673
  // ingress URL but leaves the domain saved for wake/daemon restores.
512
674
  saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
675
+ if (ngrokProcess.pid) {
676
+ stampRootProcess("tunnel", {
677
+ pid: ngrokProcess.pid,
678
+ provider: "ngrok",
679
+ startedAt: new Date().toISOString(),
680
+ assistantId: opts.assistantId,
681
+ });
682
+ }
513
683
  if (opts.domain) {
514
- saveNgrokDomain(workspaceDir, opts.domain);
684
+ saveNgrokDomain(opts.domain);
515
685
  }
516
686
  console.log("Ingress URL saved to config.");
517
687
  console.log("");
518
688
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
519
689
 
520
690
  // Keep running until the ngrok process exits or we receive a signal
521
- await new Promise<void>((resolve) => {
691
+ const waitForExit = new Promise<void>((resolve) => {
522
692
  ngrokProcess.on("exit", () => resolve());
523
693
  });
694
+ return { publicUrl, waitForExit };
524
695
  }
@@ -1,7 +1,9 @@
1
+ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
2
+
1
3
  import { spawnSync } from "node:child_process";
2
4
  import { win32 } from "node:path";
3
5
 
4
- import { GATEWAY_PORT } from "./constants.js";
6
+ import { GATEWAY_PORT } from "../constants.js";
5
7
  import {
6
8
  clearIngressUrl,
7
9
  getDefaultWorkspaceDir,
@@ -9,7 +11,7 @@ import {
9
11
  saveIngressUrl,
10
12
  savePairingTunnel,
11
13
  stampLockfileIngressUrl,
12
- } from "./ingress-config.js";
14
+ } from "../ingress-config.js";
13
15
 
14
16
  // ── Tailscale CLI discovery + invocation ────────────────────────────────────
15
17
 
@@ -351,7 +353,7 @@ export function stopTailscaleServe(
351
353
  */
352
354
  export async function runTailscaleTunnel(
353
355
  opts: RunTailscaleTunnelOptions = {},
354
- ): Promise<void> {
356
+ ): Promise<ProviderTunnelResult> {
355
357
  const deps = realTailscaleDeps();
356
358
  // What this run publishes, and therefore what Ctrl+C takes back.
357
359
  const publishedLabel = opts.preserveIngressUrl
@@ -375,7 +377,7 @@ export async function runTailscaleTunnel(
375
377
  "This URL is reachable only from devices signed in to your tailnet.",
376
378
  );
377
379
  console.log(
378
- "For public webhook ingress, use --provider ngrok or --provider cloudflare.",
380
+ "For public webhook ingress, run `cadet tunnel --help` to see the public providers.",
379
381
  );
380
382
  console.log("");
381
383
  console.log(
@@ -429,5 +431,17 @@ export async function runTailscaleTunnel(
429
431
  // Hold the process open until a signal fires. The serve itself lives in
430
432
  // tailscaled, so there is no child process to await — the registered signal
431
433
  // listeners keep the event loop alive.
432
- await new Promise<void>(() => {});
434
+ const waitForExit = new Promise<void>(() => {});
435
+ return { publicUrl, waitForExit };
433
436
  }
437
+
438
+ /** Per-provider copy `cadet tunnel`'s help renders for tailscale. */
439
+ export const TAILSCALE_TUNNEL_META: ProviderMeta = {
440
+ reach: "Tailnet-only tunnel, reachable from your own devices",
441
+ install: "install: https://tailscale.com/download, then run `tailscale up`",
442
+ notes: [
443
+ "When webhook integrations are configured it records its URL for device",
444
+ "pairing only, leaving the saved webhook callback base as it is.",
445
+ ],
446
+ supportsReservedDomain: false,
447
+ };
@@ -24,7 +24,7 @@ import { getStateDir } from "./environments/paths.js";
24
24
  import { getCurrentEnvironment } from "./environments/resolve.js";
25
25
  import { loadGuardianToken } from "./guardian-token.js";
26
26
  import { classifyReadyzResponse } from "./http-client.js";
27
- import { fetchReleases, resolveImageRefs } from "./platform-releases.js";
27
+ import { fetchReleases, resolveImageRefs } from "./cloud-releases.js";
28
28
  import { z } from "zod";
29
29
 
30
30
  const GatewayHealthSchema = z.object({
@@ -332,7 +332,7 @@ export async function fetchAssistantIngressUrl(
332
332
  publicBaseUrl?: string;
333
333
  managedCallbacks?: boolean;
334
334
  };
335
- // Ignore managed-callback URLs — those belong to the platform, not the
335
+ // Ignore managed-callback URLs — those belong to the cloud, not the
336
336
  // self-hosted assistant's own ingress.
337
337
  if (body.managedCallbacks) return undefined;
338
338
  return body.publicBaseUrl || undefined;
@@ -348,7 +348,7 @@ export async function fetchAssistantIngressUrl(
348
348
  *
349
349
  * Checks (in order):
350
350
  * 1. `entry.previousVersion` (saved by the upgrade flow from health).
351
- * 2. The releases list from the platform API — finds the version
351
+ * 2. The releases list from the cloud API — finds the version
352
352
  * immediately before `currentVersion`.
353
353
  *
354
354
  * Returns `undefined` when neither source yields a result.
@@ -869,8 +869,6 @@ export async function performDockerRollback(
869
869
  );
870
870
 
871
871
  // Capture new digests from the rolled-back containers
872
- const newDigests = await captureImageRefs(res);
873
-
874
872
  // Swap current/previous state to enable "rollback the rollback"
875
873
  const updatedEntry: AssistantEntry = {
876
874
  ...entry,
@@ -878,10 +876,6 @@ export async function performDockerRollback(
878
876
  assistantImage: targetImageTags.assistant,
879
877
  gatewayImage: targetImageTags.gateway,
880
878
  cesImage: targetImageTags["credential-executor"],
881
- assistantDigest: newDigests?.assistant,
882
- gatewayDigest: newDigests?.gateway,
883
- cesDigest: newDigests?.["credential-executor"],
884
- networkName: res.network,
885
879
  assistantPort,
886
880
  },
887
881
  previousContainerInfo: entry.containerInfo,
@@ -1030,7 +1024,6 @@ export async function performDockerRollback(
1030
1024
  }
1031
1025
 
1032
1026
  // Restore lockfile state
1033
- const revertDigests = await captureImageRefs(res);
1034
1027
  const revertedEntry: AssistantEntry = {
1035
1028
  ...entry,
1036
1029
  containerInfo: {
@@ -1042,13 +1035,6 @@ export async function performDockerRollback(
1042
1035
  cesImage:
1043
1036
  entry.containerInfo?.cesImage ??
1044
1037
  currentImageRefs["credential-executor"],
1045
- assistantDigest:
1046
- revertDigests?.assistant ?? currentImageRefs.assistant,
1047
- gatewayDigest: revertDigests?.gateway ?? currentImageRefs.gateway,
1048
- cesDigest:
1049
- revertDigests?.["credential-executor"] ??
1050
- currentImageRefs["credential-executor"],
1051
- networkName: res.network,
1052
1038
  assistantPort,
1053
1039
  },
1054
1040
  previousContainerInfo: undefined,
@@ -1,4 +1,4 @@
1
- import type { ReleaseListItem } from "./platform-releases.js";
1
+ import type { ReleaseListItem } from "./cloud-releases.js";
2
2
  import { compareVersions, versionsEqual } from "./version-compat.js";
3
3
 
4
4
  export interface UpgradeTargetResolution {
@@ -88,7 +88,7 @@ export interface UpgradePollState {
88
88
  }
89
89
 
90
90
  /**
91
- * Completion predicate for the platform upgrade poll loop. Pure.
91
+ * Completion predicate for the cloud upgrade poll loop. Pure.
92
92
  *
93
93
  * The primary signal is the DB-backed `current_release_version` (works while
94
94
  * the service group restarts or the assistant sleeps); the upgrade-status
@@ -103,9 +103,9 @@ export function selectWorkosClientId(
103
103
  }
104
104
 
105
105
  export async function fetchWorkosClientId(
106
- platformUrl: string,
106
+ cloudUrl: string,
107
107
  ): Promise<string> {
108
- const url = `${new URL(platformUrl).origin}/_allauth/app/v1/config`;
108
+ const url = `${new URL(cloudUrl).origin}/_allauth/app/v1/config`;
109
109
  const response = await loopbackSafeFetch(url);
110
110
  if (!response.ok) {
111
111
  throw new Error(`Failed to fetch auth config (${response.status})`);
@@ -157,13 +157,13 @@ export async function exchangeCodeWithWorkos(options: {
157
157
  return parsed.data.access_token;
158
158
  }
159
159
 
160
- /** Exchange the WorkOS access token for a platform session token. */
160
+ /** Exchange the WorkOS access token for a cloud session token. */
161
161
  export async function exchangeAccessTokenForSession(
162
- platformUrl: string,
162
+ cloudUrl: string,
163
163
  clientId: string,
164
164
  accessToken: string,
165
165
  ): Promise<string> {
166
- const url = `${new URL(platformUrl).origin}/_allauth/app/v1/auth/provider/token`;
166
+ const url = `${new URL(cloudUrl).origin}/_allauth/app/v1/auth/provider/token`;
167
167
  const response = await loopbackSafeFetch(url, {
168
168
  method: "POST",
169
169
  headers: { "Content-Type": "application/json" },
@@ -1,21 +0,0 @@
1
- /**
2
- * `platform_disconnected` SSE event.
3
- *
4
- * Emitted by the platform-disconnect route after stored platform
5
- * credentials are deleted, notifying connected clients that the platform
6
- * connection is gone.
7
- *
8
- * Canonical wire-contract source. Daemon code imports the type
9
- * directly from this file; external consumers import via
10
- * `@mycadet/assistant-api`.
11
- */
12
-
13
- import { z } from "zod";
14
-
15
- export const PlatformDisconnectedEventSchema = z.object({
16
- type: z.literal("platform_disconnected"),
17
- });
18
-
19
- export type PlatformDisconnectedEvent = z.infer<
20
- typeof PlatformDisconnectedEventSchema
21
- >;