@mycadet/cli 0.12.6-dev.202610082233.54244ee → 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.
Files changed (89) hide show
  1. package/eslint.config.mjs +7 -0
  2. package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
  3. package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
  4. package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
  5. package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
  6. package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
  7. package/package.json +4 -3
  8. package/src/__tests__/assistant-config.test.ts +30 -28
  9. package/src/__tests__/debug.test.ts +8 -8
  10. package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
  11. package/src/__tests__/flags.test.ts +2 -2
  12. package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
  13. package/src/__tests__/ingress-config.test.ts +2 -2
  14. package/src/__tests__/login-loopback.test.ts +1 -1
  15. package/src/__tests__/nginx-ingress.test.ts +68 -76
  16. package/src/__tests__/pair.test.ts +135 -121
  17. package/src/__tests__/platform-client.test.ts +2 -2
  18. package/src/__tests__/recover.test.ts +96 -3
  19. package/src/__tests__/retire.test.ts +5 -3
  20. package/src/__tests__/signing-key-continuity.test.ts +261 -0
  21. package/src/__tests__/sleep.test.ts +189 -6
  22. package/src/__tests__/tailscale-tunnel.test.ts +1 -1
  23. package/src/__tests__/uninstall.test.ts +171 -0
  24. package/src/__tests__/upgrade-local.test.ts +16 -3
  25. package/src/__tests__/use.test.ts +2 -2
  26. package/src/__tests__/wake-error.test.ts +76 -0
  27. package/src/__tests__/wake.test.ts +309 -36
  28. package/src/commands/backup.ts +6 -2
  29. package/src/commands/clean.ts +6 -2
  30. package/src/commands/client.ts +91 -41
  31. package/src/commands/confirm.ts +6 -2
  32. package/src/commands/connect.ts +6 -2
  33. package/src/commands/debug.ts +6 -2
  34. package/src/commands/devices.ts +6 -2
  35. package/src/commands/events.ts +6 -2
  36. package/src/commands/exec.ts +50 -12
  37. package/src/commands/flags.ts +6 -2
  38. package/src/commands/gateway.ts +6 -2
  39. package/src/commands/hatch.ts +6 -2
  40. package/src/commands/login.ts +26 -356
  41. package/src/commands/logs.ts +6 -2
  42. package/src/commands/message.ts +6 -2
  43. package/src/commands/pair.ts +6 -2
  44. package/src/commands/ps.ts +7 -2
  45. package/src/commands/recover.ts +21 -16
  46. package/src/commands/restore.ts +6 -2
  47. package/src/commands/retire.ts +6 -2
  48. package/src/commands/roadmap.ts +6 -2
  49. package/src/commands/rollback.ts +6 -2
  50. package/src/commands/setup.ts +76 -9
  51. package/src/commands/sleep.ts +131 -31
  52. package/src/commands/ssh.ts +6 -2
  53. package/src/commands/teleport.ts +6 -2
  54. package/src/commands/terminal.ts +6 -2
  55. package/src/commands/tunnel.ts +6 -2
  56. package/src/commands/uninstall.ts +228 -0
  57. package/src/commands/unpair.ts +6 -2
  58. package/src/commands/upgrade.ts +13 -8
  59. package/src/commands/use.ts +6 -2
  60. package/src/commands/voice.ts +6 -2
  61. package/src/commands/wake.ts +69 -37
  62. package/src/commands/workflows.ts +6 -2
  63. package/src/components/DefaultMainScreen.tsx +250 -36
  64. package/src/index.ts +23 -55
  65. package/src/lib/assistant-config.ts +27 -13
  66. package/src/lib/backup-ops.ts +3 -4
  67. package/src/lib/cli-error.ts +5 -0
  68. package/src/lib/client-identity.ts +8 -10
  69. package/src/lib/cloud-login.ts +422 -0
  70. package/src/lib/color.ts +16 -0
  71. package/src/lib/default-main-screen-formatting.ts +32 -8
  72. package/src/lib/environments/__tests__/paths.test.ts +9 -11
  73. package/src/lib/environments/paths.ts +2 -20
  74. package/src/lib/guardian-token.ts +72 -18
  75. package/src/lib/hatch-local.ts +52 -1
  76. package/src/lib/health-check.ts +24 -15
  77. package/src/lib/lifecycle-reporter.ts +9 -1
  78. package/src/lib/local-hatch-lock.ts +19 -27
  79. package/src/lib/local-runtime-client.ts +25 -14
  80. package/src/lib/local.ts +17 -43
  81. package/src/lib/ngrok.ts +48 -16
  82. package/src/lib/platform-client.ts +141 -72
  83. package/src/lib/retire-archive.ts +4 -4
  84. package/src/lib/retire-local.ts +2 -25
  85. package/src/lib/teleport-backup.ts +23 -9
  86. package/src/lib/terminal-client.ts +21 -9
  87. package/src/lib/tunnel-edge.ts +11 -13
  88. package/src/lib/upgrade-lifecycle.ts +16 -4
  89. package/src/lib/workos-pkce.ts +52 -21
@@ -9,7 +9,7 @@ export const LEFT_PANEL_WIDTH = 36;
9
9
  export const DEFAULT_TERMINAL_COLUMNS = 80;
10
10
  export const COMPACT_THRESHOLD = 60;
11
11
 
12
- const LEFT_HEADER_LINES = 4;
12
+ const LEFT_HEADER_LINES = 3;
13
13
  const LEFT_FOOTER_LINES = 1;
14
14
  const HEADER_TOP_BORDER_LINES = 1;
15
15
  const HEADER_BOTTOM_BORDER_LINES = 2;
@@ -24,19 +24,47 @@ const RIGHT_PANEL_TIPS_HEADING = 1;
24
24
  const TOOL_CALL_CHROME_LINES = 2;
25
25
  const MESSAGE_SPACING = 1;
26
26
 
27
+ /**
28
+ * Kept in sync with the web client's command set
29
+ * (clients/web slash-command-catalog) plus the CLI-local commands.
30
+ */
27
31
  export const HELP_COMMANDS = [
28
32
  {
29
33
  command: "/btw <question>",
30
34
  description: "Ask a side question while the assistant is working",
31
35
  },
32
36
  {
33
- command: "/quit, /exit, /q",
34
- description: "Disconnect and exit",
37
+ command: "/config [path] [value]",
38
+ description: "Read or set a runtime config value",
39
+ },
40
+ {
41
+ command: "/clean",
42
+ description: "Strip injected runtime context from the conversation",
43
+ },
44
+ {
45
+ command: "/commands",
46
+ description: "List all available commands",
47
+ },
48
+ {
49
+ command: "/compact",
50
+ description: "Force context compaction immediately",
51
+ },
52
+ {
53
+ command: "/models",
54
+ description: "List all available models",
55
+ },
56
+ {
57
+ command: "/status",
58
+ description: "Show conversation status and context usage",
35
59
  },
36
60
  {
37
61
  command: "/clear",
38
62
  description: "Clear the screen",
39
63
  },
64
+ {
65
+ command: "/quit, /exit, /q",
66
+ description: "Disconnect and exit",
67
+ },
40
68
  {
41
69
  command: "/help, ?",
42
70
  description: "Show this help",
@@ -186,7 +214,7 @@ export function truncateValue(value: unknown, maxLength: number): string {
186
214
  }
187
215
 
188
216
  export function formatHeaderTitle(assistantName?: string): string {
189
- const rawTitle = assistantName?.trim() || "Meet your Assistant!";
217
+ const rawTitle = assistantName?.trim() || "Meet your Cadet!";
190
218
  const title = rawTitle.replace(/\s+/g, " ");
191
219
  const maxTitleLength = LEFT_PANEL_WIDTH - 2;
192
220
  const displayTitle =
@@ -196,10 +224,6 @@ export function formatHeaderTitle(assistantName?: string): string {
196
224
  return ` ${displayTitle}`;
197
225
  }
198
226
 
199
- export function formatHeaderEyebrow(): string {
200
- return " Assistant";
201
- }
202
-
203
227
  export function stripAnsi(value: string): string {
204
228
  return value.replace(/\x1b\[[0-9;]*m/g, "");
205
229
  }
@@ -130,8 +130,10 @@ describe("path helpers", () => {
130
130
  });
131
131
 
132
132
  describe("getLockfilePath", () => {
133
- test("production returns ~/.cadet.lock.json", () => {
134
- expect(getLockfilePath(prod)).toBe(join(TEST_HOME, ".cadet.lock.json"));
133
+ test("production returns the config-dir lockfile.json", () => {
134
+ expect(getLockfilePath(prod)).toBe(
135
+ join(TEST_HOME, ".config", "cadet", "lockfile.json"),
136
+ );
135
137
  });
136
138
 
137
139
  test("dev returns ~/.config/cadet-dev/lockfile.json", () => {
@@ -153,7 +155,7 @@ describe("path helpers", () => {
153
155
  ...prod,
154
156
  lockfileDirOverride: "/tmp/lock",
155
157
  };
156
- expect(getLockfilePath(env)).toBe("/tmp/lock/.cadet.lock.json");
158
+ expect(getLockfilePath(env)).toBe("/tmp/lock/lockfile.json");
157
159
  });
158
160
 
159
161
  test("non-prod respects lockfileDirOverride (overrides configDir)", () => {
@@ -167,10 +169,9 @@ describe("path helpers", () => {
167
169
  });
168
170
 
169
171
  describe("getLockfilePaths", () => {
170
- test("production returns both current and legacy filenames in priority order", () => {
172
+ test("production returns a single canonical path", () => {
171
173
  expect(getLockfilePaths(prod)).toEqual([
172
- join(TEST_HOME, ".cadet.lock.json"),
173
- join(TEST_HOME, ".cadet.lockfile.json"),
174
+ join(TEST_HOME, ".config", "cadet", "lockfile.json"),
174
175
  ]);
175
176
  });
176
177
 
@@ -180,15 +181,12 @@ describe("path helpers", () => {
180
181
  ]);
181
182
  });
182
183
 
183
- test("production with lockfileDirOverride applies to both candidates", () => {
184
+ test("production with lockfileDirOverride applies to the canonical path", () => {
184
185
  const env: EnvironmentDefinition = {
185
186
  ...prod,
186
187
  lockfileDirOverride: "/tmp/lock",
187
188
  };
188
- expect(getLockfilePaths(env)).toEqual([
189
- "/tmp/lock/.cadet.lock.json",
190
- "/tmp/lock/.cadet.lockfile.json",
191
- ]);
189
+ expect(getLockfilePaths(env)).toEqual(["/tmp/lock/lockfile.json"]);
192
190
  });
193
191
 
194
192
  test("non-prod with lockfileDirOverride overrides the config dir", () => {
@@ -33,31 +33,13 @@ export function getConfigDirs(env: EnvironmentDefinition): string[] {
33
33
  return resolveConfigDirPaths(process.env, localPathOptions(env));
34
34
  }
35
35
 
36
- /**
37
- * Lockfile candidate paths for an environment, in priority order.
38
- *
39
- * For production, returns both the current `.cadet.lock.json` and the
40
- * legacy `.cadet.lockfile.json` so read-side callers can fall back to the
41
- * legacy filename on installs that predate the rename. Non-production
42
- * environments are new and have a single canonical path under the env-scoped
43
- * XDG config directory.
44
- *
45
- * Read-side callers should iterate this array and use the first existing
46
- * file (matching `cli/src/lib/assistant-config.ts:readLockfile`). Write-side
47
- * callers should use {@link getLockfilePath}, which returns the first
48
- * (canonical) entry.
49
- *
50
- * `env.lockfileDirOverride` (populated by the resolver from
51
- * `CADET_LOCKFILE_DIR`) overrides the directory the lockfile lives in for
52
- * both production and non-production environments.
53
- */
54
36
  export function getLockfilePaths(env: EnvironmentDefinition): string[] {
55
37
  return resolveLockfilePaths(process.env, localPathOptions(env));
56
38
  }
57
39
 
58
40
  /**
59
- * Canonical lockfile path for writes. For production this is the current
60
- * `.cadet.lock.json` (legacy reads handled by {@link getLockfilePaths}).
41
+ * Canonical lockfile path for writes — always the config-dir
42
+ * `lockfile.json`.
61
43
  */
62
44
  export function getLockfilePath(env: EnvironmentDefinition): string {
63
45
  return getLockfilePaths(env)[0]!;
@@ -28,6 +28,39 @@ import {
28
28
  import { getConfigDir, getConfigDirs } from "./environments/paths.js";
29
29
  import { getCurrentEnvironment } from "./environments/resolve.js";
30
30
  import { loopbackSafeFetch } from "./loopback-fetch.js";
31
+ import { z } from "zod";
32
+
33
+ /**
34
+ * The guardian token fields as they arrive untrusted (gateway refresh/init
35
+ * responses, persisted token files). Every field is independently optional
36
+ * and independently caught: one malformed field must not invalidate the
37
+ * others — callers fall back per field.
38
+ */
39
+ const GuardianTokenFieldsSchema = z
40
+ .object({
41
+ guardianPrincipalId: z.string().optional().catch(undefined),
42
+ accessToken: z.string().optional().catch(undefined),
43
+ accessTokenExpiresAt: z
44
+ .union([z.string(), z.number()])
45
+ .optional()
46
+ .catch(undefined),
47
+ refreshToken: z.string().optional().catch(undefined),
48
+ refreshTokenExpiresAt: z
49
+ .union([z.string(), z.number()])
50
+ .optional()
51
+ .catch(undefined),
52
+ refreshAfter: z.string().optional().catch(undefined),
53
+ isNew: z.boolean().optional().catch(undefined),
54
+ deviceId: z.string().optional().catch(undefined),
55
+ })
56
+ .catch({});
57
+
58
+ /** Narrow untrusted JSON to the token fields whose values have the right
59
+ * shape; fields absent or malformed come back undefined for callers to
60
+ * fall back on or reject. */
61
+ function parseGuardianTokenFields(value: unknown): Partial<GuardianTokenData> {
62
+ return GuardianTokenFieldsSchema.parse(value);
63
+ }
31
64
 
32
65
  const DEVICE_ID_SALT = "mycadet-host-id";
33
66
 
@@ -470,19 +503,24 @@ export async function refreshGuardianTokenResult(
470
503
  return classifyRefreshHttpStatus(response.status);
471
504
  }
472
505
 
473
- const json = (await response.json()) as Record<string, unknown>;
506
+ const parsed = parseGuardianTokenFields(await response.json());
507
+ if (parsed.accessToken == null) {
508
+ return {
509
+ ok: false,
510
+ status: 502,
511
+ error: "Assistant gateway returned a malformed token refresh response",
512
+ };
513
+ }
474
514
  const refreshed: GuardianTokenData = {
475
515
  guardianPrincipalId:
476
- (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
477
- accessToken: json.accessToken as string,
516
+ parsed.guardianPrincipalId ?? tokenData.guardianPrincipalId,
517
+ accessToken: parsed.accessToken,
478
518
  accessTokenExpiresAt:
479
- (json.accessTokenExpiresAt as string | number) ??
480
- tokenData.accessTokenExpiresAt,
481
- refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
519
+ parsed.accessTokenExpiresAt ?? tokenData.accessTokenExpiresAt,
520
+ refreshToken: parsed.refreshToken ?? tokenData.refreshToken,
482
521
  refreshTokenExpiresAt:
483
- (json.refreshTokenExpiresAt as string | number) ??
484
- tokenData.refreshTokenExpiresAt,
485
- refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
522
+ parsed.refreshTokenExpiresAt ?? tokenData.refreshTokenExpiresAt,
523
+ refreshAfter: parsed.refreshAfter ?? tokenData.refreshAfter,
486
524
  isNew: false,
487
525
  deviceId: tokenData.deviceId,
488
526
  leasedAt: new Date().toISOString(),
@@ -583,15 +621,28 @@ export async function leaseGuardianToken(
583
621
  throw new Error(`guardian/init failed (${response.status}): ${body}`);
584
622
  }
585
623
 
586
- const json = (await response.json()) as Record<string, unknown>;
624
+ const parsed = parseGuardianTokenFields(await response.json());
625
+ if (
626
+ parsed.guardianPrincipalId == null ||
627
+ parsed.accessToken == null ||
628
+ parsed.accessTokenExpiresAt == null ||
629
+ parsed.refreshToken == null ||
630
+ parsed.refreshTokenExpiresAt == null ||
631
+ parsed.refreshAfter == null ||
632
+ parsed.isNew == null
633
+ ) {
634
+ throw new Error(
635
+ `guardian/init returned a malformed token response (${response.status})`,
636
+ );
637
+ }
587
638
  const tokenData: GuardianTokenData = {
588
- guardianPrincipalId: json.guardianPrincipalId as string,
589
- accessToken: json.accessToken as string,
590
- accessTokenExpiresAt: json.accessTokenExpiresAt as string | number,
591
- refreshToken: json.refreshToken as string,
592
- refreshTokenExpiresAt: json.refreshTokenExpiresAt as string | number,
593
- refreshAfter: json.refreshAfter as string,
594
- isNew: json.isNew as boolean,
639
+ guardianPrincipalId: parsed.guardianPrincipalId,
640
+ accessToken: parsed.accessToken,
641
+ accessTokenExpiresAt: parsed.accessTokenExpiresAt,
642
+ refreshToken: parsed.refreshToken,
643
+ refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
644
+ refreshAfter: parsed.refreshAfter,
645
+ isNew: parsed.isNew,
595
646
  deviceId,
596
647
  leasedAt: new Date().toISOString(),
597
648
  };
@@ -675,7 +726,10 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
675
726
  for (const { path: sibling } of candidates) {
676
727
  try {
677
728
  const raw = readFileSync(sibling);
678
- const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
729
+ const parsed = parseGuardianTokenFields(
730
+ JSON.parse(raw.toString("utf-8")),
731
+ );
732
+ if (parsed.refreshTokenExpiresAt == null) continue;
679
733
  const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
680
734
  if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
681
735
  const dir = dirname(destPath);
@@ -49,6 +49,12 @@ import { logHatchNextSteps } from "./hatch-next-steps.js";
49
49
  import { printInstanceDetails } from "./instance-details.js";
50
50
  import { checkProviderApiKey } from "./api-key-check.js";
51
51
  import { loopbackSafeFetch } from "./loopback-fetch.js";
52
+ import {
53
+ describeLoginError,
54
+ ensureCloudLoginInteractive,
55
+ registerLocalAssistantWithPlatform,
56
+ } from "./cloud-login.js";
57
+ import { fetchCurrentUser, readPlatformToken } from "./platform-client.js";
52
58
  import { withLocalHatchLock } from "./local-hatch-lock.js";
53
59
 
54
60
  /**
@@ -179,6 +185,8 @@ export interface HatchLocalResult {
179
185
  guardianAccessToken?: string;
180
186
  }
181
187
 
188
+
189
+
182
190
  export async function hatchLocal(
183
191
  params: HatchLocalParams = {},
184
192
  ): Promise<HatchLocalResult> {
@@ -247,6 +255,32 @@ export async function hatchLocal(
247
255
  // Collect the key immediately, while the choice is fresh — the
248
256
  // startup logs are quiet now, so nothing separates the two prompts.
249
257
  precollectedApiKey = await collectProviderApiKey(picked);
258
+
259
+ // Picking Cadet Cloud should feel like signing in, not like a
260
+ // mystery later — open the browser login right here. Skipped for
261
+ // the desktop app (it runs its own login UI) and non-TTY runs.
262
+ if (
263
+ picked === "cadet" &&
264
+ !process.env.CADET_DESKTOP_APP &&
265
+ process.stdin.isTTY &&
266
+ !readPlatformToken()
267
+ ) {
268
+ reporter.log("");
269
+ reporter.log(
270
+ "Cadet Cloud selected — opening your browser to log in...",
271
+ );
272
+ try {
273
+ await ensureCloudLoginInteractive();
274
+ reporter.log("✅ Logged in to Cadet Cloud");
275
+ } catch (error) {
276
+ reporter.warn(
277
+ `⚠️ Cadet Cloud login failed: ${describeLoginError(error)}`,
278
+ );
279
+ reporter.warn(
280
+ " You can retry later with `cadet login`.",
281
+ );
282
+ }
283
+ }
250
284
  }
251
285
  }
252
286
 
@@ -267,7 +301,7 @@ export async function hatchLocal(
267
301
  process.env.APP_VERSION = cliPkg.version;
268
302
  }
269
303
 
270
- reporter.progress(2, 6, "Writing configuration...");
304
+ reporter.progress(2, 6, "Setting up local assistant...");
271
305
  const hatchConfigValues = buildHatchConfigValues(configValues, provider);
272
306
  const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
273
307
 
@@ -381,6 +415,23 @@ export async function hatchLocal(
381
415
  });
382
416
  }
383
417
 
418
+ // Picking Cadet Cloud ends with the assistant registered on the platform
419
+ // and its cloud credentials injected — the same post-login flow
420
+ // `cadet login` runs — so messages route through the managed connection
421
+ // immediately after hatch. Non-fatal: the assistant is up either way.
422
+ if (provider === "cadet" && readPlatformToken()) {
423
+ try {
424
+ const token = readPlatformToken()!;
425
+ const user = await fetchCurrentUser(token);
426
+ await registerLocalAssistantWithPlatform(token, user, {
427
+ bearerToken: guardianAccessToken,
428
+ log: (m) => reporter.log(m),
429
+ });
430
+ } catch {
431
+ // Non-fatal — `cadet login` can complete the registration later.
432
+ }
433
+ }
434
+
384
435
  reporter.log("");
385
436
  reporter.log(`✅ Local assistant hatched!`);
386
437
  printInstanceDetails(
@@ -1,12 +1,13 @@
1
1
  import { loopbackSafeFetch } from "./loopback-fetch.js";
2
+ import { z } from "zod";
2
3
 
3
- export const HEALTH_CHECK_TIMEOUT_MS = 1500;
4
+ const HealthResponseSchema = z.object({
5
+ status: z.string().optional(),
6
+ message: z.string().optional(),
7
+ version: z.string().optional(),
8
+ });
4
9
 
5
- interface HealthResponse {
6
- status: string;
7
- message?: string;
8
- version?: string;
9
- }
10
+ export const HEALTH_CHECK_TIMEOUT_MS = 1500;
10
11
 
11
12
  export interface HealthCheckResult {
12
13
  status: string;
@@ -57,7 +58,9 @@ export async function checkManagedHealth(
57
58
  return { status: `error (${response.status})`, detail: null };
58
59
  }
59
60
 
60
- const data = (await response.json()) as HealthResponse;
61
+ const data = HealthResponseSchema
62
+ .catch({})
63
+ .parse(await response.json());
61
64
  const status = data.status || "unknown";
62
65
  return {
63
66
  status,
@@ -168,12 +171,12 @@ export async function fetchManagedPs(
168
171
  }
169
172
  }
170
173
 
171
- interface LegacyConnectionStatus {
172
- state: string;
173
- is_awake: boolean;
174
- pod_status: string | null;
175
- detail: string | null;
176
- }
174
+ const LegacyConnectionStatusSchema = z.object({
175
+ state: z.string(),
176
+ is_awake: z.boolean(),
177
+ pod_status: z.string().nullable(),
178
+ detail: z.string().nullable(),
179
+ });
177
180
 
178
181
  async function fetchLegacyConnectionStatus(
179
182
  runtimeUrl: string,
@@ -194,7 +197,11 @@ async function fetchLegacyConnectionStatus(
194
197
  clearTimeout(timeoutId);
195
198
  if (!response.ok) return null;
196
199
 
197
- const data = (await response.json()) as LegacyConnectionStatus;
200
+ const parsed = LegacyConnectionStatusSchema.safeParse(
201
+ await response.json(),
202
+ );
203
+ if (!parsed.success) return null;
204
+ const data = parsed.data;
198
205
 
199
206
  // Translate legacy shape into the ps process tree
200
207
  const status: ManagedProcessEntry["status"] = data.is_awake
@@ -240,7 +247,9 @@ export async function checkHealth(
240
247
  return { status: `error (${response.status})`, detail: null };
241
248
  }
242
249
 
243
- const data = (await response.json()) as HealthResponse;
250
+ const data = HealthResponseSchema
251
+ .catch({})
252
+ .parse(await response.json());
244
253
  const status = data.status || "unknown";
245
254
  return {
246
255
  status,
@@ -24,7 +24,15 @@ export interface LifecycleReporter {
24
24
  * that existing subprocess consumers parse.
25
25
  */
26
26
  export const consoleLifecycleReporter: LifecycleReporter = {
27
- progress: (step, total, label) => emitProgress(step, total, label),
27
+ progress: (step, total, label) => {
28
+ emitProgress(step, total, label);
29
+ // Plain terminals get an active status line per step — without this the
30
+ // startup goes silent between prompts (the desktop channel is the only
31
+ // consumer of emitProgress otherwise).
32
+ if (!process.env.CADET_DESKTOP_APP) {
33
+ console.log(`[${step}/${total}] ${label}`);
34
+ }
35
+ },
28
36
  log: (message) => console.log(message),
29
37
  warn: (message) => console.warn(message),
30
38
  error: (message) => console.error(message),
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { createHash, randomUUID } from "crypto";
3
+ import { z } from "zod";
3
4
  import {
4
5
  closeSync,
5
6
  mkdirSync,
@@ -57,7 +58,7 @@ function processIsAlive(pid: number): boolean {
57
58
  process.kill(pid, 0);
58
59
  return true;
59
60
  } catch (error) {
60
- return (error as NodeJS.ErrnoException).code === "EPERM";
61
+ return errnoCode(error) === "EPERM";
61
62
  }
62
63
  }
63
64
 
@@ -87,30 +88,21 @@ function processStartedAt(pid: number): string | undefined {
87
88
  }
88
89
  }
89
90
 
91
+ function errnoCode(error: unknown): string | undefined {
92
+ if (!(error instanceof Error)) return undefined;
93
+ if (!("code" in error) || typeof error.code !== "string") return undefined;
94
+ return error.code;
95
+ }
96
+
97
+ const LockOwnerFileSchema = z.object({
98
+ pid: z.number().int().positive(),
99
+ processStartedAt: z.string().min(1).optional().catch(undefined),
100
+ token: z.string().min(1),
101
+ });
102
+
90
103
  function parseOwner(raw: string): LockOwner | undefined {
91
104
  try {
92
- const parsed = JSON.parse(raw) as {
93
- pid?: unknown;
94
- processStartedAt?: unknown;
95
- token?: unknown;
96
- };
97
- if (
98
- typeof parsed.pid === "number" &&
99
- Number.isInteger(parsed.pid) &&
100
- parsed.pid > 0 &&
101
- typeof parsed.token === "string" &&
102
- parsed.token.length > 0
103
- ) {
104
- return {
105
- pid: parsed.pid,
106
- processStartedAt:
107
- typeof parsed.processStartedAt === "string" &&
108
- parsed.processStartedAt.length > 0
109
- ? parsed.processStartedAt
110
- : undefined,
111
- token: parsed.token,
112
- };
113
- }
105
+ return LockOwnerFileSchema.parse(JSON.parse(raw));
114
106
  } catch {}
115
107
  return undefined;
116
108
  }
@@ -195,7 +187,7 @@ function acquireRecoveryClaim(
195
187
  writeOwnerFile(claimPath, owner);
196
188
  return () => releaseLock(claimPath, owner.token);
197
189
  } catch (error) {
198
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
190
+ if (errnoCode(error) !== "EEXIST") {
199
191
  throw error;
200
192
  }
201
193
  if (
@@ -248,7 +240,7 @@ function removeStaleLock(
248
240
  unlinkSync(lockPath);
249
241
  return true;
250
242
  } catch (error) {
251
- return (error as NodeJS.ErrnoException).code === "ENOENT";
243
+ return errnoCode(error) === "ENOENT";
252
244
  }
253
245
  } finally {
254
246
  releaseRecoveryClaim();
@@ -262,7 +254,7 @@ function releaseLock(lockPath: string, token: string): void {
262
254
  try {
263
255
  unlinkSync(lockPath);
264
256
  } catch (error) {
265
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
257
+ if (errnoCode(error) !== "ENOENT") {
266
258
  throw error;
267
259
  }
268
260
  }
@@ -306,7 +298,7 @@ export async function withLocalHatchLock<T>(
306
298
  try {
307
299
  writeOwnerFile(lockPath, owner);
308
300
  } catch (error) {
309
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
301
+ if (errnoCode(error) !== "EEXIST") {
310
302
  throw error;
311
303
  }
312
304
  if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
@@ -11,6 +11,9 @@ import {
11
11
  resolveRuntimeUrl,
12
12
  } from "./runtime-url.js";
13
13
  import { loopbackSafeFetch } from "./loopback-fetch.js";
14
+ import { z } from "zod";
15
+
16
+ const RuntimeIdentitySchema = z.object({ version: z.string().min(1) });
14
17
 
15
18
  /**
16
19
  * Thrown when the local runtime returns 409 for an export/import request
@@ -66,15 +69,23 @@ async function migrationRequestHeaders(
66
69
  return bearerHeaders(token);
67
70
  }
68
71
 
69
- interface Raw409Body {
70
- detail?: string;
72
+ const Raw409BodySchema = z.object({
73
+ detail: z.string().optional(),
71
74
  // The runtime's current 409 contract nests the payload under `error`:
72
75
  // { error: { code: "export_in_progress" | "import_in_progress", job_id } }
73
76
  // We also tolerate a legacy flat shape ({ code, job_id }) for resilience.
74
- error?: string | { code?: string; job_id?: string };
75
- code?: string;
76
- job_id?: string;
77
- }
77
+ error: z
78
+ .union([
79
+ z.string(),
80
+ z.object({
81
+ code: z.string().optional(),
82
+ job_id: z.string().optional(),
83
+ }),
84
+ ])
85
+ .optional(),
86
+ code: z.string().optional(),
87
+ job_id: z.string().optional(),
88
+ });
78
89
 
79
90
  /** Common 409 → MigrationInProgressError parsing used by the two POST helpers. */
80
91
  async function throwIfInProgress(
@@ -82,11 +93,11 @@ async function throwIfInProgress(
82
93
  defaultKind: "export_in_progress" | "import_in_progress",
83
94
  ): Promise<void> {
84
95
  if (response.status !== 409) return;
85
- const body = (await response.json().catch(() => ({}))) as Raw409Body;
86
- const nested =
87
- typeof body.error === "object" && body.error !== null
88
- ? body.error
89
- : undefined;
96
+ const parsed = Raw409BodySchema.safeParse(
97
+ await response.json().catch(() => ({})),
98
+ );
99
+ const body = parsed.success ? parsed.data : {};
100
+ const nested = typeof body.error === "object" ? body.error : undefined;
90
101
  const jobId = nested?.job_id ?? body.job_id ?? "";
91
102
  const rawKind =
92
103
  nested?.code ??
@@ -355,9 +366,9 @@ export async function localRuntimeIdentity(
355
366
  `Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
356
367
  );
357
368
  }
358
- const body = (await response.json()) as { version?: unknown };
359
- if (typeof body.version !== "string" || !body.version) {
369
+ const parsed = RuntimeIdentitySchema.safeParse(await response.json());
370
+ if (!parsed.success) {
360
371
  throw new Error("Runtime identity response missing version");
361
372
  }
362
- return { version: body.version };
373
+ return { version: parsed.data.version };
363
374
  }