@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.
- package/package.json +4 -3
- package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +66 -74
- package/src/__tests__/pair.test.ts +135 -121
- package/src/__tests__/uninstall.test.ts +171 -0
- package/src/commands/client.ts +76 -34
- package/src/commands/ps.ts +1 -0
- package/src/commands/uninstall.ts +228 -0
- package/src/index.ts +2 -0
- package/src/lib/assistant-config.ts +9 -13
- package/src/lib/backup-ops.ts +3 -4
- package/src/lib/client-identity.ts +8 -10
- package/src/lib/guardian-token.ts +72 -18
- package/src/lib/health-check.ts +24 -15
- package/src/lib/local-hatch-lock.ts +19 -27
- package/src/lib/local-runtime-client.ts +25 -14
- package/src/lib/ngrok.ts +48 -17
- package/src/lib/platform-client.ts +141 -72
- package/src/lib/retire-archive.ts +4 -4
- package/src/lib/retire-local.ts +2 -25
- package/src/lib/teleport-backup.ts +22 -13
- package/src/lib/terminal-client.ts +19 -12
- package/src/lib/tunnel-edge.ts +11 -13
- package/src/lib/upgrade-lifecycle.ts +16 -4
- package/src/lib/workos-pkce.ts +51 -25
- package/src/lib/json.ts +0 -6
|
@@ -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
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const 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:
|
|
618
|
+
let body: z.infer<typeof SyncPlatformBodySchema>;
|
|
579
619
|
try {
|
|
580
|
-
|
|
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 &&
|
|
630
|
+
if (body.syncPlatform && body.platformAssistants) {
|
|
589
631
|
result = replacePlatformAssistants(
|
|
590
632
|
lockfilePaths,
|
|
591
|
-
body.platformAssistants
|
|
592
|
-
body.organizationId
|
|
633
|
+
body.platformAssistants,
|
|
634
|
+
body.organizationId,
|
|
593
635
|
);
|
|
594
|
-
} else if (body.rename
|
|
595
|
-
const rename = body.rename as Record<string, unknown>;
|
|
636
|
+
} else if (body.rename) {
|
|
596
637
|
result = renameLockfileAssistantIfPresent(
|
|
597
638
|
lockfilePaths,
|
|
598
|
-
rename.assistantId
|
|
599
|
-
rename.name
|
|
639
|
+
body.rename.assistantId,
|
|
640
|
+
body.rename.name,
|
|
600
641
|
);
|
|
601
642
|
} else {
|
|
602
643
|
result = upsertRendererLockfileAssistant(
|
|
603
644
|
lockfilePaths,
|
|
604
|
-
body.assistant
|
|
605
|
-
body.activeAssistant
|
|
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
|
|
622
|
-
|
|
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
|
|
660
|
-
|
|
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
|
|
711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
1088
|
+
const code = errnoCode(err);
|
|
1089
|
+
const message = err instanceof Error ? err.message : undefined;
|
|
1048
1090
|
return (
|
|
1049
|
-
|
|
1050
|
-
/EADDRINUSE|address already in use/i.test(
|
|
1091
|
+
code === "EADDRINUSE" ||
|
|
1092
|
+
/EADDRINUSE|address already in use/i.test(message ?? "")
|
|
1051
1093
|
);
|
|
1052
1094
|
}
|
|
1053
1095
|
|
package/src/commands/ps.ts
CHANGED
|
@@ -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,
|