@mycadet/cli 0.12.7-dev.202610111049.d2b52a8 → 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.
- package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
- package/package.json +4 -3
- package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
- package/src/__tests__/ps-cloud-status.test.ts +53 -0
- package/src/__tests__/retire.test.ts +37 -178
- package/src/__tests__/root-process-lockfile.test.ts +99 -0
- package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
- package/src/__tests__/tunnel.test.ts +127 -80
- package/src/__tests__/upgrade-local.test.ts +3 -3
- package/src/__tests__/wake.test.ts +16 -9
- package/src/commands/client.ts +1 -1
- package/src/commands/ps.ts +181 -72
- package/src/commands/retire.ts +28 -137
- package/src/commands/tunnel.ts +76 -118
- package/src/commands/upgrade.ts +2 -9
- package/src/commands/wake.ts +5 -9
- package/src/lib/assistant-config.ts +53 -0
- package/src/lib/detached-process.ts +1 -1
- package/src/lib/ingress-config.ts +7 -34
- package/src/lib/local.ts +6 -27
- package/src/lib/nginx-ingress.ts +29 -8
- 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} +15 -4
- package/src/lib/tunnels/gateway-notify.ts +61 -0
- package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
- package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
package/src/commands/tunnel.ts
CHANGED
|
@@ -14,41 +14,36 @@ import {
|
|
|
14
14
|
type AssistantEntry,
|
|
15
15
|
} from "../lib/assistant-config";
|
|
16
16
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
17
|
-
import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
|
|
18
17
|
import { relaunchDetached } from "../lib/detached-process.js";
|
|
19
18
|
import {
|
|
20
19
|
getDefaultWorkspaceDir,
|
|
21
20
|
isLocalContainerEntry,
|
|
22
21
|
parseGatewayPortFromEntryUrls,
|
|
23
|
-
saveNgrokDomain,
|
|
24
22
|
} from "../lib/ingress-config.js";
|
|
25
23
|
import {
|
|
26
24
|
ensureTunnelEdge,
|
|
27
25
|
formatEdgeMode,
|
|
28
26
|
type TunnelEdge,
|
|
29
27
|
} from "../lib/nginx-ingress.js";
|
|
30
|
-
import { hasWebhookIntegrationsConfigured, runNgrokTunnel } from "../lib/ngrok";
|
|
31
|
-
import { shellArg } from "../lib/shell-arg.js";
|
|
32
28
|
import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
};
|
|
29
|
+
import {
|
|
30
|
+
ADOPTED_TUNNEL_RE,
|
|
31
|
+
DEFAULT_TUNNEL_PROVIDER,
|
|
32
|
+
PROVIDER_META,
|
|
33
|
+
adoptedTunnelStopNotice,
|
|
34
|
+
cadetTunnelComingSoonError,
|
|
35
|
+
clearReservedDomain,
|
|
36
|
+
hasWebhookIntegrationsConfigured,
|
|
37
|
+
providerSupportsReservedDomain,
|
|
38
|
+
runProviderTunnel,
|
|
39
|
+
} from "../lib/tunnel.js";
|
|
40
|
+
|
|
41
|
+
// The accepted `--provider` values ARE the shared registry, so validation
|
|
42
|
+
// here cannot drift from what the workspace config accepts.
|
|
48
43
|
|
|
49
44
|
interface TunnelArgs {
|
|
50
45
|
assistantName: string | null;
|
|
51
|
-
provider:
|
|
46
|
+
provider: TunnelProviderName;
|
|
52
47
|
domain: string | null;
|
|
53
48
|
clearDomain: boolean;
|
|
54
49
|
detach: boolean;
|
|
@@ -58,7 +53,7 @@ const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
|
|
|
58
53
|
|
|
59
54
|
function parseArgs(): TunnelArgs {
|
|
60
55
|
const args = process.argv.slice(3);
|
|
61
|
-
let provider:
|
|
56
|
+
let provider: TunnelProviderName | null = null;
|
|
62
57
|
let domain: string | null = null;
|
|
63
58
|
let clearDomain = false;
|
|
64
59
|
let detach = false;
|
|
@@ -76,14 +71,13 @@ function parseArgs(): TunnelArgs {
|
|
|
76
71
|
);
|
|
77
72
|
console.log("");
|
|
78
73
|
console.log(
|
|
79
|
-
"How far that URL reaches depends on the provider
|
|
80
|
-
);
|
|
81
|
-
console.log(
|
|
82
|
-
"A tailscale tunnel is reachable only from your own tailnet; webhook",
|
|
74
|
+
"How far that URL reaches depends on the provider; omit --provider for",
|
|
83
75
|
);
|
|
76
|
+
console.log("the default. A tailscale tunnel is reachable only from your");
|
|
84
77
|
console.log(
|
|
85
|
-
"integrations need a public tunnel
|
|
78
|
+
"own tailnet; webhook integrations need a public tunnel (see Providers",
|
|
86
79
|
);
|
|
80
|
+
console.log("below).");
|
|
87
81
|
console.log("");
|
|
88
82
|
console.log(
|
|
89
83
|
"The tunnel always fronts the local nginx edge, which is started automatically.",
|
|
@@ -99,19 +93,21 @@ function parseArgs(): TunnelArgs {
|
|
|
99
93
|
console.log("");
|
|
100
94
|
console.log("Options:");
|
|
101
95
|
console.log(
|
|
102
|
-
` --provider <provider>
|
|
96
|
+
` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")}`,
|
|
103
97
|
);
|
|
98
|
+
console.log(` (default: ${DEFAULT_TUNNEL_PROVIDER})`);
|
|
104
99
|
console.log(
|
|
105
|
-
" --domain <domain> Reserved
|
|
100
|
+
" --domain <domain> Reserved domain to bind, for providers that support",
|
|
106
101
|
);
|
|
107
102
|
console.log(
|
|
108
|
-
" Saved to the workspace config so `cadet wake` restores
|
|
103
|
+
" one. Saved to the workspace config so `cadet wake` restores",
|
|
109
104
|
);
|
|
105
|
+
console.log(" reuse it.");
|
|
110
106
|
console.log(
|
|
111
|
-
" --clear-domain Clear the saved
|
|
107
|
+
" --clear-domain Clear the saved reserved domain (same providers) and",
|
|
112
108
|
);
|
|
113
109
|
console.log(
|
|
114
|
-
" without one. Cannot be combined with --domain.",
|
|
110
|
+
" tunnel without one. Cannot be combined with --domain.",
|
|
115
111
|
);
|
|
116
112
|
console.log(
|
|
117
113
|
` -d, --detach Run the tunnel in the background. Waits up to ${TUNNEL_BACKGROUND_START_TIMEOUT_MS / 1000}s for the`,
|
|
@@ -123,72 +119,64 @@ function parseArgs(): TunnelArgs {
|
|
|
123
119
|
" the CLI log directory. Stop it with `kill <pid>`, except when it",
|
|
124
120
|
);
|
|
125
121
|
console.log(
|
|
126
|
-
" adopted an already-running
|
|
122
|
+
" adopted an already-running tunnel (started outside this",
|
|
127
123
|
);
|
|
128
124
|
console.log(
|
|
129
125
|
" command): killing the supervisor then leaves that tunnel running.",
|
|
130
126
|
);
|
|
131
127
|
console.log("");
|
|
132
128
|
console.log("Providers:");
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
142
|
-
console.log(
|
|
143
|
-
" No Cloudflare account required for quick tunnels.",
|
|
144
|
-
);
|
|
145
|
-
console.log(
|
|
146
|
-
" tailscale Tailscale serve. Install: https://tailscale.com/download, then run `tailscale up`",
|
|
147
|
-
);
|
|
148
|
-
console.log(
|
|
149
|
-
" Reachable only from your own tailnet (private; LetsEncrypt cert).",
|
|
150
|
-
);
|
|
151
|
-
console.log(
|
|
152
|
-
" When webhook integrations are configured it records its URL for device",
|
|
153
|
-
);
|
|
154
|
-
console.log(
|
|
155
|
-
" pairing only, leaving the saved webhook callback base as it is.",
|
|
156
|
-
);
|
|
129
|
+
for (const name of TUNNEL_PROVIDERS) {
|
|
130
|
+
const meta = PROVIDER_META[name];
|
|
131
|
+
console.log(` ${name.padEnd(12)}${meta.reach}`);
|
|
132
|
+
console.log(` ${" ".repeat(12)}${meta.install}`);
|
|
133
|
+
for (const note of meta.notes ?? []) {
|
|
134
|
+
console.log(` ${" ".repeat(12)}${note}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
157
137
|
console.log("");
|
|
158
138
|
console.log("Examples:");
|
|
159
|
-
console.log(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
139
|
+
console.log(` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER}`);
|
|
140
|
+
const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
|
|
141
|
+
if (domainExample) {
|
|
142
|
+
console.log(
|
|
143
|
+
` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --domain ${domainExample}`,
|
|
144
|
+
);
|
|
145
|
+
console.log(
|
|
146
|
+
` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --clear-domain`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
164
149
|
console.log(" $ cadet tunnel --provider cloudflare");
|
|
165
150
|
console.log(" $ cadet tunnel my-assistant --provider tailscale");
|
|
166
|
-
console.log(
|
|
151
|
+
console.log(
|
|
152
|
+
` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --detach`,
|
|
153
|
+
);
|
|
167
154
|
process.exit(0);
|
|
168
155
|
} else if (arg === "--provider") {
|
|
169
156
|
const next = args[i + 1];
|
|
170
157
|
if (!next || next.startsWith("-")) {
|
|
171
158
|
console.error(
|
|
172
|
-
`Error: --provider requires one of: ${
|
|
159
|
+
`Error: --provider requires one of: ${TUNNEL_PROVIDERS.join(", ")}`,
|
|
173
160
|
);
|
|
174
161
|
process.exit(1);
|
|
175
162
|
}
|
|
176
|
-
if (!
|
|
163
|
+
if (!TUNNEL_PROVIDERS.includes(next as TunnelProviderName)) {
|
|
177
164
|
console.error(
|
|
178
|
-
`Error: unknown tunnel provider '${next}'. Valid providers: ${
|
|
165
|
+
`Error: unknown tunnel provider '${next}'. Valid providers: ${TUNNEL_PROVIDERS.join(", ")}.`,
|
|
179
166
|
);
|
|
180
167
|
console.error(
|
|
181
168
|
`If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
|
|
182
169
|
);
|
|
183
170
|
process.exit(1);
|
|
184
171
|
}
|
|
185
|
-
provider = next as
|
|
172
|
+
provider = next as TunnelProviderName;
|
|
186
173
|
i++;
|
|
187
174
|
} else if (arg === "--domain") {
|
|
188
175
|
const next = args[i + 1];
|
|
189
176
|
if (!next || next.startsWith("-")) {
|
|
177
|
+
const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
|
|
190
178
|
console.error(
|
|
191
|
-
|
|
179
|
+
`Error: --domain requires a value${domainExample ? `, e.g. --domain ${domainExample}` : ""}`,
|
|
192
180
|
);
|
|
193
181
|
process.exit(1);
|
|
194
182
|
}
|
|
@@ -210,32 +198,22 @@ function parseArgs(): TunnelArgs {
|
|
|
210
198
|
parseAssistantTargetArg(args, FLAGS_WITH_VALUES) ?? null;
|
|
211
199
|
|
|
212
200
|
if (provider === null) {
|
|
213
|
-
|
|
214
|
-
|
|
201
|
+
provider = DEFAULT_TUNNEL_PROVIDER;
|
|
202
|
+
console.log(
|
|
203
|
+
`Using the default provider: ${provider} (pass --provider to choose another).`,
|
|
215
204
|
);
|
|
216
|
-
console.error("");
|
|
217
|
-
console.error("Run one of:");
|
|
218
|
-
// Carries the assistant through: dropping it would suggest a command that
|
|
219
|
-
// tunnels the active assistant instead of the one that was asked for.
|
|
220
|
-
const target = assistantName ? ` ${shellArg(assistantName)}` : "";
|
|
221
|
-
for (const name of TUNNEL_PROVIDERS) {
|
|
222
|
-
console.error(
|
|
223
|
-
` cadet tunnel${target} --provider ${name.padEnd(13)}${PROVIDER_REACH[name]}`,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
process.exit(1);
|
|
227
205
|
}
|
|
228
206
|
|
|
229
|
-
if (domain && provider
|
|
207
|
+
if (domain && !providerSupportsReservedDomain(provider)) {
|
|
230
208
|
console.error(
|
|
231
|
-
`Error: --domain is
|
|
209
|
+
`Error: --domain is not supported with --provider ${provider} (reserved domains have no equivalent there).`,
|
|
232
210
|
);
|
|
233
211
|
process.exit(1);
|
|
234
212
|
}
|
|
235
213
|
|
|
236
|
-
if (clearDomain && provider
|
|
214
|
+
if (clearDomain && !providerSupportsReservedDomain(provider)) {
|
|
237
215
|
console.error(
|
|
238
|
-
`Error: --clear-domain is
|
|
216
|
+
`Error: --clear-domain is not supported with --provider ${provider} (reserved domains have no equivalent there).`,
|
|
239
217
|
);
|
|
240
218
|
process.exit(1);
|
|
241
219
|
}
|
|
@@ -350,8 +328,8 @@ function guardTailnetOnlyWebhookIngress(workspaceDir: string): boolean {
|
|
|
350
328
|
console.warn(
|
|
351
329
|
"⚠ Webhook integrations are configured, and the tailscale tunnel is reachable only from your own tailnet, " +
|
|
352
330
|
"so it cannot serve their callbacks. The saved ingress base URL stays as it is and the tailnet URL is " +
|
|
353
|
-
"recorded for device pairing only. For a tunnel those integrations can reach too,
|
|
354
|
-
"
|
|
331
|
+
"recorded for device pairing only. For a tunnel those integrations can reach too, run `cadet tunnel --help` " +
|
|
332
|
+
"to see the public providers.",
|
|
355
333
|
);
|
|
356
334
|
return true;
|
|
357
335
|
}
|
|
@@ -359,16 +337,15 @@ function guardTailnetOnlyWebhookIngress(workspaceDir: string): boolean {
|
|
|
359
337
|
// Generous cap so a slow provider handshake (e.g. cloudflared's own ~30s
|
|
360
338
|
// quick-tunnel timeout) still counts as a successful, if slow, start.
|
|
361
339
|
const TUNNEL_BACKGROUND_START_TIMEOUT_MS = 35_000;
|
|
362
|
-
// Matches the "just established" line every provider prints, or
|
|
363
|
-
//
|
|
364
|
-
// that case does not own the tunnel it adopted).
|
|
340
|
+
// Matches the "just established" line every provider prints, or the adoption
|
|
341
|
+
// line printed when a run adopts an already-running agent's tunnel (captured
|
|
342
|
+
// separately since that case does not own the tunnel it adopted).
|
|
365
343
|
const TUNNEL_ESTABLISHED_RE = /Tunnel established: (\S+)/;
|
|
366
|
-
const TUNNEL_ADOPTED_RE = /Found existing ngrok tunnel: (\S+)/;
|
|
367
344
|
|
|
368
345
|
/**
|
|
369
346
|
* Re-invoke `cadet tunnel` (without `-d`/`--detach`) as a detached background
|
|
370
347
|
* process via `relaunchDetached`, waiting for the child to either print its
|
|
371
|
-
* "Tunnel established"
|
|
348
|
+
* "Tunnel established" or adopted-tunnel line (readiness) or
|
|
372
349
|
* exit early (failure), so a misconfigured provider still fails loudly in
|
|
373
350
|
* the terminal that ran `-d`.
|
|
374
351
|
*
|
|
@@ -404,7 +381,7 @@ async function spawnDetachedTunnel(): Promise<void> {
|
|
|
404
381
|
publicUrl = established[1];
|
|
405
382
|
return true;
|
|
406
383
|
}
|
|
407
|
-
const adoptedMatch =
|
|
384
|
+
const adoptedMatch = ADOPTED_TUNNEL_RE.exec(logText);
|
|
408
385
|
if (adoptedMatch) {
|
|
409
386
|
publicUrl = adoptedMatch[1];
|
|
410
387
|
adopted = true;
|
|
@@ -430,11 +407,7 @@ async function spawnDetachedTunnel(): Promise<void> {
|
|
|
430
407
|
}
|
|
431
408
|
console.log(`Running in background (pid ${child.pid}). Logs: ${logPath}`);
|
|
432
409
|
if (adopted) {
|
|
433
|
-
console.log(
|
|
434
|
-
`That ngrok agent was already running outside this command, so \`kill ${child.pid}\` only ` +
|
|
435
|
-
"stops this supervisor, not the ngrok tunnel or its saved ingress URL. Stop the ngrok agent " +
|
|
436
|
-
"directly to end the tunnel.",
|
|
437
|
-
);
|
|
410
|
+
console.log(adoptedTunnelStopNotice(child.pid));
|
|
438
411
|
} else {
|
|
439
412
|
console.log(`Stop with: kill ${child.pid}`);
|
|
440
413
|
}
|
|
@@ -449,11 +422,8 @@ export const tunnel = Object.assign(
|
|
|
449
422
|
return;
|
|
450
423
|
}
|
|
451
424
|
|
|
452
|
-
if (provider
|
|
453
|
-
throw
|
|
454
|
-
`Tunnel provider '${provider}' is not yet implemented. ` +
|
|
455
|
-
`If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
|
|
456
|
-
);
|
|
425
|
+
if (PROVIDER_META[provider].comingSoon) {
|
|
426
|
+
throw cadetTunnelComingSoonError(provider);
|
|
457
427
|
}
|
|
458
428
|
|
|
459
429
|
const { entry, gatewayPort, workspaceDir } =
|
|
@@ -463,8 +433,8 @@ export const tunnel = Object.assign(
|
|
|
463
433
|
provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
|
|
464
434
|
|
|
465
435
|
if (clearDomain) {
|
|
466
|
-
|
|
467
|
-
console.log("Cleared the saved
|
|
436
|
+
clearReservedDomain();
|
|
437
|
+
console.log("Cleared the saved reserved domain.");
|
|
468
438
|
}
|
|
469
439
|
|
|
470
440
|
let edge: TunnelEdge;
|
|
@@ -490,21 +460,9 @@ export const tunnel = Object.assign(
|
|
|
490
460
|
workspaceDir,
|
|
491
461
|
};
|
|
492
462
|
|
|
493
|
-
|
|
494
|
-
await runNgrokTunnel({
|
|
495
|
-
...baseTunnelOpts,
|
|
496
|
-
...(domain ? { domain } : {}),
|
|
497
|
-
});
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (provider === "cloudflare") {
|
|
502
|
-
await runCloudflareTunnel(baseTunnelOpts);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
await runTailscaleTunnel({
|
|
463
|
+
await runProviderTunnel(provider, {
|
|
507
464
|
...baseTunnelOpts,
|
|
465
|
+
...(domain ? { domain } : {}),
|
|
508
466
|
...(preserveIngressUrl ? { preserveIngressUrl } : {}),
|
|
509
467
|
});
|
|
510
468
|
},
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { resolveExistingLocalSigningKey } from "@mycadet/local-mode";
|
|
2
2
|
import { isCloudHome } from "../lib/cloud-values.js";
|
|
3
3
|
import { spawnSync } from "child_process";
|
|
4
|
-
import { writeFileSync } from "fs";
|
|
5
4
|
import { join } from "path";
|
|
6
5
|
|
|
7
6
|
import cliPkg from "../../package.json";
|
|
@@ -77,7 +76,7 @@ import {
|
|
|
77
76
|
startLocalDaemon,
|
|
78
77
|
stopLocalProcesses,
|
|
79
78
|
} from "../lib/local.js";
|
|
80
|
-
import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel
|
|
79
|
+
import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel.js";
|
|
81
80
|
import {
|
|
82
81
|
leaseGuardianToken,
|
|
83
82
|
resetGuardianBootstrap,
|
|
@@ -1018,17 +1017,11 @@ async function upgradeLocal(
|
|
|
1018
1017
|
console.log("✅ Local assistant processes started\n");
|
|
1019
1018
|
|
|
1020
1019
|
const workspaceDir = join(entry.resources.instanceDir, "workspace");
|
|
1021
|
-
|
|
1020
|
+
await restoreTunnelEdgeAndAutoTunnel(
|
|
1022
1021
|
entry.assistantId,
|
|
1023
1022
|
entry.resources.gatewayPort,
|
|
1024
1023
|
workspaceDir,
|
|
1025
1024
|
);
|
|
1026
|
-
if (ngrokChild?.pid) {
|
|
1027
|
-
writeFileSync(
|
|
1028
|
-
join(entry.resources.instanceDir, "ngrok.pid"),
|
|
1029
|
-
String(ngrokChild.pid),
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
1025
|
|
|
1033
1026
|
console.log("Waiting for assistant to become ready...");
|
|
1034
1027
|
const ready = await waitForReady(adminUrl);
|
package/src/commands/wake.ts
CHANGED
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
SigningKeyContinuityError,
|
|
3
3
|
resolveExistingLocalSigningKey,
|
|
4
4
|
} from "@mycadet/local-mode";
|
|
5
|
-
import { writeFileSync } from "fs";
|
|
6
5
|
import { join } from "path";
|
|
7
6
|
|
|
8
7
|
import {
|
|
@@ -47,7 +46,7 @@ import {
|
|
|
47
46
|
DOCKER_GATEWAY_READY_TIMEOUT_MS,
|
|
48
47
|
restoreContainerTunnelEdge,
|
|
49
48
|
restoreTunnelEdgeAndAutoTunnel,
|
|
50
|
-
} from "../lib/tunnel
|
|
49
|
+
} from "../lib/tunnel.js";
|
|
51
50
|
|
|
52
51
|
export const wake = Object.assign(
|
|
53
52
|
async function wake(): Promise<void> {
|
|
@@ -582,18 +581,15 @@ async function wakeAssistant(
|
|
|
582
581
|
}
|
|
583
582
|
}
|
|
584
583
|
|
|
585
|
-
// Restore the nginx edge and point the
|
|
586
|
-
//
|
|
584
|
+
// Restore the nginx edge and point the auto-tunnel at it. The spawned agent
|
|
585
|
+
// records itself in the root lockfile under `tunnel`. Non-fatal: a down
|
|
586
|
+
// edge is a degraded remote-web/webhook path, not a broken assistant.
|
|
587
587
|
const workspaceDir = join(resources.instanceDir, "workspace");
|
|
588
|
-
|
|
588
|
+
await restoreTunnelEdgeAndAutoTunnel(
|
|
589
589
|
entry.assistantId,
|
|
590
590
|
resources.gatewayPort,
|
|
591
591
|
workspaceDir,
|
|
592
592
|
);
|
|
593
|
-
if (ngrokChild?.pid) {
|
|
594
|
-
const ngrokPidFile = join(resources.instanceDir, "ngrok.pid");
|
|
595
|
-
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
596
|
-
}
|
|
597
593
|
|
|
598
594
|
if (daemonMigrationsFailed) {
|
|
599
595
|
console.log(
|
|
@@ -152,9 +152,28 @@ interface LockfileData {
|
|
|
152
152
|
assistants?: Record<string, unknown>[];
|
|
153
153
|
activeAssistant?: string;
|
|
154
154
|
cloudBaseUrl?: string;
|
|
155
|
+
tunnel?: RootProcessRecord;
|
|
156
|
+
app?: RootProcessRecord;
|
|
155
157
|
[key: string]: unknown;
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A CLI-managed OS process recorded at the lockfile root, keyed by what it is
|
|
162
|
+
* for — `tunnel` (the auto-tunnel agent fronting the edge) and `app` (the
|
|
163
|
+
* nginx ingress edge). `cadet ps` renders these alongside the per-assistant
|
|
164
|
+
* process trees, and the stop paths verify the pid before signaling it.
|
|
165
|
+
*/
|
|
166
|
+
export interface RootProcessRecord {
|
|
167
|
+
pid: number;
|
|
168
|
+
/** Which tunnel provider owns the process; "tunnel" entries only. */
|
|
169
|
+
provider?: string;
|
|
170
|
+
/** The assistant the process fronts, so `cadet ps` can nest the row. */
|
|
171
|
+
assistantId?: string;
|
|
172
|
+
startedAt: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type RootProcessKind = "tunnel" | "app";
|
|
176
|
+
|
|
158
177
|
/**
|
|
159
178
|
* Derive the daemon PID file path from a resources object. The PID file
|
|
160
179
|
* lives inside the instance's workspace directory. When no resources are
|
|
@@ -216,6 +235,40 @@ function withCliLockfileLock<T>(fn: () => T): T {
|
|
|
216
235
|
return locked.value;
|
|
217
236
|
}
|
|
218
237
|
|
|
238
|
+
/** Record a CLI-managed root process (tunnel agent, app edge) by pid. */
|
|
239
|
+
export function stampRootProcess(
|
|
240
|
+
kind: RootProcessKind,
|
|
241
|
+
record: RootProcessRecord,
|
|
242
|
+
): void {
|
|
243
|
+
withCliLockfileLock(() => {
|
|
244
|
+
const data = readLockfile();
|
|
245
|
+
data[kind] = record;
|
|
246
|
+
writeLockfileUnlocked(data);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Drop the recorded root process entry, e.g. once the process is stopped. */
|
|
251
|
+
export function clearRootProcess(kind: RootProcessKind): void {
|
|
252
|
+
withCliLockfileLock(() => {
|
|
253
|
+
const data = readLockfile();
|
|
254
|
+
if (data[kind] === undefined) return;
|
|
255
|
+
delete data[kind];
|
|
256
|
+
writeLockfileUnlocked(data);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read the root-level process entries for `cadet ps`. Read-only: no lock, so
|
|
262
|
+
* a concurrently-updating lockfile is fine — the pid is verified separately.
|
|
263
|
+
*/
|
|
264
|
+
export function readRootProcesses(): {
|
|
265
|
+
tunnel?: RootProcessRecord;
|
|
266
|
+
app?: RootProcessRecord;
|
|
267
|
+
} {
|
|
268
|
+
const data = readLockfile();
|
|
269
|
+
return { tunnel: data.tunnel, app: data.app };
|
|
270
|
+
}
|
|
271
|
+
|
|
219
272
|
/**
|
|
220
273
|
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
221
274
|
* Returns undefined if the URL is malformed or has no explicit port.
|
|
@@ -6,7 +6,7 @@ import { isCompiledCli } from "./local.js";
|
|
|
6
6
|
import { getLogDir, openLogFile, resetLogFile } from "./xdg-log.js";
|
|
7
7
|
|
|
8
8
|
export interface RelaunchDetachedOptions {
|
|
9
|
-
/** argv for the re-invoked CLI command, e.g. ["tunnel", "--provider", "
|
|
9
|
+
/** argv for the re-invoked CLI command, e.g. ["tunnel", "--provider", "cloudflare"]. */
|
|
10
10
|
args: string[];
|
|
11
11
|
/** Log file basename under the XDG log dir; truncated before spawn. */
|
|
12
12
|
logFile: string;
|
|
@@ -18,14 +18,13 @@ import { getStateDir } from "./environments/paths.js";
|
|
|
18
18
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Shared workspace-config helpers for tunnel providers
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* no-`.cadet/`-reads boundary in cli/AGENTS.md.
|
|
21
|
+
* Shared workspace-config helpers for tunnel providers and the nginx ingress
|
|
22
|
+
* proxy. Each provider fronts the local edge and records the resulting public
|
|
23
|
+
* URL under `ingress.publicBaseUrl` so webhook integrations can reach the
|
|
24
|
+
* assistant. The workspace config is the gateway-facing contract; when an
|
|
25
|
+
* `assistantId` is supplied, the URL is also mirrored onto the lockfile entry
|
|
26
|
+
* (`ingressUrl`) — the CLI-owned contract that CLI features (e.g. remote-web
|
|
27
|
+
* pairing defaults) read, per the no-`.cadet/`-reads boundary in cli/AGENTS.md.
|
|
29
28
|
*/
|
|
30
29
|
|
|
31
30
|
function parsePortFromUrl(url: unknown): number | undefined {
|
|
@@ -186,32 +185,6 @@ export function savePairingTunnel(
|
|
|
186
185
|
saveRawConfig(workspaceDir, config);
|
|
187
186
|
}
|
|
188
187
|
|
|
189
|
-
/** Persist a reserved ngrok domain under `ingress.ngrok.domain`; null clears it. */
|
|
190
|
-
export function saveNgrokDomain(
|
|
191
|
-
workspaceDir: string,
|
|
192
|
-
domain: string | null,
|
|
193
|
-
): void {
|
|
194
|
-
const config = loadRawConfig(workspaceDir);
|
|
195
|
-
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
196
|
-
if (domain) {
|
|
197
|
-
ingress.ngrok = { domain };
|
|
198
|
-
} else {
|
|
199
|
-
delete ingress.ngrok;
|
|
200
|
-
}
|
|
201
|
-
config.ingress = ingress;
|
|
202
|
-
saveRawConfig(workspaceDir, config);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Read the reserved ngrok domain from the workspace config, if saved. */
|
|
206
|
-
export function loadNgrokDomain(workspaceDir: string): string | null {
|
|
207
|
-
const ingress = loadRawConfig(workspaceDir).ingress as
|
|
208
|
-
| Record<string, unknown>
|
|
209
|
-
| undefined;
|
|
210
|
-
const ngrok = ingress?.ngrok as Record<string, unknown> | undefined;
|
|
211
|
-
const domain = ngrok?.domain;
|
|
212
|
-
return typeof domain === "string" && domain.trim() ? domain : null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
188
|
/** Clear the ingress public base URL from the workspace config. */
|
|
216
189
|
export function clearIngressUrl(
|
|
217
190
|
workspaceDir: string,
|
package/src/lib/local.ts
CHANGED
|
@@ -74,11 +74,10 @@ import {
|
|
|
74
74
|
executableName,
|
|
75
75
|
isProcessAlive,
|
|
76
76
|
pathListDelimiter,
|
|
77
|
-
readProcessCommandLine,
|
|
78
77
|
resolveProcessState,
|
|
79
|
-
stopProcess,
|
|
80
78
|
stopProcessByPidFile,
|
|
81
79
|
} from "./process.js";
|
|
80
|
+
import { stopTunnelProcess } from "./tunnel.js";
|
|
82
81
|
import { stripVersionPrefix } from "./version-compat.js";
|
|
83
82
|
import { openLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
84
83
|
|
|
@@ -1931,16 +1930,6 @@ export async function startGateway(
|
|
|
1931
1930
|
return gatewayUrl;
|
|
1932
1931
|
}
|
|
1933
1932
|
|
|
1934
|
-
/** Check whether a PID belongs to an ngrok process via its command line. */
|
|
1935
|
-
function isNgrokProcess(pid: number): boolean {
|
|
1936
|
-
try {
|
|
1937
|
-
const output = readProcessCommandLine(pid);
|
|
1938
|
-
return /ngrok/i.test(output);
|
|
1939
|
-
} catch {
|
|
1940
|
-
return false;
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
1933
|
/**
|
|
1945
1934
|
* Stop any locally-running daemon and gateway processes
|
|
1946
1935
|
* and clean up PID files. Called when hatch fails partway through
|
|
@@ -1969,22 +1958,12 @@ export async function stopLocalProcesses(
|
|
|
1969
1958
|
const cesPidFile = join(cadetDir, "ces.pid");
|
|
1970
1959
|
await stopProcessByPidFile(cesPidFile, "credential-executor");
|
|
1971
1960
|
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
|
|
1976
|
-
const ngrokPidFile = join(cadetDir, "ngrok.pid");
|
|
1977
|
-
if (existsSync(ngrokPidFile)) {
|
|
1978
|
-
try {
|
|
1979
|
-
const pid = parseInt(readFileSync(ngrokPidFile, "utf-8").trim(), 10);
|
|
1980
|
-
if (!isNaN(pid) && isNgrokProcess(pid)) {
|
|
1981
|
-
await stopProcess(pid, "ngrok");
|
|
1982
|
-
}
|
|
1983
|
-
unlinkSync(ngrokPidFile);
|
|
1984
|
-
} catch {}
|
|
1985
|
-
}
|
|
1961
|
+
// Stop the tunnel agent this CLI spawned (root lockfile `tunnel` entry),
|
|
1962
|
+
// verifying the pid still belongs to the provider's binary before signaling
|
|
1963
|
+
// it — PID reuse must never send an unrelated process a signal.
|
|
1964
|
+
await stopTunnelProcess();
|
|
1986
1965
|
|
|
1987
1966
|
// Stop the nginx ingress if one is fronting this gateway (it guards against
|
|
1988
|
-
// PID reuse itself
|
|
1967
|
+
// PID reuse itself).
|
|
1989
1968
|
await stopIngressNginx(join(cadetDir, "workspace"));
|
|
1990
1969
|
}
|