@mycadet/cli 0.12.10 → 0.13.0

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 (105) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  14. package/src/__tests__/cloud-releases.test.ts +82 -0
  15. package/src/__tests__/commands-mock.ts +101 -0
  16. package/src/__tests__/debug.test.ts +17 -91
  17. package/src/__tests__/devices.test.ts +64 -2
  18. package/src/__tests__/ensure-binary.test.ts +52 -0
  19. package/src/__tests__/gateway-token.test.ts +267 -0
  20. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/multi-local.test.ts +3 -3
  24. package/src/__tests__/nginx-ingress.test.ts +233 -36
  25. package/src/__tests__/orphan-detection.test.ts +1 -1
  26. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  27. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  28. package/src/__tests__/root-backup-docker.test.ts +294 -0
  29. package/src/__tests__/root-backup.test.ts +527 -0
  30. package/src/__tests__/root-restore-command.test.ts +147 -0
  31. package/src/__tests__/root-signing-key.test.ts +50 -0
  32. package/src/__tests__/setup.test.ts +17 -7
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  36. package/src/__tests__/tunnel-stop.test.ts +226 -0
  37. package/src/__tests__/tunnel.test.ts +13 -3
  38. package/src/__tests__/upgrade-local.test.ts +21 -11
  39. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  40. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  41. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  42. package/src/commands/backup.ts +44 -399
  43. package/src/commands/client.ts +3 -3
  44. package/src/commands/completions.ts +0 -2
  45. package/src/commands/debug.ts +75 -131
  46. package/src/commands/devices.ts +117 -47
  47. package/src/commands/gateway/token.ts +77 -23
  48. package/src/commands/hatch.ts +10 -52
  49. package/src/commands/pair.ts +9 -3
  50. package/src/commands/ps.ts +4 -2
  51. package/src/commands/restore.ts +77 -444
  52. package/src/commands/retire.ts +2 -3
  53. package/src/commands/sleep.ts +15 -2
  54. package/src/commands/teleport.ts +47 -1559
  55. package/src/commands/tunnel.ts +43 -4
  56. package/src/commands/upgrade.ts +84 -157
  57. package/src/commands/wake.ts +1 -1
  58. package/src/index.ts +0 -4
  59. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  60. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  61. package/src/lib/__tests__/web-dist.test.ts +27 -1
  62. package/src/lib/assistant-client.ts +1 -1
  63. package/src/lib/assistant-config.ts +20 -23
  64. package/src/lib/backup-ops.ts +37 -251
  65. package/src/lib/cloud-client.ts +10 -13
  66. package/src/lib/cloud-login-poll.ts +75 -0
  67. package/src/lib/cloud-login.ts +5 -2
  68. package/src/lib/cloud-releases.ts +54 -10
  69. package/src/lib/cloud-values.ts +1 -1
  70. package/src/lib/confirm-action.ts +2 -2
  71. package/src/lib/docker.ts +65 -34
  72. package/src/lib/ensure-binary.ts +65 -6
  73. package/src/lib/github-repository.ts +2 -0
  74. package/src/lib/guardian-token.ts +1 -23
  75. package/src/lib/hatch-local.ts +18 -52
  76. package/src/lib/hatch-next-steps.ts +15 -2
  77. package/src/lib/host-teleport.ts +506 -0
  78. package/src/lib/http-client.ts +25 -0
  79. package/src/lib/live-voice/tunnel.test.ts +1 -1
  80. package/src/lib/live-voice/tunnel.ts +3 -3
  81. package/src/lib/local.ts +10 -8
  82. package/src/lib/nginx-ingress.ts +254 -36
  83. package/src/lib/process.ts +13 -0
  84. package/src/lib/root-backup.ts +917 -0
  85. package/src/lib/root-signing-key.ts +31 -0
  86. package/src/lib/runtime-url.ts +0 -29
  87. package/src/lib/teleport-backup.ts +3 -16
  88. package/src/lib/tunnel.ts +86 -34
  89. package/src/lib/tunnels/cadet.ts +12 -1
  90. package/src/lib/tunnels/cloudflare.ts +36 -1
  91. package/src/lib/tunnels/ngrok.ts +16 -2
  92. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  93. package/src/lib/tunnels/tailscale.ts +79 -1
  94. package/src/lib/upgrade-lifecycle.ts +63 -141
  95. package/src/lib/web-dist.ts +23 -1
  96. package/src/lib/xdg-log.ts +11 -0
  97. package/src/__tests__/bundle-staging.test.ts +0 -268
  98. package/src/__tests__/unpair.test.ts +0 -160
  99. package/src/commands/rollback.ts +0 -479
  100. package/src/commands/unpair.ts +0 -122
  101. package/src/lib/__tests__/job-polling.test.ts +0 -278
  102. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  103. package/src/lib/bundle-staging.ts +0 -220
  104. package/src/lib/job-polling.ts +0 -195
  105. package/src/lib/local-runtime-client.ts +0 -374
@@ -0,0 +1,294 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import * as tar from "tar";
8
+ import {
9
+ DockerRootStorage,
10
+ packRoot,
11
+ restoreRootBackup,
12
+ stageRootArchive,
13
+ STATE_ROOTS,
14
+ } from "../lib/root-backup.js";
15
+ import {
16
+ dockerResourceNames,
17
+ sleepContainers,
18
+ startContainers,
19
+ } from "../lib/docker.js";
20
+ import type { AssistantEntry } from "../lib/assistant-config.js";
21
+ import { mockCommands } from "./commands-mock.js";
22
+
23
+ const entry: AssistantEntry = {
24
+ assistantId: "docker-backup",
25
+ cloud: "docker",
26
+ runtimeUrl: "http://127.0.0.1:1",
27
+ };
28
+ const names = dockerResourceNames(entry.assistantId);
29
+ const containers = [
30
+ names.assistantContainer,
31
+ names.gatewayContainer,
32
+ names.cesContainer,
33
+ ];
34
+ const rootContainers = {
35
+ workspace: names.assistantContainer,
36
+ "gateway-security": names.gatewayContainer,
37
+ "ces-security": names.cesContainer,
38
+ "ces-data": names.cesContainer,
39
+ };
40
+ let directory: string;
41
+ let commands: ReturnType<typeof mockCommands>;
42
+ const running = new Map<string, boolean>();
43
+ let writes = 0;
44
+ let failWrite = 0;
45
+
46
+ beforeEach(async () => {
47
+ directory = await mkdtemp(join(tmpdir(), "docker-root-mock-"));
48
+ commands = mockCommands();
49
+ writes = 0;
50
+ failWrite = 0;
51
+ for (const container of containers) running.set(container, true);
52
+ for (const root of STATE_ROOTS) {
53
+ await mkdir(join(directory, root), { mode: 0o700 });
54
+ await writeFile(join(directory, root, "value"), "original", {
55
+ mode: 0o600,
56
+ });
57
+ }
58
+ await writeFile(
59
+ join(directory, "gateway-security", "actor-token-signing-key"),
60
+ Buffer.alloc(32, 1),
61
+ { mode: 0o600 },
62
+ );
63
+ commands.on("docker", ["info"], {});
64
+ commands.on(
65
+ "docker",
66
+ (args) => args[0] === "inspect",
67
+ ({ args }) => {
68
+ const container = args.at(-1)!;
69
+ if (args.includes("--format"))
70
+ return {
71
+ stdout:
72
+ args[2] === "{{.State.Running}}"
73
+ ? String(running.get(container))
74
+ : running.get(container)
75
+ ? "running"
76
+ : "exited",
77
+ };
78
+ return {
79
+ stdout: JSON.stringify([
80
+ {
81
+ Image: "sha256:service-image",
82
+ State: {
83
+ Running: running.get(container),
84
+ Status: running.get(container) ? "running" : "exited",
85
+ },
86
+ Config: {
87
+ Env: ["RUNTIME_HTTP_PORT=3001", "GATEWAY_PORT=7830"],
88
+ User: "1001:1001",
89
+ },
90
+ Mounts: STATE_ROOTS.filter(
91
+ (root) => rootContainers[root] === container,
92
+ ).map((root) => ({
93
+ Type: "volume",
94
+ Name: `remote-${root}`,
95
+ Source: `/remote/${root}`,
96
+ Destination: `/${root}`,
97
+ RW: true,
98
+ })),
99
+ },
100
+ ]),
101
+ };
102
+ },
103
+ );
104
+ commands.on(
105
+ "docker",
106
+ (args) => args[0] === "kill",
107
+ ({ args }) => {
108
+ expect(args[1]).toBe("--signal=SIGTERM");
109
+ running.set(args.at(-1)!, false);
110
+ return {};
111
+ },
112
+ );
113
+ commands.on(
114
+ "docker",
115
+ (args) => args[0] === "start",
116
+ ({ args }) => {
117
+ running.set(args[1]!, true);
118
+ return {};
119
+ },
120
+ );
121
+ commands.on(
122
+ "docker",
123
+ (args) => args[0] === "cp",
124
+ async ({ args }) => {
125
+ const root = args[1]!.split(":/")[1]!.split("/")[0]!;
126
+ await cp(join(directory, root), args[2]!, { recursive: true });
127
+ return {};
128
+ },
129
+ );
130
+ commands.on(
131
+ "docker",
132
+ (args) => args[0] === "run" && args.includes("-i"),
133
+ async ({ args, input }) => {
134
+ writes++;
135
+ const mount = args[args.indexOf("--mount") + 1]!;
136
+ const root = mount.split("src=remote-")[1]!.split(",")[0]!;
137
+ expect(args).toContain("--network=none");
138
+ expect(args).toContain("--user=0");
139
+ expect(args).toContain("1001:1001");
140
+ expect(mount).not.toContain(directory);
141
+ expect(args[args.indexOf("-ec") + 1]).toContain('chown -R "$1" /restore');
142
+ await rm(join(directory, root), { recursive: true });
143
+ await mkdir(join(directory, root));
144
+ if (writes === failWrite)
145
+ return { code: 1, stderr: "injected volume replacement failure" };
146
+ await pipeline(
147
+ Readable.from(input),
148
+ tar.x({ cwd: join(directory, root), preserveOwner: false }),
149
+ );
150
+ return {};
151
+ },
152
+ );
153
+ });
154
+ afterEach(async () => {
155
+ commands.restore();
156
+ await rm(directory, { recursive: true, force: true });
157
+ });
158
+
159
+ async function archive() {
160
+ const capture = join(directory, "capture");
161
+ await mkdir(capture);
162
+ const storage = new DockerRootStorage(entry);
163
+ await sleepContainers(names, { timeoutMs: 100 });
164
+ await storage.capture(capture);
165
+ const file = join(directory, "backup.tar.gz");
166
+ await packRoot(capture, file);
167
+ return { storage, file };
168
+ }
169
+
170
+ test("Docker subprocess round-trip preserves every root, private files and clears stale entries", async () => {
171
+ const { storage, file } = await archive();
172
+ const stage = await stageRootArchive(file, directory);
173
+ for (const root of STATE_ROOTS)
174
+ await writeFile(join(directory, root, "stale"), "delete");
175
+ await storage.replace(join(stage, "state"));
176
+ for (const root of STATE_ROOTS) {
177
+ expect(await readFile(join(directory, root, "value"), "utf8")).toBe(
178
+ "original",
179
+ );
180
+ await expect(readFile(join(directory, root, "stale"))).rejects.toThrow();
181
+ }
182
+ expect(writes).toBe(4);
183
+ expect(commands.calls.filter((call) => call.args[0] === "kill")).toHaveLength(
184
+ 3,
185
+ );
186
+ });
187
+
188
+ test("failure during Docker replacement recovers prior roots before restarting", async () => {
189
+ const { storage, file } = await archive();
190
+ for (const container of containers) running.set(container, true);
191
+ for (const root of STATE_ROOTS)
192
+ await writeFile(join(directory, root, "value"), "destination");
193
+ failWrite = 2;
194
+ const recovery = join(directory, "recovery.tar.gz");
195
+ await expect(
196
+ restoreRootBackup(
197
+ entry,
198
+ file,
199
+ { recoveryPath: recovery },
200
+ {
201
+ storage,
202
+ sleep: () => sleepContainers(names, { timeoutMs: 100 }),
203
+ wake: async () => {
204
+ for (const container of containers) running.set(container, true);
205
+ },
206
+ save: () => {},
207
+ },
208
+ ),
209
+ ).rejects.toThrow("injected volume replacement failure");
210
+ expect(await storage.running()).toBe(true);
211
+ const previous = await stageRootArchive(recovery, directory);
212
+ for (const root of STATE_ROOTS) {
213
+ expect(await readFile(join(directory, root, "value"), "utf8")).toBe(
214
+ "destination",
215
+ );
216
+ expect(await readFile(join(previous, "state", root, "value"), "utf8")).toBe(
217
+ "destination",
218
+ );
219
+ }
220
+ });
221
+
222
+ test("restart probes gateway and assistant on their own service ports", async () => {
223
+ const storage = new DockerRootStorage(entry);
224
+ expect(await storage.running()).toBe(true);
225
+ for (const container of containers) running.set(container, false);
226
+ commands.on(
227
+ "docker",
228
+ (args) => args[0] === "exec",
229
+ () => ({ stdout: "ready" }),
230
+ );
231
+ await storage.resume();
232
+ const probes = commands.calls.filter((call) => call.args[0] === "exec");
233
+ expect(
234
+ probes.find((call) => call.args[1] === names.gatewayContainer)?.args.at(-1),
235
+ ).toBe("http://127.0.0.1:7830/healthz");
236
+ expect(
237
+ probes
238
+ .find((call) => call.args[1] === names.assistantContainer)
239
+ ?.args.at(-1),
240
+ ).toBe("http://127.0.0.1:3001/readyz");
241
+ });
242
+
243
+ test("default Docker sleep aborts a graceful timeout without forcing termination", async () => {
244
+ commands.on(
245
+ "docker",
246
+ (args) => args[0] === "kill",
247
+ () => ({}),
248
+ );
249
+ await expect(sleepContainers(names, { timeoutMs: 0 })).rejects.toThrow(
250
+ "did not stop gracefully",
251
+ );
252
+ expect(running.get(names.assistantContainer)).toBe(true);
253
+ expect(commands.calls.some((call) => call.args[0] === "stop")).toBe(false);
254
+ expect(
255
+ commands.calls
256
+ .filter((call) => call.args[0] === "kill")
257
+ .every((call) => call.args.includes("--signal=SIGTERM")),
258
+ ).toBe(true);
259
+ });
260
+
261
+ test("create-only provisioning creates every target container without starting services", async () => {
262
+ commands.on(
263
+ "docker",
264
+ (args) => args[0] === "create",
265
+ ({ args }) => {
266
+ running.set(args[args.indexOf("--name") + 1]!, false);
267
+ return { stdout: "created" };
268
+ },
269
+ );
270
+ await startContainers(
271
+ {
272
+ createOnly: true,
273
+ gatewayPort: 7830,
274
+ assistantPort: 3001,
275
+ imageTags: {
276
+ assistant: "assistant:target",
277
+ gateway: "gateway:target",
278
+ "credential-executor": "ces:target",
279
+ },
280
+ instanceName: entry.assistantId,
281
+ res: names,
282
+ },
283
+ () => {},
284
+ );
285
+ const creates = commands.calls.filter((call) => call.args[0] === "create");
286
+ expect(creates).toHaveLength(3);
287
+ expect(creates.every((call) => !call.args.includes("-d"))).toBe(true);
288
+ expect(
289
+ commands.calls.some(
290
+ (call) => call.args[0] === "start" || call.args[0] === "run",
291
+ ),
292
+ ).toBe(false);
293
+ expect([...running.values()].every((value) => !value)).toBe(true);
294
+ });