@mycadet/cli 0.12.10 → 0.12.11
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/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/devices.test.ts +62 -0
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +55 -17
- package/src/__tests__/ps-cloud-status.test.ts +2 -1
- package/src/__tests__/setup.test.ts +14 -4
- 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 +12 -2
- package/src/commands/devices.ts +66 -30
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +7 -1
- package/src/commands/ps.ts +4 -2
- package/src/commands/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +6 -1
- package/src/lib/assistant-config.ts +20 -5
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +3 -1
- package/src/lib/docker.ts +6 -12
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/hatch-local.ts +1 -51
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/live-voice/tunnel.ts +2 -2
- package/src/lib/local.ts +6 -6
- package/src/lib/nginx-ingress.ts +123 -21
- package/src/lib/tunnel.ts +86 -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/xdg-log.ts +11 -0
package/src/commands/hatch.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { green } from "../lib/color";
|
|
|
10
10
|
import { HATCH_ART, HATCHED_EMOJI, VALID_REMOTE_HOSTS } from "../lib/constants";
|
|
11
11
|
import type { RemoteHost } from "../lib/constants";
|
|
12
12
|
import { hatchDocker } from "../lib/docker";
|
|
13
|
-
import {
|
|
13
|
+
import { readAmbientFlagEnvVars } from "../lib/flag-args";
|
|
14
14
|
import { hatchLocal } from "../lib/hatch-local";
|
|
15
15
|
import {
|
|
16
16
|
getCloudUrl,
|
|
@@ -23,37 +23,26 @@ const DEFAULT_REMOTE: RemoteHost = "local";
|
|
|
23
23
|
|
|
24
24
|
interface HatchArgs {
|
|
25
25
|
verbose: boolean;
|
|
26
|
-
keepAlive: boolean;
|
|
27
26
|
name: string | null;
|
|
28
27
|
remote: RemoteHost;
|
|
29
28
|
watch: boolean;
|
|
30
29
|
sourcePath: string | null;
|
|
31
30
|
configValues: Record<string, string>;
|
|
32
31
|
flagEnvVars: Record<string, string>;
|
|
33
|
-
analyze: boolean;
|
|
34
|
-
disableCloud: boolean;
|
|
35
32
|
netnsContainer: string | null;
|
|
36
33
|
gatewayPort: number | null;
|
|
37
34
|
assistantCaCert: string | null;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
function parseArgs(): HatchArgs {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
|
|
45
|
-
const disableCloudAmbient =
|
|
46
|
-
process.env.CADET_DISABLE_CLOUD?.trim().toLowerCase();
|
|
47
|
-
let disableCloud =
|
|
48
|
-
disableCloudAmbient === "true" || disableCloudAmbient === "1";
|
|
38
|
+
const args = process.argv.slice(3);
|
|
39
|
+
const flagEnvVars = readAmbientFlagEnvVars();
|
|
49
40
|
let verbose = false;
|
|
50
|
-
let keepAlive = false;
|
|
51
41
|
let name: string | null = null;
|
|
52
42
|
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
53
43
|
let watch = false;
|
|
54
44
|
let sourcePath: string | null = null;
|
|
55
45
|
const configValues: Record<string, string> = {};
|
|
56
|
-
let analyze = false;
|
|
57
46
|
let netnsContainer: string | null = null;
|
|
58
47
|
let gatewayPort: number | null = null;
|
|
59
48
|
let assistantCaCert: string | null = null;
|
|
@@ -69,47 +58,33 @@ function parseArgs(): HatchArgs {
|
|
|
69
58
|
console.log(
|
|
70
59
|
" -v, --verbose Show service startup logs and extended next steps",
|
|
71
60
|
);
|
|
72
|
-
console.log(" --name <name> Custom
|
|
61
|
+
console.log(" --name <name> Custom name for this assistant");
|
|
73
62
|
console.log(
|
|
74
63
|
" --remote <host> Remote host (local, docker, custom, cloud)",
|
|
75
64
|
);
|
|
76
65
|
console.log(
|
|
77
|
-
" --watch Run
|
|
78
|
-
);
|
|
79
|
-
console.log(
|
|
80
|
-
" --source <path> Build images from a local source tree at <path>. Useful for callers that want each run to pick up local CLI changes.",
|
|
66
|
+
" --watch Run Cadet in watch mode with hot reload on source changes",
|
|
81
67
|
);
|
|
82
68
|
console.log(
|
|
83
|
-
" --
|
|
69
|
+
" --source <path> Build images from a local source tree at <path>",
|
|
84
70
|
);
|
|
85
71
|
console.log(
|
|
86
72
|
" --config <key=value> Set a workspace config value (repeatable)",
|
|
87
73
|
);
|
|
88
74
|
console.log(
|
|
89
|
-
" --
|
|
75
|
+
" --netns-container <name> Join an existing container's network namespace instead of creating a per-instance network.",
|
|
90
76
|
);
|
|
91
77
|
console.log(
|
|
92
|
-
" --
|
|
78
|
+
" --gateway-port <port> Use an explicit host port for accessing the assistant.",
|
|
93
79
|
);
|
|
94
80
|
console.log(
|
|
95
|
-
" --
|
|
96
|
-
);
|
|
97
|
-
console.log(
|
|
98
|
-
" --netns-container <name> Join an existing container's network namespace (docker target only) instead of creating a per-instance network. The namespace owner publishes host ports, so --gateway-port is required.",
|
|
99
|
-
);
|
|
100
|
-
console.log(
|
|
101
|
-
" --gateway-port <port> Use an explicit host port for the gateway runtime URL instead of auto-allocating. Required with --netns-container.",
|
|
102
|
-
);
|
|
103
|
-
console.log(
|
|
104
|
-
" --assistant-ca-cert <path> Trust an extra PEM CA bundle in the assistant container (NODE_EXTRA_CA_CERTS) from process start. Useful behind a TLS-terminating egress proxy.",
|
|
81
|
+
" --assistant-ca-cert <path> Trust an extra PEM CA bundle in the assistant container from process start.",
|
|
105
82
|
);
|
|
106
83
|
process.exit(0);
|
|
107
84
|
} else if (arg === "-v" || arg == "--verbose") {
|
|
108
85
|
verbose = true;
|
|
109
86
|
} else if (arg === "--watch") {
|
|
110
87
|
watch = true;
|
|
111
|
-
} else if (arg === "--analyze") {
|
|
112
|
-
analyze = true;
|
|
113
88
|
} else if (arg === "--source") {
|
|
114
89
|
const next = args[i + 1];
|
|
115
90
|
if (!next || next.startsWith("-")) {
|
|
@@ -118,8 +93,6 @@ function parseArgs(): HatchArgs {
|
|
|
118
93
|
}
|
|
119
94
|
sourcePath = next;
|
|
120
95
|
i++;
|
|
121
|
-
} else if (arg === "--keep-alive") {
|
|
122
|
-
keepAlive = true;
|
|
123
96
|
} else if (arg === "--name") {
|
|
124
97
|
const next = args[i + 1];
|
|
125
98
|
if (!next || next.startsWith("-")) {
|
|
@@ -163,8 +136,6 @@ function parseArgs(): HatchArgs {
|
|
|
163
136
|
const value = next.slice(eqIndex + 1);
|
|
164
137
|
configValues[key] = value;
|
|
165
138
|
i++;
|
|
166
|
-
} else if (arg === "--disable-cloud") {
|
|
167
|
-
disableCloud = true;
|
|
168
139
|
} else if (arg === "--netns-container") {
|
|
169
140
|
const next = args[i + 1];
|
|
170
141
|
if (!next || next.startsWith("-")) {
|
|
@@ -194,7 +165,7 @@ function parseArgs(): HatchArgs {
|
|
|
194
165
|
i++;
|
|
195
166
|
} else {
|
|
196
167
|
console.error(
|
|
197
|
-
`Error: Unknown argument '${arg}'. Valid options: -v, --verbose, --watch, --source <path>, --
|
|
168
|
+
`Error: Unknown argument '${arg}'. Valid options: -v, --verbose, --watch, --source <path>, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>, --netns-container <name>, --gateway-port <port>, --assistant-ca-cert <path>`,
|
|
198
169
|
);
|
|
199
170
|
process.exit(1);
|
|
200
171
|
}
|
|
@@ -202,15 +173,12 @@ function parseArgs(): HatchArgs {
|
|
|
202
173
|
|
|
203
174
|
return {
|
|
204
175
|
verbose,
|
|
205
|
-
keepAlive,
|
|
206
176
|
name,
|
|
207
177
|
remote,
|
|
208
178
|
watch,
|
|
209
179
|
sourcePath,
|
|
210
180
|
configValues,
|
|
211
181
|
flagEnvVars,
|
|
212
|
-
analyze,
|
|
213
|
-
disableCloud,
|
|
214
182
|
netnsContainer,
|
|
215
183
|
gatewayPort,
|
|
216
184
|
assistantCaCert,
|
|
@@ -230,25 +198,17 @@ export const hatch = Object.assign(
|
|
|
230
198
|
|
|
231
199
|
const {
|
|
232
200
|
verbose,
|
|
233
|
-
keepAlive,
|
|
234
201
|
name,
|
|
235
202
|
remote,
|
|
236
203
|
watch,
|
|
237
204
|
sourcePath,
|
|
238
205
|
configValues,
|
|
239
206
|
flagEnvVars,
|
|
240
|
-
analyze,
|
|
241
|
-
disableCloud,
|
|
242
207
|
netnsContainer,
|
|
243
208
|
gatewayPort,
|
|
244
209
|
assistantCaCert,
|
|
245
210
|
} = parseArgs();
|
|
246
211
|
|
|
247
|
-
if (disableCloud) {
|
|
248
|
-
process.env.CADET_DISABLE_CLOUD = "true";
|
|
249
|
-
flagEnvVars.CADET_DISABLE_CLOUD = "true";
|
|
250
|
-
}
|
|
251
|
-
|
|
252
212
|
if (watch && remote !== "local" && remote !== "docker") {
|
|
253
213
|
console.error(
|
|
254
214
|
"Error: --watch is only supported for local and docker hatch targets.",
|
|
@@ -286,7 +246,6 @@ export const hatch = Object.assign(
|
|
|
286
246
|
await hatchLocal({
|
|
287
247
|
name,
|
|
288
248
|
watch,
|
|
289
|
-
keepAlive,
|
|
290
249
|
configValues,
|
|
291
250
|
flagEnvVars,
|
|
292
251
|
verbose,
|
|
@@ -301,7 +260,6 @@ export const hatch = Object.assign(
|
|
|
301
260
|
configValues,
|
|
302
261
|
flagEnvVars,
|
|
303
262
|
sourcePath,
|
|
304
|
-
analyze,
|
|
305
263
|
netnsContainer: netnsContainer ?? undefined,
|
|
306
264
|
gatewayPort: gatewayPort ?? undefined,
|
|
307
265
|
assistantCaCertPath: assistantCaCert ?? undefined,
|
package/src/commands/pair.ts
CHANGED
|
@@ -586,6 +586,10 @@ export const pair = Object.assign(
|
|
|
586
586
|
console.log(qr);
|
|
587
587
|
});
|
|
588
588
|
console.log("");
|
|
589
|
+
console.log("Or enter this code on the pairing page:");
|
|
590
|
+
console.log("");
|
|
591
|
+
console.log(` ${challenge.deviceCode}`);
|
|
592
|
+
console.log("");
|
|
589
593
|
if (appUrl) {
|
|
590
594
|
console.log("The QR opens the Cadet app. App link:");
|
|
591
595
|
console.log("");
|
|
@@ -598,7 +602,9 @@ export const pair = Object.assign(
|
|
|
598
602
|
console.log("");
|
|
599
603
|
console.log(` ${pairUrl}`);
|
|
600
604
|
console.log("");
|
|
601
|
-
console.log(
|
|
605
|
+
console.log("Or on another computer, run:");
|
|
606
|
+
console.log("");
|
|
607
|
+
console.log(` cadet connect import "${pairUrl}"`);
|
|
602
608
|
console.log("");
|
|
603
609
|
console.log(`Expires: ${challenge.expiresAt}`);
|
|
604
610
|
},
|
package/src/commands/ps.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getNginxIngressPort } from "../lib/nginx-ingress.js";
|
|
1
2
|
import { isCloudHome } from "../lib/cloud-values.js";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
|
|
@@ -649,10 +650,10 @@ async function getRootManagedRows(): Promise<RootManagedRow[]> {
|
|
|
649
650
|
rows.push({
|
|
650
651
|
assistantId: app.assistantId,
|
|
651
652
|
row: {
|
|
652
|
-
name: "nginx
|
|
653
|
+
name: "nginx",
|
|
653
654
|
type: APP_TYPE,
|
|
654
655
|
status: withStatusEmoji(alive ? "running" : "not running"),
|
|
655
|
-
info:
|
|
656
|
+
info: `port ${app.port ?? getNginxIngressPort()}, PID ${app.pid}${alive ? "" : " (exited)"}`,
|
|
656
657
|
},
|
|
657
658
|
});
|
|
658
659
|
}
|
|
@@ -813,6 +814,7 @@ export async function listAllAssistants(verbose: boolean): Promise<void> {
|
|
|
813
814
|
),
|
|
814
815
|
{ name: "NAME", type: "TYPE", status: "STATUS", info: "INFO" },
|
|
815
816
|
);
|
|
817
|
+
console.log("");
|
|
816
818
|
}
|
|
817
819
|
|
|
818
820
|
// ── Entry point ─────────────────────────────────────────────────
|
package/src/commands/tunnel.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
formatAssistantReference,
|
|
12
12
|
loadAllAssistants,
|
|
13
|
+
readRootProcesses,
|
|
13
14
|
resolveTargetAssistant,
|
|
14
15
|
type AssistantEntry,
|
|
15
16
|
} from "../lib/assistant-config";
|
|
@@ -37,6 +38,8 @@ import {
|
|
|
37
38
|
hasWebhookIntegrationsConfigured,
|
|
38
39
|
providerSupportsReservedDomain,
|
|
39
40
|
runProviderTunnel,
|
|
41
|
+
stopTunnelProcess,
|
|
42
|
+
summarizeStartupFailure,
|
|
40
43
|
} from "../lib/tunnel.js";
|
|
41
44
|
|
|
42
45
|
// The accepted `--provider` values ARE the shared registry, so validation
|
|
@@ -63,7 +66,7 @@ function parseArgs(): TunnelArgs {
|
|
|
63
66
|
const arg = args[i];
|
|
64
67
|
if (arg === "--help" || arg === "-h") {
|
|
65
68
|
console.log(
|
|
66
|
-
"Usage: cadet tunnel [<name>] --provider <provider> [options]",
|
|
69
|
+
"Usage: cadet tunnel [<name>] --provider <provider> [options]\n cadet tunnel stop [<name>]",
|
|
67
70
|
);
|
|
68
71
|
console.log("");
|
|
69
72
|
console.log("Front a locally running assistant with an HTTPS tunnel.");
|
|
@@ -121,7 +124,7 @@ function parseArgs(): TunnelArgs {
|
|
|
121
124
|
` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
|
|
122
125
|
);
|
|
123
126
|
console.log(
|
|
124
|
-
" tunnel stops with `
|
|
127
|
+
" tunnel stops with `cadet tunnel stop`, except when it adopted an already-running",
|
|
125
128
|
);
|
|
126
129
|
console.log(
|
|
127
130
|
" tunnel (started outside this command): killing the supervisor then",
|
|
@@ -394,9 +397,17 @@ async function spawnDetachedTunnel(): Promise<void> {
|
|
|
394
397
|
});
|
|
395
398
|
|
|
396
399
|
if (exitCode !== undefined) {
|
|
400
|
+
let logText = "";
|
|
401
|
+
try {
|
|
402
|
+
logText = readFileSync(logPath, "utf-8");
|
|
403
|
+
} catch {
|
|
404
|
+
// Log file not readable — the summary falls back to none.
|
|
405
|
+
}
|
|
406
|
+
const reason = summarizeStartupFailure(logText);
|
|
397
407
|
console.error(
|
|
398
|
-
`Error: tunnel process exited during startup${
|
|
408
|
+
`Error (${exitCode ?? "unknown"}): tunnel process exited during startup${reason ? `: ${reason}` : "."}`,
|
|
399
409
|
);
|
|
410
|
+
console.error(`Logs: ${logPath}`);
|
|
400
411
|
process.exit(1);
|
|
401
412
|
}
|
|
402
413
|
|
|
@@ -411,12 +422,40 @@ async function spawnDetachedTunnel(): Promise<void> {
|
|
|
411
422
|
if (adopted) {
|
|
412
423
|
console.log(adoptedTunnelStopNotice(child.pid));
|
|
413
424
|
} else {
|
|
414
|
-
console.log(
|
|
425
|
+
console.log("Stop with: cadet tunnel stop");
|
|
415
426
|
}
|
|
416
427
|
}
|
|
417
428
|
|
|
418
429
|
export const tunnel = Object.assign(
|
|
419
430
|
async function tunnel(): Promise<void> {
|
|
431
|
+
if (process.argv[3] === "stop") {
|
|
432
|
+
const args = process.argv.slice(4);
|
|
433
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
434
|
+
console.log("Usage: cadet tunnel stop [<name>]");
|
|
435
|
+
console.log(
|
|
436
|
+
"Stop the recorded Cadet-managed tunnel, keeping nginx and the saved domain.",
|
|
437
|
+
);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (args.some((arg) => arg.startsWith("-")))
|
|
441
|
+
throw new Error("Usage: cadet tunnel stop [<name>]");
|
|
442
|
+
const { entry, workspaceDir } = resolveLocalTunnelTarget(
|
|
443
|
+
args.join(" ") || null,
|
|
444
|
+
);
|
|
445
|
+
if (!readRootProcesses().tunnel) {
|
|
446
|
+
console.log(
|
|
447
|
+
"No Cadet-managed tunnel is recorded. Externally started tunnels must be stopped directly.",
|
|
448
|
+
);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const stopped = await stopTunnelProcess(entry.assistantId, workspaceDir);
|
|
452
|
+
console.log(
|
|
453
|
+
stopped
|
|
454
|
+
? "Tunnel stopped. nginx and the saved domain are unchanged."
|
|
455
|
+
: "No running tunnel found; cleared stale tunnel state.",
|
|
456
|
+
);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
420
459
|
const { assistantName, provider, domain, clearDomain, foreground } =
|
|
421
460
|
parseArgs();
|
|
422
461
|
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -76,7 +76,10 @@ import {
|
|
|
76
76
|
startLocalDaemon,
|
|
77
77
|
stopLocalProcesses,
|
|
78
78
|
} from "../lib/local.js";
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
restoreContainerTunnelEdge,
|
|
81
|
+
restoreTunnelEdgeAndAutoTunnel,
|
|
82
|
+
} from "../lib/tunnel.js";
|
|
80
83
|
import {
|
|
81
84
|
leaseGuardianToken,
|
|
82
85
|
resetGuardianBootstrap,
|
|
@@ -516,6 +519,8 @@ async function upgradeDocker(
|
|
|
516
519
|
preUpgradeBackupPath: backupPath ?? undefined,
|
|
517
520
|
};
|
|
518
521
|
saveAssistantEntry(updatedEntry);
|
|
522
|
+
// Refresh the host-served web bundle before clients reload on completion.
|
|
523
|
+
await restoreContainerTunnelEdge(updatedEntry);
|
|
519
524
|
|
|
520
525
|
// Notify clients on the new service group that the upgrade succeeded.
|
|
521
526
|
await broadcastUpgradeEvent(
|
|
@@ -170,6 +170,10 @@ export interface RootProcessRecord {
|
|
|
170
170
|
/** The assistant the process fronts, so `cadet ps` can nest the row. */
|
|
171
171
|
assistantId?: string;
|
|
172
172
|
startedAt: string;
|
|
173
|
+
/** Listening port for the nginx edge. */
|
|
174
|
+
port?: number;
|
|
175
|
+
/** Managed process temporarily displaced by a pairing-only tunnel. */
|
|
176
|
+
previous?: RootProcessRecord;
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
export type RootProcessKind = "tunnel" | "app";
|
|
@@ -247,12 +251,21 @@ export function stampRootProcess(
|
|
|
247
251
|
});
|
|
248
252
|
}
|
|
249
253
|
|
|
250
|
-
/**
|
|
251
|
-
export function clearRootProcess(
|
|
254
|
+
/** Release a recorded process, preserving a replacement or restoring a displaced record. */
|
|
255
|
+
export function clearRootProcess(
|
|
256
|
+
kind: RootProcessKind,
|
|
257
|
+
expectedPid?: number,
|
|
258
|
+
): void {
|
|
252
259
|
withCliLockfileLock(() => {
|
|
253
260
|
const data = readLockfile();
|
|
254
|
-
|
|
255
|
-
|
|
261
|
+
const record = data[kind];
|
|
262
|
+
if (
|
|
263
|
+
record === undefined ||
|
|
264
|
+
(expectedPid !== undefined && record.pid !== expectedPid)
|
|
265
|
+
)
|
|
266
|
+
return;
|
|
267
|
+
if (record.previous) data[kind] = record.previous;
|
|
268
|
+
else delete data[kind];
|
|
256
269
|
writeLockfileUnlocked(data);
|
|
257
270
|
});
|
|
258
271
|
}
|
|
@@ -442,7 +455,9 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
442
455
|
}
|
|
443
456
|
|
|
444
457
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
445
|
-
return
|
|
458
|
+
return (
|
|
459
|
+
loadAllAssistants().find((entry) => entry.assistantId === name) ?? null
|
|
460
|
+
);
|
|
446
461
|
}
|
|
447
462
|
|
|
448
463
|
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
3
|
+
|
|
4
|
+
const requestSchema = z.object({
|
|
5
|
+
request_id: z.string().regex(/^[a-f0-9]{64}$/),
|
|
6
|
+
poll_secret: z.string().regex(/^[a-f0-9]{64}$/),
|
|
7
|
+
verification_uri: z.string().url(),
|
|
8
|
+
user_code: z.string().min(1),
|
|
9
|
+
interval: z.number().min(1).max(10),
|
|
10
|
+
});
|
|
11
|
+
const resultSchema = z.object({ session_token: z.string().min(1) });
|
|
12
|
+
|
|
13
|
+
/** Browser approval and polling work when the terminal and browser are on different hosts. */
|
|
14
|
+
export async function pollingCloudLogin(
|
|
15
|
+
cloudUrl: string,
|
|
16
|
+
options: {
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
fetch?: (
|
|
19
|
+
input: string | URL | Request,
|
|
20
|
+
init?: RequestInit,
|
|
21
|
+
) => Promise<Response>;
|
|
22
|
+
log?: (message: string) => void;
|
|
23
|
+
} = {},
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const request = options.fetch ?? loopbackSafeFetch;
|
|
26
|
+
const log = options.log ?? console.log;
|
|
27
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 300_000);
|
|
28
|
+
const origin = new URL(cloudUrl).origin;
|
|
29
|
+
try {
|
|
30
|
+
const started = await request(`${origin}/v1/auth/cli/start`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
signal,
|
|
33
|
+
});
|
|
34
|
+
if (!started.ok)
|
|
35
|
+
throw new Error(`Could not start terminal login (${started.status}).`);
|
|
36
|
+
const login = requestSchema.parse(await started.json());
|
|
37
|
+
log(`Open this URL to log in: ${login.verification_uri}`);
|
|
38
|
+
log(
|
|
39
|
+
`Confirm code ${login.user_code} in your browser. Waiting up to 5 minutes...`,
|
|
40
|
+
);
|
|
41
|
+
while (!signal.aborted) {
|
|
42
|
+
const response = await request(`${origin}/v1/auth/cli/poll`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
signal,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
request_id: login.request_id,
|
|
48
|
+
poll_secret: login.poll_secret,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (response.status === 200)
|
|
52
|
+
return resultSchema.parse(await response.json()).session_token;
|
|
53
|
+
if (response.status === 410)
|
|
54
|
+
throw new Error("Login request expired. Run cadet login again.");
|
|
55
|
+
if (response.status !== 202)
|
|
56
|
+
throw new Error(`Login polling failed (${response.status}).`);
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
const abort = () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
reject(new Error("Login timed out after 5 minutes."));
|
|
61
|
+
};
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
signal.removeEventListener("abort", abort);
|
|
64
|
+
resolve();
|
|
65
|
+
}, login.interval * 1000);
|
|
66
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
if (signal.aborted) abort();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
throw new Error("Login timed out after 5 minutes.");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (signal.aborted) throw new Error("Login timed out after 5 minutes.");
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/lib/cloud-login.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* login when the user picks that provider).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { pollingCloudLogin } from "./cloud-login-poll.js";
|
|
9
10
|
import { randomBytes } from "crypto";
|
|
10
11
|
import { createServer } from "http";
|
|
11
12
|
import type { AddressInfo } from "net";
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
generatePkcePair,
|
|
38
39
|
} from "./workos-pkce.js";
|
|
39
40
|
|
|
40
|
-
const LOGIN_TIMEOUT_MS =
|
|
41
|
+
const LOGIN_TIMEOUT_MS = 300_000;
|
|
41
42
|
|
|
42
43
|
function escapeHtml(s: string): string {
|
|
43
44
|
return s
|
|
@@ -258,6 +259,7 @@ export function startLoopbackListener(
|
|
|
258
259
|
|
|
259
260
|
/** App-held WorkOS PKCE login */
|
|
260
261
|
export async function workosPkceLogin(cloudUrl: string): Promise<string> {
|
|
262
|
+
if (!process.stdin.isTTY) return pollingCloudLogin(cloudUrl);
|
|
261
263
|
const clientId = await fetchWorkosClientId(cloudUrl);
|
|
262
264
|
const { verifier, challenge } = generatePkcePair();
|
|
263
265
|
const state = randomBytes(32).toString("hex");
|
package/src/lib/docker.ts
CHANGED
|
@@ -305,7 +305,6 @@ export interface HatchDockerParams {
|
|
|
305
305
|
* also enables hot-reload.
|
|
306
306
|
*/
|
|
307
307
|
sourcePath?: string | null;
|
|
308
|
-
analyze?: boolean;
|
|
309
308
|
/**
|
|
310
309
|
* Name of an existing container whose network namespace the assistant,
|
|
311
310
|
* gateway, and credential-executor join (`--network=container:<name>`),
|
|
@@ -1719,7 +1718,6 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1719
1718
|
logFd,
|
|
1720
1719
|
runtimeUrl,
|
|
1721
1720
|
containersUpAt,
|
|
1722
|
-
analyze: params.analyze ?? false,
|
|
1723
1721
|
});
|
|
1724
1722
|
|
|
1725
1723
|
if (!ready && !(watch && repoRoot)) {
|
|
@@ -1828,7 +1826,6 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1828
1826
|
logFd: number | "ignore";
|
|
1829
1827
|
runtimeUrl: string;
|
|
1830
1828
|
containersUpAt: number;
|
|
1831
|
-
analyze: boolean;
|
|
1832
1829
|
}): Promise<{
|
|
1833
1830
|
ready: boolean;
|
|
1834
1831
|
guardianAccessToken?: string;
|
|
@@ -1844,7 +1841,6 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1844
1841
|
logFd,
|
|
1845
1842
|
runtimeUrl,
|
|
1846
1843
|
containersUpAt,
|
|
1847
|
-
analyze,
|
|
1848
1844
|
} = opts;
|
|
1849
1845
|
|
|
1850
1846
|
const log = (msg: string): void => {
|
|
@@ -1964,14 +1960,12 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1964
1960
|
const containersUpToReadyMs = readyAt - start;
|
|
1965
1961
|
const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
|
|
1966
1962
|
log(`Assistant ready after ${elapsedSec}s`);
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
);
|
|
1974
|
-
}
|
|
1963
|
+
console.info(
|
|
1964
|
+
`[cadet-hatch-timing] ${JSON.stringify({
|
|
1965
|
+
containers_up_to_ready_ms: containersUpToReadyMs,
|
|
1966
|
+
instance: instanceName,
|
|
1967
|
+
})}`,
|
|
1968
|
+
);
|
|
1975
1969
|
|
|
1976
1970
|
// Lease guardian token. The /readyz check confirms both gateway and
|
|
1977
1971
|
// assistant are reachable. Retry with backoff in case there is a brief
|
package/src/lib/ensure-binary.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface EnsureBinaryOptions {
|
|
|
31
31
|
runInstall?: (argv: string[]) => boolean;
|
|
32
32
|
/** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
|
|
33
33
|
confirmInstall?: () => Promise<boolean>;
|
|
34
|
+
platform?: NodeJS.Platform;
|
|
35
|
+
confirmHomebrewInstall?: () => Promise<boolean>;
|
|
34
36
|
streams?: {
|
|
35
37
|
input?: NodeJS.ReadStream | Readable;
|
|
36
38
|
output?: NodeJS.WriteStream | Writable;
|
|
@@ -69,9 +71,60 @@ export async function ensureBinaryInstalled(
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
const pmAvailable = opts.pmAvailable ?? probePackageManager;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
let argv = Object.entries(opts.packages).find(([pm]) => pmAvailable(pm))?.[1];
|
|
75
|
+
if (
|
|
76
|
+
!argv &&
|
|
77
|
+
(opts.platform ?? process.platform) === "darwin" &&
|
|
78
|
+
opts.packages.brew
|
|
79
|
+
) {
|
|
80
|
+
const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
|
|
81
|
+
pmAvailable,
|
|
82
|
+
);
|
|
83
|
+
if (brew) {
|
|
84
|
+
const prefix = brew.slice(0, -"/bin/brew".length);
|
|
85
|
+
process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
|
|
86
|
+
argv = [brew, ...opts.packages.brew.slice(1)];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
!argv &&
|
|
91
|
+
(opts.platform ?? process.platform) === "darwin" &&
|
|
92
|
+
opts.packages.brew
|
|
93
|
+
) {
|
|
94
|
+
output.write(
|
|
95
|
+
"Homebrew is required to install this dependency, but it isn't installed.\n",
|
|
96
|
+
);
|
|
97
|
+
const confirmHomebrew =
|
|
98
|
+
opts.confirmHomebrewInstall ?? defaultConfirm(opts, "Homebrew");
|
|
99
|
+
let approved = false;
|
|
100
|
+
try {
|
|
101
|
+
approved = await confirmHomebrew();
|
|
102
|
+
} catch {
|
|
103
|
+
/* No interactive approval. */
|
|
104
|
+
}
|
|
105
|
+
if (approved) {
|
|
106
|
+
const bootstrap = [
|
|
107
|
+
"/bin/bash",
|
|
108
|
+
"-c",
|
|
109
|
+
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/7a3f48c7e498d7df3237fd41c30f4d492857edf4/install.sh)"',
|
|
110
|
+
];
|
|
111
|
+
const installed = opts.runInstall
|
|
112
|
+
? opts.runInstall(bootstrap)
|
|
113
|
+
: runInstallArgv(bootstrap);
|
|
114
|
+
const brew = installed
|
|
115
|
+
? ["brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
|
|
116
|
+
pmAvailable,
|
|
117
|
+
)
|
|
118
|
+
: undefined;
|
|
119
|
+
if (brew) {
|
|
120
|
+
const prefix =
|
|
121
|
+
brew === "brew" ? "" : brew.slice(0, -"/bin/brew".length);
|
|
122
|
+
if (prefix)
|
|
123
|
+
process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
|
|
124
|
+
argv = [brew, ...opts.packages.brew.slice(1)];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
75
128
|
if (!argv) {
|
|
76
129
|
output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
|
|
77
130
|
return null;
|
|
@@ -90,7 +143,10 @@ export async function ensureBinaryInstalled(
|
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
|
|
93
|
-
function defaultConfirm(
|
|
146
|
+
function defaultConfirm(
|
|
147
|
+
opts: EnsureBinaryOptions,
|
|
148
|
+
binary = opts.binary,
|
|
149
|
+
): () => Promise<boolean> {
|
|
94
150
|
return async () => {
|
|
95
151
|
const input = opts.streams?.input ?? process.stdin;
|
|
96
152
|
const output = opts.streams?.output ?? process.stdout;
|
|
@@ -99,7 +155,7 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
|
|
|
99
155
|
throw new Error("Install confirm requires an interactive terminal.");
|
|
100
156
|
}
|
|
101
157
|
const answer = (
|
|
102
|
-
await readLineText(`Install ${
|
|
158
|
+
await readLineText(`Install ${binary} now? [Y/n] `, {
|
|
103
159
|
input,
|
|
104
160
|
output,
|
|
105
161
|
})
|
|
@@ -114,7 +170,10 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
|
|
|
114
170
|
function runInstallArgv(argv: string[]): boolean {
|
|
115
171
|
const [command, ...args] = argv;
|
|
116
172
|
if (!command) return false;
|
|
117
|
-
const result = spawnSync(command, args, {
|
|
173
|
+
const result = spawnSync(command, args, {
|
|
174
|
+
stdio: "inherit",
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
});
|
|
118
177
|
return !result.error && result.status === 0;
|
|
119
178
|
}
|
|
120
179
|
|