@mycadet/cli 0.12.8 → 0.12.9-dev.202610131620.d833e01

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.
Files changed (67) hide show
  1. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
  4. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/package.json +4 -3
  6. package/src/__tests__/client-web-config.test.ts +0 -1
  7. package/src/__tests__/cloud-releases.test.ts +23 -13
  8. package/src/__tests__/ensure-binary.test.ts +182 -0
  9. package/src/__tests__/nginx-ingress.test.ts +6 -11
  10. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  11. package/src/__tests__/pair.test.ts +149 -78
  12. package/src/__tests__/ps-cloud-status.test.ts +4 -3
  13. package/src/__tests__/ps-table.test.ts +79 -0
  14. package/src/__tests__/tailscale.test.ts +1 -1
  15. package/src/__tests__/teleport.test.ts +1 -16
  16. package/src/__tests__/tunnel.test.ts +274 -82
  17. package/src/__tests__/uninstall.test.ts +141 -9
  18. package/src/__tests__/wake.test.ts +2 -2
  19. package/src/commands/backup.ts +3 -3
  20. package/src/commands/client.ts +4 -6
  21. package/src/commands/completions.test.ts +104 -0
  22. package/src/commands/completions.ts +87 -0
  23. package/src/commands/exec.ts +7 -7
  24. package/src/commands/flags.ts +2 -2
  25. package/src/commands/hatch.ts +5 -2
  26. package/src/commands/logs.ts +7 -7
  27. package/src/commands/pair.ts +140 -37
  28. package/src/commands/ps.ts +63 -26
  29. package/src/commands/restore.ts +2 -2
  30. package/src/commands/retire.ts +2 -2
  31. package/src/commands/rollback.ts +2 -2
  32. package/src/commands/sleep.ts +2 -2
  33. package/src/commands/teleport.ts +18 -37
  34. package/src/commands/terminal.ts +1 -1
  35. package/src/commands/tunnel.ts +101 -78
  36. package/src/commands/uninstall.ts +108 -73
  37. package/src/commands/upgrade.ts +8 -8
  38. package/src/commands/use.ts +1 -1
  39. package/src/commands/wake.ts +6 -6
  40. package/src/index.ts +15 -0
  41. package/src/lib/__tests__/docker.test.ts +168 -20
  42. package/src/lib/__tests__/job-polling.test.ts +1 -1
  43. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  44. package/src/lib/cloud-client.ts +0 -3
  45. package/src/lib/cloud-login.ts +0 -1
  46. package/src/lib/cloud-releases.ts +6 -4
  47. package/src/lib/color.ts +4 -0
  48. package/src/lib/docker.ts +190 -102
  49. package/src/lib/ensure-binary.ts +171 -0
  50. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  51. package/src/lib/environments/paths.ts +16 -8
  52. package/src/lib/environments/resolve.ts +2 -4
  53. package/src/lib/flag-args.test.ts +1 -1
  54. package/src/lib/flag-args.ts +1 -1
  55. package/src/lib/guardian-token.ts +1 -1
  56. package/src/lib/hatch-local.ts +15 -8
  57. package/src/lib/hatch-next-steps.ts +5 -9
  58. package/src/lib/job-polling.ts +1 -1
  59. package/src/lib/local.ts +37 -4
  60. package/src/lib/nginx-ingress.ts +3 -5
  61. package/src/lib/pair.ts +1 -1
  62. package/src/lib/step-runner.ts +6 -1
  63. package/src/lib/sync-cloud-assistants.ts +2 -2
  64. package/src/lib/tunnels/cloudflare.ts +21 -11
  65. package/src/lib/tunnels/ngrok.ts +198 -108
  66. package/src/lib/tunnels/tailscale.ts +34 -4
  67. package/src/lib/upgrade-lifecycle.ts +5 -4
@@ -22,9 +22,10 @@ import {
22
22
  } from "../lib/ingress-config.js";
23
23
  import {
24
24
  ensureTunnelEdge,
25
- formatEdgeMode,
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,
@@ -46,7 +47,7 @@ interface TunnelArgs {
46
47
  provider: TunnelProviderName;
47
48
  domain: string | null;
48
49
  clearDomain: boolean;
49
- detach: boolean;
50
+ foreground: boolean;
50
51
  }
51
52
 
52
53
  const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
@@ -56,7 +57,7 @@ function parseArgs(): TunnelArgs {
56
57
  let provider: TunnelProviderName | null = null;
57
58
  let domain: string | null = null;
58
59
  let clearDomain = false;
59
- let detach = false;
60
+ let foreground = false;
60
61
 
61
62
  for (let i = 0; i < args.length; i++) {
62
63
  const arg = args[i];
@@ -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
- "own tailnet; webhook integrations need a public tunnel (see Providers",
78
+ "the default. A tailscale tunnel is reachable only from your",
79
79
  );
80
- console.log("below).");
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 (macOS: brew install nginx, Linux: sudo apt install nginx).",
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 (defaults to active or only local)",
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
  );
@@ -110,19 +112,22 @@ function parseArgs(): TunnelArgs {
110
112
  " tunnel without one. Cannot be combined with --domain.",
111
113
  );
112
114
  console.log(
113
- ` -d, --detach Run the tunnel in the background. Waits up to ${TUNNEL_BACKGROUND_START_TIMEOUT_MS / 1000}s for the`,
115
+ ` --foreground Stream the tunnel's logs to this terminal. By default the tunnel`,
114
116
  );
115
117
  console.log(
116
- " initial established/failed status before returning; logs go to",
118
+ " runs in the background: the command returns after printing the public",
117
119
  );
118
120
  console.log(
119
- " the CLI log directory. Stop it with `kill <pid>`, except when it",
121
+ ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
120
122
  );
121
123
  console.log(
122
- " adopted an already-running tunnel (started outside this",
124
+ " tunnel stops with `kill <pid>`, except when it adopted an already-running",
123
125
  );
124
126
  console.log(
125
- " command): killing the supervisor then leaves that tunnel running.",
127
+ " tunnel (started outside this command): killing the supervisor then",
128
+ );
129
+ console.log(
130
+ " leaves that tunnel running.",
126
131
  );
127
132
  console.log("");
128
133
  console.log("Providers:");
@@ -137,7 +142,8 @@ function parseArgs(): TunnelArgs {
137
142
  console.log("");
138
143
  console.log("Examples:");
139
144
  console.log(` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER}`);
140
- const domainExample = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
145
+ const domainExample =
146
+ PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].domainExample;
141
147
  if (domainExample) {
142
148
  console.log(
143
149
  ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --domain ${domainExample}`,
@@ -148,9 +154,6 @@ function parseArgs(): TunnelArgs {
148
154
  }
149
155
  console.log(" $ cadet tunnel --provider cloudflare");
150
156
  console.log(" $ cadet tunnel my-assistant --provider tailscale");
151
- console.log(
152
- ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --detach`,
153
- );
154
157
  process.exit(0);
155
158
  } else if (arg === "--provider") {
156
159
  const next = args[i + 1];
@@ -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 = PROVIDER_META[DEFAULT_TUNNEL_PROVIDER].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
  );
@@ -184,8 +188,8 @@ function parseArgs(): TunnelArgs {
184
188
  i++;
185
189
  } else if (arg === "--clear-domain") {
186
190
  clearDomain = true;
187
- } else if (arg === "-d" || arg === "--detach") {
188
- detach = true;
191
+ } else if (arg === "--foreground") {
192
+ foreground = true;
189
193
  } else if (arg.startsWith("-")) {
190
194
  console.error(`Error: Unknown option '${arg}'.`);
191
195
  process.exit(1);
@@ -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)) {
@@ -225,7 +227,7 @@ function parseArgs(): TunnelArgs {
225
227
  process.exit(1);
226
228
  }
227
229
 
228
- return { assistantName, provider, domain, clearDomain, detach };
230
+ return { assistantName, provider, domain, clearDomain, foreground };
229
231
  }
230
232
 
231
233
  /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
@@ -343,31 +345,31 @@ const TUNNEL_BACKGROUND_START_TIMEOUT_MS = 35_000;
343
345
  const TUNNEL_ESTABLISHED_RE = /Tunnel established: (\S+)/;
344
346
 
345
347
  /**
346
- * Re-invoke `cadet tunnel` (without `-d`/`--detach`) as a detached background
347
- * process via `relaunchDetached`, waiting for the child to either print its
348
- * "Tunnel established" or adopted-tunnel line (readiness) or
349
- * exit early (failure), so a misconfigured provider still fails loudly in
350
- * the terminal that ran `-d`.
348
+ * Re-invoke `cadet tunnel --foreground` as a detached background process via
349
+ * `relaunchDetached`, waiting for the child to either print its
350
+ * "Tunnel established" or adopted-tunnel line (readiness) or exit early
351
+ * (failure), so a misconfigured provider still fails loudly in the terminal
352
+ * that ran `cadet tunnel`.
351
353
  *
352
- * The log filename is scoped to this (parent) process's pid so concurrent
353
- * `-d` runs (e.g. tunneling two different local assistants at once) don't
354
- * truncate and read each other's readiness output.
354
+ * The log file is the fixed `tunnel.log` so there is one stable path to tail;
355
+ * concurrent tunnel runs share (and truncate) it.
355
356
  */
356
357
  async function spawnDetachedTunnel(): Promise<void> {
357
358
  const childArgs: string[] = ["tunnel"];
358
359
  for (const arg of process.argv.slice(3)) {
359
- if (arg === "-d" || arg === "--detach") {
360
+ if (arg === "--foreground") {
360
361
  continue;
361
362
  }
362
363
  childArgs.push(arg);
363
364
  }
365
+ childArgs.push("--foreground");
364
366
 
365
367
  let publicUrl: string | undefined;
366
368
  let adopted = false;
367
369
 
368
370
  const { child, logPath, ready, exitCode } = await relaunchDetached({
369
371
  args: childArgs,
370
- logFile: `tunnel-${process.pid}.log`,
372
+ logFile: "tunnel.log",
371
373
  timeoutMs: TUNNEL_BACKGROUND_START_TIMEOUT_MS,
372
374
  isReady: (logPath) => {
373
375
  let logText = "";
@@ -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
- const { assistantName, provider, domain, clearDomain, detach } = parseArgs();
420
+ const { assistantName, provider, domain, clearDomain, foreground } =
421
+ parseArgs();
419
422
 
420
- if (detach) {
421
- await spawnDetachedTunnel();
422
- return;
423
- }
423
+ if (!foreground) {
424
+ await spawnDetachedTunnel();
425
+ return;
426
+ }
424
427
 
425
- if (PROVIDER_META[provider].comingSoon) {
426
- throw cadetTunnelComingSoonError(provider);
427
- }
428
+ if (PROVIDER_META[provider].comingSoon) {
429
+ throw cadetTunnelComingSoonError(provider);
430
+ }
428
431
 
429
- const { entry, gatewayPort, workspaceDir } =
430
- resolveLocalTunnelTarget(assistantName);
432
+ const { entry, gatewayPort, workspaceDir } =
433
+ resolveLocalTunnelTarget(assistantName);
431
434
 
432
- const preserveIngressUrl =
433
- provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
435
+ const preserveIngressUrl =
436
+ provider === "tailscale" && guardTailnetOnlyWebhookIngress(workspaceDir);
434
437
 
435
- if (clearDomain) {
436
- clearReservedDomain();
437
- console.log("Cleared the saved reserved domain.");
438
- }
438
+ if (clearDomain) {
439
+ clearReservedDomain();
440
+ console.log("Cleared the saved reserved domain.");
441
+ }
439
442
 
440
- let edge: TunnelEdge;
441
- try {
442
- edge = await ensureTunnelEdge({
443
- assistantId: entry.assistantId,
444
- workspaceDir,
445
- gatewayPort,
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
- } catch (err) {
448
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
449
- process.exit(1);
450
- }
458
+ if (!nginxVersion) {
459
+ console.error("Error: nginx is required to run the tunnel edge.");
460
+ process.exit(1);
461
+ }
451
462
 
452
- console.log(
453
- `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port} ` +
454
- `(serves ${formatEdgeMode(edge.includesWebApp)}).`,
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
- const baseTunnelOpts = {
458
- port: edge.port,
459
- assistantId: entry.assistantId,
460
- workspaceDir,
461
- };
481
+ const baseTunnelOpts = {
482
+ port: edge.port,
483
+ assistantId: entry.assistantId,
484
+ workspaceDir,
485
+ };
462
486
 
463
- await runProviderTunnel(provider, {
464
- ...baseTunnelOpts,
465
- ...(domain ? { domain } : {}),
466
- ...(preserveIngressUrl ? { preserveIngressUrl } : {}),
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
-
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
2
- import { basename, dirname, join } from "path";
2
+ import { basename, dirname, join, sep } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
5
  import {
@@ -14,8 +14,8 @@ import {
14
14
  import { retireDocker } from "../lib/docker.js";
15
15
  import {
16
16
  getConfigDirs,
17
- getInputHistoryPath,
18
17
  getLogDir,
18
+ getSharedStateDir,
19
19
  getStateDir,
20
20
  } from "../lib/environments/paths.js";
21
21
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
@@ -98,62 +98,84 @@ function findInstalledPackageRoot(startDir: string): string | null {
98
98
  return null;
99
99
  }
100
100
 
101
+ /** The CLI's own install location, when it can be deleted. */
102
+ interface SelfDeleteTarget {
103
+ path: string;
104
+ description: string;
105
+ }
106
+
101
107
  /**
102
- * Remove the running CLI. Handles the two real install shapes:
103
- * - the compiled `cadet` binary (process.execPath IS the install)
104
- * - a global package install (bun install -g mycadet / npm install -g)
105
- * Under a source checkout or a bare runtime (bun/node), nothing is deleted —
106
- * the runtime binary must never be a deletion target.
108
+ * Where this CLI is running from, when that is an INSTALL we can remove:
109
+ * the compiled `cadet` binary (process.execPath IS the install) or a global
110
+ * package install (bun install -g mycadet / npm install -g mycadet). Under
111
+ * a source checkout or a bare runtime (bun/node), null — the runtime binary
112
+ * must never be a deletion target. The uninstall prompt lists the same
113
+ * target it later deletes, so both share this resolver.
107
114
  */
108
- function selfDelete(): void {
115
+ function resolveSelfDeleteTarget(): SelfDeleteTarget | null {
109
116
  const execPath = process.execPath;
110
117
  const execBase = basename(execPath);
111
118
  if (execBase === "cadet" || execBase === "cadet.exe") {
119
+ return { path: execPath, description: "the Cadet executable" };
120
+ }
121
+ const runningFile = fileURLToPath(import.meta.url);
122
+ const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
123
+ if (pkgRoot) {
124
+ return { path: pkgRoot, description: "the installed Cadet CLI package" };
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Delete the install {@link resolveSelfDeleteTarget} found. The two shapes
131
+ * delete differently — the running binary needs the win32 rename dance, a
132
+ * package dir is a plain recursive delete — and the bin symlinks the
133
+ * package manager planted (e.g. ~/.bun/bin/cadet, the npm global bin) are
134
+ * left dangling — say so.
135
+ */
136
+ function selfDelete(): void {
137
+ const target = resolveSelfDeleteTarget();
138
+ if (!target) {
139
+ console.log(
140
+ "Cadet is running from a source checkout — skipping self-removal.",
141
+ );
142
+ console.log(
143
+ "Remove the installed copy with `bun remove -g mycadet` or `npm uninstall -g mycadet`.",
144
+ );
145
+ return;
146
+ }
147
+ const execBase = basename(target.path);
148
+ const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
149
+ if (isCompiledBinary) {
112
150
  try {
113
151
  if (process.platform === "win32") {
114
152
  // Windows refuses to unlink a running executable — stage it under a
115
153
  // junk name and delete that.
116
- const stale = `${execPath}.uninstalled`;
117
- renameSync(execPath, stale);
154
+ const stale = `${target.path}.uninstalled`;
155
+ renameSync(target.path, stale);
118
156
  rmSync(stale, { force: true, recursive: true });
119
157
  } else {
120
- unlinkSync(execPath);
158
+ unlinkSync(target.path);
121
159
  }
122
- console.log(`Removed ${execPath}.`);
123
- return;
160
+ console.log(`Removed ${target.path}.`);
124
161
  } catch (err) {
125
162
  console.warn(
126
- `⚠️ Could not remove ${execPath}: ${
163
+ `⚠️ Could not remove ${target.path}: ${
127
164
  err instanceof Error ? err.message : String(err)
128
165
  } — remove it by hand.`,
129
166
  );
130
- return;
131
167
  }
132
- }
133
-
134
- // Package-manager install: remove the package directory we are running
135
- // from, best-effort. Bin symlinks the package manager planted (e.g.
136
- // ~/.bun/bin/cadet, the npm global bin) are left dangling — say so.
137
- const runningFile = fileURLToPath(import.meta.url);
138
- const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
139
- if (!pkgRoot) {
140
- console.log(
141
- "Cadet is running from a source checkout — skipping self-removal.",
142
- );
143
- console.log(
144
- "Remove the installed copy with `bun remove -g mycadet` (or `npm uninstall -g mycadet`).",
145
- );
146
168
  return;
147
169
  }
148
170
  try {
149
- rmSync(pkgRoot, { recursive: true, force: true });
150
- console.log(`Removed ${pkgRoot}.`);
171
+ rmSync(target.path, { recursive: true, force: true });
172
+ console.log(`Removed ${target.path}.`);
151
173
  console.log(
152
174
  "The `cadet` command on your PATH is now stale — remove it if your package manager didn't (`bun remove -g mycadet` or `npm uninstall -g mycadet`).",
153
175
  );
154
176
  } catch (err) {
155
177
  console.warn(
156
- `⚠️ Could not remove ${pkgRoot}: ${
178
+ `⚠️ Could not remove ${target.path}: ${
157
179
  err instanceof Error ? err.message : String(err)
158
180
  } — remove it with \`bun remove -g mycadet\` (or \`npm uninstall -g mycadet\`).`,
159
181
  );
@@ -173,10 +195,24 @@ function collectDeleteTargets(env = getCurrentEnvironment()): DeleteTarget[] {
173
195
  { path: getLogDir(env), description: "logs" },
174
196
  // Not environment-scoped: deleting it affects every cadet CLI on the
175
197
  // machine — called out in the prompt, not hidden.
176
- { path: getInputHistoryPath(), description: "shared terminal input history" },
198
+ {
199
+ path: getSharedStateDir(),
200
+ description: "shared state, including the terminal input history",
201
+ },
177
202
  ];
178
203
  // Only targets that exist — the prompt lists what will actually go.
179
- return targets.filter((t) => existsSync(t.path));
204
+ const existing = targets.filter((t) => existsSync(t.path));
205
+ // The log dir lives INSIDE a listed target (the config dir on
206
+ // macOS/Linux, the state dir on Windows) and goes with its parent's
207
+ // recursive delete — never list a path twice.
208
+ const deduped: DeleteTarget[] = [];
209
+ for (const t of existing) {
210
+ const covered = deduped.some(
211
+ (k) => t.path === k.path || t.path.startsWith(k.path + sep),
212
+ );
213
+ if (!covered) deduped.push(t);
214
+ }
215
+ return deduped;
180
216
  }
181
217
 
182
218
  function rmrf(target: string): void {
@@ -190,63 +226,48 @@ export const uninstall = Object.assign(
190
226
  console.log("Usage: cadet uninstall [--yes]");
191
227
  console.log("");
192
228
  console.log(
193
- "Remove Cadet entirely: delete every location it uses for this environment (assistant state, retired archives, config, logs) and then delete the executable itself.",
229
+ "Remove Cadet entirely: retire any remaining assistants, delete every location it uses for this environment (assistant state, retired archives, config, logs), and remove the Cadet installation itself.",
194
230
  );
195
231
  process.exit(0);
196
232
  }
197
233
  const yes = args.includes("--yes");
198
234
 
199
- // Assistants still on the rack are retired — with their data deleted —
200
- // as part of the uninstall. Uninstall deletes every location cadet uses
201
- // INCLUDING the retired archives, so retiring first (which archives)
202
- // only matters for the teardown it performs: docker assistants keep
203
- // containers, volumes, and a network in docker that the filesystem
204
- // sweep below never reaches. A warning, not a refusal (v0.0.10 run
205
- // feedback: the hard error just blocked the uninstall).
235
+ const env = getCurrentEnvironment();
236
+ const envLabel = env.name ?? "production";
206
237
  const existing = loadAllAssistants();
238
+ const targets = collectDeleteTargets(env);
239
+ const selfTarget = resolveSelfDeleteTarget();
240
+
241
+ console.log(
242
+ `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
243
+ );
244
+
245
+ // Assistants still on the rack are retired — with their data deleted —
246
+ // as part of the uninstall, and retiring happens only AFTER the
247
+ // confirmation below: it is part of what the prompt describes, never a
248
+ // side effect of running the command. Uninstall deletes every location
249
+ // cadet uses INCLUDING the retired archives, so retiring first (which
250
+ // archives) only matters for the teardown it performs: docker
251
+ // assistants keep containers, volumes, and a network in docker that
252
+ // the filesystem sweep below never reaches. A warning, not a refusal
253
+ // (v0.0.10 run feedback: the hard error just blocked the uninstall).
207
254
  if (existing.length > 0) {
208
255
  console.warn(
209
- `\n⚠️ ${existing.length} assistant(s) still exist: ${existing.map((a) => a.assistantId).join(", ")}`,
256
+ `\n⚠️ ${existing.length} ${existing.length === 1 ? "assistant still exists" : "assistants still exist"}: ${existing.map((a) => a.assistantId).join(", ")}`,
210
257
  );
211
258
  console.warn(
212
- " They will be retired now — their data (and any retired archives) will be permanently deleted by this uninstall.",
259
+ " They will be retired as part of this uninstall — their data (and any retired archives) will be permanently deleted.",
213
260
  );
214
- for (const entry of existing) {
215
- if (entry.cloud === "apple-container" || entry.cloud === "paired") {
216
- console.log(` Skipping '${entry.assistantId}' (${entry.cloud}) — its lifecycle is managed elsewhere.`);
217
- continue;
218
- }
219
- try {
220
- await retireAssistantData(entry);
221
- } catch (err) {
222
- console.error(
223
- ` Warning: could not fully retire '${entry.assistantId}': ${err instanceof Error ? err.message : String(err)}`,
224
- );
225
- }
226
- }
227
261
  }
228
262
 
229
- const env = getCurrentEnvironment();
230
- const envLabel = env.name ?? "production";
231
- const targets = collectDeleteTargets(env);
232
- const execPath = process.execPath;
233
-
234
- console.log(
235
- `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
236
- );
237
- if (targets.length === 0) {
263
+ if (targets.length === 0 && selfTarget === null) {
238
264
  console.log("No Cadet data directories found to delete.");
239
265
  } else {
240
266
  console.log("It will permanently delete:");
241
- for (const t of targets) {
242
- console.log(` - ${t.path} (${t.description})`);
267
+ for (const t of selfTarget ? [...targets, selfTarget] : targets) {
268
+ console.log(` - ${t.path} — ${t.description}`);
243
269
  }
244
270
  }
245
- const execBase = basename(execPath);
246
- const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
247
- if (isCompiledBinary) {
248
- console.log(`It will then remove the Cadet executable at ${execPath}.`);
249
- }
250
271
 
251
272
  if (!yes) {
252
273
  if (!canPromptForConfirmation()) {
@@ -265,6 +286,20 @@ export const uninstall = Object.assign(
265
286
  }
266
287
  }
267
288
 
289
+ for (const entry of existing) {
290
+ if (entry.cloud === "apple-container" || entry.cloud === "paired") {
291
+ console.log(` Skipping '${entry.assistantId}' on ${entry.cloud} — its lifecycle is managed elsewhere.`);
292
+ continue;
293
+ }
294
+ try {
295
+ await retireAssistantData(entry);
296
+ } catch (err) {
297
+ console.error(
298
+ ` Warning: could not fully retire '${entry.assistantId}': ${err instanceof Error ? err.message : String(err)}`,
299
+ );
300
+ }
301
+ }
302
+
268
303
  for (const t of targets) {
269
304
  rmrf(t.path);
270
305
  console.log(`Deleted ${t.path}.`);
@@ -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 (default: active or only assistant)",
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 (default: CLI version)",
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} (--force)...\n`);
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 exec("docker", ["pull", image]);
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 (continuing with upgrade)\n");
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} (--force)...\n`);
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 (continuing with upgrade)\n");
936
+ console.warn("⚠️ Pre-upgrade backup failed, continuing with upgrade\n");
937
937
  }
938
938
 
939
939
  let bootstrapSecret = entry.guardianBootstrapSecret;
@@ -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} (not found in lockfile)`);
41
+ console.log(`Active assistant: ${active} — not found in lockfile`);
42
42
  }
43
43
  } else {
44
44
  console.log("No active assistant set.");