@mycadet/cli 0.12.11 → 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 (86) 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 +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  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-releases.test.ts +82 -0
  14. package/src/__tests__/commands-mock.ts +101 -0
  15. package/src/__tests__/debug.test.ts +17 -91
  16. package/src/__tests__/devices.test.ts +2 -2
  17. package/src/__tests__/gateway-token.test.ts +267 -0
  18. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  19. package/src/__tests__/host-teleport.test.ts +405 -0
  20. package/src/__tests__/nginx-ingress.test.ts +182 -23
  21. package/src/__tests__/orphan-detection.test.ts +1 -1
  22. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  23. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  24. package/src/__tests__/root-backup-docker.test.ts +294 -0
  25. package/src/__tests__/root-backup.test.ts +527 -0
  26. package/src/__tests__/root-restore-command.test.ts +147 -0
  27. package/src/__tests__/root-signing-key.test.ts +50 -0
  28. package/src/__tests__/setup.test.ts +3 -3
  29. package/src/__tests__/sleep.test.ts +11 -0
  30. package/src/__tests__/teleport.test.ts +68 -2996
  31. package/src/__tests__/tunnel.test.ts +1 -1
  32. package/src/__tests__/upgrade-local.test.ts +21 -11
  33. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  34. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  35. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  36. package/src/commands/backup.ts +44 -399
  37. package/src/commands/client.ts +3 -3
  38. package/src/commands/completions.ts +0 -2
  39. package/src/commands/debug.ts +75 -131
  40. package/src/commands/devices.ts +51 -17
  41. package/src/commands/gateway/token.ts +77 -23
  42. package/src/commands/pair.ts +2 -2
  43. package/src/commands/restore.ts +77 -444
  44. package/src/commands/retire.ts +2 -3
  45. package/src/commands/sleep.ts +15 -2
  46. package/src/commands/teleport.ts +47 -1559
  47. package/src/commands/upgrade.ts +78 -156
  48. package/src/commands/wake.ts +1 -1
  49. package/src/index.ts +0 -4
  50. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  51. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  52. package/src/lib/__tests__/web-dist.test.ts +27 -1
  53. package/src/lib/assistant-client.ts +1 -1
  54. package/src/lib/assistant-config.ts +0 -18
  55. package/src/lib/backup-ops.ts +37 -251
  56. package/src/lib/cloud-client.ts +10 -13
  57. package/src/lib/cloud-login.ts +2 -1
  58. package/src/lib/cloud-releases.ts +54 -10
  59. package/src/lib/cloud-values.ts +1 -1
  60. package/src/lib/confirm-action.ts +2 -2
  61. package/src/lib/docker.ts +59 -22
  62. package/src/lib/github-repository.ts +2 -0
  63. package/src/lib/guardian-token.ts +1 -23
  64. package/src/lib/hatch-local.ts +17 -1
  65. package/src/lib/host-teleport.ts +506 -0
  66. package/src/lib/http-client.ts +25 -0
  67. package/src/lib/live-voice/tunnel.test.ts +1 -1
  68. package/src/lib/live-voice/tunnel.ts +1 -1
  69. package/src/lib/local.ts +4 -2
  70. package/src/lib/nginx-ingress.ts +134 -18
  71. package/src/lib/process.ts +13 -0
  72. package/src/lib/root-backup.ts +917 -0
  73. package/src/lib/root-signing-key.ts +31 -0
  74. package/src/lib/runtime-url.ts +0 -29
  75. package/src/lib/teleport-backup.ts +3 -16
  76. package/src/lib/upgrade-lifecycle.ts +63 -141
  77. package/src/lib/web-dist.ts +23 -1
  78. package/src/__tests__/bundle-staging.test.ts +0 -268
  79. package/src/__tests__/unpair.test.ts +0 -160
  80. package/src/commands/rollback.ts +0 -479
  81. package/src/commands/unpair.ts +0 -122
  82. package/src/lib/__tests__/job-polling.test.ts +0 -278
  83. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  84. package/src/lib/bundle-staging.ts +0 -220
  85. package/src/lib/job-polling.ts +0 -195
  86. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,220 +0,0 @@
1
- import { randomUUID } from "crypto";
2
- import {
3
- copyFileSync,
4
- existsSync,
5
- mkdirSync,
6
- statSync,
7
- unlinkSync,
8
- } from "fs";
9
- import { join } from "path";
10
-
11
- import type { AssistantEntry } from "./assistant-config.js";
12
- import { dockerResourceNames } from "./docker.js";
13
- import { loopbackSafeFetch } from "./loopback-fetch.js";
14
- import { exec } from "./step-runner.js";
15
-
16
- /** Workspace-relative directory the daemon will open for staged restores. */
17
- export const RESTORE_STAGING_DIRNAME = ".restore-staging";
18
-
19
- export type RestoreStagingTarget =
20
- | { kind: "local"; instanceDir: string }
21
- | { kind: "docker"; assistantId: string };
22
-
23
- export interface StagedBundle {
24
- /** Path the daemon should receive in `{ path }`. */
25
- relativePath: string;
26
- cleanup: () => Promise<void>;
27
- }
28
-
29
- const IMPORT_POLL_INTERVAL_MS = 2_000;
30
- const IMPORT_POLL_TIMEOUT_MS = 60 * 60 * 1000;
31
-
32
- export function stagingTargetFromEntry(
33
- entry: AssistantEntry,
34
- ): RestoreStagingTarget | null {
35
- const cloud =
36
- entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
37
- if (cloud === "docker") {
38
- return { kind: "docker", assistantId: entry.assistantId };
39
- }
40
- if (cloud === "local" && entry.resources?.instanceDir) {
41
- return { kind: "local", instanceDir: entry.resources.instanceDir };
42
- }
43
- return null;
44
- }
45
-
46
- function localWorkspaceDir(instanceDir: string): string {
47
- return join(instanceDir, "workspace");
48
- }
49
-
50
- export function formatBundleSizeMb(bytes: number): string {
51
- return (bytes / (1024 * 1024)).toFixed(2);
52
- }
53
-
54
- /**
55
- * Copy a host `.vbundle` into the assistant workspace staging directory
56
- * so the daemon can stream it without a large HTTP body.
57
- */
58
- export async function stageBundleForRestore(
59
- target: RestoreStagingTarget,
60
- hostBundlePath: string,
61
- ): Promise<StagedBundle> {
62
- const filename = `${randomUUID()}.vbundle`;
63
- const relativePath = `${RESTORE_STAGING_DIRNAME}/${filename}`;
64
-
65
- if (target.kind === "local") {
66
- const stagingDir = join(
67
- localWorkspaceDir(target.instanceDir),
68
- RESTORE_STAGING_DIRNAME,
69
- );
70
- mkdirSync(stagingDir, { recursive: true });
71
- const dest = join(stagingDir, filename);
72
- copyFileSync(hostBundlePath, dest);
73
- return {
74
- relativePath,
75
- cleanup: async () => {
76
- try {
77
- unlinkSync(dest);
78
- } catch {
79
- // Best-effort: a successful import may have swapped the workspace.
80
- }
81
- },
82
- };
83
- }
84
-
85
- const container = dockerResourceNames(target.assistantId).assistantContainer;
86
- const dest = `/workspace/${relativePath}`;
87
- await exec("docker", [
88
- "exec",
89
- container,
90
- "mkdir",
91
- "-p",
92
- `/workspace/${RESTORE_STAGING_DIRNAME}`,
93
- ]);
94
- await exec("docker", ["cp", hostBundlePath, `${container}:${dest}`]);
95
- return {
96
- relativePath,
97
- cleanup: async () => {
98
- try {
99
- await exec("docker", ["exec", container, "rm", "-f", dest]);
100
- } catch {
101
- // Best-effort: a successful import may have swapped the workspace.
102
- }
103
- },
104
- };
105
- }
106
-
107
- export async function preflightStagedBundle(
108
- runtimeUrl: string,
109
- accessToken: string,
110
- relativePath: string,
111
- ): Promise<Response> {
112
- return loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import-preflight`, {
113
- method: "POST",
114
- headers: {
115
- Authorization: `Bearer ${accessToken}`,
116
- "Content-Type": "application/json",
117
- },
118
- body: JSON.stringify({ path: relativePath }),
119
- signal: AbortSignal.timeout(120_000),
120
- });
121
- }
122
-
123
- /**
124
- * Import a staged bundle. The gateway JSON import path returns 202 and
125
- * requires status polling; a direct daemon response of 200 is accepted too.
126
- */
127
- export async function importStagedBundle(
128
- runtimeUrl: string,
129
- accessToken: string,
130
- relativePath: string,
131
- ): Promise<Response> {
132
- const postImport = () =>
133
- loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
134
- method: "POST",
135
- headers: {
136
- Authorization: `Bearer ${accessToken}`,
137
- "Content-Type": "application/json",
138
- },
139
- body: JSON.stringify({ path: relativePath }),
140
- signal: AbortSignal.timeout(120_000),
141
- });
142
-
143
- const response = await postImport();
144
-
145
- if (response.status === 202) {
146
- const accepted = (await response.json()) as { job_id?: string };
147
- if (!accepted.job_id) {
148
- return new Response(
149
- JSON.stringify({ error: "Import did not return a job_id" }),
150
- { status: 502, headers: { "Content-Type": "application/json" } },
151
- );
152
- }
153
- return pollImportJob(runtimeUrl, accessToken, accepted.job_id);
154
- }
155
-
156
- return response;
157
- }
158
-
159
- async function pollImportJob(
160
- runtimeUrl: string,
161
- accessToken: string,
162
- jobId: string,
163
- ): Promise<Response> {
164
- const deadline = Date.now() + IMPORT_POLL_TIMEOUT_MS;
165
- while (Date.now() < deadline) {
166
- const statusResponse = await loopbackSafeFetch(
167
- `${runtimeUrl}/v1/migrations/import/${jobId}/status`,
168
- {
169
- headers: {
170
- Authorization: `Bearer ${accessToken}`,
171
- },
172
- signal: AbortSignal.timeout(30_000),
173
- },
174
- );
175
- if (statusResponse.status === 404) {
176
- return new Response(
177
- JSON.stringify({ error: `Unknown import job: ${jobId}` }),
178
- { status: 404, headers: { "Content-Type": "application/json" } },
179
- );
180
- }
181
- if (!statusResponse.ok) {
182
- return statusResponse;
183
- }
184
-
185
- const job = (await statusResponse.json()) as {
186
- status?: string;
187
- error?: string;
188
- result?: unknown;
189
- };
190
- if (job.status === "complete") {
191
- return new Response(JSON.stringify(job.result ?? { success: true }), {
192
- status: 200,
193
- headers: { "Content-Type": "application/json" },
194
- });
195
- }
196
- if (job.status === "failed") {
197
- return new Response(
198
- JSON.stringify({
199
- success: false,
200
- message: job.error ?? "Import failed",
201
- }),
202
- { status: 500, headers: { "Content-Type": "application/json" } },
203
- );
204
- }
205
-
206
- await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_INTERVAL_MS));
207
- }
208
-
209
- return new Response(
210
- JSON.stringify({ error: "Import timed out after 60 minutes" }),
211
- { status: 504, headers: { "Content-Type": "application/json" } },
212
- );
213
- }
214
-
215
- export function bundleFileSizeBytes(path: string): number {
216
- if (!existsSync(path)) {
217
- return 0;
218
- }
219
- return statSync(path).size;
220
- }
@@ -1,195 +0,0 @@
1
- import type { UnifiedJobStatus } from "./cloud-client.js";
2
-
3
- /**
4
- * Terminal status returned by {@link pollJobUntilDone}. Callers decide
5
- * whether to treat `failed` as a fatal error or retry logic concern.
6
- */
7
- export type TerminalJobStatus = Extract<
8
- UnifiedJobStatus,
9
- { status: "complete" | "failed" }
10
- >;
11
-
12
- export interface PollJobUntilDoneOptions {
13
- /** Async producer that returns the latest job status. */
14
- poll: () => Promise<UnifiedJobStatus>;
15
- /** Sleep between successive polls. Defaults to 2_000 ms. */
16
- intervalMs?: number;
17
- /** Maximum wall-clock time to wait. Defaults to 60 minutes. */
18
- timeoutMs?: number;
19
- /** Human-readable label used in the timeout error message (e.g. "export job"). */
20
- label: string;
21
- /**
22
- * Maximum consecutive transient (retryable) poll errors tolerated before
23
- * the last error is propagated. Transient errors (5xx / network) between
24
- * successful polls reset the counter. Defaults to 5.
25
- */
26
- maxTransientErrors?: number;
27
- /**
28
- * Optional async hook invoked when `poll()` throws an error containing a
29
- * `401` HTTP status. The callback is expected to refresh whatever
30
- * credential the poll closure reads (e.g. re-lease a guardian token), then
31
- * return. The polling loop will retry the poll after the callback resolves
32
- * instead of propagating the 401.
33
- *
34
- * Used by long-running migrations where the cached access token may expire
35
- * mid-poll. Without this hook, 4xx errors (except 429) are permanent and
36
- * would abandon a migration that's still running on the server.
37
- */
38
- refreshOn401?: () => Promise<void>;
39
- /**
40
- * Maximum consecutive 401 refreshes tolerated before the last 401 is
41
- * propagated. Tracked separately from {@link maxTransientErrors} because
42
- * a persistent 401 after a refresh usually means the underlying credential
43
- * is revoked, not a transient network issue. Defaults to 3.
44
- */
45
- maxAuthRefreshes?: number;
46
- }
47
-
48
- const DEFAULT_INTERVAL_MS = 2_000;
49
- // Matches the server-side runtime migration window: the GCS upload PUT and
50
- // the import-URL fetch in assistant/src/runtime/routes/migration-routes.ts
51
- // use AbortSignal.timeout(60 * 60 * 1000), so a shorter CLI poll cap would
52
- // abort a job that's still legitimately in progress on the server.
53
- const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
54
- const DEFAULT_MAX_TRANSIENT_ERRORS = 5;
55
- const DEFAULT_MAX_AUTH_REFRESHES = 3;
56
-
57
- function is401Error(err: unknown): boolean {
58
- const msg = err instanceof Error ? err.message : String(err);
59
- return /\b401\b/.test(msg);
60
- }
61
-
62
- function sleep(ms: number): Promise<void> {
63
- return new Promise((resolve) => setTimeout(resolve, ms));
64
- }
65
-
66
- /**
67
- * Heuristic classification used by {@link pollJobUntilDone} to decide whether
68
- * to retry a failed poll.
69
- *
70
- * - 5xx responses and unclassifiable network-style errors (fetch failed,
71
- * ECONNRESET, etc.) are treated as transient.
72
- * - 4xx responses are treated as permanent, except 429 (rate limited) which is
73
- * transient.
74
- * - "not found" errors are permanent — they indicate the job id is wrong and
75
- * retrying won't help.
76
- *
77
- * The poll helpers (`platformPollJobStatus`, `localRuntimePollJobStatus`)
78
- * raise errors whose message contains the HTTP status (e.g. `"Local job
79
- * status check failed: 503 Service Unavailable"`), so we parse that out when
80
- * available and default to "retry" when unsure.
81
- */
82
- function isTransientPollError(err: unknown): boolean {
83
- const msg = err instanceof Error ? err.message : String(err);
84
-
85
- if (msg.includes("not found")) return false;
86
-
87
- const match = msg.match(/(?:status check failed|failed)[^\d]*(\d{3})/i);
88
- if (match) {
89
- const code = parseInt(match[1], 10);
90
- if (code === 429) return true;
91
- if (code >= 400 && code < 500) return false;
92
- if (code >= 500) return true;
93
- }
94
-
95
- // Unclassifiable (e.g. "fetch failed", ECONNRESET) — treat as transient so
96
- // a single network hiccup doesn't abort a long-running migration.
97
- return true;
98
- }
99
-
100
- /**
101
- * Poll `options.poll` until it returns a terminal status (`complete` or
102
- * `failed`), or until `timeoutMs` elapses.
103
- *
104
- * On terminal status, returns the status object — including the `failed`
105
- * case. The caller decides how to treat a failed terminal status (e.g.
106
- * print the `error` field and exit). Timeouts throw.
107
- *
108
- * Transient errors raised by `poll()` (5xx, network hiccups, rate-limits) are
109
- * retried up to `maxTransientErrors` times before the last error propagates,
110
- * matching the pre-rewrite migration-export polling loop's behavior so a
111
- * single flaky poll doesn't abort a migration that may still be running.
112
- */
113
- export async function pollJobUntilDone(
114
- options: PollJobUntilDoneOptions,
115
- ): Promise<TerminalJobStatus> {
116
- const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
117
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
118
- const maxTransientErrors =
119
- options.maxTransientErrors ?? DEFAULT_MAX_TRANSIENT_ERRORS;
120
- const maxAuthRefreshes =
121
- options.maxAuthRefreshes ?? DEFAULT_MAX_AUTH_REFRESHES;
122
- const deadline = Date.now() + timeoutMs;
123
-
124
- let consecutiveTransientErrors = 0;
125
- let consecutiveAuthRefreshes = 0;
126
-
127
- // First poll happens immediately so fast-path completions don't wait
128
- // one interval before returning.
129
- while (true) {
130
- let status: UnifiedJobStatus;
131
- try {
132
- status = await options.poll();
133
- consecutiveTransientErrors = 0;
134
- consecutiveAuthRefreshes = 0;
135
- } catch (err) {
136
- // 401 Unauthorized takes precedence over the generic transient
137
- // classifier: when a refresh callback is registered, a long-running
138
- // poll loop can re-lease its credential and keep going instead of
139
- // abandoning a migration that's still running on the server.
140
- if (options.refreshOn401 && is401Error(err)) {
141
- consecutiveAuthRefreshes += 1;
142
- if (consecutiveAuthRefreshes > maxAuthRefreshes) {
143
- throw err;
144
- }
145
- const msg = err instanceof Error ? err.message : String(err);
146
- console.warn(
147
- `${options.label} polling got 401, refreshing auth and retrying... (${msg})`,
148
- );
149
- await options.refreshOn401();
150
- if (Date.now() >= deadline) {
151
- throw new Error(
152
- `Timed out waiting for ${options.label} after ${Math.round(
153
- timeoutMs / 1000,
154
- )}s`,
155
- );
156
- }
157
- await sleep(intervalMs);
158
- continue;
159
- }
160
-
161
- if (!isTransientPollError(err)) {
162
- throw err;
163
- }
164
- consecutiveTransientErrors += 1;
165
- if (consecutiveTransientErrors > maxTransientErrors) {
166
- throw err;
167
- }
168
- const msg = err instanceof Error ? err.message : String(err);
169
- console.warn(`${options.label} polling failed: ${msg}. Retrying...`);
170
- if (Date.now() >= deadline) {
171
- throw new Error(
172
- `Timed out waiting for ${options.label} after ${Math.round(
173
- timeoutMs / 1000,
174
- )}s`,
175
- );
176
- }
177
- await sleep(intervalMs);
178
- continue;
179
- }
180
-
181
- if (status.status === "complete" || status.status === "failed") {
182
- return status;
183
- }
184
-
185
- if (Date.now() >= deadline) {
186
- throw new Error(
187
- `Timed out waiting for ${options.label} after ${Math.round(
188
- timeoutMs / 1000,
189
- )}s`,
190
- );
191
- }
192
-
193
- await sleep(intervalMs);
194
- }
195
- }