@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e

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 (111) 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/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  17. package/src/__tests__/cloud-releases.test.ts +82 -0
  18. package/src/__tests__/commands-mock.ts +101 -0
  19. package/src/__tests__/debug.test.ts +17 -91
  20. package/src/__tests__/devices.test.ts +64 -2
  21. package/src/__tests__/ensure-binary.test.ts +52 -0
  22. package/src/__tests__/gateway-token.test.ts +267 -0
  23. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  24. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  25. package/src/__tests__/host-teleport.test.ts +405 -0
  26. package/src/__tests__/multi-local.test.ts +3 -3
  27. package/src/__tests__/nginx-ingress.test.ts +587 -36
  28. package/src/__tests__/orphan-detection.test.ts +1 -1
  29. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  30. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  31. package/src/__tests__/root-backup-docker.test.ts +294 -0
  32. package/src/__tests__/root-backup.test.ts +527 -0
  33. package/src/__tests__/root-restore-command.test.ts +147 -0
  34. package/src/__tests__/root-signing-key.test.ts +50 -0
  35. package/src/__tests__/setup.test.ts +17 -7
  36. package/src/__tests__/shell-mode.test.ts +83 -0
  37. package/src/__tests__/sleep.test.ts +11 -0
  38. package/src/__tests__/teleport.test.ts +68 -2996
  39. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  40. package/src/__tests__/tunnel-stop.test.ts +226 -0
  41. package/src/__tests__/tunnel.test.ts +22 -4
  42. package/src/__tests__/upgrade-local.test.ts +21 -11
  43. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  44. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  45. package/src/__tests__/wake.test.ts +4 -1
  46. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  47. package/src/commands/backup.ts +44 -399
  48. package/src/commands/client.ts +3 -3
  49. package/src/commands/completions.ts +0 -2
  50. package/src/commands/debug.ts +75 -131
  51. package/src/commands/devices.ts +117 -47
  52. package/src/commands/gateway/token.ts +77 -23
  53. package/src/commands/hatch.ts +10 -52
  54. package/src/commands/pair.ts +9 -3
  55. package/src/commands/ps.ts +4 -2
  56. package/src/commands/restore.ts +77 -444
  57. package/src/commands/retire.ts +2 -3
  58. package/src/commands/sleep.ts +15 -2
  59. package/src/commands/teleport.ts +47 -1559
  60. package/src/commands/tunnel.ts +91 -11
  61. package/src/commands/upgrade.ts +84 -157
  62. package/src/commands/wake.ts +1 -1
  63. package/src/index.ts +0 -4
  64. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  65. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  66. package/src/lib/__tests__/web-dist.test.ts +27 -1
  67. package/src/lib/assistant-client.ts +1 -1
  68. package/src/lib/assistant-config.ts +43 -23
  69. package/src/lib/backup-ops.ts +37 -251
  70. package/src/lib/cloud-client.ts +10 -13
  71. package/src/lib/cloud-login-poll.ts +75 -0
  72. package/src/lib/cloud-login.ts +5 -2
  73. package/src/lib/cloud-releases.ts +54 -10
  74. package/src/lib/cloud-values.ts +1 -1
  75. package/src/lib/confirm-action.ts +2 -2
  76. package/src/lib/docker.ts +65 -34
  77. package/src/lib/ensure-binary.ts +65 -6
  78. package/src/lib/github-repository.ts +2 -0
  79. package/src/lib/guardian-token.ts +1 -23
  80. package/src/lib/hatch-local.ts +18 -52
  81. package/src/lib/hatch-next-steps.ts +15 -2
  82. package/src/lib/host-teleport.ts +506 -0
  83. package/src/lib/http-client.ts +25 -0
  84. package/src/lib/ingress-config.ts +31 -0
  85. package/src/lib/live-voice/tunnel.test.ts +1 -1
  86. package/src/lib/live-voice/tunnel.ts +3 -3
  87. package/src/lib/local.ts +10 -8
  88. package/src/lib/nginx-ingress.ts +572 -95
  89. package/src/lib/process.ts +13 -0
  90. package/src/lib/root-backup.ts +917 -0
  91. package/src/lib/root-signing-key.ts +31 -0
  92. package/src/lib/runtime-url.ts +0 -29
  93. package/src/lib/teleport-backup.ts +3 -16
  94. package/src/lib/tunnel.ts +87 -34
  95. package/src/lib/tunnels/cadet.ts +12 -1
  96. package/src/lib/tunnels/cloudflare.ts +36 -1
  97. package/src/lib/tunnels/ngrok.ts +16 -2
  98. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  99. package/src/lib/tunnels/tailscale.ts +79 -1
  100. package/src/lib/upgrade-lifecycle.ts +63 -141
  101. package/src/lib/web-dist.ts +79 -1
  102. package/src/lib/xdg-log.ts +11 -0
  103. package/src/__tests__/bundle-staging.test.ts +0 -268
  104. package/src/__tests__/unpair.test.ts +0 -160
  105. package/src/commands/rollback.ts +0 -479
  106. package/src/commands/unpair.ts +0 -122
  107. package/src/lib/__tests__/job-polling.test.ts +0 -278
  108. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  109. package/src/lib/bundle-staging.ts +0 -220
  110. package/src/lib/job-polling.ts +0 -195
  111. package/src/lib/local-runtime-client.ts +0 -374
@@ -146,13 +146,10 @@ const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
146
146
  * orgs in another tab). Clearing the entry forces the next `authHeaders`
147
147
  * call to refetch the org ID from the cloud.
148
148
  *
149
- * Exported so other modules (e.g. local-runtime-client) can implement the
149
+ * Exported so cloud API consumers can implement the
150
150
  * same retry pattern without needing direct access to the cache map.
151
151
  */
152
- export function invalidateOrgIdCache(
153
- token: string,
154
- cloudUrl?: string,
155
- ): void {
152
+ export function invalidateOrgIdCache(token: string, cloudUrl?: string): void {
156
153
  orgIdCache.delete(`${token}::${cloudUrl ?? ""}`);
157
154
  }
158
155
 
@@ -658,9 +655,9 @@ export async function hatchAssistant(
658
655
  }
659
656
 
660
657
  if (response.status === 401 || response.status === 403) {
661
- const detail = CloudErrorDetailSchema
662
- .catch({})
663
- .parse(await response.json().catch(() => ({})));
658
+ const detail = CloudErrorDetailSchema.catch({}).parse(
659
+ await response.json().catch(() => ({})),
660
+ );
664
661
  throw new Error(
665
662
  detail.detail ??
666
663
  "Invalid or expired token. Run `cadet login` to re-authenticate.",
@@ -671,9 +668,9 @@ export async function hatchAssistant(
671
668
  throw new Error("Insufficient balance to hatch a new assistant.");
672
669
  }
673
670
 
674
- const errorBody = CloudErrorDetailSchema
675
- .catch({})
676
- .parse(await response.json().catch(() => ({})));
671
+ const errorBody = CloudErrorDetailSchema.catch({}).parse(
672
+ await response.json().catch(() => ({})),
673
+ );
677
674
  throw new Error(
678
675
  errorBody.detail ??
679
676
  `Platform API error: ${response.status} ${response.statusText}`,
@@ -987,7 +984,7 @@ export async function platformImportPreflightFromGcs(
987
984
  {
988
985
  method: "POST",
989
986
  headers: await authHeaders(token, cloudUrl),
990
- signal: AbortSignal.timeout(120_000),
987
+ signal: AbortSignal.timeout(30 * 60_000),
991
988
  body: JSON.stringify({ bundle_key: bundleKey }),
992
989
  },
993
990
  );
@@ -1010,7 +1007,7 @@ export async function platformImportBundleFromGcs(
1010
1007
  method: "POST",
1011
1008
  headers: await authHeaders(token, cloudUrl),
1012
1009
  body: JSON.stringify({ bundle_key: bundleKey }),
1013
- signal: AbortSignal.timeout(60_000),
1010
+ signal: AbortSignal.timeout(30 * 60_000),
1014
1011
  },
1015
1012
  );
1016
1013
 
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
3
+
4
+ const requestSchema = z.object({
5
+ request_id: z.string().regex(/^[a-f0-9]{64}$/),
6
+ poll_secret: z.string().regex(/^[a-f0-9]{64}$/),
7
+ verification_uri: z.string().url(),
8
+ user_code: z.string().min(1),
9
+ interval: z.number().min(1).max(10),
10
+ });
11
+ const resultSchema = z.object({ session_token: z.string().min(1) });
12
+
13
+ /** Browser approval and polling work when the terminal and browser are on different hosts. */
14
+ export async function pollingCloudLogin(
15
+ cloudUrl: string,
16
+ options: {
17
+ timeoutMs?: number;
18
+ fetch?: (
19
+ input: string | URL | Request,
20
+ init?: RequestInit,
21
+ ) => Promise<Response>;
22
+ log?: (message: string) => void;
23
+ } = {},
24
+ ): Promise<string> {
25
+ const request = options.fetch ?? loopbackSafeFetch;
26
+ const log = options.log ?? console.log;
27
+ const signal = AbortSignal.timeout(options.timeoutMs ?? 300_000);
28
+ const origin = new URL(cloudUrl).origin;
29
+ try {
30
+ const started = await request(`${origin}/v1/auth/cli/start`, {
31
+ method: "POST",
32
+ signal,
33
+ });
34
+ if (!started.ok)
35
+ throw new Error(`Could not start terminal login (${started.status}).`);
36
+ const login = requestSchema.parse(await started.json());
37
+ log(`Open this URL to log in: ${login.verification_uri}`);
38
+ log(
39
+ `Confirm code ${login.user_code} in your browser. Waiting up to 5 minutes...`,
40
+ );
41
+ while (!signal.aborted) {
42
+ const response = await request(`${origin}/v1/auth/cli/poll`, {
43
+ method: "POST",
44
+ signal,
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({
47
+ request_id: login.request_id,
48
+ poll_secret: login.poll_secret,
49
+ }),
50
+ });
51
+ if (response.status === 200)
52
+ return resultSchema.parse(await response.json()).session_token;
53
+ if (response.status === 410)
54
+ throw new Error("Login request expired. Run cadet login again.");
55
+ if (response.status !== 202)
56
+ throw new Error(`Login polling failed (${response.status}).`);
57
+ await new Promise<void>((resolve, reject) => {
58
+ const abort = () => {
59
+ clearTimeout(timer);
60
+ reject(new Error("Login timed out after 5 minutes."));
61
+ };
62
+ const timer = setTimeout(() => {
63
+ signal.removeEventListener("abort", abort);
64
+ resolve();
65
+ }, login.interval * 1000);
66
+ signal.addEventListener("abort", abort, { once: true });
67
+ if (signal.aborted) abort();
68
+ });
69
+ }
70
+ throw new Error("Login timed out after 5 minutes.");
71
+ } catch (error) {
72
+ if (signal.aborted) throw new Error("Login timed out after 5 minutes.");
73
+ throw error;
74
+ }
75
+ }
@@ -6,6 +6,7 @@
6
6
  * login when the user picks that provider).
7
7
  */
8
8
 
9
+ import { pollingCloudLogin } from "./cloud-login-poll.js";
9
10
  import { randomBytes } from "crypto";
10
11
  import { createServer } from "http";
11
12
  import type { AddressInfo } from "net";
@@ -27,7 +28,8 @@ import {
27
28
  readCloudToken,
28
29
  saveCloudToken,
29
30
  } from "./cloud-client.js";
30
- import { fetchAssistantIngressUrl, fetchCurrentVersion } from "./upgrade-lifecycle.js";
31
+ import { fetchAssistantIngressUrl } from "./upgrade-lifecycle.js";
32
+ import { fetchCurrentVersion } from "./http-client.js";
31
33
  import {
32
34
  CALLBACK_PATH,
33
35
  buildAuthorizeUrl,
@@ -37,7 +39,7 @@ import {
37
39
  generatePkcePair,
38
40
  } from "./workos-pkce.js";
39
41
 
40
- const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
42
+ const LOGIN_TIMEOUT_MS = 300_000;
41
43
 
42
44
  function escapeHtml(s: string): string {
43
45
  return s
@@ -258,6 +260,7 @@ export function startLoopbackListener(
258
260
 
259
261
  /** App-held WorkOS PKCE login */
260
262
  export async function workosPkceLogin(cloudUrl: string): Promise<string> {
263
+ if (!process.stdin.isTTY) return pollingCloudLogin(cloudUrl);
261
264
  const clientId = await fetchWorkosClientId(cloudUrl);
262
265
  const { verifier, challenge } = generatePkcePair();
263
266
  const state = randomBytes(32).toString("hex");
@@ -1,3 +1,6 @@
1
+ import { z } from "zod";
2
+
3
+ import { CADET_GITHUB_REPOSITORY } from "./github-repository.js";
1
4
  import { getCloudUrl } from "./cloud-client.js";
2
5
  import { dockerhubImages } from "./docker.js";
3
6
  import type { ServiceName } from "./docker.js";
@@ -22,12 +25,40 @@ function releasesOptsFor(channel: ReleaseChannel): { channel?: "preview" } {
22
25
  return channel === "preview" ? { channel: "preview" } : {};
23
26
  }
24
27
 
25
- export interface ReleaseListItem {
26
- version: string;
27
- is_stable?: boolean;
28
- assistant_image_ref?: string | null;
29
- gateway_image_ref?: string | null;
30
- credential_executor_image_ref?: string | null;
28
+ const ReleaseListItemSchema = z.object({
29
+ version: z.string().min(1),
30
+ is_stable: z.boolean().optional(),
31
+ assistant_image_ref: z.string().nullable().optional(),
32
+ gateway_image_ref: z.string().nullable().optional(),
33
+ credential_executor_image_ref: z.string().nullable().optional(),
34
+ });
35
+
36
+ export type ReleaseListItem = z.infer<typeof ReleaseListItemSchema>;
37
+
38
+ const STABLE_VERSION = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
39
+ const GitHubReleaseSchema = z.object({
40
+ tag_name: z.string().regex(STABLE_VERSION),
41
+ draft: z.literal(false),
42
+ prerelease: z.literal(false),
43
+ });
44
+
45
+ async function fetchLatestGitHubStableVersion(): Promise<string | null> {
46
+ try {
47
+ const response = await loopbackSafeFetch(
48
+ `https://api.github.com/repos/${CADET_GITHUB_REPOSITORY}/releases/latest`,
49
+ {
50
+ headers: { Accept: "application/vnd.github+json" },
51
+ signal: AbortSignal.timeout(10_000),
52
+ },
53
+ );
54
+ if (!response.ok) {
55
+ return null;
56
+ }
57
+ const parsed = GitHubReleaseSchema.safeParse(await response.json());
58
+ return parsed.success ? parsed.data.tag_name.replace(/^v/, "") : null;
59
+ } catch {
60
+ return null;
61
+ }
31
62
  }
32
63
 
33
64
  /**
@@ -56,15 +87,21 @@ export async function fetchReleases(opts?: {
56
87
  `${cloudUrl}/v1/releases/?${filter}&limit=${RELEASES_FETCH_LIMIT}`,
57
88
  { signal: AbortSignal.timeout(10_000) },
58
89
  );
59
- if (!response.ok) return null;
60
- return (await response.json()) as ReleaseListItem[];
90
+ if (!response.ok) {
91
+ return null;
92
+ }
93
+ const parsed = z
94
+ .array(ReleaseListItemSchema)
95
+ .safeParse(await response.json());
96
+ return parsed.success ? parsed.data : null;
61
97
  } catch {
62
98
  return null;
63
99
  }
64
100
  }
65
101
 
66
102
  /**
67
- * Fetch the latest release version for a channel from the Cadet Cloud API.
103
+ * Fetch the latest release version from Cadet Cloud, falling back to GitHub
104
+ * for stable releases when the cloud has no usable result.
68
105
  * Returns the version string (e.g. "0.7.0") or null if unavailable.
69
106
  * The releases endpoint returns entries ordered newest-first, so the first
70
107
  * entry is the latest for the requested channel.
@@ -73,7 +110,14 @@ export async function fetchLatestVersion(
73
110
  channel: ReleaseChannel = "stable",
74
111
  ): Promise<string | null> {
75
112
  const releases = await fetchReleases(releasesOptsFor(channel));
76
- return releases?.[0]?.version ?? null;
113
+ if (channel === "preview") {
114
+ return releases?.[0]?.version ?? null;
115
+ }
116
+ const latest = releases?.find(
117
+ (release) =>
118
+ release.is_stable !== false && STABLE_VERSION.test(release.version),
119
+ );
120
+ return latest?.version ?? fetchLatestGitHubStableVersion();
77
121
  }
78
122
 
79
123
  /**
@@ -1,3 +1,3 @@
1
1
  export function isCloudHome(cloud: unknown): boolean {
2
- return cloud === "cloud" || cloud === "cadet";
2
+ return cloud === "cloud";
3
3
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared interactive confirmation for destructive CLI commands (retire, unpair,
3
- * …). Per cli/AGENTS.md, a command that removes assistant state must print the
2
+ * Shared interactive confirmation for destructive CLI commands (retire,
3
+ * devices revoke, …). Per cli/AGENTS.md, a command that removes assistant state must print the
4
4
  * resolved identity and require confirmation, with a `--yes` bypass for
5
5
  * automation.
6
6
  */
package/src/lib/docker.ts CHANGED
@@ -296,6 +296,8 @@ export interface HatchDockerParams {
296
296
  /** Extra env vars forwarded into the assistant container. */
297
297
  flagEnvVars?: Record<string, string>;
298
298
  setupProviderCredentials?: boolean;
299
+ /** Whole-installation state installed into stopped containers before startup. */
300
+ initialArchive?: string;
299
301
  /**
300
302
  * Path to a local source tree to build images from before hatching. When
301
303
  * provided, this path is used directly as the repo root and no file
@@ -305,7 +307,6 @@ export interface HatchDockerParams {
305
307
  * also enables hot-reload.
306
308
  */
307
309
  sourcePath?: string | null;
308
- analyze?: boolean;
309
310
  /**
310
311
  * Name of an existing container whose network namespace the assistant,
311
312
  * gateway, and credential-executor join (`--network=container:<name>`),
@@ -813,6 +814,7 @@ export const SERVICE_START_ORDER: ServiceName[] = [
813
814
  /** Start all three containers in dependency order. */
814
815
  export async function startContainers(
815
816
  opts: {
817
+ createOnly?: boolean;
816
818
  signingKey?: string;
817
819
  bootstrapSecret?: string;
818
820
  cesServiceToken?: string;
@@ -833,8 +835,13 @@ export async function startContainers(
833
835
  avatarDevicePath: resolveAvatarDevicePath(),
834
836
  });
835
837
  for (const service of SERVICE_START_ORDER) {
836
- log(`🚀 Starting ${service} container...`);
837
- await exec("docker", runArgs[service]());
838
+ const args = runArgs[service]();
839
+ if (opts.createOnly) {
840
+ args[0] = "create";
841
+ args.splice(args.indexOf("-d"), 1);
842
+ }
843
+ log(`${opts.createOnly ? "Creating" : "Starting"} ${service} container...`);
844
+ await exec("docker", args);
838
845
  }
839
846
  }
840
847
 
@@ -850,14 +857,23 @@ export async function stopContainers(
850
857
  /** Stop containers without removing them (preserves state for `docker start`). */
851
858
  export async function sleepContainers(
852
859
  res: ReturnType<typeof dockerResourceNames>,
860
+ options: { timeoutMs?: number } = {},
853
861
  ): Promise<void> {
854
862
  for (const container of [
855
- res.cesContainer,
856
- res.gatewayContainer,
857
863
  res.assistantContainer,
864
+ res.gatewayContainer,
865
+ res.cesContainer,
858
866
  ]) {
859
867
  try {
860
- await exec("docker", ["stop", container]);
868
+ const status = await execOutput("docker", ["inspect", "--format", "{{.State.Status}}", container]);
869
+ if (status === "exited" || status === "created") continue;
870
+ if (status !== "running") throw new Error(`${container} is ${status}; cannot stop gracefully.`);
871
+ await exec("docker", ["kill", "--signal=SIGTERM", container]);
872
+ const deadline = Date.now() + (options.timeoutMs ?? 60_000);
873
+ while (await execOutput("docker", ["inspect", "--format", "{{.State.Running}}", container]) === "true") {
874
+ if (Date.now() >= deadline) throw new Error(`${container} did not stop gracefully.`);
875
+ await new Promise((resolve) => setTimeout(resolve, 250));
876
+ }
861
877
  } catch (err) {
862
878
  const msg =
863
879
  err instanceof Error ? err.message.toLowerCase() : String(err);
@@ -1631,7 +1647,9 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1631
1647
  }
1632
1648
 
1633
1649
  const cesServiceToken = randomBytes(32).toString("hex");
1634
- const signingKey = randomBytes(32).toString("hex");
1650
+ const signingKey = params.initialArchive
1651
+ ? undefined
1652
+ : randomBytes(32).toString("hex");
1635
1653
 
1636
1654
  // When launched by a remote hatch startup script, the env var
1637
1655
  // GUARDIAN_BOOTSTRAP_SECRET is already set with the laptop's secret.
@@ -1670,8 +1688,26 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1670
1688
  ...flagEnvVars,
1671
1689
  CADET_DEVICE_ID: hostDeviceId,
1672
1690
  };
1691
+ const runtimeUrl = `http://localhost:${gatewayPort}`;
1692
+ const dockerEntry: AssistantEntry = {
1693
+ assistantId: instanceName,
1694
+ runtimeUrl,
1695
+ cloud: "docker",
1696
+ hatchedAt: new Date().toISOString(),
1697
+ guardianBootstrapSecret: ownSecret,
1698
+ containerInfo: {
1699
+ assistantImage: imageTags.assistant,
1700
+ gatewayImage: imageTags.gateway,
1701
+ cesImage: imageTags["credential-executor"],
1702
+ assistantPort,
1703
+ },
1704
+ };
1705
+ if (params.initialArchive) {
1706
+ saveAssistantEntry(dockerEntry);
1707
+ }
1673
1708
  await startContainers(
1674
1709
  {
1710
+ createOnly: !!params.initialArchive,
1675
1711
  signingKey,
1676
1712
  bootstrapSecret,
1677
1713
  cesServiceToken,
@@ -1689,29 +1725,29 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1689
1725
  );
1690
1726
  const containersUpAt = Date.now();
1691
1727
 
1692
- const runtimeUrl = `http://localhost:${gatewayPort}`;
1693
- const dockerEntry: AssistantEntry = {
1694
- assistantId: instanceName,
1695
- runtimeUrl,
1696
- cloud: "docker",
1697
- hatchedAt: new Date().toISOString(),
1698
- guardianBootstrapSecret: ownSecret,
1699
- containerInfo: {
1700
- assistantImage: imageTags.assistant,
1701
- gatewayImage: imageTags.gateway,
1702
- cesImage: imageTags["credential-executor"],
1703
- assistantPort,
1704
- },
1705
- };
1706
1728
  emitProgress(5, 6, "Saving configuration...");
1707
1729
  saveAssistantEntry(dockerEntry);
1708
1730
  setActiveAssistant(instanceName);
1709
1731
 
1732
+ if (params.initialArchive) {
1733
+ const { initializeRootArchive, DockerRootStorage } =
1734
+ await import("./root-backup.js");
1735
+ await initializeRootArchive(dockerEntry, params.initialArchive);
1736
+ const storage = new DockerRootStorage(dockerEntry);
1737
+ storage.initialRunning = new Set(Object.values(storage.containers));
1738
+ await storage.resume();
1739
+ }
1740
+
1710
1741
  emitProgress(6, 6, "Waiting for services...");
1711
1742
  // Hatch always detaches; only watch mode keeps the log stream attached.
1712
1743
  const waitDetached = !watch;
1713
- const { ready, guardianAccessToken, notReadyReason } =
1714
- await waitForGatewayAndLease({
1744
+ const { ready, guardianAccessToken, notReadyReason } = params.initialArchive
1745
+ ? {
1746
+ ready: true,
1747
+ guardianAccessToken: undefined,
1748
+ notReadyReason: undefined,
1749
+ }
1750
+ : await waitForGatewayAndLease({
1715
1751
  bootstrapSecret: ownSecret,
1716
1752
  containerName: res.assistantContainer,
1717
1753
  detached: waitDetached,
@@ -1719,7 +1755,6 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1719
1755
  logFd,
1720
1756
  runtimeUrl,
1721
1757
  containersUpAt,
1722
- analyze: params.analyze ?? false,
1723
1758
  });
1724
1759
 
1725
1760
  if (!ready && !(watch && repoRoot)) {
@@ -1828,7 +1863,6 @@ async function waitForGatewayAndLease(opts: {
1828
1863
  logFd: number | "ignore";
1829
1864
  runtimeUrl: string;
1830
1865
  containersUpAt: number;
1831
- analyze: boolean;
1832
1866
  }): Promise<{
1833
1867
  ready: boolean;
1834
1868
  guardianAccessToken?: string;
@@ -1844,7 +1878,6 @@ async function waitForGatewayAndLease(opts: {
1844
1878
  logFd,
1845
1879
  runtimeUrl,
1846
1880
  containersUpAt,
1847
- analyze,
1848
1881
  } = opts;
1849
1882
 
1850
1883
  const log = (msg: string): void => {
@@ -1964,14 +1997,12 @@ async function waitForGatewayAndLease(opts: {
1964
1997
  const containersUpToReadyMs = readyAt - start;
1965
1998
  const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
1966
1999
  log(`Assistant ready after ${elapsedSec}s`);
1967
- if (analyze) {
1968
- console.info(
1969
- `[cadet-hatch-timing] ${JSON.stringify({
1970
- containers_up_to_ready_ms: containersUpToReadyMs,
1971
- instance: instanceName,
1972
- })}`,
1973
- );
1974
- }
2000
+ console.info(
2001
+ `[cadet-hatch-timing] ${JSON.stringify({
2002
+ containers_up_to_ready_ms: containersUpToReadyMs,
2003
+ instance: instanceName,
2004
+ })}`,
2005
+ );
1975
2006
 
1976
2007
  // Lease guardian token. The /readyz check confirms both gateway and
1977
2008
  // assistant are reachable. Retry with backoff in case there is a brief
@@ -31,6 +31,8 @@ export interface EnsureBinaryOptions {
31
31
  runInstall?: (argv: string[]) => boolean;
32
32
  /** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
33
33
  confirmInstall?: () => Promise<boolean>;
34
+ platform?: NodeJS.Platform;
35
+ confirmHomebrewInstall?: () => Promise<boolean>;
34
36
  streams?: {
35
37
  input?: NodeJS.ReadStream | Readable;
36
38
  output?: NodeJS.WriteStream | Writable;
@@ -69,9 +71,60 @@ export async function ensureBinaryInstalled(
69
71
  }
70
72
 
71
73
  const pmAvailable = opts.pmAvailable ?? probePackageManager;
72
- const argv = Object.entries(opts.packages).find(([pm]) =>
73
- pmAvailable(pm),
74
- )?.[1];
74
+ let argv = Object.entries(opts.packages).find(([pm]) => pmAvailable(pm))?.[1];
75
+ if (
76
+ !argv &&
77
+ (opts.platform ?? process.platform) === "darwin" &&
78
+ opts.packages.brew
79
+ ) {
80
+ const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
81
+ pmAvailable,
82
+ );
83
+ if (brew) {
84
+ const prefix = brew.slice(0, -"/bin/brew".length);
85
+ process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
86
+ argv = [brew, ...opts.packages.brew.slice(1)];
87
+ }
88
+ }
89
+ if (
90
+ !argv &&
91
+ (opts.platform ?? process.platform) === "darwin" &&
92
+ opts.packages.brew
93
+ ) {
94
+ output.write(
95
+ "Homebrew is required to install this dependency, but it isn't installed.\n",
96
+ );
97
+ const confirmHomebrew =
98
+ opts.confirmHomebrewInstall ?? defaultConfirm(opts, "Homebrew");
99
+ let approved = false;
100
+ try {
101
+ approved = await confirmHomebrew();
102
+ } catch {
103
+ /* No interactive approval. */
104
+ }
105
+ if (approved) {
106
+ const bootstrap = [
107
+ "/bin/bash",
108
+ "-c",
109
+ '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/7a3f48c7e498d7df3237fd41c30f4d492857edf4/install.sh)"',
110
+ ];
111
+ const installed = opts.runInstall
112
+ ? opts.runInstall(bootstrap)
113
+ : runInstallArgv(bootstrap);
114
+ const brew = installed
115
+ ? ["brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
116
+ pmAvailable,
117
+ )
118
+ : undefined;
119
+ if (brew) {
120
+ const prefix =
121
+ brew === "brew" ? "" : brew.slice(0, -"/bin/brew".length);
122
+ if (prefix)
123
+ process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
124
+ argv = [brew, ...opts.packages.brew.slice(1)];
125
+ }
126
+ }
127
+ }
75
128
  if (!argv) {
76
129
  output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
77
130
  return null;
@@ -90,7 +143,10 @@ export async function ensureBinaryInstalled(
90
143
  }
91
144
 
92
145
  /** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
93
- function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
146
+ function defaultConfirm(
147
+ opts: EnsureBinaryOptions,
148
+ binary = opts.binary,
149
+ ): () => Promise<boolean> {
94
150
  return async () => {
95
151
  const input = opts.streams?.input ?? process.stdin;
96
152
  const output = opts.streams?.output ?? process.stdout;
@@ -99,7 +155,7 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
99
155
  throw new Error("Install confirm requires an interactive terminal.");
100
156
  }
101
157
  const answer = (
102
- await readLineText(`Install ${opts.binary} now? [Y/n] `, {
158
+ await readLineText(`Install ${binary} now? [Y/n] `, {
103
159
  input,
104
160
  output,
105
161
  })
@@ -114,7 +170,10 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
114
170
  function runInstallArgv(argv: string[]): boolean {
115
171
  const [command, ...args] = argv;
116
172
  if (!command) return false;
117
- const result = spawnSync(command, args, { stdio: "inherit" });
173
+ const result = spawnSync(command, args, {
174
+ stdio: "inherit",
175
+ windowsHide: true,
176
+ });
118
177
  return !result.error && result.status === 0;
119
178
  }
120
179
 
@@ -0,0 +1,2 @@
1
+ /** Source repository for published Cadet releases. */
2
+ export const CADET_GITHUB_REPOSITORY = "vargasjr-dev/mycadet-temp";
@@ -7,7 +7,6 @@ import {
7
7
  mkdirSync,
8
8
  openSync,
9
9
  readFileSync,
10
- rmdirSync,
11
10
  statSync,
12
11
  unlinkSync,
13
12
  writeFileSync,
@@ -79,27 +78,6 @@ function getGuardianTokenPath(assistantId: string): string {
79
78
  return guardianTokenPath(resolveConfigDir(process.env), assistantId);
80
79
  }
81
80
 
82
- /**
83
- * Best-effort removal of an assistant's stored guardian token (used by
84
- * `cadet unpair` to forget a paired connection). Never throws if the token
85
- * file or its per-assistant directory is already absent.
86
- */
87
- export function deleteGuardianToken(assistantId: string): void {
88
- const tokenPath = getGuardianTokenPath(assistantId);
89
- try {
90
- unlinkSync(tokenPath);
91
- } catch {
92
- /* already gone */
93
- }
94
- // Clean up the now-empty per-assistant directory; rmdir throws if it still
95
- // holds other files, in which case we leave it.
96
- try {
97
- rmdirSync(dirname(tokenPath));
98
- } catch {
99
- /* not empty or absent */
100
- }
101
- }
102
-
103
81
  function getPersistedDeviceIdPath(): string {
104
82
  return join(getConfigDir(getCurrentEnvironment()), "device-id");
105
83
  }
@@ -454,7 +432,7 @@ export async function refreshGuardianTokenResult(
454
432
  return {
455
433
  ok: false,
456
434
  status: 401,
457
- error: "Guardian token expired. Re-run `cadet hatch` or `cadet wake`.",
435
+ error: "Guardian refresh token expired",
458
436
  };
459
437
  }
460
438