@mycadet/cli 0.12.7 → 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.
- package/README.md +2 -2
- package/knip.json +1 -1
- package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +21 -0
- package/node_modules/@mycadet/assistant-api/events/host-bash.ts +1 -1
- package/node_modules/@mycadet/assistant-api/events/host-cu.ts +1 -1
- package/node_modules/@mycadet/assistant-api/events/host-file.ts +1 -1
- package/node_modules/@mycadet/assistant-api/events/notification-intent.ts +1 -1
- package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +18 -0
- package/node_modules/@mycadet/assistant-api/index.ts +11 -11
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +2 -2
- package/node_modules/@mycadet/{service-contracts/src/platform-credential.ts → assistant-api/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
- package/node_modules/@mycadet/assistant-api/responses/home.ts +1 -1
- package/node_modules/@mycadet/assistant-api/surfaces.ts +4 -4
- package/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
- package/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
- package/node_modules/@mycadet/environments/src/seeds.ts +6 -6
- package/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
- package/node_modules/@mycadet/environments/src/types.ts +6 -6
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -6
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/types.ts +6 -6
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +2 -2
- 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
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
- package/node_modules/@mycadet/local-mode/src/__tests__/pair.test.ts +5 -5
- package/node_modules/@mycadet/local-mode/src/__tests__/retire.test.ts +1 -1
- package/node_modules/@mycadet/local-mode/src/config.ts +2 -2
- package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
- package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +3 -3
- package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +10 -5
- package/node_modules/@mycadet/local-mode/src/lockfile.test.ts +15 -15
- package/node_modules/@mycadet/local-mode/src/lockfile.ts +9 -9
- package/node_modules/@mycadet/local-mode/src/pair.ts +2 -2
- package/node_modules/@mycadet/local-mode/src/retire.ts +3 -3
- package/node_modules/@mycadet/local-mode/src/teleport-backup-policy.ts +2 -2
- package/node_modules/@mycadet/local-mode/src/unpair.ts +2 -2
- package/node_modules/@mycadet/service-contracts/package.json +2 -2
- package/node_modules/@mycadet/{local-mode/node_modules/@mycadet/service-contracts/src/platform-credential.ts → service-contracts/src/cloud-credential.ts} +9 -9
- package/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
- package/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
- package/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
- package/package.json +3 -2
- package/src/__tests__/assistant-api-key-resolution.test.ts +6 -6
- package/src/__tests__/{assistant-client-platform-auth.test.ts → assistant-client-cloud-auth.test.ts} +10 -10
- package/src/__tests__/assistant-client-refresh.test.ts +2 -2
- package/src/__tests__/backup.test.ts +86 -91
- package/src/__tests__/client-token.test.ts +1 -1
- package/src/__tests__/client-tui-refresh.test.ts +1 -1
- package/src/__tests__/client-web-config.test.ts +2 -2
- package/src/__tests__/{platform-client.test.ts → cloud-client.test.ts} +59 -59
- package/src/__tests__/{platform-releases.test.ts → cloud-releases.test.ts} +28 -18
- package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
- package/src/__tests__/connect-import.test.ts +55 -39
- package/src/__tests__/debug.test.ts +38 -38
- package/src/__tests__/ensure-binary.test.ts +182 -0
- package/src/__tests__/guardian-token.test.ts +149 -56
- package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
- package/src/__tests__/helpers/guardian-token.ts +22 -0
- package/src/__tests__/helpers/http.ts +10 -0
- package/src/__tests__/nginx-ingress.test.ts +21 -20
- package/src/__tests__/ngrok-spawn.test.ts +49 -0
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/pair.test.ts +1 -8
- package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +79 -26
- package/src/__tests__/ps-table.test.ts +79 -0
- package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
- package/src/__tests__/retire.test.ts +58 -199
- package/src/__tests__/root-process-lockfile.test.ts +99 -0
- package/src/__tests__/sleep.test.ts +19 -19
- package/src/__tests__/statefulset.test.ts +8 -8
- package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +2 -2
- package/src/__tests__/teleport-backup.test.ts +3 -3
- package/src/__tests__/teleport.test.ts +181 -197
- package/src/__tests__/tunnel.test.ts +257 -222
- package/src/__tests__/uninstall.test.ts +13 -4
- package/src/__tests__/upgrade-local.test.ts +3 -3
- package/src/__tests__/upgrade-replay-env.test.ts +12 -12
- package/src/__tests__/wake.test.ts +260 -43
- package/src/__tests__/workos-pkce.test.ts +2 -2
- package/src/commands/backup.ts +24 -24
- package/src/commands/client.ts +76 -76
- package/src/commands/completions.test.ts +104 -0
- package/src/commands/completions.ts +87 -0
- package/src/commands/debug.ts +20 -20
- package/src/commands/exec.ts +12 -12
- package/src/commands/flags.ts +2 -2
- package/src/commands/hatch.ts +23 -23
- package/src/commands/login.ts +22 -22
- package/src/commands/logs.ts +8 -8
- package/src/commands/ps.ts +244 -86
- package/src/commands/restart.ts +30 -0
- package/src/commands/restore.ts +140 -22
- package/src/commands/retire.ts +48 -150
- package/src/commands/rollback.ts +20 -26
- package/src/commands/setup.ts +2 -2
- package/src/commands/sleep.ts +2 -2
- package/src/commands/teleport.ts +109 -109
- package/src/commands/terminal.ts +158 -18
- package/src/commands/tunnel.ts +138 -157
- package/src/commands/uninstall.ts +57 -9
- package/src/commands/unpair.ts +3 -3
- package/src/commands/upgrade.ts +30 -54
- package/src/commands/use.ts +1 -1
- package/src/commands/wake.ts +166 -50
- package/src/index.ts +18 -9
- package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
- package/src/lib/__tests__/docker.test.ts +168 -20
- package/src/lib/__tests__/job-polling.test.ts +2 -2
- package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
- package/src/lib/__tests__/runtime-url.test.ts +1 -1
- package/src/lib/assistant-api-key-resolution.ts +5 -5
- package/src/lib/assistant-client.ts +8 -8
- package/src/lib/assistant-config.ts +58 -12
- package/src/lib/{platform-client.ts → cloud-client.ts} +107 -98
- package/src/lib/cloud-login.ts +24 -24
- package/src/lib/{platform-releases.ts → cloud-releases.ts} +11 -9
- package/src/lib/cloud-values.ts +3 -0
- package/src/lib/config-utils.ts +1 -1
- package/src/lib/detached-process.ts +1 -1
- package/src/lib/docker.ts +260 -53
- package/src/lib/ensure-binary.ts +171 -0
- package/src/lib/environments/__tests__/paths.test.ts +2 -2
- package/src/lib/environments/__tests__/resolve.test.ts +26 -26
- package/src/lib/environments/resolve.ts +8 -8
- package/src/lib/flag-args.test.ts +1 -1
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/guardian-token.ts +37 -5
- package/src/lib/hatch-local.ts +19 -23
- package/src/lib/health-check.ts +6 -6
- package/src/lib/ingress-config.ts +8 -35
- package/src/lib/job-polling.ts +2 -2
- package/src/lib/live-voice/connection.ts +12 -12
- package/src/lib/live-voice/tunnel.test.ts +11 -11
- package/src/lib/live-voice/tunnel.ts +15 -15
- package/src/lib/local-runtime-client.ts +20 -20
- package/src/lib/local.ts +10 -31
- package/src/lib/nginx-ingress.ts +42 -15
- package/src/lib/pair.ts +1 -1
- package/src/lib/process.ts +1 -1
- package/src/lib/provider-secrets.ts +3 -3
- package/src/lib/retire-apple-container.ts +1 -1
- package/src/lib/runtime-url.ts +8 -8
- package/src/lib/statefulset.ts +2 -2
- package/src/lib/sync-cloud-assistants.ts +19 -19
- package/src/lib/teleport-backup.ts +4 -4
- package/src/lib/terminal-client.ts +17 -17
- package/src/lib/terminal-session.ts +24 -24
- package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
- package/src/lib/tunnels/cadet.ts +29 -0
- package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +36 -15
- package/src/lib/tunnels/gateway-notify.ts +61 -0
- package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +350 -89
- package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +53 -9
- package/src/lib/upgrade-lifecycle.ts +8 -21
- package/src/lib/upgrade-preflight.ts +2 -2
- package/src/lib/workos-pkce.ts +5 -5
- package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
- package/node_modules/@mycadet/assistant-api/events/show-platform-login.ts +0 -20
- package/src/commands/recover.ts +0 -131
- package/src/commands/roadmap.ts +0 -459
- package/src/commands/ssh.ts +0 -144
- package/src/commands/workflows.ts +0 -305
- package/src/lib/managed-cloud.ts +0 -3
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
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 {
|
|
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";
|
|
16
|
+
import { ensureBinaryInstalled, readLineText } from "../ensure-binary.js";
|
|
6
17
|
import {
|
|
7
18
|
clearIngressUrl,
|
|
8
19
|
getDefaultWorkspaceDir,
|
|
9
|
-
loadNgrokDomain,
|
|
10
20
|
loadRawConfig,
|
|
11
21
|
saveIngressUrl,
|
|
12
|
-
|
|
13
|
-
} from "
|
|
14
|
-
import {
|
|
22
|
+
} from "../ingress-config.js";
|
|
23
|
+
import { getConfigDir } from "../environments/paths.js";
|
|
24
|
+
import { getCurrentEnvironment } from "../environments/resolve.js";
|
|
25
|
+
import { loopbackSafeFetch } from "../loopback-fetch.js";
|
|
26
|
+
import { readProcessCommandLine } from "../process.js";
|
|
15
27
|
import { z } from "zod";
|
|
28
|
+
import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
|
|
29
|
+
import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
|
|
16
30
|
|
|
17
31
|
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
18
32
|
const NGROK_POLL_INTERVAL_MS = 500;
|
|
@@ -42,6 +56,14 @@ export const IngressConfigSchema = z.object({
|
|
|
42
56
|
publicBaseUrl: z.string().optional(),
|
|
43
57
|
});
|
|
44
58
|
|
|
59
|
+
/** Per-provider copy `cadet tunnel`'s help renders for ngrok. */
|
|
60
|
+
export const NGROK_TUNNEL_META: ProviderMeta = {
|
|
61
|
+
reach: "Public tunnel that webhook integrations can reach",
|
|
62
|
+
install: "install: brew install ngrok/ngrok/ngrok",
|
|
63
|
+
domainExample: "my-assistant.ngrok.app",
|
|
64
|
+
supportsReservedDomain: true,
|
|
65
|
+
};
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* Check whether ngrok is installed and accessible on the PATH.
|
|
47
69
|
* Returns the version string if installed, null otherwise.
|
|
@@ -60,6 +82,52 @@ export function getNgrokVersion(): string | null {
|
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
/** Whether captured agent output says the startup failure was authentication. */
|
|
86
|
+
export function isNgrokAuthError(output: string): boolean {
|
|
87
|
+
return /authtoken|authenticat|unauthorized|usage limit|ERR_NGROK_10\d\d|ERR_NGROK_40\d\d/i.test(
|
|
88
|
+
output,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An ngrok agent that exited during startup, with its captured output. */
|
|
93
|
+
export class NgrokStartupError extends Error {
|
|
94
|
+
readonly exitCode: number;
|
|
95
|
+
readonly output: string;
|
|
96
|
+
|
|
97
|
+
constructor(exitCode: number, output: string) {
|
|
98
|
+
super(`ngrok exited with code ${exitCode}`);
|
|
99
|
+
this.exitCode = exitCode;
|
|
100
|
+
this.output = output;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Paste-in attempts before the tunnel command gives up and says how. */
|
|
105
|
+
const MAX_AUTH_RETRIES = 2;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run `ngrok config add-authtoken <token>` — writing the user's default
|
|
109
|
+
* ngrok config, which is exactly the config the tunnel spawn loads. The
|
|
110
|
+
* token is never echoed: output is piped and any failure message has the
|
|
111
|
+
* token scrubbed.
|
|
112
|
+
*/
|
|
113
|
+
export function saveNgrokAuthToken(token: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("ngrok", ["config", "add-authtoken", token], {
|
|
116
|
+
encoding: "utf-8",
|
|
117
|
+
timeout: 10_000,
|
|
118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
});
|
|
121
|
+
return true;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
console.error(
|
|
125
|
+
`⚠ Couldn't save the authtoken: ${message.replaceAll(token, "***")}`,
|
|
126
|
+
);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
63
131
|
/**
|
|
64
132
|
* Query the ngrok local API for running tunnels.
|
|
65
133
|
* Returns the list of tunnels, or null if the API is unreachable.
|
|
@@ -118,9 +186,106 @@ function staleAgentDiagnostic(tunnels: NgrokTunnel[], port: number): string {
|
|
|
118
186
|
|
|
119
187
|
/** Recovery copy for a saved domain whose reservation may have lapsed. */
|
|
120
188
|
function savedDomainRecoveryHint(domain: string): string {
|
|
121
|
-
return `The saved ngrok domain '${domain}' (
|
|
189
|
+
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.`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const NGROK_TUNNEL_NAME = "cadet";
|
|
193
|
+
|
|
194
|
+
const NgrokTunnelDefSchema = z.looseObject({
|
|
195
|
+
proto: z.string().optional(),
|
|
196
|
+
addr: z.string().optional(),
|
|
197
|
+
domain: z.string().optional(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const NgrokConfigFileSchema = z.looseObject({
|
|
201
|
+
version: z.string().optional(),
|
|
202
|
+
tunnels: z.record(z.string(), NgrokTunnelDefSchema).optional(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
type NgrokConfigFile = z.infer<typeof NgrokConfigFileSchema>;
|
|
206
|
+
type NgrokTunnelDef = z.infer<typeof NgrokTunnelDefSchema>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The ngrok-format config file this CLI owns in the XDG config dir. It no
|
|
210
|
+
* longer participates in spawning (plain `ngrok http` loads the user's
|
|
211
|
+
* default config, keeping their authtoken in play); it is the source of
|
|
212
|
+
* truth for the reserved domain at this layer — the workspace config no
|
|
213
|
+
* longer records one.
|
|
214
|
+
*/
|
|
215
|
+
export function ngrokConfigPath(): string {
|
|
216
|
+
return join(getConfigDir(getCurrentEnvironment()), "tunnels", "ngrok.yaml");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readNgrokConfigFile(): NgrokConfigFile {
|
|
220
|
+
const path = ngrokConfigPath();
|
|
221
|
+
if (!existsSync(path)) return {};
|
|
222
|
+
try {
|
|
223
|
+
const parsed = NgrokConfigFileSchema.safeParse(
|
|
224
|
+
parseYaml(readFileSync(path, "utf-8")),
|
|
225
|
+
);
|
|
226
|
+
return parsed.success ? parsed.data : {};
|
|
227
|
+
} catch {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeNgrokConfigFile(config: NgrokConfigFile): void {
|
|
233
|
+
const path = ngrokConfigPath();
|
|
234
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
235
|
+
writeFileSync(path, stringifyYaml(config));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Persist a reserved ngrok domain in the XDG ngrok config; null clears it.
|
|
240
|
+
*
|
|
241
|
+
* The reserved domain is an ngrok reservation concept (cloudflare quick
|
|
242
|
+
* tunnels and tailscale serve have no equivalent), which is why this lives in
|
|
243
|
+
* the ngrok provider module rather than the shared ingress-config helpers.
|
|
244
|
+
*/
|
|
245
|
+
export function saveNgrokDomain(domain: string | null): void {
|
|
246
|
+
const config = readNgrokConfigFile();
|
|
247
|
+
const cadet: NgrokTunnelDef = {
|
|
248
|
+
...config.tunnels?.[NGROK_TUNNEL_NAME],
|
|
249
|
+
proto: "http",
|
|
250
|
+
};
|
|
251
|
+
if (domain) {
|
|
252
|
+
cadet.domain = domain;
|
|
253
|
+
} else {
|
|
254
|
+
delete cadet.domain;
|
|
255
|
+
}
|
|
256
|
+
const tunnels: Record<string, NgrokTunnelDef> = { ...config.tunnels };
|
|
257
|
+
tunnels[NGROK_TUNNEL_NAME] = cadet;
|
|
258
|
+
const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
|
|
259
|
+
next.tunnels = tunnels;
|
|
260
|
+
writeNgrokConfigFile(next);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Read the reserved ngrok domain from the XDG ngrok config, if saved. */
|
|
264
|
+
export function loadNgrokDomain(): string | null {
|
|
265
|
+
const domain = readNgrokConfigFile().tunnels?.[NGROK_TUNNEL_NAME]?.domain;
|
|
266
|
+
return domain?.trim() ? domain : null;
|
|
122
267
|
}
|
|
123
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Check whether a PID belongs to an ngrok process via its command line, so a
|
|
271
|
+
* recorded pid is verified before it is signaled (PID reuse guard).
|
|
272
|
+
*/
|
|
273
|
+
export function isNgrokProcess(pid: number): boolean {
|
|
274
|
+
try {
|
|
275
|
+
return /ngrok/i.test(readProcessCommandLine(pid));
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Matches the adoption line the tunnel workflows print when they find an
|
|
283
|
+
* already-running tunnel, so the detached-tunnel supervisor can detect
|
|
284
|
+
* adoption without this module's implementation details leaking into the
|
|
285
|
+
* command layer.
|
|
286
|
+
*/
|
|
287
|
+
export const ADOPTED_TUNNEL_RE = /Found existing ngrok tunnel: (\S+)/;
|
|
288
|
+
|
|
124
289
|
/** Whether a tunnel public URL's host equals the given reserved domain. */
|
|
125
290
|
function urlMatchesDomain(publicUrl: string, domain: string): boolean {
|
|
126
291
|
try {
|
|
@@ -132,16 +297,34 @@ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
|
|
|
132
297
|
}
|
|
133
298
|
}
|
|
134
299
|
|
|
300
|
+
/** The argv for an ngrok agent forwarding the edge port, domain when set. */
|
|
301
|
+
export function buildNgrokSpawnArgs(
|
|
302
|
+
targetPort: number,
|
|
303
|
+
domain?: string,
|
|
304
|
+
): string[] {
|
|
305
|
+
const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
|
|
306
|
+
if (domain) {
|
|
307
|
+
args.push(`--domain=${domain}`);
|
|
308
|
+
}
|
|
309
|
+
return args;
|
|
310
|
+
}
|
|
311
|
+
|
|
135
312
|
/**
|
|
136
313
|
* Start an ngrok process tunneling HTTP traffic to the given local port.
|
|
137
314
|
*
|
|
315
|
+
* Plain `ngrok http` is spawned deliberately: ngrok only loads the user's
|
|
316
|
+
* default config — where `ngrok config add-authtoken` writes — when no
|
|
317
|
+
* `--config` is passed, so this is what keeps the authtoken in play. The
|
|
318
|
+
* explicit `127.0.0.1:` host avoids a bare port, which ngrok expands to
|
|
319
|
+
* `localhost`, whose ::1 answer resolves first on macOS and races the edge's
|
|
320
|
+
* IPv4 bind.
|
|
321
|
+
*
|
|
138
322
|
* When `logFilePath` is provided, stdout/stderr are redirected to that file
|
|
139
323
|
* instead of being piped. This avoids keeping pipe handles open in the
|
|
140
324
|
* parent process — which would either prevent the CLI from exiting (if
|
|
141
325
|
* handles are left open) or send SIGPIPE to ngrok (if destroyed).
|
|
142
326
|
*
|
|
143
|
-
* When `domain` is set, the tunnel binds that reserved ngrok domain
|
|
144
|
-
* `--domain=<domain>`.
|
|
327
|
+
* When `domain` is set, the spawned tunnel binds that reserved ngrok domain.
|
|
145
328
|
*
|
|
146
329
|
* Returns the spawned child process.
|
|
147
330
|
*/
|
|
@@ -159,14 +342,7 @@ export function startNgrokProcess(
|
|
|
159
342
|
stdio = ["ignore", fd, fd];
|
|
160
343
|
}
|
|
161
344
|
|
|
162
|
-
|
|
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
|
-
}
|
|
168
|
-
|
|
169
|
-
const child = spawn("ngrok", args, {
|
|
345
|
+
const child = spawn("ngrok", buildNgrokSpawnArgs(targetPort, domain), {
|
|
170
346
|
detached: true,
|
|
171
347
|
stdio,
|
|
172
348
|
windowsHide: true,
|
|
@@ -225,10 +401,7 @@ export function hasWebhookIntegrations(
|
|
|
225
401
|
const telegram = TelegramIntegrationSchema.safeParse(config.telegram);
|
|
226
402
|
if (telegram.success && telegram.data.botUsername) return true;
|
|
227
403
|
const twilio = TwilioIntegrationSchema.safeParse(config.twilio);
|
|
228
|
-
if (
|
|
229
|
-
twilio.success &&
|
|
230
|
-
(twilio.data.accountSid || twilio.data.phoneNumber)
|
|
231
|
-
) {
|
|
404
|
+
if (twilio.success && (twilio.data.accountSid || twilio.data.phoneNumber)) {
|
|
232
405
|
return true;
|
|
233
406
|
}
|
|
234
407
|
return false;
|
|
@@ -282,7 +455,7 @@ export async function maybeStartNgrokTunnel(
|
|
|
282
455
|
workspaceDir: string,
|
|
283
456
|
assistantId?: string,
|
|
284
457
|
): Promise<ChildProcess | null> {
|
|
285
|
-
// Managed/containerized deployments route webhooks through the
|
|
458
|
+
// Managed/containerized deployments route webhooks through the cloud's
|
|
286
459
|
// callback proxy. ngrok is not needed and would not be reachable from the
|
|
287
460
|
// platform anyway — skip it entirely.
|
|
288
461
|
const isContainerized =
|
|
@@ -295,7 +468,7 @@ export async function maybeStartNgrokTunnel(
|
|
|
295
468
|
const version = getNgrokVersion();
|
|
296
469
|
if (!version) return null;
|
|
297
470
|
|
|
298
|
-
const savedDomain = loadNgrokDomain(
|
|
471
|
+
const savedDomain = loadNgrokDomain() ?? undefined;
|
|
299
472
|
|
|
300
473
|
// Reuse an existing tunnel if one is already running
|
|
301
474
|
const runningTunnels = (await queryNgrokTunnels()) ?? [];
|
|
@@ -312,6 +485,10 @@ export async function maybeStartNgrokTunnel(
|
|
|
312
485
|
}
|
|
313
486
|
console.log(` Found existing ngrok tunnel: ${existingUrl}`);
|
|
314
487
|
saveIngressUrl(workspaceDir, existingUrl, assistantId, "ngrok");
|
|
488
|
+
await notifyGatewayOfPublicUrl(existingUrl, assistantId);
|
|
489
|
+
// The adopted tunnel belongs to some other agent we do not own a pid for,
|
|
490
|
+
// so any recorded tunnel process entry would be stale from here on.
|
|
491
|
+
clearRootProcess("tunnel");
|
|
315
492
|
return null;
|
|
316
493
|
}
|
|
317
494
|
if (runningTunnels.length > 0) {
|
|
@@ -339,6 +516,15 @@ export async function maybeStartNgrokTunnel(
|
|
|
339
516
|
try {
|
|
340
517
|
const publicUrl = await waitForNgrokUrl(targetPort, savedDomain);
|
|
341
518
|
saveIngressUrl(workspaceDir, publicUrl, assistantId, "ngrok");
|
|
519
|
+
await notifyGatewayOfPublicUrl(publicUrl, assistantId);
|
|
520
|
+
if (ngrokProcess.pid) {
|
|
521
|
+
stampRootProcess("tunnel", {
|
|
522
|
+
pid: ngrokProcess.pid,
|
|
523
|
+
provider: "ngrok",
|
|
524
|
+
startedAt: new Date().toISOString(),
|
|
525
|
+
assistantId,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
342
528
|
console.log(` Tunnel established: ${publicUrl}`);
|
|
343
529
|
|
|
344
530
|
return ngrokProcess;
|
|
@@ -350,6 +536,7 @@ export async function maybeStartNgrokTunnel(
|
|
|
350
536
|
console.warn(` ⚠ ${savedDomainRecoveryHint(savedDomain)}`);
|
|
351
537
|
}
|
|
352
538
|
if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
|
|
539
|
+
clearRootProcess("tunnel");
|
|
353
540
|
return null;
|
|
354
541
|
}
|
|
355
542
|
}
|
|
@@ -370,23 +557,33 @@ export interface RunNgrokTunnelOptions {
|
|
|
370
557
|
|
|
371
558
|
/**
|
|
372
559
|
* Run the ngrok tunnel workflow: check installation, find or start a tunnel,
|
|
373
|
-
* save the public URL to config
|
|
560
|
+
* and save the public URL to config. Hands the URL back so the tunnel level
|
|
561
|
+
* can notify the gateway of it; `waitForExit` settles when the run ends
|
|
562
|
+
* (process exit, signal, or detach).
|
|
374
563
|
*/
|
|
375
564
|
export async function runNgrokTunnel(
|
|
376
565
|
opts: RunNgrokTunnelOptions = {},
|
|
377
|
-
): Promise<
|
|
378
|
-
const version =
|
|
566
|
+
): Promise<ProviderTunnelResult> {
|
|
567
|
+
const version = await ensureBinaryInstalled({
|
|
568
|
+
binary: "ngrok",
|
|
569
|
+
purpose: "the ngrok tunnel",
|
|
570
|
+
packages: {
|
|
571
|
+
brew: ["brew", "install", "ngrok/ngrok/ngrok"],
|
|
572
|
+
snap: ["sudo", "snap", "install", "ngrok"],
|
|
573
|
+
choco: ["choco", "install", "-y", "ngrok"],
|
|
574
|
+
winget: [
|
|
575
|
+
"winget",
|
|
576
|
+
"install",
|
|
577
|
+
"--silent",
|
|
578
|
+
"--accept-package-agreements",
|
|
579
|
+
"--accept-source-agreements",
|
|
580
|
+
"ngrok.ngrok",
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
manualHint: "See https://ngrok.com/download for how to install",
|
|
584
|
+
probe: getNgrokVersion,
|
|
585
|
+
});
|
|
379
586
|
if (!version) {
|
|
380
|
-
console.error("Error: ngrok is not installed.");
|
|
381
|
-
console.error("");
|
|
382
|
-
console.error("Install ngrok:");
|
|
383
|
-
console.error(" macOS: brew install ngrok/ngrok/ngrok");
|
|
384
|
-
console.error(" Linux: sudo snap install ngrok");
|
|
385
|
-
console.error("");
|
|
386
|
-
console.error("Then authenticate: ngrok config add-authtoken <your-token>");
|
|
387
|
-
console.error(
|
|
388
|
-
" Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
389
|
-
);
|
|
390
587
|
process.exit(1);
|
|
391
588
|
}
|
|
392
589
|
|
|
@@ -397,7 +594,7 @@ export async function runNgrokTunnel(
|
|
|
397
594
|
|
|
398
595
|
// The saved domain is standing intent: a run without --domain reuses it,
|
|
399
596
|
// and only an explicit --domain rewrites it.
|
|
400
|
-
const domain = opts.domain ?? loadNgrokDomain(
|
|
597
|
+
const domain = opts.domain ?? loadNgrokDomain() ?? undefined;
|
|
401
598
|
if (domain && !opts.domain) {
|
|
402
599
|
console.log(`Using saved ngrok domain: ${domain}`);
|
|
403
600
|
}
|
|
@@ -426,8 +623,10 @@ export async function runNgrokTunnel(
|
|
|
426
623
|
}
|
|
427
624
|
console.log(`Found existing ngrok tunnel: ${existingUrl}`);
|
|
428
625
|
saveIngressUrl(workspaceDir, existingUrl, opts.assistantId, "ngrok");
|
|
626
|
+
// Not our child process: a recorded entry would be stale or foreign.
|
|
627
|
+
clearRootProcess("tunnel");
|
|
429
628
|
if (opts.domain) {
|
|
430
|
-
saveNgrokDomain(
|
|
629
|
+
saveNgrokDomain(opts.domain);
|
|
431
630
|
}
|
|
432
631
|
console.log("Ingress URL saved to config.");
|
|
433
632
|
console.log("");
|
|
@@ -436,23 +635,26 @@ export async function runNgrokTunnel(
|
|
|
436
635
|
);
|
|
437
636
|
|
|
438
637
|
// Block until SIGINT/SIGTERM
|
|
439
|
-
|
|
638
|
+
const waitForExit = new Promise<void>((resolve) => {
|
|
440
639
|
process.on("SIGINT", () => resolve());
|
|
441
640
|
process.on("SIGTERM", () => resolve());
|
|
442
641
|
});
|
|
443
|
-
return;
|
|
642
|
+
return { publicUrl: existingUrl, waitForExit };
|
|
444
643
|
}
|
|
445
644
|
|
|
446
645
|
console.log(`Starting ngrok tunnel to localhost:${port}...`);
|
|
447
646
|
|
|
448
647
|
let publicUrl: string | undefined;
|
|
449
|
-
|
|
450
|
-
|
|
648
|
+
// Points at the live agent across auth retries; signals and cleanup act on
|
|
649
|
+
// whichever attempt is current.
|
|
650
|
+
let currentNgrok: ChildProcess | null = null;
|
|
651
|
+
let authAttempts = 0;
|
|
451
652
|
|
|
452
653
|
const cleanup = () => {
|
|
453
|
-
if (!
|
|
454
|
-
|
|
654
|
+
if (currentNgrok && !currentNgrok.killed) {
|
|
655
|
+
currentNgrok.kill("SIGTERM");
|
|
455
656
|
}
|
|
657
|
+
clearRootProcess("tunnel");
|
|
456
658
|
if (publicUrl) {
|
|
457
659
|
console.log("\nClearing ingress URL from config...");
|
|
458
660
|
clearIngressUrl(workspaceDir, opts.assistantId);
|
|
@@ -468,57 +670,116 @@ export async function runNgrokTunnel(
|
|
|
468
670
|
process.exit(0);
|
|
469
671
|
});
|
|
470
672
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
});
|
|
673
|
+
while (publicUrl === undefined) {
|
|
674
|
+
const ngrokProcess = startNgrokProcess(port, undefined, domain);
|
|
675
|
+
currentNgrok = ngrokProcess;
|
|
475
676
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
677
|
+
// Captured per attempt so an authentication failure can be told apart
|
|
678
|
+
// from other startup failures and recovered with a pasted-in token.
|
|
679
|
+
const ngrokOutput: string[] = [];
|
|
680
|
+
|
|
681
|
+
ngrokProcess.on("error", (err: Error) => {
|
|
682
|
+
console.error(`ngrok process error: ${err.message}`);
|
|
482
683
|
process.exit(1);
|
|
483
|
-
}
|
|
484
|
-
});
|
|
684
|
+
});
|
|
485
685
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
686
|
+
// Pipe ngrok stdout/stderr to console for visibility
|
|
687
|
+
ngrokProcess.stdout?.on("data", (data: Buffer) => {
|
|
688
|
+
const line = data.toString().trim();
|
|
689
|
+
if (line) {
|
|
690
|
+
ngrokOutput.push(line);
|
|
691
|
+
console.log(`[ngrok] ${line}`);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
ngrokProcess.stderr?.on("data", (data: Buffer) => {
|
|
695
|
+
const line = data.toString().trim();
|
|
696
|
+
if (line) {
|
|
697
|
+
ngrokOutput.push(line);
|
|
698
|
+
console.error(`[ngrok] ${line}`);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
495
701
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
702
|
+
// An agent that exits during startup rejects this instead of leaving
|
|
703
|
+
// waitForNgrokUrl to poll a dead API for its full timeout.
|
|
704
|
+
const exitFailure = new Promise<never>((_, reject) => {
|
|
705
|
+
ngrokProcess.on("exit", (code: number | null) => {
|
|
706
|
+
if (code !== null && code !== 0) {
|
|
707
|
+
reject(new NgrokStartupError(code, ngrokOutput.join("\n")));
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
let url: string;
|
|
713
|
+
try {
|
|
714
|
+
url = await Promise.race([waitForNgrokUrl(port, domain), exitFailure]);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (err instanceof NgrokStartupError && isNgrokAuthError(err.output)) {
|
|
717
|
+
if (authAttempts >= MAX_AUTH_RETRIES) {
|
|
718
|
+
console.error("\nngrok still rejects the tunnel as unauthenticated.");
|
|
719
|
+
console.error(
|
|
720
|
+
"Authenticate manually: ngrok config add-authtoken <your-token>",
|
|
721
|
+
);
|
|
722
|
+
console.error(
|
|
723
|
+
" Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
724
|
+
);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
console.log("");
|
|
728
|
+
console.log("⚠ ngrok rejected the tunnel: it isn't authenticated.");
|
|
729
|
+
console.log(
|
|
730
|
+
" Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
731
|
+
);
|
|
732
|
+
const token = (
|
|
733
|
+
await readLineText(
|
|
734
|
+
"Paste your ngrok authtoken to continue (Enter to cancel): ",
|
|
735
|
+
)
|
|
736
|
+
).trim();
|
|
737
|
+
if (!token) {
|
|
738
|
+
console.log("No authtoken entered.");
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
authAttempts++;
|
|
742
|
+
if (saveNgrokAuthToken(token)) {
|
|
743
|
+
console.log("✅ ngrok authtoken saved. Retrying the tunnel...");
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
cleanup();
|
|
748
|
+
if (domain && !opts.domain) {
|
|
749
|
+
console.error(savedDomainRecoveryHint(domain));
|
|
750
|
+
}
|
|
751
|
+
throw err;
|
|
502
752
|
}
|
|
503
|
-
|
|
504
|
-
}
|
|
753
|
+
publicUrl = url;
|
|
505
754
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
755
|
+
console.log("");
|
|
756
|
+
console.log(`Tunnel established: ${publicUrl}`);
|
|
757
|
+
console.log(`Forwarding to: localhost:${port}`);
|
|
758
|
+
|
|
759
|
+
// The domain is standing intent, not tunnel state: cleanup clears the
|
|
760
|
+
// ingress URL but leaves the domain saved for wake/daemon restores.
|
|
761
|
+
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
|
|
762
|
+
if (ngrokProcess.pid) {
|
|
763
|
+
stampRootProcess("tunnel", {
|
|
764
|
+
pid: ngrokProcess.pid,
|
|
765
|
+
provider: "ngrok",
|
|
766
|
+
startedAt: new Date().toISOString(),
|
|
767
|
+
assistantId: opts.assistantId,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
if (opts.domain) {
|
|
771
|
+
saveNgrokDomain(opts.domain);
|
|
772
|
+
}
|
|
773
|
+
console.log("Ingress URL saved to config.");
|
|
774
|
+
console.log("");
|
|
775
|
+
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
509
776
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
777
|
+
// Keep running until the ngrok process exits or we receive a signal
|
|
778
|
+
const waitForExit = new Promise<void>((resolve) => {
|
|
779
|
+
ngrokProcess.on("exit", () => resolve());
|
|
780
|
+
});
|
|
781
|
+
return { publicUrl, waitForExit };
|
|
515
782
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
519
|
-
|
|
520
|
-
// Keep running until the ngrok process exits or we receive a signal
|
|
521
|
-
await new Promise<void>((resolve) => {
|
|
522
|
-
ngrokProcess.on("exit", () => resolve());
|
|
523
|
-
});
|
|
783
|
+
// Unreachable: the loop only exits via return, process.exit, or a throw.
|
|
784
|
+
throw new Error("ngrok tunnel did not start.");
|
|
524
785
|
}
|