@mycadet/cli 0.12.9 → 0.12.10

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.
@@ -47,7 +47,7 @@ interface TunnelArgs {
47
47
  provider: TunnelProviderName;
48
48
  domain: string | null;
49
49
  clearDomain: boolean;
50
- detach: boolean;
50
+ foreground: boolean;
51
51
  }
52
52
 
53
53
  const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
@@ -57,7 +57,7 @@ function parseArgs(): TunnelArgs {
57
57
  let provider: TunnelProviderName | null = null;
58
58
  let domain: string | null = null;
59
59
  let clearDomain = false;
60
- let detach = false;
60
+ let foreground = false;
61
61
 
62
62
  for (let i = 0; i < args.length; i++) {
63
63
  const arg = args[i];
@@ -112,19 +112,22 @@ function parseArgs(): TunnelArgs {
112
112
  " tunnel without one. Cannot be combined with --domain.",
113
113
  );
114
114
  console.log(
115
- ` -d, --detach Run the tunnel in the background. Waits up to ${TUNNEL_BACKGROUND_START_TIMEOUT_MS / 1000}s for the`,
115
+ ` --foreground Stream the tunnel's logs to this terminal. By default the tunnel`,
116
116
  );
117
117
  console.log(
118
- " initial established/failed status before returning; logs go to",
118
+ " runs in the background: the command returns after printing the public",
119
119
  );
120
120
  console.log(
121
- " the CLI log directory. Stop it with `kill <pid>`, except when it",
121
+ ` URL, and logs stream to tunnel.log in the CLI log directory. A background`,
122
122
  );
123
123
  console.log(
124
- " adopted an already-running tunnel (started outside this",
124
+ " tunnel stops with `kill <pid>`, except when it adopted an already-running",
125
125
  );
126
126
  console.log(
127
- " command): killing the supervisor then leaves that tunnel running.",
127
+ " tunnel (started outside this command): killing the supervisor then",
128
+ );
129
+ console.log(
130
+ " leaves that tunnel running.",
128
131
  );
129
132
  console.log("");
130
133
  console.log("Providers:");
@@ -151,9 +154,6 @@ function parseArgs(): TunnelArgs {
151
154
  }
152
155
  console.log(" $ cadet tunnel --provider cloudflare");
153
156
  console.log(" $ cadet tunnel my-assistant --provider tailscale");
154
- console.log(
155
- ` $ cadet tunnel --provider ${DEFAULT_TUNNEL_PROVIDER} --detach`,
156
- );
157
157
  process.exit(0);
158
158
  } else if (arg === "--provider") {
159
159
  const next = args[i + 1];
@@ -188,8 +188,8 @@ function parseArgs(): TunnelArgs {
188
188
  i++;
189
189
  } else if (arg === "--clear-domain") {
190
190
  clearDomain = true;
191
- } else if (arg === "-d" || arg === "--detach") {
192
- detach = true;
191
+ } else if (arg === "--foreground") {
192
+ foreground = true;
193
193
  } else if (arg.startsWith("-")) {
194
194
  console.error(`Error: Unknown option '${arg}'.`);
195
195
  process.exit(1);
@@ -227,7 +227,7 @@ function parseArgs(): TunnelArgs {
227
227
  process.exit(1);
228
228
  }
229
229
 
230
- return { assistantName, provider, domain, clearDomain, detach };
230
+ return { assistantName, provider, domain, clearDomain, foreground };
231
231
  }
232
232
 
233
233
  /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
@@ -345,31 +345,31 @@ const TUNNEL_BACKGROUND_START_TIMEOUT_MS = 35_000;
345
345
  const TUNNEL_ESTABLISHED_RE = /Tunnel established: (\S+)/;
346
346
 
347
347
  /**
348
- * Re-invoke `cadet tunnel` (without `-d`/`--detach`) as a detached background
349
- * process via `relaunchDetached`, waiting for the child to either print its
350
- * "Tunnel established" or adopted-tunnel line (readiness) or
351
- * exit early (failure), so a misconfigured provider still fails loudly in
352
- * the terminal that ran `-d`.
348
+ * Re-invoke `cadet tunnel --foreground` as a detached background process via
349
+ * `relaunchDetached`, waiting for the child to either print its
350
+ * "Tunnel established" or adopted-tunnel line (readiness) or exit early
351
+ * (failure), so a misconfigured provider still fails loudly in the terminal
352
+ * that ran `cadet tunnel`.
353
353
  *
354
- * The log filename is scoped to this (parent) process's pid so concurrent
355
- * `-d` runs (e.g. tunneling two different local assistants at once) don't
356
- * truncate and read each other's readiness output.
354
+ * The log file is the fixed `tunnel.log` so there is one stable path to tail;
355
+ * concurrent tunnel runs share (and truncate) it.
357
356
  */
358
357
  async function spawnDetachedTunnel(): Promise<void> {
359
358
  const childArgs: string[] = ["tunnel"];
360
359
  for (const arg of process.argv.slice(3)) {
361
- if (arg === "-d" || arg === "--detach") {
360
+ if (arg === "--foreground") {
362
361
  continue;
363
362
  }
364
363
  childArgs.push(arg);
365
364
  }
365
+ childArgs.push("--foreground");
366
366
 
367
367
  let publicUrl: string | undefined;
368
368
  let adopted = false;
369
369
 
370
370
  const { child, logPath, ready, exitCode } = await relaunchDetached({
371
371
  args: childArgs,
372
- logFile: `tunnel-${process.pid}.log`,
372
+ logFile: "tunnel.log",
373
373
  timeoutMs: TUNNEL_BACKGROUND_START_TIMEOUT_MS,
374
374
  isReady: (logPath) => {
375
375
  let logText = "";
@@ -417,10 +417,10 @@ async function spawnDetachedTunnel(): Promise<void> {
417
417
 
418
418
  export const tunnel = Object.assign(
419
419
  async function tunnel(): Promise<void> {
420
- const { assistantName, provider, domain, clearDomain, detach } =
420
+ const { assistantName, provider, domain, clearDomain, foreground } =
421
421
  parseArgs();
422
422
 
423
- if (detach) {
423
+ if (!foreground) {
424
424
  await spawnDetachedTunnel();
425
425
  return;
426
426
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
2
- import { basename, dirname, join } from "path";
2
+ import { basename, dirname, join, sep } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
5
  import {
@@ -14,8 +14,8 @@ import {
14
14
  import { retireDocker } from "../lib/docker.js";
15
15
  import {
16
16
  getConfigDirs,
17
- getInputHistoryPath,
18
17
  getLogDir,
18
+ getSharedStateDir,
19
19
  getStateDir,
20
20
  } from "../lib/environments/paths.js";
21
21
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
@@ -98,62 +98,84 @@ function findInstalledPackageRoot(startDir: string): string | null {
98
98
  return null;
99
99
  }
100
100
 
101
+ /** The CLI's own install location, when it can be deleted. */
102
+ interface SelfDeleteTarget {
103
+ path: string;
104
+ description: string;
105
+ }
106
+
101
107
  /**
102
- * Remove the running CLI. Handles the two real install shapes:
103
- * - the compiled `cadet` binary (process.execPath IS the install)
104
- * - a global package install (bun install -g mycadet / npm install -g)
105
- * Under a source checkout or a bare runtime (bun/node), nothing is deleted —
106
- * the runtime binary must never be a deletion target.
108
+ * Where this CLI is running from, when that is an INSTALL we can remove:
109
+ * the compiled `cadet` binary (process.execPath IS the install) or a global
110
+ * package install (bun install -g mycadet / npm install -g mycadet). Under
111
+ * a source checkout or a bare runtime (bun/node), null — the runtime binary
112
+ * must never be a deletion target. The uninstall prompt lists the same
113
+ * target it later deletes, so both share this resolver.
107
114
  */
108
- function selfDelete(): void {
115
+ function resolveSelfDeleteTarget(): SelfDeleteTarget | null {
109
116
  const execPath = process.execPath;
110
117
  const execBase = basename(execPath);
111
118
  if (execBase === "cadet" || execBase === "cadet.exe") {
119
+ return { path: execPath, description: "the Cadet executable" };
120
+ }
121
+ const runningFile = fileURLToPath(import.meta.url);
122
+ const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
123
+ if (pkgRoot) {
124
+ return { path: pkgRoot, description: "the installed Cadet CLI package" };
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Delete the install {@link resolveSelfDeleteTarget} found. The two shapes
131
+ * delete differently — the running binary needs the win32 rename dance, a
132
+ * package dir is a plain recursive delete — and the bin symlinks the
133
+ * package manager planted (e.g. ~/.bun/bin/cadet, the npm global bin) are
134
+ * left dangling — say so.
135
+ */
136
+ function selfDelete(): void {
137
+ const target = resolveSelfDeleteTarget();
138
+ if (!target) {
139
+ console.log(
140
+ "Cadet is running from a source checkout — skipping self-removal.",
141
+ );
142
+ console.log(
143
+ "Remove the installed copy with `bun remove -g mycadet` or `npm uninstall -g mycadet`.",
144
+ );
145
+ return;
146
+ }
147
+ const execBase = basename(target.path);
148
+ const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
149
+ if (isCompiledBinary) {
112
150
  try {
113
151
  if (process.platform === "win32") {
114
152
  // Windows refuses to unlink a running executable — stage it under a
115
153
  // junk name and delete that.
116
- const stale = `${execPath}.uninstalled`;
117
- renameSync(execPath, stale);
154
+ const stale = `${target.path}.uninstalled`;
155
+ renameSync(target.path, stale);
118
156
  rmSync(stale, { force: true, recursive: true });
119
157
  } else {
120
- unlinkSync(execPath);
158
+ unlinkSync(target.path);
121
159
  }
122
- console.log(`Removed ${execPath}.`);
123
- return;
160
+ console.log(`Removed ${target.path}.`);
124
161
  } catch (err) {
125
162
  console.warn(
126
- `⚠️ Could not remove ${execPath}: ${
163
+ `⚠️ Could not remove ${target.path}: ${
127
164
  err instanceof Error ? err.message : String(err)
128
165
  } — remove it by hand.`,
129
166
  );
130
- return;
131
167
  }
132
- }
133
-
134
- // Package-manager install: remove the package directory we are running
135
- // from, best-effort. Bin symlinks the package manager planted (e.g.
136
- // ~/.bun/bin/cadet, the npm global bin) are left dangling — say so.
137
- const runningFile = fileURLToPath(import.meta.url);
138
- const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
139
- if (!pkgRoot) {
140
- console.log(
141
- "Cadet is running from a source checkout — skipping self-removal.",
142
- );
143
- console.log(
144
- "Remove the installed copy with `bun remove -g mycadet` or `npm uninstall -g mycadet`.",
145
- );
146
168
  return;
147
169
  }
148
170
  try {
149
- rmSync(pkgRoot, { recursive: true, force: true });
150
- console.log(`Removed ${pkgRoot}.`);
171
+ rmSync(target.path, { recursive: true, force: true });
172
+ console.log(`Removed ${target.path}.`);
151
173
  console.log(
152
174
  "The `cadet` command on your PATH is now stale — remove it if your package manager didn't (`bun remove -g mycadet` or `npm uninstall -g mycadet`).",
153
175
  );
154
176
  } catch (err) {
155
177
  console.warn(
156
- `⚠️ Could not remove ${pkgRoot}: ${
178
+ `⚠️ Could not remove ${target.path}: ${
157
179
  err instanceof Error ? err.message : String(err)
158
180
  } — remove it with \`bun remove -g mycadet\` (or \`npm uninstall -g mycadet\`).`,
159
181
  );
@@ -173,10 +195,24 @@ function collectDeleteTargets(env = getCurrentEnvironment()): DeleteTarget[] {
173
195
  { path: getLogDir(env), description: "logs" },
174
196
  // Not environment-scoped: deleting it affects every cadet CLI on the
175
197
  // machine — called out in the prompt, not hidden.
176
- { path: getInputHistoryPath(), description: "shared terminal input history" },
198
+ {
199
+ path: getSharedStateDir(),
200
+ description: "shared state, including the terminal input history",
201
+ },
177
202
  ];
178
203
  // Only targets that exist — the prompt lists what will actually go.
179
- return targets.filter((t) => existsSync(t.path));
204
+ const existing = targets.filter((t) => existsSync(t.path));
205
+ // The log dir lives INSIDE a listed target (the config dir on
206
+ // macOS/Linux, the state dir on Windows) and goes with its parent's
207
+ // recursive delete — never list a path twice.
208
+ const deduped: DeleteTarget[] = [];
209
+ for (const t of existing) {
210
+ const covered = deduped.some(
211
+ (k) => t.path === k.path || t.path.startsWith(k.path + sep),
212
+ );
213
+ if (!covered) deduped.push(t);
214
+ }
215
+ return deduped;
180
216
  }
181
217
 
182
218
  function rmrf(target: string): void {
@@ -190,63 +226,48 @@ export const uninstall = Object.assign(
190
226
  console.log("Usage: cadet uninstall [--yes]");
191
227
  console.log("");
192
228
  console.log(
193
- "Remove Cadet entirely: delete every location it uses for this environment (assistant state, retired archives, config, logs) and then delete the executable itself.",
229
+ "Remove Cadet entirely: retire any remaining assistants, delete every location it uses for this environment (assistant state, retired archives, config, logs), and remove the Cadet installation itself.",
194
230
  );
195
231
  process.exit(0);
196
232
  }
197
233
  const yes = args.includes("--yes");
198
234
 
199
- // Assistants still on the rack are retired — with their data deleted —
200
- // as part of the uninstall. Uninstall deletes every location cadet uses
201
- // INCLUDING the retired archives, so retiring first (which archives)
202
- // only matters for the teardown it performs: docker assistants keep
203
- // containers, volumes, and a network in docker that the filesystem
204
- // sweep below never reaches. A warning, not a refusal (v0.0.10 run
205
- // feedback: the hard error just blocked the uninstall).
235
+ const env = getCurrentEnvironment();
236
+ const envLabel = env.name ?? "production";
206
237
  const existing = loadAllAssistants();
238
+ const targets = collectDeleteTargets(env);
239
+ const selfTarget = resolveSelfDeleteTarget();
240
+
241
+ console.log(
242
+ `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
243
+ );
244
+
245
+ // Assistants still on the rack are retired — with their data deleted —
246
+ // as part of the uninstall, and retiring happens only AFTER the
247
+ // confirmation below: it is part of what the prompt describes, never a
248
+ // side effect of running the command. Uninstall deletes every location
249
+ // cadet uses INCLUDING the retired archives, so retiring first (which
250
+ // archives) only matters for the teardown it performs: docker
251
+ // assistants keep containers, volumes, and a network in docker that
252
+ // the filesystem sweep below never reaches. A warning, not a refusal
253
+ // (v0.0.10 run feedback: the hard error just blocked the uninstall).
207
254
  if (existing.length > 0) {
208
255
  console.warn(
209
256
  `\n⚠️ ${existing.length} ${existing.length === 1 ? "assistant still exists" : "assistants still exist"}: ${existing.map((a) => a.assistantId).join(", ")}`,
210
257
  );
211
258
  console.warn(
212
- " They will be retired now — their data (and any retired archives) will be permanently deleted by this uninstall.",
259
+ " They will be retired as part of this uninstall — their data (and any retired archives) will be permanently deleted.",
213
260
  );
214
- for (const entry of existing) {
215
- if (entry.cloud === "apple-container" || entry.cloud === "paired") {
216
- console.log(` Skipping '${entry.assistantId}' on ${entry.cloud} — its lifecycle is managed elsewhere.`);
217
- continue;
218
- }
219
- try {
220
- await retireAssistantData(entry);
221
- } catch (err) {
222
- console.error(
223
- ` Warning: could not fully retire '${entry.assistantId}': ${err instanceof Error ? err.message : String(err)}`,
224
- );
225
- }
226
- }
227
261
  }
228
262
 
229
- const env = getCurrentEnvironment();
230
- const envLabel = env.name ?? "production";
231
- const targets = collectDeleteTargets(env);
232
- const execPath = process.execPath;
233
-
234
- console.log(
235
- `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
236
- );
237
- if (targets.length === 0) {
263
+ if (targets.length === 0 && selfTarget === null) {
238
264
  console.log("No Cadet data directories found to delete.");
239
265
  } else {
240
266
  console.log("It will permanently delete:");
241
- for (const t of targets) {
267
+ for (const t of selfTarget ? [...targets, selfTarget] : targets) {
242
268
  console.log(` - ${t.path} — ${t.description}`);
243
269
  }
244
270
  }
245
- const execBase = basename(execPath);
246
- const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
247
- if (isCompiledBinary) {
248
- console.log(`It will then remove the Cadet executable at ${execPath}.`);
249
- }
250
271
 
251
272
  if (!yes) {
252
273
  if (!canPromptForConfirmation()) {
@@ -265,6 +286,20 @@ export const uninstall = Object.assign(
265
286
  }
266
287
  }
267
288
 
289
+ for (const entry of existing) {
290
+ if (entry.cloud === "apple-container" || entry.cloud === "paired") {
291
+ console.log(` Skipping '${entry.assistantId}' on ${entry.cloud} — its lifecycle is managed elsewhere.`);
292
+ continue;
293
+ }
294
+ try {
295
+ await retireAssistantData(entry);
296
+ } catch (err) {
297
+ console.error(
298
+ ` Warning: could not fully retire '${entry.assistantId}': ${err instanceof Error ? err.message : String(err)}`,
299
+ );
300
+ }
301
+ }
302
+
268
303
  for (const t of targets) {
269
304
  rmrf(t.path);
270
305
  console.log(`Deleted ${t.path}.`);
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { LocalInstanceResources } from "../assistant-config.js";
4
+ import { gatewayCliInvocation } from "../local.js";
5
+
6
+ function fakeResources(instanceDir: string): LocalInstanceResources {
7
+ // gatewayCliInvocation only reads instanceDir; the rest of the resources
8
+ // shape is irrelevant here.
9
+ return { instanceDir } as unknown as LocalInstanceResources;
10
+ }
11
+
12
+ describe("gatewayCliInvocation", () => {
13
+ test("runs the gateway package's CLI entry through bun", () => {
14
+ const invocation = gatewayCliInvocation();
15
+ expect(invocation.args[0]).toBe("run");
16
+ expect(invocation.args[1]).toMatch(/gateway\/src\/cli\/index\.ts$/);
17
+ });
18
+
19
+ test("pins CADET_WORKSPACE_DIR to the instance dir for named instances", () => {
20
+ const invocation = gatewayCliInvocation(
21
+ fakeResources("/data/assistants/testy"),
22
+ );
23
+ expect(invocation.env.CADET_WORKSPACE_DIR).toBe(
24
+ "/data/assistants/testy/workspace",
25
+ );
26
+ });
27
+
28
+ test("pins GATEWAY_SECURITY_DIR to the instance dir for named instances", () => {
29
+ const invocation = gatewayCliInvocation(
30
+ fakeResources("/data/assistants/testy"),
31
+ );
32
+ expect(invocation.env.GATEWAY_SECURITY_DIR).toBe(
33
+ "/data/assistants/testy/gateway-security",
34
+ );
35
+ });
36
+
37
+ test("leaves the workspace resolution untouched for the default instance", () => {
38
+ delete process.env.CADET_WORKSPACE_DIR;
39
+ const invocation = gatewayCliInvocation();
40
+ expect(invocation.env.CADET_WORKSPACE_DIR).toBeUndefined();
41
+ });
42
+ });
@@ -604,7 +604,6 @@ export interface CredentialInjectionParams {
604
604
  bearerToken?: string;
605
605
  assistantApiKey?: string | null;
606
606
  cloudAssistantId: string;
607
- cloudBaseUrl: string;
608
607
  organizationId: string;
609
608
  userId?: string;
610
609
  webhookSecret?: string | null;
@@ -631,8 +630,6 @@ export async function injectCredentialsIntoAssistant(
631
630
  promises.push(inject("cadet:assistant_api_key", params.assistantApiKey));
632
631
  }
633
632
 
634
- promises.push(inject("cadet:platform_base_url", params.cloudBaseUrl));
635
-
636
633
  if (params.webhookSecret) {
637
634
  promises.push(inject("cadet:webhook_secret", params.webhookSecret));
638
635
  }
@@ -409,7 +409,6 @@ export async function registerLocalAssistantWithPlatform(
409
409
  bearerToken: options.bearerToken ?? entry.bearerToken,
410
410
  assistantApiKey,
411
411
  cloudAssistantId: registration.assistant.id,
412
- cloudBaseUrl: cloudUrl,
413
412
  organizationId: orgId,
414
413
  userId: user.id,
415
414
  webhookSecret: registration.webhook_secret,
package/src/lib/color.ts CHANGED
@@ -14,3 +14,7 @@ export function red(text: string, stream?: NodeJS.WriteStream): string {
14
14
  export function yellow(text: string, stream?: NodeJS.WriteStream): string {
15
15
  return colorize("\x1b[33m", text, stream);
16
16
  }
17
+
18
+ export function green(text: string, stream?: NodeJS.WriteStream): string {
19
+ return colorize("\x1b[32m", text, stream);
20
+ }
package/src/lib/docker.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  ASSISTANT_INTERNAL_PORT,
28
28
  getDefaultPorts,
29
29
  } from "./environments/paths.js";
30
+ import { green, yellow } from "./color.js";
30
31
  import { getCurrentEnvironment } from "./environments/resolve.js";
31
32
  import { leaseGuardianToken } from "./guardian-token";
32
33
  import { logHatchNextSteps } from "./hatch-next-steps.js";
@@ -42,7 +43,6 @@ import { isDevVersion } from "./version-compat.js";
42
43
  import {
43
44
  collectProviderApiKey,
44
45
  configureHatchProviderApiKey,
45
- formatProviderName,
46
46
  promptProviderChoice,
47
47
  resolveHatchProvider,
48
48
  shouldPromptForHatchProvider,
@@ -1544,7 +1544,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1544
1544
  }
1545
1545
  }
1546
1546
  }
1547
- log("✅ Docker images acquired");
1547
+ log(green("✅ Docker images acquired", process.stdout));
1548
1548
  }
1549
1549
 
1550
1550
  const res = dockerResourceNames(instanceName);
@@ -1745,18 +1745,16 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1745
1745
  );
1746
1746
  } else if (providerSetupAction === "missing-token" && provider !== null) {
1747
1747
  log(
1748
- `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
1749
- ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
1748
+ yellow(
1749
+ `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
1750
+ ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
1751
+ process.stdout,
1752
+ ),
1750
1753
  );
1751
1754
  } else if (
1752
1755
  providerSetupAction === "configure" &&
1753
1756
  provider !== undefined
1754
1757
  ) {
1755
- log(
1756
- provider === null
1757
- ? "Checking provider credentials..."
1758
- : `Checking ${formatProviderName(provider)} credentials...`,
1759
- );
1760
1758
  await configureHatchProviderApiKey({
1761
1759
  gatewayUrl: runtimeUrl,
1762
1760
  provider,
@@ -1855,25 +1853,12 @@ async function waitForGatewayAndLease(opts: {
1855
1853
  };
1856
1854
 
1857
1855
  if (detached) {
1858
- log("\n✅ Docker assistant hatched!\n");
1859
- log("Instance details:");
1860
- printInstanceDetails(
1861
- {
1862
- name: instanceName,
1863
- runtime: runtimeUrl,
1864
- container: containerName,
1865
- },
1866
- log,
1867
- );
1868
- log("");
1869
- log(`Stop with: cadet retire ${instanceName}`);
1870
-
1871
- // Lease a guardian token even in detached mode so that `cadet ps`,
1872
- // `cadet exec`, and other CLI commands can authenticate to the
1873
- // gateway. Skip the /readyz readiness poll (the caller asked to detach
1874
- // and not block) but retry the lease itself since the gateway may need
1875
- // a moment to accept connections after the container starts.
1876
- const leaseStart = Date.now();
1856
+ // Lease a guardian token BEFORE declaring the assistant hatched so the
1857
+ // banner means `cadet ps`, `cadet exec`, and other CLI commands can
1858
+ // already authenticate. Skip the /readyz readiness poll (the caller
1859
+ // asked to detach and not block) but retry the lease silently since
1860
+ // the gateway may need a moment to accept connections after the
1861
+ // container starts; only a final failure surfaces to the user.
1877
1862
  const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
1878
1863
  let guardianAccessToken: string | undefined;
1879
1864
  while (Date.now() < leaseDeadline) {
@@ -1884,27 +1869,33 @@ async function waitForGatewayAndLease(opts: {
1884
1869
  bootstrapSecret,
1885
1870
  );
1886
1871
  guardianAccessToken = tokenData.accessToken;
1887
- const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1888
- log(
1889
- `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
1890
- );
1891
1872
  break;
1892
- } catch (err) {
1893
- const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1894
- const msg = err instanceof Error ? err.message : String(err);
1895
- log(
1896
- `Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
1897
- );
1873
+ } catch {
1898
1874
  await new Promise((r) => setTimeout(r, 2000));
1899
1875
  }
1900
1876
  }
1901
1877
  if (!guardianAccessToken) {
1902
1878
  log(
1903
- `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1904
- ` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
1905
- ` Re-hatch or run \`cadet setup\` to recover.`,
1879
+ yellow(
1880
+ `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1881
+ ` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
1882
+ ` Re-hatch or run \`cadet setup\` to recover.`,
1883
+ process.stdout,
1884
+ ),
1906
1885
  );
1907
1886
  }
1887
+
1888
+ log(`\n${green("✅ Docker assistant hatched!", process.stdout)}\n`);
1889
+ log("Instance details:");
1890
+ printInstanceDetails(
1891
+ {
1892
+ name: instanceName,
1893
+ runtime: runtimeUrl,
1894
+ container: containerName,
1895
+ },
1896
+ log,
1897
+ );
1898
+ log("");
1908
1899
  return { ready: true, guardianAccessToken };
1909
1900
  }
1910
1901
 
@@ -1985,7 +1976,6 @@ async function waitForGatewayAndLease(opts: {
1985
1976
  // Lease guardian token. The /readyz check confirms both gateway and
1986
1977
  // assistant are reachable. Retry with backoff in case there is a brief
1987
1978
  // window where readiness passes but the guardian endpoint is not yet ready.
1988
- log(`Guardian token lease: starting for ${instanceName} at ${runtimeUrl}`);
1989
1979
  const leaseStart = Date.now();
1990
1980
  const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
1991
1981
  let leaseSuccess = false;
@@ -1999,38 +1989,30 @@ async function waitForGatewayAndLease(opts: {
1999
1989
  instanceName,
2000
1990
  bootstrapSecret,
2001
1991
  );
2002
- const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
2003
- log(
2004
- `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
2005
- );
2006
1992
  guardianAccessToken = tokenData.accessToken;
2007
1993
  leaseSuccess = true;
2008
1994
  break;
2009
1995
  } catch (err) {
2010
1996
  lastLeaseError =
2011
1997
  err instanceof Error ? (err.stack ?? err.message) : String(err);
2012
- // Log periodically so the user knows we're still trying
2013
- const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
2014
- log(
2015
- `Guardian token lease: attempt failed after ${elapsed}s (${
2016
- lastLeaseError.split("\n")[0]
2017
- }), retrying...`,
2018
- );
2019
1998
  }
2020
1999
  await new Promise((r) => setTimeout(r, 2000));
2021
2000
  }
2022
2001
 
2023
2002
  if (!leaseSuccess) {
2024
2003
  log(
2025
- `\u26a0\ufe0f Guardian token lease: FAILED after ${(
2026
- (Date.now() - leaseStart) /
2027
- 1000
2028
- ).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
2004
+ yellow(
2005
+ `\u26a0\ufe0f Guardian token lease: FAILED after ${(
2006
+ (Date.now() - leaseStart) /
2007
+ 1000
2008
+ ).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
2009
+ process.stdout,
2010
+ ),
2029
2011
  );
2030
2012
  }
2031
2013
 
2032
2014
  log("");
2033
- log(`\u2705 Docker containers are up and running!`);
2015
+ log(green(`\u2705 Docker containers are up and running!`, process.stdout));
2034
2016
  log(` Name: ${instanceName}`);
2035
2017
  log(` Runtime: ${runtimeUrl}`);
2036
2018
  log("");