@mycadet/cli 0.12.8 → 0.12.9-dev.202610131452.9acab64
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 +4 -3
- package/src/__tests__/client-web-config.test.ts +0 -1
- 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 +6 -11
- package/src/__tests__/ngrok-spawn.test.ts +49 -0
- package/src/__tests__/pair.test.ts +149 -78
- package/src/__tests__/ps-cloud-status.test.ts +4 -3
- package/src/__tests__/ps-table.test.ts +79 -0
- package/src/__tests__/tailscale.test.ts +1 -1
- package/src/__tests__/teleport.test.ts +1 -16
- package/src/__tests__/tunnel.test.ts +274 -82
- package/src/__tests__/uninstall.test.ts +141 -9
- package/src/__tests__/wake.test.ts +2 -2
- package/src/commands/backup.ts +3 -3
- package/src/commands/client.ts +4 -6
- 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 +5 -2
- package/src/commands/logs.ts +7 -7
- package/src/commands/pair.ts +140 -37
- package/src/commands/ps.ts +63 -26
- 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 +18 -37
- package/src/commands/terminal.ts +1 -1
- package/src/commands/tunnel.ts +101 -78
- package/src/commands/uninstall.ts +108 -73
- 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/__tests__/local-gateway-cli.test.ts +42 -0
- package/src/lib/cloud-client.ts +0 -3
- package/src/lib/cloud-login.ts +0 -1
- package/src/lib/cloud-releases.ts +6 -4
- package/src/lib/color.ts +4 -0
- package/src/lib/docker.ts +190 -102
- package/src/lib/ensure-binary.ts +171 -0
- package/src/lib/environments/__tests__/paths.test.ts +27 -1
- package/src/lib/environments/paths.ts +16 -8
- package/src/lib/environments/resolve.ts +2 -4
- 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 +15 -8
- package/src/lib/hatch-next-steps.ts +5 -9
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local.ts +37 -4
- package/src/lib/nginx-ingress.ts +3 -5
- package/src/lib/pair.ts +1 -1
- package/src/lib/step-runner.ts +6 -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
package/src/lib/docker.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
ASSISTANT_INTERNAL_PORT,
|
|
28
28
|
getDefaultPorts,
|
|
29
29
|
} from "./environments/paths.js";
|
|
30
|
+
import { green, yellow } from "./color.js";
|
|
30
31
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
31
32
|
import { leaseGuardianToken } from "./guardian-token";
|
|
32
33
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
@@ -40,8 +41,8 @@ import {
|
|
|
40
41
|
import { resolveOwnImageRefs } from "./cloud-releases.js";
|
|
41
42
|
import { isDevVersion } from "./version-compat.js";
|
|
42
43
|
import {
|
|
44
|
+
collectProviderApiKey,
|
|
43
45
|
configureHatchProviderApiKey,
|
|
44
|
-
formatProviderName,
|
|
45
46
|
promptProviderChoice,
|
|
46
47
|
resolveHatchProvider,
|
|
47
48
|
shouldPromptForHatchProvider,
|
|
@@ -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;
|
|
@@ -1440,7 +1544,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1440
1544
|
}
|
|
1441
1545
|
}
|
|
1442
1546
|
}
|
|
1443
|
-
log("✅ Docker images acquired");
|
|
1547
|
+
log(green("✅ Docker images acquired", process.stdout));
|
|
1444
1548
|
}
|
|
1445
1549
|
|
|
1446
1550
|
const res = dockerResourceNames(instanceName);
|
|
@@ -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) {
|
|
@@ -1640,23 +1745,22 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1640
1745
|
);
|
|
1641
1746
|
} else if (providerSetupAction === "missing-token" && provider !== null) {
|
|
1642
1747
|
log(
|
|
1643
|
-
|
|
1644
|
-
|
|
1748
|
+
yellow(
|
|
1749
|
+
`⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
|
|
1750
|
+
` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
|
|
1751
|
+
process.stdout,
|
|
1752
|
+
),
|
|
1645
1753
|
);
|
|
1646
1754
|
} else if (
|
|
1647
1755
|
providerSetupAction === "configure" &&
|
|
1648
1756
|
provider !== undefined
|
|
1649
1757
|
) {
|
|
1650
|
-
log(
|
|
1651
|
-
provider === null
|
|
1652
|
-
? "Checking provider credentials..."
|
|
1653
|
-
: `Checking ${formatProviderName(provider)} credentials...`,
|
|
1654
|
-
);
|
|
1655
1758
|
await configureHatchProviderApiKey({
|
|
1656
1759
|
gatewayUrl: runtimeUrl,
|
|
1657
1760
|
provider,
|
|
1658
1761
|
bearerToken: guardianAccessToken,
|
|
1659
1762
|
env: process.env,
|
|
1763
|
+
precollectedKey: precollectedApiKey,
|
|
1660
1764
|
log,
|
|
1661
1765
|
});
|
|
1662
1766
|
}
|
|
@@ -1749,25 +1853,12 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1749
1853
|
};
|
|
1750
1854
|
|
|
1751
1855
|
if (detached) {
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
container: containerName,
|
|
1759
|
-
},
|
|
1760
|
-
log,
|
|
1761
|
-
);
|
|
1762
|
-
log("");
|
|
1763
|
-
log(`Stop with: cadet retire ${instanceName}`);
|
|
1764
|
-
|
|
1765
|
-
// Lease a guardian token even in detached mode so that `cadet ps`,
|
|
1766
|
-
// `cadet exec`, and other CLI commands can authenticate to the
|
|
1767
|
-
// gateway. Skip the /readyz readiness poll (the caller asked to detach
|
|
1768
|
-
// and not block) but retry the lease itself since the gateway may need
|
|
1769
|
-
// a moment to accept connections after the container starts.
|
|
1770
|
-
const leaseStart = Date.now();
|
|
1856
|
+
// Lease a guardian token BEFORE declaring the assistant hatched so the
|
|
1857
|
+
// banner means `cadet ps`, `cadet exec`, and other CLI commands can
|
|
1858
|
+
// already authenticate. Skip the /readyz readiness poll (the caller
|
|
1859
|
+
// asked to detach and not block) but retry the lease silently since
|
|
1860
|
+
// the gateway may need a moment to accept connections after the
|
|
1861
|
+
// container starts; only a final failure surfaces to the user.
|
|
1771
1862
|
const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
|
|
1772
1863
|
let guardianAccessToken: string | undefined;
|
|
1773
1864
|
while (Date.now() < leaseDeadline) {
|
|
@@ -1778,27 +1869,33 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1778
1869
|
bootstrapSecret,
|
|
1779
1870
|
);
|
|
1780
1871
|
guardianAccessToken = tokenData.accessToken;
|
|
1781
|
-
const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
|
|
1782
|
-
log(
|
|
1783
|
-
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
|
|
1784
|
-
);
|
|
1785
1872
|
break;
|
|
1786
|
-
} catch
|
|
1787
|
-
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1788
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1789
|
-
log(
|
|
1790
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
|
|
1791
|
-
);
|
|
1873
|
+
} catch {
|
|
1792
1874
|
await new Promise((r) => setTimeout(r, 2000));
|
|
1793
1875
|
}
|
|
1794
1876
|
}
|
|
1795
1877
|
if (!guardianAccessToken) {
|
|
1796
1878
|
log(
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1879
|
+
yellow(
|
|
1880
|
+
`⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
|
|
1881
|
+
` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
|
|
1882
|
+
` Re-hatch or run \`cadet setup\` to recover.`,
|
|
1883
|
+
process.stdout,
|
|
1884
|
+
),
|
|
1800
1885
|
);
|
|
1801
1886
|
}
|
|
1887
|
+
|
|
1888
|
+
log(`\n${green("✅ Docker assistant hatched!", process.stdout)}\n`);
|
|
1889
|
+
log("Instance details:");
|
|
1890
|
+
printInstanceDetails(
|
|
1891
|
+
{
|
|
1892
|
+
name: instanceName,
|
|
1893
|
+
runtime: runtimeUrl,
|
|
1894
|
+
container: containerName,
|
|
1895
|
+
},
|
|
1896
|
+
log,
|
|
1897
|
+
);
|
|
1898
|
+
log("");
|
|
1802
1899
|
return { ready: true, guardianAccessToken };
|
|
1803
1900
|
}
|
|
1804
1901
|
|
|
@@ -1879,7 +1976,6 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1879
1976
|
// Lease guardian token. The /readyz check confirms both gateway and
|
|
1880
1977
|
// assistant are reachable. Retry with backoff in case there is a brief
|
|
1881
1978
|
// window where readiness passes but the guardian endpoint is not yet ready.
|
|
1882
|
-
log(`Guardian token lease: starting for ${instanceName} at ${runtimeUrl}`);
|
|
1883
1979
|
const leaseStart = Date.now();
|
|
1884
1980
|
const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
|
|
1885
1981
|
let leaseSuccess = false;
|
|
@@ -1893,38 +1989,30 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1893
1989
|
instanceName,
|
|
1894
1990
|
bootstrapSecret,
|
|
1895
1991
|
);
|
|
1896
|
-
const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
|
|
1897
|
-
log(
|
|
1898
|
-
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
|
|
1899
|
-
);
|
|
1900
1992
|
guardianAccessToken = tokenData.accessToken;
|
|
1901
1993
|
leaseSuccess = true;
|
|
1902
1994
|
break;
|
|
1903
1995
|
} catch (err) {
|
|
1904
1996
|
lastLeaseError =
|
|
1905
1997
|
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
1906
|
-
// Log periodically so the user knows we're still trying
|
|
1907
|
-
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1908
|
-
log(
|
|
1909
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
1910
|
-
lastLeaseError.split("\n")[0]
|
|
1911
|
-
}), retrying...`,
|
|
1912
|
-
);
|
|
1913
1998
|
}
|
|
1914
1999
|
await new Promise((r) => setTimeout(r, 2000));
|
|
1915
2000
|
}
|
|
1916
2001
|
|
|
1917
2002
|
if (!leaseSuccess) {
|
|
1918
2003
|
log(
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2004
|
+
yellow(
|
|
2005
|
+
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
2006
|
+
(Date.now() - leaseStart) /
|
|
2007
|
+
1000
|
|
2008
|
+
).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
|
|
2009
|
+
process.stdout,
|
|
2010
|
+
),
|
|
1923
2011
|
);
|
|
1924
2012
|
}
|
|
1925
2013
|
|
|
1926
2014
|
log("");
|
|
1927
|
-
log(`\u2705 Docker containers are up and running
|
|
2015
|
+
log(green(`\u2705 Docker containers are up and running!`, process.stdout));
|
|
1928
2016
|
log(` Name: ${instanceName}`);
|
|
1929
2017
|
log(` Runtime: ${runtimeUrl}`);
|
|
1930
2018
|
log("");
|
|
@@ -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
|
+
}
|
|
@@ -39,9 +39,11 @@ const {
|
|
|
39
39
|
getConfigDir,
|
|
40
40
|
getConfigDirs,
|
|
41
41
|
getDefaultPorts,
|
|
42
|
+
getInputHistoryPath,
|
|
42
43
|
getLockfilePath,
|
|
43
44
|
getLockfilePaths,
|
|
44
45
|
getMultiInstanceDir,
|
|
46
|
+
getSharedStateDir,
|
|
45
47
|
} = await import("../paths.js");
|
|
46
48
|
type EnvironmentDefinition =
|
|
47
49
|
import("@mycadet/environments").EnvironmentDefinition;
|
|
@@ -58,7 +60,12 @@ const dev: EnvironmentDefinition = {
|
|
|
58
60
|
webUrl: "https://dev.mycadet.ai",
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
const XDG_ENV_VARS = [
|
|
63
|
+
const XDG_ENV_VARS = [
|
|
64
|
+
"XDG_DATA_HOME",
|
|
65
|
+
"XDG_CONFIG_HOME",
|
|
66
|
+
"XDG_STATE_HOME",
|
|
67
|
+
"APPDATA",
|
|
68
|
+
] as const;
|
|
62
69
|
|
|
63
70
|
describe("path helpers", () => {
|
|
64
71
|
let savedEnv: Record<string, string | undefined>;
|
|
@@ -224,6 +231,25 @@ describe("path helpers", () => {
|
|
|
224
231
|
});
|
|
225
232
|
});
|
|
226
233
|
|
|
234
|
+
describe("getSharedStateDir", () => {
|
|
235
|
+
test("is not environment-scoped: ~/.local/state/cadet for every env", () => {
|
|
236
|
+
expect(getSharedStateDir()).toBe(
|
|
237
|
+
join(TEST_HOME, ".local", "state", "cadet"),
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("respects XDG_STATE_HOME", () => {
|
|
242
|
+
process.env.XDG_STATE_HOME = "/custom/state";
|
|
243
|
+
expect(getSharedStateDir()).toBe("/custom/state/cadet");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("holds the shared terminal input history file", () => {
|
|
247
|
+
expect(getInputHistoryPath()).toBe(
|
|
248
|
+
join(getSharedStateDir(), "input-history"),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
227
253
|
describe("getDefaultPorts", () => {
|
|
228
254
|
test("returns production defaults for production", () => {
|
|
229
255
|
const ports = getDefaultPorts(prod);
|