@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
package/src/commands/tunnel.ts
CHANGED
|
@@ -22,9 +22,10 @@ import {
|
|
|
22
22
|
} from "../lib/ingress-config.js";
|
|
23
23
|
import {
|
|
24
24
|
ensureTunnelEdge,
|
|
25
|
-
|
|
25
|
+
getNginxVersion,
|
|
26
26
|
type TunnelEdge,
|
|
27
27
|
} from "../lib/nginx-ingress.js";
|
|
28
|
+
import { ensureBinaryInstalled } from "../lib/ensure-binary.js";
|
|
28
29
|
import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
|
|
29
30
|
import {
|
|
30
31
|
ADOPTED_TUNNEL_RE,
|
|
@@ -73,29 +74,30 @@ function parseArgs(): TunnelArgs {
|
|
|
73
74
|
console.log(
|
|
74
75
|
"How far that URL reaches depends on the provider; omit --provider for",
|
|
75
76
|
);
|
|
76
|
-
console.log("the default. A tailscale tunnel is reachable only from your");
|
|
77
77
|
console.log(
|
|
78
|
-
"
|
|
78
|
+
"the default. A tailscale tunnel is reachable only from your",
|
|
79
79
|
);
|
|
80
|
-
console.log(
|
|
80
|
+
console.log(
|
|
81
|
+
"own tailnet; webhook integrations need a public tunnel. See Providers",
|
|
82
|
+
);
|
|
83
|
+
console.log("below.");
|
|
81
84
|
console.log("");
|
|
82
85
|
console.log(
|
|
83
86
|
"The tunnel always fronts the local nginx edge, which is started automatically.",
|
|
84
87
|
);
|
|
85
88
|
console.log(
|
|
86
|
-
"nginx must be installed
|
|
89
|
+
"nginx must be installed; if it is missing, cadet offers to install it.",
|
|
87
90
|
);
|
|
88
91
|
console.log("");
|
|
89
92
|
console.log("Arguments:");
|
|
90
93
|
console.log(
|
|
91
|
-
" <name> Name of the assistant
|
|
94
|
+
" <name> Name of the assistant, defaults to active or only local",
|
|
92
95
|
);
|
|
93
96
|
console.log("");
|
|
94
97
|
console.log("Options:");
|
|
95
98
|
console.log(
|
|
96
|
-
` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")}`,
|
|
99
|
+
` --provider <provider> Tunnel provider: ${TUNNEL_PROVIDERS.join(", ")} — default ${DEFAULT_TUNNEL_PROVIDER}`,
|
|
97
100
|
);
|
|
98
|
-
console.log(` (default: ${DEFAULT_TUNNEL_PROVIDER})`);
|
|
99
101
|
console.log(
|
|
100
102
|
" --domain <domain> Reserved domain to bind, for providers that support",
|
|
101
103
|
);
|
|
@@ -137,7 +139,8 @@ function parseArgs(): TunnelArgs {
|
|
|
137
139
|
console.log("");
|
|
138
140
|
console.log("Examples:");
|
|
139
141
|
console.log(` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER}`);
|
|
140
|
-
const domainExample =
|
|
142
|
+
const domainExample =
|
|
143
|
+
PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
|
|
141
144
|
if (domainExample) {
|
|
142
145
|
console.log(
|
|
143
146
|
` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --domain ${domainExample}`,
|
|
@@ -174,7 +177,8 @@ function parseArgs(): TunnelArgs {
|
|
|
174
177
|
} else if (arg === "--domain") {
|
|
175
178
|
const next = args[i + 1];
|
|
176
179
|
if (!next || next.startsWith("-")) {
|
|
177
|
-
const domainExample =
|
|
180
|
+
const domainExample =
|
|
181
|
+
PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
|
|
178
182
|
console.error(
|
|
179
183
|
`Error: --domain requires a value${domainExample ? `, e.g. --domain ${domainExample}` : ""}`,
|
|
180
184
|
);
|
|
@@ -199,9 +203,7 @@ function parseArgs(): TunnelArgs {
|
|
|
199
203
|
|
|
200
204
|
if (provider === null) {
|
|
201
205
|
provider = DEFAULT_TUNNEL_PROVIDER;
|
|
202
|
-
console.log(
|
|
203
|
-
`Using the default provider: ${provider} (pass --provider to choose another).`,
|
|
204
|
-
);
|
|
206
|
+
console.log(`Using the default provider: ${provider}.`);
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
if (domain && !providerSupportsReservedDomain(provider)) {
|
|
@@ -415,57 +417,78 @@ async function spawnDetachedTunnel(): Promise<void> {
|
|
|
415
417
|
|
|
416
418
|
export const tunnel = Object.assign(
|
|
417
419
|
async function tunnel(): Promise<void> {
|
|
418
|
-
|
|
420
|
+
const { assistantName, provider, domain, clearDomain, detach } =
|
|
421
|
+
parseArgs();
|
|
419
422
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
423
|
+
if (detach) {
|
|
424
|
+
await spawnDetachedTunnel();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
424
427
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
+
if (PROVIDER_META[provider].comingSoon) {
|
|
429
|
+
throw cadetTunnelComingSoonError(provider);
|
|
430
|
+
}
|
|
428
431
|
|
|
429
|
-
|
|
430
|
-
|
|
432
|
+
const { entry, gatewayPort, workspaceDir } =
|
|
433
|
+
resolveLocalTunnelTarget(assistantName);
|
|
431
434
|
|
|
432
|
-
|
|
433
|
-
|
|
435
|
+
const preserveIngressUrl =
|
|
436
|
+
provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
|
|
434
437
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
438
|
+
if (clearDomain) {
|
|
439
|
+
clearReservedDomain();
|
|
440
|
+
console.log("Cleared the saved reserved domain.");
|
|
441
|
+
}
|
|
439
442
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
443
|
+
const nginxVersion = await ensureBinaryInstalled({
|
|
444
|
+
binary: "nginx",
|
|
445
|
+
purpose: "the tunnel edge",
|
|
446
|
+
packages: {
|
|
447
|
+
brew: ["brew", "install", "nginx"],
|
|
448
|
+
"apt-get": ["sudo", "apt-get", "install", "-y", "nginx"],
|
|
449
|
+
dnf: ["sudo", "dnf", "install", "-y", "nginx"],
|
|
450
|
+
pacman: ["sudo", "pacman", "-S", "--noconfirm", "nginx"],
|
|
451
|
+
zypper: ["sudo", "zypper", "install", "-y", "nginx"],
|
|
452
|
+
choco: ["choco", "install", "-y", "nginx"],
|
|
453
|
+
},
|
|
454
|
+
manualHint:
|
|
455
|
+
"See https://nginx.org/en/docs/install.html for how to install, or point NGINX_BIN at an existing binary.",
|
|
456
|
+
probe: getNginxVersion,
|
|
446
457
|
});
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
if (!nginxVersion) {
|
|
459
|
+
console.error("Error: nginx is required to run the tunnel edge.");
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
451
462
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
463
|
+
let edge: TunnelEdge;
|
|
464
|
+
try {
|
|
465
|
+
edge = await ensureTunnelEdge({
|
|
466
|
+
assistantId: entry.assistantId,
|
|
467
|
+
workspaceDir,
|
|
468
|
+
gatewayPort,
|
|
469
|
+
});
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error(
|
|
472
|
+
`Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
473
|
+
);
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
console.log(
|
|
478
|
+
`${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port}.`,
|
|
479
|
+
);
|
|
456
480
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
481
|
+
const baseTunnelOpts = {
|
|
482
|
+
port: edge.port,
|
|
483
|
+
assistantId: entry.assistantId,
|
|
484
|
+
workspaceDir,
|
|
485
|
+
};
|
|
462
486
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
},
|
|
487
|
+
await runProviderTunnel(provider, {
|
|
488
|
+
...baseTunnelOpts,
|
|
489
|
+
...(domain ? { domain } : {}),
|
|
490
|
+
...(preserveIngressUrl ? { preserveIngressUrl } : {}),
|
|
491
|
+
});
|
|
492
|
+
},
|
|
469
493
|
{ description: "Create a tunnel for a locally hosted assistant" },
|
|
470
494
|
);
|
|
471
|
-
|
|
@@ -141,7 +141,7 @@ function selfDelete(): void {
|
|
|
141
141
|
"Cadet is running from a source checkout — skipping self-removal.",
|
|
142
142
|
);
|
|
143
143
|
console.log(
|
|
144
|
-
"Remove the installed copy with `bun remove -g mycadet`
|
|
144
|
+
"Remove the installed copy with `bun remove -g mycadet` or `npm uninstall -g mycadet`.",
|
|
145
145
|
);
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
@@ -206,14 +206,14 @@ export const uninstall = Object.assign(
|
|
|
206
206
|
const existing = loadAllAssistants();
|
|
207
207
|
if (existing.length > 0) {
|
|
208
208
|
console.warn(
|
|
209
|
-
`\n⚠️ ${existing.length} assistant
|
|
209
|
+
`\n⚠️ ${existing.length} ${existing.length === 1 ? "assistant still exists" : "assistants still exist"}: ${existing.map((a) => a.assistantId).join(", ")}`,
|
|
210
210
|
);
|
|
211
211
|
console.warn(
|
|
212
212
|
" They will be retired now — their data (and any retired archives) will be permanently deleted by this uninstall.",
|
|
213
213
|
);
|
|
214
214
|
for (const entry of existing) {
|
|
215
215
|
if (entry.cloud === "apple-container" || entry.cloud === "paired") {
|
|
216
|
-
console.log(` Skipping '${entry.assistantId}'
|
|
216
|
+
console.log(` Skipping '${entry.assistantId}' on ${entry.cloud} — its lifecycle is managed elsewhere.`);
|
|
217
217
|
continue;
|
|
218
218
|
}
|
|
219
219
|
try {
|
|
@@ -239,7 +239,7 @@ export const uninstall = Object.assign(
|
|
|
239
239
|
} else {
|
|
240
240
|
console.log("It will permanently delete:");
|
|
241
241
|
for (const t of targets) {
|
|
242
|
-
console.log(` - ${t.path}
|
|
242
|
+
console.log(` - ${t.path} — ${t.description}`);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
const execBase = basename(execPath);
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
GATEWAY_INTERNAL_PORT,
|
|
20
20
|
ASSISTANT_INTERNAL_PORT,
|
|
21
21
|
dockerResourceNames,
|
|
22
|
+
pullImage,
|
|
22
23
|
startContainers,
|
|
23
24
|
stopContainers,
|
|
24
25
|
} from "../lib/docker";
|
|
@@ -46,7 +47,6 @@ import {
|
|
|
46
47
|
} from "../lib/backup-ops.js";
|
|
47
48
|
import { emitCliError, categorizeUpgradeError } from "../lib/cli-error.js";
|
|
48
49
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
49
|
-
import { exec } from "../lib/step-runner.js";
|
|
50
50
|
import {
|
|
51
51
|
broadcastUpgradeEvent,
|
|
52
52
|
attemptFailedStateRestore,
|
|
@@ -118,12 +118,12 @@ function parseArgs(): UpgradeArgs {
|
|
|
118
118
|
console.log("");
|
|
119
119
|
console.log("Arguments:");
|
|
120
120
|
console.log(
|
|
121
|
-
" <name> Name of the assistant to upgrade
|
|
121
|
+
" <name> Name of the assistant to upgrade, defaults to active or only assistant",
|
|
122
122
|
);
|
|
123
123
|
console.log("");
|
|
124
124
|
console.log("Options:");
|
|
125
125
|
console.log(
|
|
126
|
-
" --version <version> Target version to upgrade to
|
|
126
|
+
" --version <version> Target version to upgrade to, defaults to the CLI version",
|
|
127
127
|
);
|
|
128
128
|
console.log(
|
|
129
129
|
" --latest Upgrade to the latest stable release, updating the CLI first if needed",
|
|
@@ -316,7 +316,7 @@ async function upgradeDocker(
|
|
|
316
316
|
);
|
|
317
317
|
return;
|
|
318
318
|
}
|
|
319
|
-
console.log(`🔁 Reinstalling ${versionTag}
|
|
319
|
+
console.log(`🔁 Reinstalling ${versionTag} with --force...\n`);
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
console.log("🔍 Resolving image references...");
|
|
@@ -406,7 +406,7 @@ async function upgradeDocker(
|
|
|
406
406
|
try {
|
|
407
407
|
for (const [service, image] of pullImages) {
|
|
408
408
|
console.log(` Pulling ${service}: ${image}`);
|
|
409
|
-
await
|
|
409
|
+
await pullImage(image);
|
|
410
410
|
}
|
|
411
411
|
} catch (pullErr) {
|
|
412
412
|
const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
|
|
@@ -453,7 +453,7 @@ async function upgradeDocker(
|
|
|
453
453
|
// Clean up old pre-upgrade backups, keep last 3
|
|
454
454
|
pruneOldBackups(entry.assistantId, 3);
|
|
455
455
|
} else {
|
|
456
|
-
console.warn("⚠️ Pre-upgrade backup failed
|
|
456
|
+
console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
// Persist the backup path so `cadet rollback` can restore the exact backup
|
|
@@ -891,7 +891,7 @@ async function upgradeLocal(
|
|
|
891
891
|
);
|
|
892
892
|
return;
|
|
893
893
|
}
|
|
894
|
-
console.log(`🔁 Reinstalling ${targetVersion}
|
|
894
|
+
console.log(`🔁 Reinstalling ${targetVersion} with --force...\n`);
|
|
895
895
|
}
|
|
896
896
|
|
|
897
897
|
console.log(
|
|
@@ -933,7 +933,7 @@ async function upgradeLocal(
|
|
|
933
933
|
console.log(` Backup saved: ${backupPath}\n`);
|
|
934
934
|
pruneOldBackups(entry.assistantId, 3);
|
|
935
935
|
} else {
|
|
936
|
-
console.warn("⚠️ Pre-upgrade backup failed
|
|
936
|
+
console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
|
|
937
937
|
}
|
|
938
938
|
|
|
939
939
|
let bootstrapSecret = entry.guardianBootstrapSecret;
|
package/src/commands/use.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const use = Object.assign(
|
|
|
38
38
|
`Active assistant: ${formatAssistantReference(result.entry)}`,
|
|
39
39
|
);
|
|
40
40
|
} else {
|
|
41
|
-
console.log(`Active assistant: ${active}
|
|
41
|
+
console.log(`Active assistant: ${active} — not found in lockfile`);
|
|
42
42
|
}
|
|
43
43
|
} else {
|
|
44
44
|
console.log("No active assistant set.");
|
package/src/commands/wake.ts
CHANGED
|
@@ -58,7 +58,7 @@ export const wake = Object.assign(
|
|
|
58
58
|
console.log("");
|
|
59
59
|
console.log("Arguments:");
|
|
60
60
|
console.log(
|
|
61
|
-
" <name> Name of the assistant to start
|
|
61
|
+
" <name> Name of the assistant to start, defaults to active or only local",
|
|
62
62
|
);
|
|
63
63
|
console.log("");
|
|
64
64
|
console.log("Options:");
|
|
@@ -122,7 +122,7 @@ export const wake = Object.assign(
|
|
|
122
122
|
// Foreground blocking is a single-assistant concern (--all refuses the
|
|
123
123
|
// flag), and the docker path already warned that foreground is ignored.
|
|
124
124
|
if (foreground && entry.cloud !== "docker") {
|
|
125
|
-
console.log("Running in foreground
|
|
125
|
+
console.log("Running in the foreground, Ctrl+C to stop...\n");
|
|
126
126
|
// Block forever — the daemon is running with inherited stdio so its
|
|
127
127
|
// output streams to this terminal. When the user hits Ctrl+C, SIGINT
|
|
128
128
|
// propagates to the daemon child and both exit.
|
|
@@ -213,7 +213,7 @@ async function wakeAssistant(
|
|
|
213
213
|
.filter(Boolean)
|
|
214
214
|
.join(" and ");
|
|
215
215
|
console.warn(
|
|
216
|
-
`Warning: ${ignored} ignored for Docker instances
|
|
216
|
+
`Warning: ${ignored} ignored for Docker instances.`,
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
const res = dockerResourceNames(entry.assistantId);
|
|
@@ -364,7 +364,7 @@ async function wakeAssistant(
|
|
|
364
364
|
daemonMigrationsFailed = daemonState.status === "migration_failed";
|
|
365
365
|
if (watch) {
|
|
366
366
|
console.log(
|
|
367
|
-
`Assistant running (pid ${daemonState.pid}) — watch mode not available
|
|
367
|
+
`Assistant running (pid ${daemonState.pid}) — watch mode not available, no source files. Keeping existing process.`,
|
|
368
368
|
);
|
|
369
369
|
} else if (daemonMigrationsFailed) {
|
|
370
370
|
console.log(
|
|
@@ -488,7 +488,7 @@ async function wakeAssistant(
|
|
|
488
488
|
} else {
|
|
489
489
|
if (watch) {
|
|
490
490
|
console.log(
|
|
491
|
-
`Gateway running (pid ${gatewayState.pid}) — watch mode not available
|
|
491
|
+
`Gateway running (pid ${gatewayState.pid}) — watch mode not available, no source files. Keeping existing process.`,
|
|
492
492
|
);
|
|
493
493
|
} else {
|
|
494
494
|
console.log(`Gateway already running (pid ${gatewayState.pid}).`);
|
|
@@ -569,7 +569,7 @@ async function wakeAssistant(
|
|
|
569
569
|
await new Promise((r) => setTimeout(r, 2000 * 2 ** (attempt - 1)));
|
|
570
570
|
} else {
|
|
571
571
|
console.warn(
|
|
572
|
-
` Guardian token re-provision failed${err instanceof GuardianLeasePermanentError ?
|
|
572
|
+
` Guardian token re-provision failed${err instanceof GuardianLeasePermanentError ?", not retrying — a malformed 200 will not fix itself" : ` after ${maxAttempts} attempts`}: ${err}`,
|
|
573
573
|
);
|
|
574
574
|
// The user explicitly confirmed this destructive repair — a
|
|
575
575
|
// success exit here would make callers (the web recovery flow)
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import cliPkg from "../package.json";
|
|
|
14
14
|
import { backup } from "./commands/backup";
|
|
15
15
|
import { clean } from "./commands/clean";
|
|
16
16
|
import { client } from "./commands/client";
|
|
17
|
+
import { completeCadet, completions } from "./commands/completions";
|
|
17
18
|
import { confirm } from "./commands/confirm";
|
|
18
19
|
import { connect } from "./commands/connect";
|
|
19
20
|
import { devices } from "./commands/devices";
|
|
@@ -53,6 +54,7 @@ const commands = {
|
|
|
53
54
|
backup,
|
|
54
55
|
clean,
|
|
55
56
|
client,
|
|
57
|
+
completions,
|
|
56
58
|
confirm,
|
|
57
59
|
connect,
|
|
58
60
|
debug,
|
|
@@ -231,6 +233,19 @@ async function main() {
|
|
|
231
233
|
process.exit(0);
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
// Hidden completion hook: the bash completion script calls
|
|
237
|
+
// `cadet __complete <words-up-to-cursor>` and filters the printed
|
|
238
|
+
// candidates against the word under the cursor. Deliberately unlisted in
|
|
239
|
+
// the command map so it never shows up in help.
|
|
240
|
+
if (commandName === "__complete") {
|
|
241
|
+
const candidates = completeCadet(
|
|
242
|
+
process.argv.slice(3),
|
|
243
|
+
Object.keys(commands),
|
|
244
|
+
);
|
|
245
|
+
console.log(candidates.join("\n"));
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
const command = commands[commandName as CommandName];
|
|
235
250
|
|
|
236
251
|
if (!command) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
collectWatchTargets,
|
|
9
9
|
dockerResourceNames,
|
|
10
10
|
ensureDockerDaemonRunning,
|
|
11
|
+
pullImage,
|
|
11
12
|
resolveAvatarDevicePath,
|
|
12
13
|
resolveDockerHatchMode,
|
|
13
14
|
resolveDockerProviderCredentialSetupAction,
|
|
@@ -158,6 +159,38 @@ describe("resolveDockerProviderCredentialSetupAction", () => {
|
|
|
158
159
|
}),
|
|
159
160
|
).toBe("skip");
|
|
160
161
|
});
|
|
162
|
+
|
|
163
|
+
test("detached hatch with a pre-collected key configures instead of deferring", () => {
|
|
164
|
+
expect(
|
|
165
|
+
resolveDockerProviderCredentialSetupAction({
|
|
166
|
+
provider: "fireworks",
|
|
167
|
+
guardianAccessToken: "guardian-token",
|
|
168
|
+
detached: true,
|
|
169
|
+
precollectedKey: "fw-key-123",
|
|
170
|
+
}),
|
|
171
|
+
).toBe("configure");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("pre-collected key without a guardian token reports missing-token (no silent defer)", () => {
|
|
175
|
+
expect(
|
|
176
|
+
resolveDockerProviderCredentialSetupAction({
|
|
177
|
+
provider: "fireworks",
|
|
178
|
+
detached: true,
|
|
179
|
+
precollectedKey: "fw-key-123",
|
|
180
|
+
}),
|
|
181
|
+
).toBe("missing-token");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an explicitly skipped key still counts as answered — configure runs and reports the gap", () => {
|
|
185
|
+
expect(
|
|
186
|
+
resolveDockerProviderCredentialSetupAction({
|
|
187
|
+
provider: "fireworks",
|
|
188
|
+
guardianAccessToken: "guardian-token",
|
|
189
|
+
detached: true,
|
|
190
|
+
precollectedKey: "",
|
|
191
|
+
}),
|
|
192
|
+
).toBe("configure");
|
|
193
|
+
});
|
|
161
194
|
});
|
|
162
195
|
|
|
163
196
|
describe("buildServiceRunArgs — gateway", () => {
|
|
@@ -384,25 +417,48 @@ describe("collectWatchTargets", () => {
|
|
|
384
417
|
});
|
|
385
418
|
|
|
386
419
|
/**
|
|
387
|
-
* Build a fake `exec`
|
|
388
|
-
*
|
|
389
|
-
* (
|
|
390
|
-
*
|
|
420
|
+
* Build a STATEFUL fake `exec` modeling the Docker daemon + Colima interplay:
|
|
421
|
+
* `docker info` succeeds only while `daemonUp`; a `colima start` heals the
|
|
422
|
+
* daemon (unless the fake is told to model the zombie state, where Colima's
|
|
423
|
+
* state file says "already running, ignoring" but the daemon stays dead —
|
|
424
|
+
* only the stop → start cycle recovers). Injected into
|
|
425
|
+
* `ensureDockerDaemonRunning` so these cases need no `mock.module` (which is
|
|
426
|
+
* process-global and would leak across the CLI's single-process test run) and
|
|
427
|
+
* no real Docker/Colima subprocesses.
|
|
391
428
|
*/
|
|
392
|
-
function fakeExec(
|
|
429
|
+
function fakeExec(
|
|
430
|
+
opts: {
|
|
431
|
+
/** Daemon already reachable when the test starts. */
|
|
432
|
+
daemonUpAtStart?: boolean;
|
|
433
|
+
/** `colima start` exits nonzero instead of healing. */
|
|
434
|
+
startFails?: boolean;
|
|
435
|
+
/** First `colima start` is a zombie no-op that does NOT heal the daemon. */
|
|
436
|
+
zombieFirstStart?: boolean;
|
|
437
|
+
} = {},
|
|
438
|
+
) {
|
|
393
439
|
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
440
|
+
let daemonUp = opts.daemonUpAtStart ?? false;
|
|
441
|
+
let starts = 0;
|
|
394
442
|
const exec = async (cmd: string, args: string[] = []): Promise<void> => {
|
|
395
443
|
calls.push({ cmd, args });
|
|
396
|
-
if (cmd === "docker" && args[0] === "info"
|
|
397
|
-
throw new Error("Cannot connect to the Docker daemon");
|
|
444
|
+
if (cmd === "docker" && args[0] === "info") {
|
|
445
|
+
if (!daemonUp) throw new Error("Cannot connect to the Docker daemon");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (cmd === "colima" && args[0] === "start") {
|
|
449
|
+
starts++;
|
|
450
|
+
if (opts.startFails) throw new Error("colima start exited 1");
|
|
451
|
+
if (!opts.zombieFirstStart || starts > 1) daemonUp = true;
|
|
452
|
+
return;
|
|
398
453
|
}
|
|
399
|
-
if (cmd === "colima" && args[0] === "
|
|
400
|
-
|
|
454
|
+
if (cmd === "colima" && args[0] === "stop") {
|
|
455
|
+
daemonUp = false;
|
|
456
|
+
return;
|
|
401
457
|
}
|
|
402
458
|
};
|
|
403
459
|
const ran = (cmd: string, sub: string): boolean =>
|
|
404
460
|
calls.some((c) => c.cmd === cmd && c.args[0] === sub);
|
|
405
|
-
return { exec
|
|
461
|
+
return { exec, ran };
|
|
406
462
|
}
|
|
407
463
|
|
|
408
464
|
describe("ensureDockerDaemonRunning", () => {
|
|
@@ -410,30 +466,122 @@ describe("ensureDockerDaemonRunning", () => {
|
|
|
410
466
|
// Colima, because that switches the active `docker context` and points
|
|
411
467
|
// `docker start` at a VM that never held the instance's containers.
|
|
412
468
|
test("reuses an already-reachable daemon (e.g. Docker Desktop) and never touches Colima", async () => {
|
|
413
|
-
const { exec, ran } = fakeExec({
|
|
469
|
+
const { exec, ran } = fakeExec({ daemonUpAtStart: true });
|
|
414
470
|
|
|
415
471
|
await ensureDockerDaemonRunning(exec);
|
|
416
472
|
|
|
417
473
|
expect(ran("docker", "info")).toBe(true);
|
|
418
|
-
expect(ran("colima", "status")).toBe(false);
|
|
419
474
|
expect(ran("colima", "start")).toBe(false);
|
|
475
|
+
expect(ran("colima", "stop")).toBe(false);
|
|
420
476
|
});
|
|
421
477
|
|
|
422
|
-
test("starts Colima
|
|
423
|
-
const { exec, ran } = fakeExec(
|
|
478
|
+
test("starts Colima when no daemon is reachable and confirms the daemon answered", async () => {
|
|
479
|
+
const { exec, ran } = fakeExec();
|
|
424
480
|
|
|
425
|
-
await ensureDockerDaemonRunning(exec);
|
|
481
|
+
await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
|
|
426
482
|
|
|
427
483
|
expect(ran("docker", "info")).toBe(true);
|
|
428
484
|
expect(ran("colima", "start")).toBe(true);
|
|
485
|
+
expect(ran("colima", "stop")).toBe(false);
|
|
429
486
|
});
|
|
430
487
|
|
|
431
|
-
|
|
432
|
-
|
|
488
|
+
// Regression (v0.12.8 release feedback): a zombie Colima — stale state file
|
|
489
|
+
// saying "already running" while the daemon inside is dead — used to sail
|
|
490
|
+
// through wake (no `colima status` oracle anymore) and leave every later
|
|
491
|
+
// docker command dying on the unreachable socket.
|
|
492
|
+
test("recovers a zombie Colima: start no-ops, daemon stays dead → stop --force → start → daemon up", async () => {
|
|
493
|
+
const { exec, ran } = fakeExec({ zombieFirstStart: true });
|
|
433
494
|
|
|
434
|
-
await ensureDockerDaemonRunning(exec);
|
|
495
|
+
await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
|
|
435
496
|
|
|
436
|
-
expect(ran("colima", "
|
|
437
|
-
expect(ran("colima", "
|
|
497
|
+
expect(ran("colima", "start")).toBe(true);
|
|
498
|
+
expect(ran("colima", "stop")).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("never deletes the Colima VM — the VM disk holds every image and volume", async () => {
|
|
502
|
+
const { exec, ran } = fakeExec({ zombieFirstStart: true });
|
|
503
|
+
|
|
504
|
+
await ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 });
|
|
505
|
+
|
|
506
|
+
expect(ran("colima", "delete")).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("throws an actionable error when even the restart cannot bring the daemon up", () => {
|
|
510
|
+
// A start that always exits nonzero: the first failure surfaces its
|
|
511
|
+
// output, the recovery stop+start runs, and the final wait still finds
|
|
512
|
+
// no daemon → the helper throws instead of letting hatch proceed.
|
|
513
|
+
const { exec } = fakeExec({ startFails: true });
|
|
514
|
+
expect(
|
|
515
|
+
ensureDockerDaemonRunning(exec, { settleTimeoutMs: 0 }),
|
|
516
|
+
).rejects.toThrow(/Failed to restart Colima/);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("pullImage", () => {
|
|
521
|
+
function fakePull(
|
|
522
|
+
opts: {
|
|
523
|
+
failFirstPullWith?: string;
|
|
524
|
+
failAllPullsWith?: string;
|
|
525
|
+
} = {},
|
|
526
|
+
) {
|
|
527
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
528
|
+
let pulls = 0;
|
|
529
|
+
const exec = async (cmd: string, args: string[] = []): Promise<void> => {
|
|
530
|
+
calls.push({ cmd, args });
|
|
531
|
+
if (cmd === "docker" && args[0] === "pull") {
|
|
532
|
+
pulls++;
|
|
533
|
+
if (opts.failAllPullsWith) throw new Error(opts.failAllPullsWith);
|
|
534
|
+
if (opts.failFirstPullWith && pulls === 1) {
|
|
535
|
+
throw new Error(opts.failFirstPullWith);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// `docker logout` (and anything else) succeeds silently.
|
|
539
|
+
};
|
|
540
|
+
const ran = (sub: string): boolean =>
|
|
541
|
+
calls.some((c) => c.cmd === "docker" && c.args[0] === sub);
|
|
542
|
+
return { exec, ran };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
test("pulls without touching stored credentials on success", async () => {
|
|
546
|
+
const { exec, ran } = fakePull();
|
|
547
|
+
|
|
548
|
+
await pullImage("mycadet/mycadet-assistant:v1", () => {}, exec);
|
|
549
|
+
|
|
550
|
+
expect(ran("pull")).toBe(true);
|
|
551
|
+
expect(ran("logout")).toBe(false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("clears stale Docker Hub credentials and retries once on an expired-token error", async () => {
|
|
555
|
+
const { exec, ran } = fakePull({
|
|
556
|
+
failFirstPullWith:
|
|
557
|
+
"Error response from daemon: authentication required - personal access token is expired",
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await pullImage("mycadet/mycadet-assistant:v1", () => {}, exec);
|
|
561
|
+
|
|
562
|
+
expect(ran("logout")).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("wraps the failure with actionable copy when the auth retry also fails", () => {
|
|
566
|
+
const { exec } = fakePull({
|
|
567
|
+
failAllPullsWith:
|
|
568
|
+
"Error response from daemon: authentication required - personal access token is expired",
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(
|
|
572
|
+
pullImage("mycadet/mycadet-assistant:v1", () => {}, exec),
|
|
573
|
+
).rejects.toThrow("Failed to pull mycadet/mycadet-assistant:v1");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("rethrows non-auth pull errors raw and never logs out", () => {
|
|
577
|
+
const { exec, ran } = fakePull({
|
|
578
|
+
failAllPullsWith:
|
|
579
|
+
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(
|
|
583
|
+
pullImage("mycadet/mycadet-assistant:v1", () => {}, exec),
|
|
584
|
+
).rejects.toThrow("Cannot connect to the Docker daemon");
|
|
585
|
+
expect(ran("logout")).toBe(false);
|
|
438
586
|
});
|
|
439
587
|
});
|
|
@@ -203,7 +203,7 @@ describe("pollJobUntilDone", () => {
|
|
|
203
203
|
expect(refreshes).toBe(1);
|
|
204
204
|
expect(calls).toBe(2);
|
|
205
205
|
// The 401 branch logs its own distinct warning (not the generic
|
|
206
|
-
// "polling failed
|
|
206
|
+
// "polling failed:" one) so operators can distinguish an
|
|
207
207
|
// auth refresh from a transient-error retry in the output.
|
|
208
208
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
209
209
|
expect.stringContaining("refreshing auth"),
|