@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e
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 +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +8 -3
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +587 -36
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +8 -7
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +17 -7
- package/src/__tests__/shell-mode.test.ts +83 -0
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +22 -4
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/wake.test.ts +4 -1
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +91 -11
- package/src/commands/upgrade.ts +84 -157
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +43 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/ingress-config.ts +31 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +572 -95
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +87 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- package/src/lib/xdg-log.ts +11 -0
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Persist gateway identity in the service root without following an existing file link. */
|
|
12
|
+
export function persistRootSigningKey(root: string, key: string): void {
|
|
13
|
+
if (!/^[a-f0-9]{64}$/i.test(key)) {
|
|
14
|
+
throw new Error("Invalid gateway signing key.");
|
|
15
|
+
}
|
|
16
|
+
const directory = join(root, "gateway-security");
|
|
17
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
18
|
+
if (!lstatSync(directory).isDirectory()) {
|
|
19
|
+
throw new Error("Gateway security root must be a directory.");
|
|
20
|
+
}
|
|
21
|
+
const temporary = join(directory, `.signing-key-${randomUUID()}`);
|
|
22
|
+
try {
|
|
23
|
+
writeFileSync(temporary, Buffer.from(key, "hex"), {
|
|
24
|
+
mode: 0o600,
|
|
25
|
+
flag: "wx",
|
|
26
|
+
});
|
|
27
|
+
renameSync(temporary, join(directory, "actor-token-signing-key"));
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(temporary, { force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/lib/runtime-url.ts
CHANGED
|
@@ -4,35 +4,6 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { getLocalLanIPv4 } from "./local";
|
|
5
5
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Resolve the URL for a runtime migration endpoint, taking the assistant's
|
|
9
|
-
* topology into account.
|
|
10
|
-
*
|
|
11
|
-
* - For local/docker assistants, `runtimeUrl` is the loopback gateway and
|
|
12
|
-
* the runtime serves `/v1/migrations/<subpath>` directly. The CLI hits
|
|
13
|
-
* that path with guardian-token bearer auth.
|
|
14
|
-
* - For cloud-managed (cloud="cadet") assistants, `runtimeUrl` is the
|
|
15
|
-
* platform host (e.g. `https://cloud.mycadet.ai`). The cloud's
|
|
16
|
-
* `MigrationViewSet` does NOT expose `export-to-gcs` or arbitrary runtime
|
|
17
|
-
* migration paths under `/v1/migrations/...`. The wildcard runtime proxy
|
|
18
|
-
* at `/v1/assistants/<id>/<path:rest>` is what forwards arbitrary runtime
|
|
19
|
-
* paths to the cloud runtime — vembda's unified proxy bootstraps the
|
|
20
|
-
* guardian token internally for the runtime call. From the CLI side it's
|
|
21
|
-
* user-session auth.
|
|
22
|
-
*
|
|
23
|
-
* The `subpath` is appended to the migrations namespace verbatim
|
|
24
|
-
* (e.g. `"export-to-gcs"`, `"import-from-gcs"`, `\`jobs/${jobId}\``).
|
|
25
|
-
*/
|
|
26
|
-
export function resolveRuntimeMigrationUrl(
|
|
27
|
-
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
28
|
-
subpath: string,
|
|
29
|
-
): string {
|
|
30
|
-
if (isCloudHome(entry.cloud)) {
|
|
31
|
-
return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/migrations/${subpath}`;
|
|
32
|
-
}
|
|
33
|
-
return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
7
|
/**
|
|
37
8
|
* Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
|
|
38
9
|
* taking the assistant's topology into account.
|
|
@@ -1,17 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Platform (cloud="cadet") half of the pre-teleport safety backup.
|
|
3
|
-
*
|
|
4
|
-
* A platform source is snapshotted through Django's user-facing backup
|
|
5
|
-
* endpoint, which cuts a PVC VolumeSnapshot via vembda. The POST returns as
|
|
6
|
-
* soon as the snapshot object exists; it becomes restorable asynchronously,
|
|
7
|
-
* so {@link createPlatformBackup} polls the listing until the new snapshot
|
|
8
|
-
* reports `ready_to_use` before returning. A snapshot that never becomes
|
|
9
|
-
* ready within the timeout is a backup failure, not a success.
|
|
10
|
-
*
|
|
11
|
-
* Local and docker sources are backed up host-side by `backup-ops` instead,
|
|
12
|
-
* so the restore point survives the source's retirement. Policy shared with
|
|
13
|
-
* the web teleport lives in `@mycadet/local-mode/teleport-backup-policy`.
|
|
14
|
-
*/
|
|
1
|
+
/** Cloud API safety backups use session auth and wait for a restorable root tar. */
|
|
15
2
|
|
|
16
3
|
import {
|
|
17
4
|
MANAGED_BACKUP_READY_POLL_INTERVAL_MS,
|
|
@@ -93,7 +80,7 @@ async function fetchPlatformBackups(
|
|
|
93
80
|
}
|
|
94
81
|
|
|
95
82
|
/**
|
|
96
|
-
* `created_at` of every restorable
|
|
83
|
+
* `created_at` of every restorable root archive of a cloud assistant:
|
|
97
84
|
* `GET /v1/assistants/<id>/backups/`.
|
|
98
85
|
*/
|
|
99
86
|
export async function listPlatformBackups(
|
|
@@ -109,7 +96,7 @@ export interface CreatePlatformBackupOptions {
|
|
|
109
96
|
}
|
|
110
97
|
|
|
111
98
|
/**
|
|
112
|
-
*
|
|
99
|
+
* Capture a root archive of a cloud assistant and wait until it is
|
|
113
100
|
* restorable: `POST /v1/assistants/<id>/backups/`, then re-list until the
|
|
114
101
|
* returned `snapshot_name` reports `ready_to_use`. Throws if the POST fails
|
|
115
102
|
* or the snapshot is not ready within `timeoutMs`.
|
package/src/lib/tunnel.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TUNNEL_PROVIDERS } from "@mycadet/service-contracts/ingress";
|
|
1
3
|
import {
|
|
2
4
|
clearRootProcess,
|
|
3
5
|
loadAllAssistants,
|
|
@@ -24,26 +26,24 @@ import { waitForDaemonReady } from "./http-client.js";
|
|
|
24
26
|
import {
|
|
25
27
|
hasWebhookIntegrations,
|
|
26
28
|
IngressConfigSchema,
|
|
27
|
-
isNgrokProcess,
|
|
28
29
|
maybeStartNgrokTunnel,
|
|
29
30
|
NGROK_TUNNEL_META,
|
|
30
|
-
|
|
31
|
+
NGROK_PROVIDER,
|
|
31
32
|
saveNgrokDomain,
|
|
32
33
|
} from "./tunnels/ngrok.js";
|
|
33
|
-
import { stopProcess } from "./process.js";
|
|
34
34
|
import { shellArg } from "./shell-arg.js";
|
|
35
35
|
import { notifyGatewayOfPublicUrl } from "./tunnels/gateway-notify.js";
|
|
36
36
|
import {
|
|
37
37
|
selectRestartTunnelRecord,
|
|
38
38
|
type TunnelProviderName,
|
|
39
39
|
} from "@mycadet/service-contracts/ingress";
|
|
40
|
-
import {
|
|
40
|
+
import { CADET_PROVIDER, CADET_TUNNEL_META } from "./tunnels/cadet.js";
|
|
41
41
|
import {
|
|
42
|
-
|
|
42
|
+
CLOUDFLARE_PROVIDER,
|
|
43
43
|
CLOUDFLARE_TUNNEL_META,
|
|
44
44
|
} from "./tunnels/cloudflare.js";
|
|
45
45
|
import {
|
|
46
|
-
|
|
46
|
+
TAILSCALE_PROVIDER,
|
|
47
47
|
TAILSCALE_TUNNEL_META,
|
|
48
48
|
} from "./tunnels/tailscale.js";
|
|
49
49
|
|
|
@@ -119,6 +119,25 @@ export interface ProviderTunnelResult {
|
|
|
119
119
|
waitForExit: Promise<void>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export interface ProviderStopOptions {
|
|
123
|
+
record: RootProcessRecord;
|
|
124
|
+
assistantId?: string;
|
|
125
|
+
workspaceDir?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface TunnelProvider {
|
|
129
|
+
run(opts: ProviderTunnelOptions): Promise<ProviderTunnelResult>;
|
|
130
|
+
stop(opts: ProviderStopOptions): Promise<boolean>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const providers: Record<TunnelProviderName, TunnelProvider> = {
|
|
134
|
+
ngrok: NGROK_PROVIDER,
|
|
135
|
+
cloudflare: CLOUDFLARE_PROVIDER,
|
|
136
|
+
tailscale: TAILSCALE_PROVIDER,
|
|
137
|
+
cadet: CADET_PROVIDER,
|
|
138
|
+
};
|
|
139
|
+
const providerNameSchema = z.enum(TUNNEL_PROVIDERS);
|
|
140
|
+
|
|
122
141
|
/**
|
|
123
142
|
* Run a tunnel provider by name — the dispatch `cadet tunnel` uses, so the
|
|
124
143
|
* command layer never names a provider implementation directly. Notifies the
|
|
@@ -140,29 +159,7 @@ function runProvider(
|
|
|
140
159
|
provider: TunnelProviderName,
|
|
141
160
|
opts: ProviderTunnelOptions,
|
|
142
161
|
): Promise<ProviderTunnelResult> {
|
|
143
|
-
|
|
144
|
-
port: opts.port,
|
|
145
|
-
assistantId: opts.assistantId,
|
|
146
|
-
workspaceDir: opts.workspaceDir,
|
|
147
|
-
};
|
|
148
|
-
if (provider === "ngrok") {
|
|
149
|
-
return runNgrokTunnel({
|
|
150
|
-
...base,
|
|
151
|
-
...(opts.domain ? { domain: opts.domain } : {}),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
if (provider === "cadet") {
|
|
155
|
-
return runCadetTunnel(opts);
|
|
156
|
-
}
|
|
157
|
-
if (provider === "cloudflare") {
|
|
158
|
-
return runCloudflareTunnel(base);
|
|
159
|
-
}
|
|
160
|
-
return runTailscaleTunnel({
|
|
161
|
-
...base,
|
|
162
|
-
...(opts.preserveIngressUrl
|
|
163
|
-
? { preserveIngressUrl: opts.preserveIngressUrl }
|
|
164
|
-
: {}),
|
|
165
|
-
});
|
|
162
|
+
return providers[provider].run(opts);
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
/** Drop the saved reserved-domain record (providers that keep one). */
|
|
@@ -176,13 +173,31 @@ export function clearReservedDomain(): void {
|
|
|
176
173
|
* signaling — a stale entry after PID reuse must not kill an unrelated
|
|
177
174
|
* process.
|
|
178
175
|
*/
|
|
179
|
-
export async function stopTunnelProcess(
|
|
176
|
+
export async function stopTunnelProcess(
|
|
177
|
+
assistantId?: string,
|
|
178
|
+
workspaceDir?: string,
|
|
179
|
+
): Promise<boolean> {
|
|
180
180
|
const recorded: RootProcessRecord | undefined = readRootProcesses().tunnel;
|
|
181
|
-
if (!recorded) return;
|
|
182
|
-
if (
|
|
183
|
-
|
|
181
|
+
if (!recorded) return false;
|
|
182
|
+
if (
|
|
183
|
+
assistantId &&
|
|
184
|
+
recorded.assistantId &&
|
|
185
|
+
recorded.assistantId !== assistantId
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`The recorded tunnel belongs to '${recorded.assistantId}', not '${assistantId}'.`,
|
|
189
|
+
);
|
|
184
190
|
}
|
|
185
|
-
|
|
191
|
+
const provider = providerNameSchema.parse(
|
|
192
|
+
recorded.provider ?? DEFAULT_TUNNEL_PROVIDER,
|
|
193
|
+
);
|
|
194
|
+
const stopped = await providers[provider].stop({
|
|
195
|
+
record: recorded,
|
|
196
|
+
assistantId,
|
|
197
|
+
workspaceDir,
|
|
198
|
+
});
|
|
199
|
+
clearRootProcess("tunnel", recorded.pid);
|
|
200
|
+
return stopped;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
/**
|
|
@@ -197,6 +212,43 @@ export function adoptedTunnelStopNotice(pid: number | undefined): string {
|
|
|
197
212
|
);
|
|
198
213
|
}
|
|
199
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Pull the single most meaningful failure line out of a tunnel log so the
|
|
217
|
+
* `cadet tunnel` wrapper can name why the child died instead of only pointing
|
|
218
|
+
* at the log file. Priority: nginx emerg/bind failures, ngrok error codes,
|
|
219
|
+
* auth/usage failures, then any generic error/fatal/failed line — the LAST
|
|
220
|
+
* match wins (the freshest), capped to keep the console line readable. Falls
|
|
221
|
+
* back to the last non-empty log line, or null for an empty log.
|
|
222
|
+
*/
|
|
223
|
+
export function summarizeStartupFailure(logText: string): string | null {
|
|
224
|
+
const lines = logText
|
|
225
|
+
.split("\n")
|
|
226
|
+
.map((line) => line.trim())
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
if (lines.length === 0) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const patterns: RegExp[] = [
|
|
232
|
+
/\[emerg\]/,
|
|
233
|
+
/bind\(\).*failed/i,
|
|
234
|
+
/address already in use/i,
|
|
235
|
+
/ERR_NGROK_\d+/,
|
|
236
|
+
/authtoken|authentication failed|usage limit|unauthorized/i,
|
|
237
|
+
/\berror\b|\bfatal\b|\bfailed\b/i,
|
|
238
|
+
];
|
|
239
|
+
const cap = (line: string): string =>
|
|
240
|
+
line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
241
|
+
for (const pattern of patterns) {
|
|
242
|
+
const matches = lines.filter((line) => pattern.test(line));
|
|
243
|
+
const last = matches.at(-1);
|
|
244
|
+
if (last !== undefined) {
|
|
245
|
+
return cap(last);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const lastLine = lines.at(-1);
|
|
249
|
+
return lastLine === undefined ? null : cap(lastLine);
|
|
250
|
+
}
|
|
251
|
+
|
|
200
252
|
export {
|
|
201
253
|
ADOPTED_TUNNEL_RE,
|
|
202
254
|
hasWebhookIntegrationsConfigured,
|
|
@@ -335,6 +387,7 @@ export async function restoreTunnelEdge(
|
|
|
335
387
|
console.log(
|
|
336
388
|
` Tunnel edge ${edge.started ? "started" : "already running"} on 127.0.0.1:${edge.port} (${formatEdgeMode(
|
|
337
389
|
edge.includesWebApp,
|
|
390
|
+
edge.shellMode,
|
|
338
391
|
)}).`,
|
|
339
392
|
);
|
|
340
393
|
return edge.port;
|
package/src/lib/tunnels/cadet.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { STALE_CLI_UPDATE_HINT } from "../stale-cli-hint.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
TunnelProvider,
|
|
4
|
+
ProviderMeta,
|
|
5
|
+
ProviderTunnelOptions,
|
|
6
|
+
} from "../tunnel.js";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* The managed `cadet` tunnel. It is listed in the shared registry so the
|
|
@@ -27,3 +31,10 @@ export async function runCadetTunnel(
|
|
|
27
31
|
): Promise<never> {
|
|
28
32
|
throw cadetTunnelComingSoonError("cadet");
|
|
29
33
|
}
|
|
34
|
+
|
|
35
|
+
export const CADET_PROVIDER: TunnelProvider = {
|
|
36
|
+
run: runCadetTunnel,
|
|
37
|
+
async stop() {
|
|
38
|
+
throw cadetTunnelComingSoonError("cadet");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
|
|
2
|
+
import { readProcessCommandLine } from "../process.js";
|
|
3
|
+
import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
|
|
4
|
+
import type {
|
|
5
|
+
TunnelProvider,
|
|
6
|
+
ProviderMeta,
|
|
7
|
+
ProviderTunnelResult,
|
|
8
|
+
} from "../tunnel.js";
|
|
2
9
|
|
|
3
10
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
4
11
|
|
|
@@ -106,6 +113,7 @@ export function waitForCloudflareTunnelUrl(
|
|
|
106
113
|
});
|
|
107
114
|
|
|
108
115
|
child.on("exit", (code) => {
|
|
116
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
109
117
|
if (resolved) return;
|
|
110
118
|
clearTimeout(timer);
|
|
111
119
|
reject(
|
|
@@ -177,6 +185,7 @@ export async function runCloudflareTunnel(
|
|
|
177
185
|
|
|
178
186
|
const cleanup = (): void => {
|
|
179
187
|
if (!child.killed) child.kill("SIGTERM");
|
|
188
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
180
189
|
if (publicUrl) {
|
|
181
190
|
console.log("\nClearing ingress URL from config...");
|
|
182
191
|
clearIngressUrl(workspaceDir, opts.assistantId);
|
|
@@ -198,6 +207,7 @@ export async function runCloudflareTunnel(
|
|
|
198
207
|
});
|
|
199
208
|
|
|
200
209
|
child.on("exit", (code) => {
|
|
210
|
+
if (child.pid) clearRootProcess("tunnel", child.pid);
|
|
201
211
|
// Always clear the saved ingress URL when the tunnel process ends so
|
|
202
212
|
// webhook integrations don't keep hitting a dead endpoint.
|
|
203
213
|
if (publicUrl !== undefined) {
|
|
@@ -233,6 +243,13 @@ export async function runCloudflareTunnel(
|
|
|
233
243
|
console.log("");
|
|
234
244
|
|
|
235
245
|
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "cloudflare");
|
|
246
|
+
if (child.pid)
|
|
247
|
+
stampRootProcess("tunnel", {
|
|
248
|
+
pid: child.pid,
|
|
249
|
+
provider: "cloudflare",
|
|
250
|
+
assistantId: opts.assistantId,
|
|
251
|
+
startedAt: new Date().toISOString(),
|
|
252
|
+
});
|
|
236
253
|
console.log("Ingress URL saved to config.");
|
|
237
254
|
console.log("");
|
|
238
255
|
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
@@ -251,3 +268,21 @@ export const CLOUDFLARE_TUNNEL_META: ProviderMeta = {
|
|
|
251
268
|
notes: ["No Cloudflare account required for quick tunnels."],
|
|
252
269
|
supportsReservedDomain: false,
|
|
253
270
|
};
|
|
271
|
+
|
|
272
|
+
export const CLOUDFLARE_PROVIDER: TunnelProvider = {
|
|
273
|
+
run: runCloudflareTunnel,
|
|
274
|
+
stop: (opts) =>
|
|
275
|
+
stopOwnedTunnelAgent(
|
|
276
|
+
opts,
|
|
277
|
+
(pid) => {
|
|
278
|
+
try {
|
|
279
|
+
return /(?:^|[\\/\s])cloudflared(?:\.exe)?(?:\s|$)/i.test(
|
|
280
|
+
readProcessCommandLine(pid),
|
|
281
|
+
);
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
"cloudflared",
|
|
287
|
+
),
|
|
288
|
+
};
|
package/src/lib/tunnels/ngrok.ts
CHANGED
|
@@ -25,7 +25,12 @@ import { getCurrentEnvironment } from "../environments/resolve.js";
|
|
|
25
25
|
import { loopbackSafeFetch } from "../loopback-fetch.js";
|
|
26
26
|
import { readProcessCommandLine } from "../process.js";
|
|
27
27
|
import { z } from "zod";
|
|
28
|
-
import
|
|
28
|
+
import { stopOwnedTunnelAgent } from "./process-lifecycle.js";
|
|
29
|
+
import type {
|
|
30
|
+
TunnelProvider,
|
|
31
|
+
ProviderMeta,
|
|
32
|
+
ProviderTunnelResult,
|
|
33
|
+
} from "../tunnel.js";
|
|
29
34
|
import { notifyGatewayOfPublicUrl } from "./gateway-notify.js";
|
|
30
35
|
|
|
31
36
|
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
@@ -631,8 +636,12 @@ export async function runNgrokTunnel(
|
|
|
631
636
|
console.log("Ingress URL saved to config.");
|
|
632
637
|
console.log("");
|
|
633
638
|
console.log(
|
|
634
|
-
|
|
639
|
+
`An ngrok agent from an earlier run is still up serving 127.0.0.1:${port} (${existingUrl}) — this run adopts it instead of starting a new agent.`,
|
|
635
640
|
);
|
|
641
|
+
console.log(
|
|
642
|
+
"Adopted agents do not appear in `cadet ps` because this run does not own the process.",
|
|
643
|
+
);
|
|
644
|
+
console.log("Press Ctrl+C to detach (tunnel stays active).");
|
|
636
645
|
|
|
637
646
|
// Block until SIGINT/SIGTERM
|
|
638
647
|
const waitForExit = new Promise<void>((resolve) => {
|
|
@@ -783,3 +792,8 @@ export async function runNgrokTunnel(
|
|
|
783
792
|
// Unreachable: the loop only exits via return, process.exit, or a throw.
|
|
784
793
|
throw new Error("ngrok tunnel did not start.");
|
|
785
794
|
}
|
|
795
|
+
|
|
796
|
+
export const NGROK_PROVIDER: TunnelProvider = {
|
|
797
|
+
run: runNgrokTunnel,
|
|
798
|
+
stop: (opts) => stopOwnedTunnelAgent(opts, isNgrokProcess, "ngrok"),
|
|
799
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { clearIngressUrl } from "../ingress-config.js";
|
|
2
|
+
import { stopProcess } from "../process.js";
|
|
3
|
+
import type { ProviderStopOptions } from "../tunnel.js";
|
|
4
|
+
|
|
5
|
+
/** Stop only an identified provider process and retain state on termination failure. */
|
|
6
|
+
export async function stopOwnedTunnelAgent(
|
|
7
|
+
opts: ProviderStopOptions,
|
|
8
|
+
matches: (pid: number) => boolean,
|
|
9
|
+
label: string,
|
|
10
|
+
): Promise<boolean> {
|
|
11
|
+
let stopped = false;
|
|
12
|
+
if (matches(opts.record.pid)) {
|
|
13
|
+
stopped = await stopProcess(opts.record.pid, "tunnel");
|
|
14
|
+
if (!stopped && matches(opts.record.pid)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Could not stop ${label} process ${opts.record.pid}; saved tunnel state was retained.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (opts.workspaceDir)
|
|
21
|
+
clearIngressUrl(
|
|
22
|
+
opts.workspaceDir,
|
|
23
|
+
opts.assistantId ?? opts.record.assistantId,
|
|
24
|
+
);
|
|
25
|
+
return stopped;
|
|
26
|
+
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
clearRootProcess,
|
|
4
|
+
readRootProcesses,
|
|
5
|
+
stampRootProcess,
|
|
6
|
+
} from "../assistant-config.js";
|
|
7
|
+
import { readProcessCommandLine, stopProcess } from "../process.js";
|
|
8
|
+
import type {
|
|
9
|
+
TunnelProvider,
|
|
10
|
+
ProviderStopOptions,
|
|
11
|
+
ProviderMeta,
|
|
12
|
+
ProviderTunnelResult,
|
|
13
|
+
} from "../tunnel.js";
|
|
2
14
|
|
|
3
15
|
import { spawnSync } from "node:child_process";
|
|
4
16
|
import { win32 } from "node:path";
|
|
@@ -7,6 +19,7 @@ import { GATEWAY_PORT } from "../constants.js";
|
|
|
7
19
|
import { ensureBinaryInstalled } from "../ensure-binary.js";
|
|
8
20
|
import {
|
|
9
21
|
clearIngressUrl,
|
|
22
|
+
loadRawConfig,
|
|
10
23
|
getDefaultWorkspaceDir,
|
|
11
24
|
readLockfileIngressUrl,
|
|
12
25
|
saveIngressUrl,
|
|
@@ -418,6 +431,16 @@ export async function runTailscaleTunnel(
|
|
|
418
431
|
);
|
|
419
432
|
console.log("or stop it later with: tailscale serve --https=443 off");
|
|
420
433
|
|
|
434
|
+
const previous = opts.preserveIngressUrl
|
|
435
|
+
? readRootProcesses().tunnel
|
|
436
|
+
: undefined;
|
|
437
|
+
stampRootProcess("tunnel", {
|
|
438
|
+
...(previous?.provider !== "tailscale" ? { previous } : {}),
|
|
439
|
+
pid: process.pid,
|
|
440
|
+
provider: "tailscale",
|
|
441
|
+
assistantId: opts.assistantId,
|
|
442
|
+
startedAt: new Date().toISOString(),
|
|
443
|
+
});
|
|
421
444
|
const teardown = (): void => {
|
|
422
445
|
console.log(
|
|
423
446
|
`\nStopping tailscale serve and clearing the ${publishedLabel}...`,
|
|
@@ -441,6 +464,7 @@ export async function runTailscaleTunnel(
|
|
|
441
464
|
);
|
|
442
465
|
}
|
|
443
466
|
if (shouldClearIngressUrl(result)) {
|
|
467
|
+
clearRootProcess("tunnel", process.pid);
|
|
444
468
|
retractServeUrl(opts, workspaceDir, previousLockfileIngressUrl);
|
|
445
469
|
} else {
|
|
446
470
|
console.error(
|
|
@@ -475,3 +499,57 @@ export const TAILSCALE_TUNNEL_META: ProviderMeta = {
|
|
|
475
499
|
],
|
|
476
500
|
supportsReservedDomain: false,
|
|
477
501
|
};
|
|
502
|
+
|
|
503
|
+
const stoppedTailscaleStateSchema = z.object({
|
|
504
|
+
ingress: z
|
|
505
|
+
.object({
|
|
506
|
+
publicBaseUrl: z.string().optional(),
|
|
507
|
+
pairingTunnel: z.object({ provider: z.string() }).optional(),
|
|
508
|
+
})
|
|
509
|
+
.optional(),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
export async function stopTailscaleTunnel(
|
|
513
|
+
opts: ProviderStopOptions,
|
|
514
|
+
deps: TailscaleDeps = {
|
|
515
|
+
findBinary: async () => probeTailscaleBinary(),
|
|
516
|
+
run: realRun,
|
|
517
|
+
},
|
|
518
|
+
): Promise<boolean> {
|
|
519
|
+
const binary = await deps.findBinary();
|
|
520
|
+
if (!binary) throw new Error(getTailscaleInstallMessage());
|
|
521
|
+
const result = stopTailscaleServe(binary, deps);
|
|
522
|
+
if (result.status !== 0)
|
|
523
|
+
throw new Error(`Could not stop tailscale serve: ${joinOutput(result)}`);
|
|
524
|
+
// The serve belongs to tailscaled; only the Cadet supervisor may be signaled.
|
|
525
|
+
let supervisor = false;
|
|
526
|
+
try {
|
|
527
|
+
const command = readProcessCommandLine(opts.record.pid);
|
|
528
|
+
supervisor =
|
|
529
|
+
/cadet/i.test(command) && /(?:^|\s)tunnel(?:\s|$)/.test(command);
|
|
530
|
+
} catch {
|
|
531
|
+
/* The supervisor may already have exited. */
|
|
532
|
+
}
|
|
533
|
+
if (supervisor && opts.record.pid !== process.pid)
|
|
534
|
+
await stopProcess(opts.record.pid, "tunnel supervisor");
|
|
535
|
+
if (opts.workspaceDir) {
|
|
536
|
+
const state = stoppedTailscaleStateSchema.parse(
|
|
537
|
+
loadRawConfig(opts.workspaceDir),
|
|
538
|
+
).ingress;
|
|
539
|
+
const assistantId = opts.assistantId ?? opts.record.assistantId;
|
|
540
|
+
retractServeUrl(
|
|
541
|
+
{
|
|
542
|
+
assistantId,
|
|
543
|
+
preserveIngressUrl: state?.pairingTunnel?.provider === "tailscale",
|
|
544
|
+
},
|
|
545
|
+
opts.workspaceDir,
|
|
546
|
+
state?.publicBaseUrl ?? null,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export const TAILSCALE_PROVIDER: TunnelProvider = {
|
|
553
|
+
run: runTailscaleTunnel,
|
|
554
|
+
stop: stopTailscaleTunnel,
|
|
555
|
+
};
|