@mycadet/cli 0.12.8 → 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/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/package.json +2 -2
- package/src/__tests__/cloud-releases.test.ts +23 -13
- package/src/__tests__/ensure-binary.test.ts +182 -0
- package/src/__tests__/nginx-ingress.test.ts +3 -2
- package/src/__tests__/ngrok-spawn.test.ts +49 -0
- package/src/__tests__/ps-table.test.ts +79 -0
- package/src/__tests__/tailscale.test.ts +1 -1
- package/src/__tests__/teleport.test.ts +1 -1
- package/src/__tests__/tunnel.test.ts +21 -14
- package/src/__tests__/uninstall.test.ts +1 -1
- package/src/__tests__/wake.test.ts +2 -2
- package/src/commands/backup.ts +3 -3
- package/src/commands/completions.test.ts +104 -0
- package/src/commands/completions.ts +87 -0
- package/src/commands/exec.ts +7 -7
- package/src/commands/flags.ts +2 -2
- package/src/commands/hatch.ts +1 -1
- package/src/commands/logs.ts +7 -7
- package/src/commands/ps.ts +57 -8
- package/src/commands/restore.ts +2 -2
- package/src/commands/retire.ts +2 -2
- package/src/commands/rollback.ts +2 -2
- package/src/commands/sleep.ts +2 -2
- package/src/commands/teleport.ts +16 -16
- package/src/commands/terminal.ts +1 -1
- package/src/commands/tunnel.ts +78 -55
- package/src/commands/uninstall.ts +4 -4
- package/src/commands/upgrade.ts +8 -8
- package/src/commands/use.ts +1 -1
- package/src/commands/wake.ts +6 -6
- package/src/index.ts +15 -0
- package/src/lib/__tests__/docker.test.ts +168 -20
- package/src/lib/__tests__/job-polling.test.ts +1 -1
- package/src/lib/cloud-releases.ts +6 -4
- package/src/lib/docker.ts +150 -44
- package/src/lib/ensure-binary.ts +171 -0
- package/src/lib/flag-args.test.ts +1 -1
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/guardian-token.ts +1 -1
- package/src/lib/hatch-local.ts +1 -1
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local.ts +1 -1
- package/src/lib/nginx-ingress.ts +2 -2
- package/src/lib/pair.ts +1 -1
- package/src/lib/sync-cloud-assistants.ts +2 -2
- package/src/lib/tunnels/cloudflare.ts +21 -11
- package/src/lib/tunnels/ngrok.ts +198 -108
- package/src/lib/tunnels/tailscale.ts +34 -4
- package/src/lib/upgrade-lifecycle.ts +5 -4
|
@@ -85,7 +85,9 @@ export async function fetchLatestStableVersion(): Promise<string | null> {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* Image refs for a target version: the tag is the target version
|
|
88
|
+
* Image refs for a target version: the tag is the target version exactly as
|
|
89
|
+
* the release workflows push it — bare ("0.12.8", "0.12.8-dev…"); a
|
|
90
|
+
* git-tag-style "v" prefix on the input is tolerated and stripped. The repo
|
|
89
91
|
* follows the CLI's own version shape (dev CLI → `-dev` repos).
|
|
90
92
|
*/
|
|
91
93
|
export function resolveImageRefs(
|
|
@@ -93,8 +95,8 @@ export function resolveImageRefs(
|
|
|
93
95
|
cliVersion: string,
|
|
94
96
|
): Record<ServiceName, string> {
|
|
95
97
|
const versionTag = targetVersion.startsWith("v")
|
|
96
|
-
? targetVersion
|
|
97
|
-
:
|
|
98
|
+
? targetVersion.slice(1)
|
|
99
|
+
: targetVersion;
|
|
98
100
|
const images = dockerhubImages(cliVersion);
|
|
99
101
|
return {
|
|
100
102
|
assistant: `${images.assistant}:${versionTag}`,
|
|
@@ -107,5 +109,5 @@ export function resolveImageRefs(
|
|
|
107
109
|
export function resolveOwnImageRefs(
|
|
108
110
|
cliVersion: string,
|
|
109
111
|
): Record<ServiceName, string> {
|
|
110
|
-
return resolveImageRefs(cliVersion
|
|
112
|
+
return resolveImageRefs(cliVersion || "latest", cliVersion);
|
|
111
113
|
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { resolveOwnImageRefs } from "./cloud-releases.js";
|
|
41
41
|
import { isDevVersion } from "./version-compat.js";
|
|
42
42
|
import {
|
|
43
|
+
collectProviderApiKey,
|
|
43
44
|
configureHatchProviderApiKey,
|
|
44
45
|
formatProviderName,
|
|
45
46
|
promptProviderChoice,
|
|
@@ -340,14 +341,96 @@ export function resolveDockerProviderCredentialSetupAction(options: {
|
|
|
340
341
|
provider: string | null | undefined;
|
|
341
342
|
guardianAccessToken?: string;
|
|
342
343
|
detached: boolean;
|
|
344
|
+
/**
|
|
345
|
+
* Key — or an explicit skip ("") — collected from the user right after
|
|
346
|
+
* they picked the provider. When present (even empty), the user already
|
|
347
|
+
* answered at the prompt, so detached hatches configure (or report the
|
|
348
|
+
* missing key) instead of deferring to a second `cadet setup` run.
|
|
349
|
+
*/
|
|
350
|
+
precollectedKey?: string;
|
|
343
351
|
}): DockerProviderCredentialSetupAction {
|
|
344
352
|
if (options.provider === undefined) return "skip";
|
|
345
353
|
if (options.provider === null) return options.detached ? "skip" : "configure";
|
|
346
|
-
if (options.detached)
|
|
354
|
+
if (options.detached) {
|
|
355
|
+
if (options.precollectedKey !== undefined) {
|
|
356
|
+
return options.guardianAccessToken ? "configure" : "missing-token";
|
|
357
|
+
}
|
|
358
|
+
return "defer";
|
|
359
|
+
}
|
|
347
360
|
if (!options.guardianAccessToken) return "missing-token";
|
|
348
361
|
return "configure";
|
|
349
362
|
}
|
|
350
363
|
|
|
364
|
+
/** Time to wait for the Docker daemon to answer `docker info` after a Colima start. */
|
|
365
|
+
const COLIMA_SETTLE_TIMEOUT_MS = 15_000;
|
|
366
|
+
|
|
367
|
+
/** Poll `docker info` until the daemon answers or the timeout elapses. */
|
|
368
|
+
async function waitForDockerDaemon(
|
|
369
|
+
execFn: typeof exec,
|
|
370
|
+
timeoutMs: number,
|
|
371
|
+
): Promise<boolean> {
|
|
372
|
+
const deadline = Date.now() + timeoutMs;
|
|
373
|
+
for (;;) {
|
|
374
|
+
try {
|
|
375
|
+
await execFn("docker", ["info"]);
|
|
376
|
+
return true;
|
|
377
|
+
} catch {
|
|
378
|
+
if (Date.now() >= deadline) return false;
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Start Colima and wait for the Docker daemon to actually answer.
|
|
386
|
+
*
|
|
387
|
+
* `colima start` exiting 0 only means Colima's own state file says the VM is
|
|
388
|
+
* up — that state can be stale (zombie VM after a host sleep or crash), in
|
|
389
|
+
* which case start is a no-op ("already running, ignoring") while the daemon
|
|
390
|
+
* inside stays dead. So success is decided by re-probing `docker info`, and a
|
|
391
|
+
* failed probe triggers the manual-fix cycle: force-stop and start fresh.
|
|
392
|
+
* Never `colima delete` — the VM disk holds every container image and volume.
|
|
393
|
+
* `startArgs` carries extra flags (e.g. `--memory`) for a first start; the
|
|
394
|
+
* wake path starts an existing instance bare.
|
|
395
|
+
*/
|
|
396
|
+
async function startColimaAndAwaitDaemon(
|
|
397
|
+
execFn: typeof exec,
|
|
398
|
+
log: (msg: string) => void,
|
|
399
|
+
opts: { startArgs?: string[]; settleTimeoutMs?: number } = {},
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
const startArgs = opts.startArgs ?? [];
|
|
402
|
+
const settleTimeoutMs = opts.settleTimeoutMs ?? COLIMA_SETTLE_TIMEOUT_MS;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await execFn("colima", ["start", ...startArgs]);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
// exec captures the child's output and only surfaces it in the error —
|
|
408
|
+
// print it so a failed start is never silent.
|
|
409
|
+
log(`⚠️ colima start failed: ${err instanceof Error ? err.message : err}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (await waitForDockerDaemon(execFn, settleTimeoutMs)) return;
|
|
413
|
+
|
|
414
|
+
log(
|
|
415
|
+
"🔄 Colima state looks stale — restarting the VM (colima stop --force, then start)...",
|
|
416
|
+
);
|
|
417
|
+
await execFn("colima", ["stop", "--force"]).catch(() => {});
|
|
418
|
+
try {
|
|
419
|
+
await execFn("colima", ["start", ...startArgs]);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Failed to restart Colima. Please run 'colima stop --force && colima start' manually.\n${message}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (!(await waitForDockerDaemon(execFn, settleTimeoutMs))) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
"Docker daemon is still not reachable after restarting Colima. " +
|
|
429
|
+
"Check `colima status` and `docker info`, then try again.",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
351
434
|
/**
|
|
352
435
|
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
353
436
|
* Installs Colima and Docker via direct binary download if missing (no sudo
|
|
@@ -418,41 +501,9 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
418
501
|
}
|
|
419
502
|
|
|
420
503
|
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"--memory",
|
|
425
|
-
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
426
|
-
]);
|
|
427
|
-
} catch {
|
|
428
|
-
// Colima may fail if a previous VM instance is in a corrupt state.
|
|
429
|
-
// Attempt to delete the stale instance and retry once.
|
|
430
|
-
console.log(
|
|
431
|
-
"⚠️ Colima start failed — attempting to reset stale VM state...",
|
|
432
|
-
);
|
|
433
|
-
try {
|
|
434
|
-
await exec("colima", ["stop", "--force"]).catch(() => {});
|
|
435
|
-
await exec("colima", ["delete", "--force"]);
|
|
436
|
-
} catch {
|
|
437
|
-
// If delete also fails, fall through to the retry which will
|
|
438
|
-
// produce a clear error message.
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
try {
|
|
442
|
-
console.log("🔄 Retrying colima start...");
|
|
443
|
-
await exec("colima", [
|
|
444
|
-
"start",
|
|
445
|
-
"--memory",
|
|
446
|
-
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
447
|
-
]);
|
|
448
|
-
} catch (retryErr) {
|
|
449
|
-
const message =
|
|
450
|
-
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
451
|
-
throw new Error(
|
|
452
|
-
`Failed to start Colima after resetting stale VM state. Please run 'colima start' manually.\n${message}`,
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
504
|
+
await startColimaAndAwaitDaemon(exec, console.log, {
|
|
505
|
+
startArgs: ["--memory", String(COLIMA_DEFAULT_MEMORY_GIB)],
|
|
506
|
+
});
|
|
456
507
|
}
|
|
457
508
|
}
|
|
458
509
|
|
|
@@ -486,6 +537,56 @@ export async function removeContainer(containerName: string): Promise<void> {
|
|
|
486
537
|
}
|
|
487
538
|
}
|
|
488
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Error signatures Docker Hub returns when the host has STALE Docker Hub
|
|
542
|
+
* credentials: an expired personal access token saved by a past
|
|
543
|
+
* `docker login` gets attached to the pull, and Hub rejects the request
|
|
544
|
+
* instead of falling back to anonymous access for a public image.
|
|
545
|
+
*/
|
|
546
|
+
export function isExpiredDockerHubCredentialError(err: unknown): boolean {
|
|
547
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
548
|
+
return /personal access token is expired|authentication required|unauthorized/i.test(
|
|
549
|
+
message,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Pull an image, retrying once without stale credentials when Docker Hub
|
|
555
|
+
* rejects the pull with an expired-token/auth error — public pulls need no
|
|
556
|
+
* login, so `docker logout` (clears the stored Docker Hub credentials the
|
|
557
|
+
* docker CLI attaches to pulls) costs nothing. Other failures propagate raw;
|
|
558
|
+
* a still-failing auth retry is rethrown with actionable copy.
|
|
559
|
+
*/
|
|
560
|
+
export async function pullImage(
|
|
561
|
+
ref: string,
|
|
562
|
+
log: (msg: string) => void = console.log,
|
|
563
|
+
execFn: typeof exec = exec,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
try {
|
|
566
|
+
await execFn("docker", ["pull", ref]);
|
|
567
|
+
return;
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (!isExpiredDockerHubCredentialError(err)) throw err;
|
|
570
|
+
log(
|
|
571
|
+
`⚠️ Docker Hub rejected the pull of ${ref} with an expired-credential ` +
|
|
572
|
+
`error — clearing stale Docker Hub login and retrying (public images ` +
|
|
573
|
+
`need no credentials)...`,
|
|
574
|
+
);
|
|
575
|
+
await execFn("docker", ["logout"]).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
await execFn("docker", ["pull", ref]);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
581
|
+
throw new Error(
|
|
582
|
+
`Failed to pull ${ref}.\n${message}\n` +
|
|
583
|
+
`If the Docker daemon is not running, start it, e.g. 'colima start' on macOS. ` +
|
|
584
|
+
`If the error mentions authentication, run 'docker logout' and check your ` +
|
|
585
|
+
`Docker Hub credentials — public image pulls need none.`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
489
590
|
export async function retireDocker(name: string): Promise<void> {
|
|
490
591
|
console.log(`\u{1F5D1}\ufe0f Stopping Docker containers for '${name}'...\n`);
|
|
491
592
|
|
|
@@ -846,7 +947,7 @@ export async function recreateDockerContainers(entry: AssistantEntry): Promise<v
|
|
|
846
947
|
// already-present image is a cheap no-op.
|
|
847
948
|
for (const image of [info.assistantImage, info.gatewayImage, info.cesImage]) {
|
|
848
949
|
log(`📦 Pulling ${image}...`);
|
|
849
|
-
await
|
|
950
|
+
await pullImage(image, log);
|
|
850
951
|
}
|
|
851
952
|
|
|
852
953
|
const gatewayPort = parseGatewayPort(entry.runtimeUrl) ??
|
|
@@ -912,6 +1013,7 @@ function parseGatewayPort(runtimeUrl: string): number | null {
|
|
|
912
1013
|
*/
|
|
913
1014
|
export async function ensureDockerDaemonRunning(
|
|
914
1015
|
execFn: typeof exec = exec,
|
|
1016
|
+
opts: { settleTimeoutMs?: number } = {},
|
|
915
1017
|
): Promise<void> {
|
|
916
1018
|
ensureLocalBinOnPath();
|
|
917
1019
|
|
|
@@ -924,12 +1026,10 @@ export async function ensureDockerDaemonRunning(
|
|
|
924
1026
|
// No daemon reachable — fall through to the Colima fallback below.
|
|
925
1027
|
}
|
|
926
1028
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
await execFn("colima", ["start"]);
|
|
932
|
-
}
|
|
1029
|
+
// Deliberately no `colima status` probe: it trusts Colima's own state file,
|
|
1030
|
+
// which can be stale (zombie VM) — daemon reachability is the oracle.
|
|
1031
|
+
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
1032
|
+
await startColimaAndAwaitDaemon(execFn, console.log, opts);
|
|
933
1033
|
}
|
|
934
1034
|
|
|
935
1035
|
/**
|
|
@@ -1234,6 +1334,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1234
1334
|
let provider = setupProviderCredentials
|
|
1235
1335
|
? resolveHatchProvider(configValues)
|
|
1236
1336
|
: undefined;
|
|
1337
|
+
let precollectedApiKey: string | undefined;
|
|
1237
1338
|
|
|
1238
1339
|
if (
|
|
1239
1340
|
shouldPromptForHatchProvider({
|
|
@@ -1246,6 +1347,9 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1246
1347
|
const picked = await promptProviderChoice();
|
|
1247
1348
|
if (picked) {
|
|
1248
1349
|
provider = picked;
|
|
1350
|
+
// Collect the key immediately, while the choice is fresh — the
|
|
1351
|
+
// startup logs are quiet now, so nothing separates the two prompts.
|
|
1352
|
+
precollectedApiKey = await collectProviderApiKey(picked);
|
|
1249
1353
|
}
|
|
1250
1354
|
}
|
|
1251
1355
|
|
|
@@ -1427,7 +1531,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1427
1531
|
const MAX_PULL_RETRIES = 3;
|
|
1428
1532
|
for (let attempt = 1; attempt <= MAX_PULL_RETRIES; attempt++) {
|
|
1429
1533
|
try {
|
|
1430
|
-
await
|
|
1534
|
+
await pullImage(ref, log);
|
|
1431
1535
|
break;
|
|
1432
1536
|
} catch (err) {
|
|
1433
1537
|
if (attempt === MAX_PULL_RETRIES) throw err;
|
|
@@ -1631,6 +1735,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1631
1735
|
provider,
|
|
1632
1736
|
guardianAccessToken,
|
|
1633
1737
|
detached: waitDetached,
|
|
1738
|
+
precollectedKey: precollectedApiKey,
|
|
1634
1739
|
});
|
|
1635
1740
|
|
|
1636
1741
|
if (providerSetupAction === "defer" && provider !== null) {
|
|
@@ -1657,6 +1762,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1657
1762
|
provider,
|
|
1658
1763
|
bearerToken: guardianAccessToken,
|
|
1659
1764
|
env: process.env,
|
|
1765
|
+
precollectedKey: precollectedApiKey,
|
|
1660
1766
|
log,
|
|
1661
1767
|
});
|
|
1662
1768
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Consolidated missing-dependency flow for interactive commands: probe for a
|
|
6
|
+
* binary, and when it is absent, offer to install it — naming no commands —
|
|
7
|
+
* then run the right package manager for the platform and verify. Callers
|
|
8
|
+
* supply the recipes; everything else (prompt, package-manager discovery,
|
|
9
|
+
* fallback copy) lives here so every command behaves the same.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface EnsureBinaryOptions {
|
|
13
|
+
/** The binary as invoked on PATH, e.g. "nginx". */
|
|
14
|
+
binary: string;
|
|
15
|
+
/** What the running command needs it for, e.g. "the tunnel edge". */
|
|
16
|
+
purpose: string;
|
|
17
|
+
/**
|
|
18
|
+
* Package-manager name → full install argv (sudo included where needed),
|
|
19
|
+
* e.g. `{ brew: ["brew", "install", "nginx"] }`. The first entry whose
|
|
20
|
+
* package manager is available wins. Leave out a platform rather than
|
|
21
|
+
* guessing at an unreliable package ID — the manual hint covers it.
|
|
22
|
+
*/
|
|
23
|
+
packages: Record<string, string[]>;
|
|
24
|
+
/** Shown when the user declines or auto-install fails. */
|
|
25
|
+
manualHint: string;
|
|
26
|
+
/** Probe: a version-ish string when the binary is usable, null otherwise. */
|
|
27
|
+
probe: () => string | null;
|
|
28
|
+
/** Check that a package manager exists. Default: `<pm> --version` probe. */
|
|
29
|
+
pmAvailable?: (pm: string) => boolean;
|
|
30
|
+
/** Run an install argv; true on success. Default: inherit-stdio spawnSync. */
|
|
31
|
+
runInstall?: (argv: string[]) => boolean;
|
|
32
|
+
/** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
|
|
33
|
+
confirmInstall?: () => Promise<boolean>;
|
|
34
|
+
streams?: {
|
|
35
|
+
input?: NodeJS.ReadStream | Readable;
|
|
36
|
+
output?: NodeJS.WriteStream | Writable;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Prompt-then-install a missing binary. Returns the probe result — the
|
|
42
|
+
* existing version when the binary was already present, the fresh one after a
|
|
43
|
+
* successful install — or null when the binary is missing and the user
|
|
44
|
+
* declines, no package manager matches, or the install fails. Non-interactive
|
|
45
|
+
* callers (no TTY) never hang: they get the manual hint and null.
|
|
46
|
+
*/
|
|
47
|
+
export async function ensureBinaryInstalled(
|
|
48
|
+
opts: EnsureBinaryOptions,
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
const output = opts.streams?.output ?? process.stdout;
|
|
51
|
+
|
|
52
|
+
const existing = opts.probe();
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
|
|
55
|
+
output.write(
|
|
56
|
+
`\n${opts.binary} is required for ${opts.purpose}, but it isn't installed.\n`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const confirm = opts.confirmInstall ?? defaultConfirm(opts);
|
|
60
|
+
let install = false;
|
|
61
|
+
try {
|
|
62
|
+
install = await confirm();
|
|
63
|
+
} catch {
|
|
64
|
+
install = false;
|
|
65
|
+
}
|
|
66
|
+
if (!install) {
|
|
67
|
+
output.write(`Skipping install. ${opts.manualHint}\n`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pmAvailable = opts.pmAvailable ?? probePackageManager;
|
|
72
|
+
const argv = Object.entries(opts.packages).find(([pm]) =>
|
|
73
|
+
pmAvailable(pm),
|
|
74
|
+
)?.[1];
|
|
75
|
+
if (!argv) {
|
|
76
|
+
output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ok = opts.runInstall ? opts.runInstall(argv) : runInstallArgv(argv);
|
|
81
|
+
const version = ok ? opts.probe() : null;
|
|
82
|
+
if (!version) {
|
|
83
|
+
output.write(
|
|
84
|
+
`⚠ Couldn't install ${opts.binary} automatically. ${opts.manualHint}\n`,
|
|
85
|
+
);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
output.write(`✅ Installed ${opts.binary} (${version}).\n`);
|
|
89
|
+
return version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
|
|
93
|
+
function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
|
|
94
|
+
return async () => {
|
|
95
|
+
const input = opts.streams?.input ?? process.stdin;
|
|
96
|
+
const output = opts.streams?.output ?? process.stdout;
|
|
97
|
+
// Readable has no isTTY; only a real terminal answers the prompt.
|
|
98
|
+
if (!("isTTY" in input) || !input.isTTY) {
|
|
99
|
+
throw new Error("Install confirm requires an interactive terminal.");
|
|
100
|
+
}
|
|
101
|
+
const answer = (
|
|
102
|
+
await readLineText(`Install ${opts.binary} now? [Y/n] `, {
|
|
103
|
+
input,
|
|
104
|
+
output,
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
.trim()
|
|
108
|
+
.toLowerCase();
|
|
109
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Default installer: blocking spawn with inherited stdio (passwords flow). */
|
|
114
|
+
function runInstallArgv(argv: string[]): boolean {
|
|
115
|
+
const [command, ...args] = argv;
|
|
116
|
+
if (!command) return false;
|
|
117
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
118
|
+
return !result.error && result.status === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Default package-manager check: the manager answers `--version`. */
|
|
122
|
+
function probePackageManager(pm: string): boolean {
|
|
123
|
+
const result = spawnSync(pm, ["--version"], {
|
|
124
|
+
stdio: "ignore",
|
|
125
|
+
shell: process.platform === "win32",
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
return !result.error && result.status === 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read one visible line from stdin (Enter submits). Shared by the binary
|
|
133
|
+
* install confirm and the ngrok authtoken paste-in, so both degrade the same
|
|
134
|
+
* way on a non-interactive terminal (the caller decides what empty means).
|
|
135
|
+
*/
|
|
136
|
+
export async function readLineText(
|
|
137
|
+
prompt: string,
|
|
138
|
+
streams: { input?: Readable; output?: Writable } = {},
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
const input = streams.input ?? process.stdin;
|
|
141
|
+
const output = streams.output ?? process.stdout;
|
|
142
|
+
|
|
143
|
+
output.write(prompt);
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
let line = "";
|
|
147
|
+
const onData = (chunk: Buffer | string): void => {
|
|
148
|
+
line += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
149
|
+
const newline = line.indexOf("\n");
|
|
150
|
+
if (newline !== -1) {
|
|
151
|
+
done(line.slice(0, newline).replace(/\r$/, ""));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const cleanup = (): void => {
|
|
155
|
+
input.removeListener("data", onData);
|
|
156
|
+
input.removeListener("error", onError);
|
|
157
|
+
input.pause();
|
|
158
|
+
};
|
|
159
|
+
const done = (value: string): void => {
|
|
160
|
+
cleanup();
|
|
161
|
+
resolve(value);
|
|
162
|
+
};
|
|
163
|
+
const onError = (err: Error): void => {
|
|
164
|
+
cleanup();
|
|
165
|
+
reject(err);
|
|
166
|
+
};
|
|
167
|
+
input.resume();
|
|
168
|
+
input.on("data", onData);
|
|
169
|
+
input.on("error", onError);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -75,7 +75,7 @@ describe("parseFeatureFlagArgs", () => {
|
|
|
75
75
|
"process.exit",
|
|
76
76
|
);
|
|
77
77
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
78
|
-
'Error: invalid flag key "UPPER". Keys must be kebab-case
|
|
78
|
+
'Error: invalid flag key "UPPER". Keys must be kebab-case, e.g. "browser"',
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
exitSpy.mockRestore();
|
package/src/lib/flag-args.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function parseFeatureFlagArgs(args: string[]): {
|
|
|
38
38
|
|
|
39
39
|
if (!ALLOWED_KEY_RE.test(key)) {
|
|
40
40
|
console.error(
|
|
41
|
-
`Error: invalid flag key "${key}". Keys must be kebab-case
|
|
41
|
+
`Error: invalid flag key "${key}". Keys must be kebab-case, e.g. "browser"`,
|
|
42
42
|
);
|
|
43
43
|
process.exit(1);
|
|
44
44
|
}
|
|
@@ -429,7 +429,7 @@ export async function refreshGuardianTokenResult(
|
|
|
429
429
|
console.warn(
|
|
430
430
|
`Refusing to refresh the guardian token over an insecure URL (${gatewayUrl}). ` +
|
|
431
431
|
"The refresh token is only sent over https or a loopback address: " +
|
|
432
|
-
"use an https URL
|
|
432
|
+
"use an https URL, e.g. a tunnel, or connect over loopback.",
|
|
433
433
|
);
|
|
434
434
|
return {
|
|
435
435
|
ok: false,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -384,7 +384,7 @@ export async function hatchLocal(
|
|
|
384
384
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
385
385
|
} else {
|
|
386
386
|
reporter.error(
|
|
387
|
-
`⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? "
|
|
387
|
+
`⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? ", not retrying — a malformed 200 will not fix itself" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
|
|
388
388
|
` The assistant is running but guardian-token.json was not written.\n` +
|
|
389
389
|
` Run \`cadet restart ${JSON.stringify(instanceName)} --repair-guardian\` to re-lease a token.`,
|
|
390
390
|
);
|
package/src/lib/job-polling.ts
CHANGED
|
@@ -166,7 +166,7 @@ export async function pollJobUntilDone(
|
|
|
166
166
|
throw err;
|
|
167
167
|
}
|
|
168
168
|
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
-
console.warn(`${options.label} polling failed
|
|
169
|
+
console.warn(`${options.label} polling failed: ${msg}. Retrying...`);
|
|
170
170
|
if (Date.now() >= deadline) {
|
|
171
171
|
throw new Error(
|
|
172
172
|
`Timed out waiting for ${options.label} after ${Math.round(
|
package/src/lib/local.ts
CHANGED
|
@@ -937,7 +937,7 @@ async function startDaemonWatchFromSource(
|
|
|
937
937
|
}
|
|
938
938
|
writeFileSync(pidFile, String(daemonPid), "utf-8");
|
|
939
939
|
|
|
940
|
-
console.log(" Assistant started in watch mode
|
|
940
|
+
console.log(" Assistant started in watch mode with bun --watch");
|
|
941
941
|
return trackDaemonSpawn(child);
|
|
942
942
|
}
|
|
943
943
|
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -1162,13 +1162,13 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1162
1162
|
case "nginx-missing":
|
|
1163
1163
|
throw new Error(
|
|
1164
1164
|
"nginx is not installed, so the tunnel edge cannot start. " +
|
|
1165
|
-
"
|
|
1165
|
+
"See https://nginx.org/en/docs/install.html for how to install, " +
|
|
1166
1166
|
"or point NGINX_BIN at an existing binary.",
|
|
1167
1167
|
);
|
|
1168
1168
|
case "web-dist-missing":
|
|
1169
1169
|
throw new Error(
|
|
1170
1170
|
"Unable to locate built web assets for the remote web edge. " +
|
|
1171
|
-
"Build the SPA
|
|
1171
|
+
"Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
|
|
1172
1172
|
"or install @mycadet/web so its packaged dist directory is available.",
|
|
1173
1173
|
);
|
|
1174
1174
|
case "unreachable":
|
package/src/lib/pair.ts
CHANGED
|
@@ -50,6 +50,6 @@ export function formatWebApproveFailure(
|
|
|
50
50
|
return [
|
|
51
51
|
`${rejection} ${gatewayUrl} (assistant "${assistantReference}", environment "${envName}").`,
|
|
52
52
|
`Codes are minted by the gateway that serves the pair page, expire after ${CODE_TTL_MINUTES} minutes, and are single-use.`,
|
|
53
|
-
"If the pair page came from a different assistant or environment
|
|
53
|
+
"If the pair page came from a different assistant or environment, such as the desktop app's, re-run with that environment's CADET_ENVIRONMENT and that assistant's name.",
|
|
54
54
|
].join("\n");
|
|
55
55
|
}
|
|
@@ -88,7 +88,7 @@ export async function syncCloudAssistants(
|
|
|
88
88
|
log?.("Fetching Cadet Cloud assistants…");
|
|
89
89
|
cloudAssistants = await fetchCloudAssistants(token);
|
|
90
90
|
log?.(
|
|
91
|
-
`Cadet Cloud returned ${cloudAssistants.length} assistant
|
|
91
|
+
`Cadet Cloud returned ${cloudAssistants.length} ${cloudAssistants.length === 1 ? "assistant" : "assistants"}: ${cloudAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
|
|
92
92
|
);
|
|
93
93
|
} catch (err) {
|
|
94
94
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -118,7 +118,7 @@ export async function syncCloudAssistants(
|
|
|
118
118
|
);
|
|
119
119
|
const existingCloudIds = new Set(existingCloudById.keys());
|
|
120
120
|
log?.(
|
|
121
|
-
`Lockfile has ${existingCloudIds.size} cloud assistant
|
|
121
|
+
`Lockfile has ${existingCloudIds.size} ${existingCloudIds.size === 1 ? "cloud assistant" : "cloud assistants"}: ${[...existingCloudIds].join(", ") || "(none)"}`,
|
|
122
122
|
);
|
|
123
123
|
|
|
124
124
|
let added = 0;
|
|
@@ -2,6 +2,8 @@ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
4
4
|
|
|
5
|
+
import { ensureBinaryInstalled } from "../ensure-binary.js";
|
|
6
|
+
|
|
5
7
|
import { GATEWAY_PORT } from "../constants.js";
|
|
6
8
|
import {
|
|
7
9
|
clearIngressUrl,
|
|
@@ -138,18 +140,26 @@ export interface RunCloudflareTunnelOptions {
|
|
|
138
140
|
export async function runCloudflareTunnel(
|
|
139
141
|
opts: RunCloudflareTunnelOptions = {},
|
|
140
142
|
): Promise<ProviderTunnelResult> {
|
|
141
|
-
const version =
|
|
143
|
+
const version = await ensureBinaryInstalled({
|
|
144
|
+
binary: "cloudflared",
|
|
145
|
+
purpose: "the cloudflare tunnel",
|
|
146
|
+
packages: {
|
|
147
|
+
brew: ["brew", "install", "cloudflare/cloudflare/cloudflared"],
|
|
148
|
+
winget: [
|
|
149
|
+
"winget",
|
|
150
|
+
"install",
|
|
151
|
+
"--silent",
|
|
152
|
+
"--accept-package-agreements",
|
|
153
|
+
"--accept-source-agreements",
|
|
154
|
+
"Cloudflare.cloudflared",
|
|
155
|
+
],
|
|
156
|
+
choco: ["choco", "install", "-y", "cloudflared"],
|
|
157
|
+
},
|
|
158
|
+
manualHint:
|
|
159
|
+
"See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ for how to install",
|
|
160
|
+
probe: getCloudflareTunnelVersion,
|
|
161
|
+
});
|
|
142
162
|
if (!version) {
|
|
143
|
-
console.error("Error: cloudflared is not installed.");
|
|
144
|
-
console.error("");
|
|
145
|
-
console.error("Install cloudflared:");
|
|
146
|
-
console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
|
|
147
|
-
console.error(" Linux: https://pkg.cloudflare.com/index.html");
|
|
148
|
-
console.error(
|
|
149
|
-
" Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
150
|
-
);
|
|
151
|
-
console.error("");
|
|
152
|
-
console.error("No Cloudflare account is required for quick tunnels.");
|
|
153
163
|
process.exit(1);
|
|
154
164
|
}
|
|
155
165
|
|