@mycadet/cli 0.12.6-dev.202610091338.ee7f3e3 → 0.12.7

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.
@@ -0,0 +1,171 @@
1
+ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { dirname, join } from "path";
5
+
6
+ import { uninstall } from "../commands/uninstall.js";
7
+ import {
8
+ getConfigDirs,
9
+ getInputHistoryPath,
10
+ getLogDir,
11
+ getStateDir,
12
+ } from "../lib/environments/paths.js";
13
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
14
+ import type { AssistantEntry } from "../lib/assistant-config.js";
15
+
16
+ // The command reads its argv at call time.
17
+ const ORIGINAL_ARGV = process.argv;
18
+
19
+ // Every XDG location the command touches MUST be redirected: the homedir
20
+ // fallback would otherwise point at the real user's data dirs.
21
+ function redirectXdg(): string {
22
+ const root = mkdtempSync(join(tmpdir(), "cadet-uninstall-"));
23
+ process.env.XDG_DATA_HOME = join(root, "data");
24
+ process.env.XDG_CONFIG_HOME = join(root, "config");
25
+ process.env.XDG_STATE_HOME = join(root, "state");
26
+ process.env.CADET_ENVIRONMENT = "production";
27
+ process.env.CADET_LOCKFILE_DIR = join(root, "lockdir");
28
+ mkdirSync(process.env.CADET_LOCKFILE_DIR, { recursive: true });
29
+ return root;
30
+ }
31
+
32
+ const RESTORED_ENV_KEYS = [
33
+ "XDG_DATA_HOME",
34
+ "XDG_CONFIG_HOME",
35
+ "XDG_STATE_HOME",
36
+ "CADET_ENVIRONMENT",
37
+ "CADET_LOCKFILE_DIR",
38
+ ] as const;
39
+
40
+ function withRestoredEnv(run: () => Promise<void>): Promise<void> {
41
+ const before = Object.fromEntries(
42
+ RESTORED_ENV_KEYS.map((k) => [k, process.env[k]]),
43
+ );
44
+ return run().finally(() => {
45
+ for (const k of RESTORED_ENV_KEYS) {
46
+ if (before[k] === undefined) delete process.env[k];
47
+ else process.env[k] = before[k]!;
48
+ }
49
+ process.argv = ORIGINAL_ARGV;
50
+ });
51
+ }
52
+
53
+ function makeEntry(assistantId: string): AssistantEntry {
54
+ return {
55
+ assistantId,
56
+ runtimeUrl: "http://127.0.0.1:7801",
57
+ cloud: "local",
58
+ resources: {
59
+ instanceDir: join(tmpdir(), "cadet-uninstall-instance"),
60
+ daemonPort: 7801,
61
+ gatewayPort: 7831,
62
+ qdrantPort: 6334,
63
+ cesPort: 7790,
64
+ },
65
+ };
66
+ }
67
+
68
+ describe("cadet uninstall", () => {
69
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
70
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
71
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
72
+
73
+ afterEach(() => {
74
+ logSpy.mockClear();
75
+ errorSpy.mockClear();
76
+ warnSpy.mockClear();
77
+ });
78
+
79
+ afterAll(() => {
80
+ logSpy.mockRestore();
81
+ errorSpy.mockRestore();
82
+ warnSpy.mockRestore();
83
+ });
84
+
85
+ test("refuses while assistants remain — retire them first", async () => {
86
+ await withRestoredEnv(async () => {
87
+ const root = redirectXdg();
88
+ // A remaining assistant in the lockfile means there is data that
89
+ // should be retired (and archived) first.
90
+ writeFileSync(
91
+ join(process.env.CADET_LOCKFILE_DIR!, "lockfile.json"),
92
+ JSON.stringify({ assistants: [makeEntry("test-assistant")] }) + "\n",
93
+ );
94
+
95
+ const exitMock = spyOn(process, "exit");
96
+ exitMock.mockImplementation((code?: number | string | undefined): never => {
97
+ throw new Error(`process.exit:${code}`);
98
+ });
99
+ try {
100
+ await expect(uninstall()).rejects.toThrow("process.exit:1");
101
+ expect(errorSpy).toHaveBeenCalledWith(
102
+ expect.stringContaining("Retire them first"),
103
+ );
104
+ } finally {
105
+ exitMock.mockRestore();
106
+ }
107
+ rmSync(root, { recursive: true, force: true });
108
+ });
109
+ });
110
+
111
+ test("refuses non-interactively without --yes", async () => {
112
+ await withRestoredEnv(async () => {
113
+ const root = redirectXdg();
114
+
115
+ const exitMock = spyOn(process, "exit");
116
+ exitMock.mockImplementation((code?: number | string | undefined): never => {
117
+ throw new Error(`process.exit:${code}`);
118
+ });
119
+ try {
120
+ process.argv = ["bun", "cadet", "uninstall"];
121
+ await expect(uninstall()).rejects.toThrow("process.exit:1");
122
+ expect(errorSpy).toHaveBeenCalledWith(
123
+ expect.stringContaining("non-interactive"),
124
+ );
125
+ } finally {
126
+ exitMock.mockRestore();
127
+ }
128
+ rmSync(root, { recursive: true, force: true });
129
+ });
130
+ });
131
+
132
+ test("--yes deletes the environment's XDG locations and leaves the source checkout alone", async () => {
133
+ await withRestoredEnv(async () => {
134
+ const root = redirectXdg();
135
+ const env = getCurrentEnvironment();
136
+
137
+ // Pre-create every target the command should delete, using the SAME
138
+ // path helpers so the test stays honest about what they resolve to.
139
+ const stateDir = getStateDir(env);
140
+ mkdirSync(join(stateDir, "assistants", "some-assistant"), {
141
+ recursive: true,
142
+ });
143
+ mkdirSync(join(stateDir, "retired"), { recursive: true });
144
+ const configDir = getConfigDirs(env)[0]!;
145
+ mkdirSync(configDir, { recursive: true });
146
+ const logDir = getLogDir(env);
147
+ mkdirSync(logDir, { recursive: true });
148
+ const historyPath = getInputHistoryPath();
149
+ mkdirSync(dirname(historyPath), { recursive: true });
150
+ writeFileSync(historyPath, "history");
151
+
152
+ process.argv = ["bun", "cadet", "uninstall", "--yes"];
153
+ await uninstall();
154
+
155
+ expect(existsSync(stateDir)).toBe(false);
156
+ expect(existsSync(configDir)).toBe(false);
157
+ expect(existsSync(logDir)).toBe(false);
158
+ expect(existsSync(historyPath)).toBe(false);
159
+
160
+ // Running under bun from a git checkout: self-removal must print the
161
+ // note and delete NOTHING outside the XDG targets.
162
+ const note = logSpy.mock.calls
163
+ .map((args) => args.map(String).join(" "))
164
+ .join("\n");
165
+ expect(note).toContain("source checkout");
166
+ expect(existsSync(process.execPath)).toBe(true);
167
+
168
+ rmSync(root, { recursive: true, force: true });
169
+ });
170
+ });
171
+ });
@@ -77,9 +77,47 @@ import { relaunchDetached } from "../lib/detached-process.js";
77
77
  import { probePort } from "../lib/port-probe.js";
78
78
  import { openBrowser } from "../lib/open-browser";
79
79
  import { findWebDistDir } from "../lib/web-dist.js";
80
+ import { z } from "zod";
81
+
82
+ const AssistantIdBodySchema = z.object({
83
+ assistantId: z.string().optional(),
84
+ });
85
+
86
+ const TokenBodySchema = z.object({ token: z.unknown() }).nullable();
87
+
88
+ const SyncPlatformBodySchema = z.object({
89
+ syncPlatform: z.unknown().optional(),
90
+ platformAssistants: z.array(z.record(z.string(), z.unknown())).optional(),
91
+ organizationId: z.string().optional(),
92
+ rename: z
93
+ .object({ assistantId: z.string(), name: z.string() })
94
+ .optional(),
95
+ assistant: z.record(z.string(), z.unknown()).optional(),
96
+ activeAssistant: z.string().optional(),
97
+ });
98
+
99
+ const RemoteBodySchema = z.object({ remote: z.string().optional() });
100
+
101
+ const UnpairBodySchema = z.object({
102
+ assistantId: z.unknown().optional(),
103
+ hashedDeviceId: z.unknown().optional(),
104
+ });
105
+
106
+ const DeviceBodySchema = z.object({
107
+ address: z.unknown().optional(),
108
+ handle: z.unknown().optional(),
109
+ name: z.unknown().optional(),
110
+ });
111
+
112
+ function errnoCode(error: unknown): string | undefined {
113
+ if (!(error instanceof Error)) return undefined;
114
+ if (!("code" in error) || typeof error.code !== "string") return undefined;
115
+ return error.code;
116
+ }
80
117
 
81
118
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
82
119
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
120
+ const SupportedInterfaceSchema = z.enum(SUPPORTED_INTERFACES);
83
121
 
84
122
  const ANSI = {
85
123
  reset: "\x1b[0m",
@@ -299,13 +337,14 @@ export function parseArgs(): ParsedArgs {
299
337
  assistantName = undefined;
300
338
  } else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
301
339
  const value = flagArgs[++i];
302
- if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
340
+ const parsedInterface = SupportedInterfaceSchema.safeParse(value);
341
+ if (!parsedInterface.success) {
303
342
  console.error(
304
343
  `Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
305
344
  );
306
345
  process.exit(1);
307
346
  }
308
- interfaceId = value as SupportedInterface;
347
+ interfaceId = parsedInterface.data;
309
348
  }
310
349
  }
311
350
 
@@ -548,10 +587,11 @@ async function handleLocalEndpoints(
548
587
  return Response.json({ ok: true });
549
588
  }
550
589
  if (req.method === "POST") {
551
- const body = (await req.json().catch(() => null)) as {
552
- token?: unknown;
553
- } | null;
554
- const token = body?.token;
590
+ const parsed = TokenBodySchema.safeParse(
591
+ await req.json().catch(() => null),
592
+ );
593
+ const token =
594
+ parsed.success && parsed.data ? parsed.data.token : undefined;
555
595
  if (typeof token !== "string" || !/^[A-Za-z0-9]+$/.test(token)) {
556
596
  return Response.json(
557
597
  { ok: false, error: "Invalid token" },
@@ -575,9 +615,11 @@ async function handleLocalEndpoints(
575
615
  return new Response(null, { status: result.status });
576
616
  }
577
617
  if (req.method === "POST") {
578
- let body: Record<string, unknown>;
618
+ let body: z.infer<typeof SyncPlatformBodySchema>;
579
619
  try {
580
- body = (await req.json()) as Record<string, unknown>;
620
+ const parsed = SyncPlatformBodySchema.safeParse(await req.json());
621
+ if (!parsed.success) throw new Error("invalid body");
622
+ body = parsed.data;
581
623
  } catch {
582
624
  return Response.json(
583
625
  { ok: false, error: "Invalid JSON body" },
@@ -585,24 +627,23 @@ async function handleLocalEndpoints(
585
627
  );
586
628
  }
587
629
  let result;
588
- if (body.syncPlatform && Array.isArray(body.platformAssistants)) {
630
+ if (body.syncPlatform && body.platformAssistants) {
589
631
  result = replacePlatformAssistants(
590
632
  lockfilePaths,
591
- body.platformAssistants as Array<Record<string, unknown>>,
592
- body.organizationId as string | undefined,
633
+ body.platformAssistants,
634
+ body.organizationId,
593
635
  );
594
- } else if (body.rename && typeof body.rename === "object") {
595
- const rename = body.rename as Record<string, unknown>;
636
+ } else if (body.rename) {
596
637
  result = renameLockfileAssistantIfPresent(
597
638
  lockfilePaths,
598
- rename.assistantId as string,
599
- rename.name as string,
639
+ body.rename.assistantId,
640
+ body.rename.name,
600
641
  );
601
642
  } else {
602
643
  result = upsertRendererLockfileAssistant(
603
644
  lockfilePaths,
604
- body.assistant as Record<string, unknown>,
605
- body.activeAssistant as string | undefined,
645
+ body.assistant ?? {},
646
+ body.activeAssistant,
606
647
  );
607
648
  }
608
649
  return Response.json(result, { status: result.ok ? 200 : result.status });
@@ -618,10 +659,8 @@ async function handleLocalEndpoints(
618
659
  const contentType = req.headers.get("content-type") ?? "";
619
660
  if (contentType.includes("json")) {
620
661
  try {
621
- const body = (await req.json()) as {
622
- remote?: string;
623
- };
624
- if (body.remote) remote = body.remote;
662
+ const parsed = RemoteBodySchema.safeParse(await req.json());
663
+ if (parsed.success && parsed.data.remote) remote = parsed.data.remote;
625
664
  } catch {
626
665
  return Response.json(
627
666
  { ok: false, error: "Invalid JSON body" },
@@ -656,8 +695,9 @@ async function handleLocalEndpoints(
656
695
 
657
696
  let assistantId: string | undefined;
658
697
  try {
659
- const body = (await req.json()) as { assistantId?: string };
660
- assistantId = body.assistantId;
698
+ const parsed = AssistantIdBodySchema.safeParse(await req.json());
699
+ if (!parsed.success) throw new Error("invalid body");
700
+ assistantId = parsed.data.assistantId;
661
701
  } catch {
662
702
  return Response.json(
663
703
  { ok: false, error: "Invalid JSON body" },
@@ -707,8 +747,9 @@ async function handleLocalEndpoints(
707
747
 
708
748
  let assistantId: string | undefined;
709
749
  try {
710
- const body = (await req.json()) as { assistantId?: string };
711
- assistantId = body.assistantId;
750
+ const parsed = AssistantIdBodySchema.safeParse(await req.json());
751
+ if (!parsed.success) throw new Error("invalid body");
752
+ assistantId = parsed.data.assistantId;
712
753
  } catch {
713
754
  return Response.json(
714
755
  { ok: false, error: "Invalid JSON body" },
@@ -743,7 +784,9 @@ async function handleLocalEndpoints(
743
784
 
744
785
  let body: { assistantId?: unknown; hashedDeviceId?: unknown };
745
786
  try {
746
- body = (await req.json()) as typeof body;
787
+ const parsed = UnpairBodySchema.safeParse(await req.json());
788
+ if (!parsed.success) throw new Error("invalid body");
789
+ body = parsed.data;
747
790
  } catch {
748
791
  return Response.json(
749
792
  { ok: false, error: "Invalid JSON body" },
@@ -802,11 +845,9 @@ async function handleLocalEndpoints(
802
845
 
803
846
  let body: { address?: unknown; handle?: unknown; name?: unknown };
804
847
  try {
805
- body = (await req.json()) as {
806
- address?: unknown;
807
- handle?: unknown;
808
- name?: unknown;
809
- };
848
+ const parsed = DeviceBodySchema.safeParse(await req.json());
849
+ if (!parsed.success) throw new Error("invalid body");
850
+ body = parsed.data;
810
851
  } catch {
811
852
  return Response.json(
812
853
  { ok: false, error: "Invalid JSON body" },
@@ -1044,10 +1085,11 @@ type WebFetchHandler = (
1044
1085
  ) => Promise<Response>;
1045
1086
 
1046
1087
  function isAddrInUse(err: unknown): boolean {
1047
- const e = err as { code?: string; message?: string } | undefined;
1088
+ const code = errnoCode(err);
1089
+ const message = err instanceof Error ? err.message : undefined;
1048
1090
  return (
1049
- e?.code === "EADDRINUSE" ||
1050
- /EADDRINUSE|address already in use/i.test(e?.message ?? "")
1091
+ code === "EADDRINUSE" ||
1092
+ /EADDRINUSE|address already in use/i.test(message ?? "")
1051
1093
  );
1052
1094
  }
1053
1095
 
@@ -677,6 +677,7 @@ export async function listAllAssistants(verbose: boolean): Promise<void> {
677
677
  );
678
678
 
679
679
  printTable(rows, { name: "NAME", status: "STATUS", info: "INFO" });
680
+ console.log("");
680
681
  }
681
682
 
682
683
  // ── Entry point ─────────────────────────────────────────────────
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
2
+ import { basename, dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ import { loadAllAssistants } from "../lib/assistant-config.js";
6
+ import {
7
+ canPromptForConfirmation,
8
+ confirmAction,
9
+ } from "../lib/confirm-action.js";
10
+ import {
11
+ getConfigDirs,
12
+ getInputHistoryPath,
13
+ getLogDir,
14
+ getStateDir,
15
+ } from "../lib/environments/paths.js";
16
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
17
+
18
+ /** A directory (or file) scheduled for deletion, with what it holds. */
19
+ interface DeleteTarget {
20
+ path: string;
21
+ description: string;
22
+ }
23
+
24
+ /**
25
+ * Owning packages a global install of the CLI can live under, in priority
26
+ * order — @mycadet/cli always wins over its meta wrapper.
27
+ */
28
+ const OWNED_PACKAGE_NAMES: ReadonlyArray<string> = [
29
+ "@mycadet/cli",
30
+ "@mycadet/meta",
31
+ ];
32
+
33
+ /**
34
+ * Find the package root the CLI is running from (…/node_modules/mycadet),
35
+ * or null when running from a source tree. A `.git` ANYWHERE up the tree
36
+ * (repo root or monorepo parent — a checkout's cli/ subpackage has no .git
37
+ * of its own) means this is source, never an install.
38
+ */
39
+ function findInstalledPackageRoot(startDir: string): string | null {
40
+ // Pass 1: any .git ancestor = a checkout, never an install.
41
+ for (let dir = startDir; ; dir = dirname(dir)) {
42
+ if (existsSync(join(dir, ".git"))) return null;
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break; // Reached the filesystem root.
45
+ }
46
+ // Pass 2: walk up looking for the owned package root, priority order —
47
+ // the whole chain is checked for each name in turn, so @mycadet/cli wins
48
+ // over @mycadet/meta wherever both appear.
49
+ for (const owned of OWNED_PACKAGE_NAMES) {
50
+ for (let dir = startDir; ; dir = dirname(dir)) {
51
+ const pkgPath = join(dir, "package.json");
52
+ if (existsSync(pkgPath)) {
53
+ try {
54
+ const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
55
+ if (
56
+ typeof parsed === "object" &&
57
+ parsed !== null &&
58
+ "name" in parsed &&
59
+ parsed.name === owned
60
+ ) {
61
+ return dir;
62
+ }
63
+ } catch {
64
+ // Unreadable package.json — keep walking up.
65
+ }
66
+ }
67
+ const parent = dirname(dir);
68
+ if (parent === dir) break;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Remove the running CLI. Handles the two real install shapes:
76
+ * - the compiled `cadet` binary (process.execPath IS the install)
77
+ * - a global package install (bun install -g mycadet / npm install -g)
78
+ * Under a source checkout or a bare runtime (bun/node), nothing is deleted —
79
+ * the runtime binary must never be a deletion target.
80
+ */
81
+ function selfDelete(): void {
82
+ const execPath = process.execPath;
83
+ const execBase = basename(execPath);
84
+ if (execBase === "cadet" || execBase === "cadet.exe") {
85
+ try {
86
+ if (process.platform === "win32") {
87
+ // Windows refuses to unlink a running executable — stage it under a
88
+ // junk name and delete that.
89
+ const stale = `${execPath}.uninstalled`;
90
+ renameSync(execPath, stale);
91
+ rmSync(stale, { force: true, recursive: true });
92
+ } else {
93
+ unlinkSync(execPath);
94
+ }
95
+ console.log(`Removed ${execPath}.`);
96
+ return;
97
+ } catch (err) {
98
+ console.warn(
99
+ `⚠️ Could not remove ${execPath}: ${
100
+ err instanceof Error ? err.message : String(err)
101
+ } — remove it by hand.`,
102
+ );
103
+ return;
104
+ }
105
+ }
106
+
107
+ // Package-manager install: remove the package directory we are running
108
+ // from, best-effort. Bin symlinks the package manager planted (e.g.
109
+ // ~/.bun/bin/cadet, the npm global bin) are left dangling — say so.
110
+ const runningFile = fileURLToPath(import.meta.url);
111
+ const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
112
+ if (!pkgRoot) {
113
+ console.log(
114
+ "Cadet is running from a source checkout — skipping self-removal.",
115
+ );
116
+ console.log(
117
+ "Remove the installed copy with `bun remove -g mycadet` (or `npm uninstall -g mycadet`).",
118
+ );
119
+ return;
120
+ }
121
+ try {
122
+ rmSync(pkgRoot, { recursive: true, force: true });
123
+ console.log(`Removed ${pkgRoot}.`);
124
+ console.log(
125
+ "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`).",
126
+ );
127
+ } catch (err) {
128
+ console.warn(
129
+ `⚠️ Could not remove ${pkgRoot}: ${
130
+ err instanceof Error ? err.message : String(err)
131
+ } — remove it with \`bun remove -g mycadet\` (or \`npm uninstall -g mycadet\`).`,
132
+ );
133
+ }
134
+ }
135
+
136
+ function collectDeleteTargets(env = getCurrentEnvironment()): DeleteTarget[] {
137
+ const targets: DeleteTarget[] = [
138
+ {
139
+ path: getStateDir(env),
140
+ description: "assistant state, retired archives, and the assistants it holds",
141
+ },
142
+ ...getConfigDirs(env).map((dir) => ({
143
+ path: dir,
144
+ description: "config and lockfile",
145
+ })),
146
+ { path: getLogDir(env), description: "logs" },
147
+ // Not environment-scoped: deleting it affects every cadet CLI on the
148
+ // machine — called out in the prompt, not hidden.
149
+ { path: getInputHistoryPath(), description: "shared terminal input history" },
150
+ ];
151
+ // Only targets that exist — the prompt lists what will actually go.
152
+ return targets.filter((t) => existsSync(t.path));
153
+ }
154
+
155
+ function rmrf(target: string): void {
156
+ rmSync(target, { recursive: true, force: true });
157
+ }
158
+
159
+ export const uninstall = Object.assign(
160
+ async function uninstall(): Promise<void> {
161
+ const args = process.argv.slice(3);
162
+ if (args.includes("--help") || args.includes("-h")) {
163
+ console.log("Usage: cadet uninstall [--yes]");
164
+ console.log("");
165
+ console.log(
166
+ "Remove Cadet entirely: delete every location it uses for this environment (assistant state, retired archives, config, logs) and then delete the executable itself.",
167
+ );
168
+ process.exit(0);
169
+ }
170
+ const yes = args.includes("--yes");
171
+
172
+ // Retire tears down an assistant AND archives its data. Uninstall assumes
173
+ // an empty rack: retiring first is the way to keep an archive.
174
+ if (loadAllAssistants().length > 0) {
175
+ console.error(
176
+ "Error: You still have assistants. Retire them first (`cadet retire <name>`) — retiring archives their data before removing it.",
177
+ );
178
+ process.exit(1);
179
+ }
180
+
181
+ const env = getCurrentEnvironment();
182
+ const envLabel = env.name ?? "production";
183
+ const targets = collectDeleteTargets(env);
184
+ const execPath = process.execPath;
185
+
186
+ console.log(
187
+ `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
188
+ );
189
+ if (targets.length === 0) {
190
+ console.log("No Cadet data directories found to delete.");
191
+ } else {
192
+ console.log("It will permanently delete:");
193
+ for (const t of targets) {
194
+ console.log(` - ${t.path} (${t.description})`);
195
+ }
196
+ }
197
+ const execBase = basename(execPath);
198
+ const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
199
+ if (isCompiledBinary) {
200
+ console.log(`It will then remove the Cadet executable at ${execPath}.`);
201
+ }
202
+
203
+ if (!yes) {
204
+ if (!canPromptForConfirmation()) {
205
+ console.error(
206
+ "Error: Refusing to uninstall without confirmation in a non-interactive terminal.",
207
+ );
208
+ console.error("Re-run with --yes to confirm from automation.");
209
+ process.exit(1);
210
+ }
211
+ const confirmed = await confirmAction(
212
+ "Press Enter to uninstall Cadet, or Esc/q to cancel: ",
213
+ );
214
+ if (!confirmed) {
215
+ console.log("Uninstall cancelled.");
216
+ process.exit(1);
217
+ }
218
+ }
219
+
220
+ for (const t of targets) {
221
+ rmrf(t.path);
222
+ console.log(`Deleted ${t.path}.`);
223
+ }
224
+
225
+ selfDelete();
226
+ },
227
+ { description: "Remove Cadet entirely" },
228
+ );
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import { debug } from "./commands/debug";
39
39
  import { teleport } from "./commands/teleport";
40
40
  import { terminal } from "./commands/terminal";
41
41
  import { tunnel } from "./commands/tunnel";
42
+ import { uninstall } from "./commands/uninstall";
42
43
  import { unpair } from "./commands/unpair";
43
44
  import { upgrade } from "./commands/upgrade";
44
45
  import { use } from "./commands/use";
@@ -81,6 +82,7 @@ const commands = {
81
82
  teleport,
82
83
  terminal,
83
84
  tunnel,
85
+ uninstall,
84
86
  unpair,
85
87
  upgrade,
86
88
  use,