@mycadet/cli 0.12.8 → 0.12.9-dev.202610131620.d833e01

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 (67) hide show
  1. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
  4. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/package.json +4 -3
  6. package/src/__tests__/client-web-config.test.ts +0 -1
  7. package/src/__tests__/cloud-releases.test.ts +23 -13
  8. package/src/__tests__/ensure-binary.test.ts +182 -0
  9. package/src/__tests__/nginx-ingress.test.ts +6 -11
  10. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  11. package/src/__tests__/pair.test.ts +149 -78
  12. package/src/__tests__/ps-cloud-status.test.ts +4 -3
  13. package/src/__tests__/ps-table.test.ts +79 -0
  14. package/src/__tests__/tailscale.test.ts +1 -1
  15. package/src/__tests__/teleport.test.ts +1 -16
  16. package/src/__tests__/tunnel.test.ts +274 -82
  17. package/src/__tests__/uninstall.test.ts +141 -9
  18. package/src/__tests__/wake.test.ts +2 -2
  19. package/src/commands/backup.ts +3 -3
  20. package/src/commands/client.ts +4 -6
  21. package/src/commands/completions.test.ts +104 -0
  22. package/src/commands/completions.ts +87 -0
  23. package/src/commands/exec.ts +7 -7
  24. package/src/commands/flags.ts +2 -2
  25. package/src/commands/hatch.ts +5 -2
  26. package/src/commands/logs.ts +7 -7
  27. package/src/commands/pair.ts +140 -37
  28. package/src/commands/ps.ts +63 -26
  29. package/src/commands/restore.ts +2 -2
  30. package/src/commands/retire.ts +2 -2
  31. package/src/commands/rollback.ts +2 -2
  32. package/src/commands/sleep.ts +2 -2
  33. package/src/commands/teleport.ts +18 -37
  34. package/src/commands/terminal.ts +1 -1
  35. package/src/commands/tunnel.ts +101 -78
  36. package/src/commands/uninstall.ts +108 -73
  37. package/src/commands/upgrade.ts +8 -8
  38. package/src/commands/use.ts +1 -1
  39. package/src/commands/wake.ts +6 -6
  40. package/src/index.ts +15 -0
  41. package/src/lib/__tests__/docker.test.ts +168 -20
  42. package/src/lib/__tests__/job-polling.test.ts +1 -1
  43. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  44. package/src/lib/cloud-client.ts +0 -3
  45. package/src/lib/cloud-login.ts +0 -1
  46. package/src/lib/cloud-releases.ts +6 -4
  47. package/src/lib/color.ts +4 -0
  48. package/src/lib/docker.ts +190 -102
  49. package/src/lib/ensure-binary.ts +171 -0
  50. package/src/lib/environments/__tests__/paths.test.ts +27 -1
  51. package/src/lib/environments/paths.ts +16 -8
  52. package/src/lib/environments/resolve.ts +2 -4
  53. package/src/lib/flag-args.test.ts +1 -1
  54. package/src/lib/flag-args.ts +1 -1
  55. package/src/lib/guardian-token.ts +1 -1
  56. package/src/lib/hatch-local.ts +15 -8
  57. package/src/lib/hatch-next-steps.ts +5 -9
  58. package/src/lib/job-polling.ts +1 -1
  59. package/src/lib/local.ts +37 -4
  60. package/src/lib/nginx-ingress.ts +3 -5
  61. package/src/lib/pair.ts +1 -1
  62. package/src/lib/step-runner.ts +6 -1
  63. package/src/lib/sync-cloud-assistants.ts +2 -2
  64. package/src/lib/tunnels/cloudflare.ts +21 -11
  65. package/src/lib/tunnels/ngrok.ts +198 -108
  66. package/src/lib/tunnels/tailscale.ts +34 -4
  67. package/src/lib/upgrade-lifecycle.ts +5 -4
@@ -82,18 +82,26 @@ export function getLogDir(env: EnvironmentDefinition): string {
82
82
  }
83
83
 
84
84
  /**
85
- * Path to the interactive CLI's input history file.
85
+ * The interactive CLI's shared state directory — `$XDG_STATE_HOME/cadet`.
86
86
  *
87
- * Follows the XDG Base Directory spec: history files are state data
88
- * (persistent across runs but not portable / user-owned content), so they
89
- * belong under `$XDG_STATE_HOME`, mirroring `bash`, `zsh`, `psql`, and `gh`.
90
- * Defaults to `~/.local/state/cadet/input-history`.
87
+ * Follows the XDG Base Directory spec: session state is data that persists
88
+ * across runs but is not portable user-owned content, mirroring `bash`,
89
+ * `zsh`, `psql`, and `gh`. Defaults to `~/.local/state/cadet`.
91
90
  *
92
- * Not environment-scoped: terminal input history is per-user, not per-assistant,
93
- * so dev and prod CLIs share the same history file.
91
+ * Not environment-scoped: it holds per-user state (the terminal input
92
+ * history, below) that dev and prod CLIs share.
93
+ */
94
+ export function getSharedStateDir(): string {
95
+ return join(xdgStateHome(), "cadet");
96
+ }
97
+
98
+ /**
99
+ * Path to the interactive CLI's input history file, inside
100
+ * {@link getSharedStateDir}. Defaults to
101
+ * `~/.local/state/cadet/input-history`.
94
102
  */
95
103
  export function getInputHistoryPath(): string {
96
- return join(xdgStateHome(), "cadet", "input-history");
104
+ return join(getSharedStateDir(), "input-history");
97
105
  }
98
106
 
99
107
  /**
@@ -67,10 +67,8 @@ export function getCurrentEnvironment(
67
67
  const seed = SEEDS[name];
68
68
  if (!seed) {
69
69
  if (name !== DEFAULT_ENVIRONMENT_NAME) {
70
- // Warn on stderr instead of throwing, to match the silent-fallback
71
- // behavior in assistant/src/util/platform.ts:getXdgCadetConfigDirName,
72
- // which silently falls back to production; the CLI agrees so neither
73
- // writer ends up in a disjoint state on a typo.
70
+ // Warn on stderr instead of throwing: an unknown environment falls back
71
+ // to production, so a typo never splits writes between two states.
74
72
  process.stderr.write(
75
73
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
76
74
  `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
@@ -75,7 +75,7 @@ describe("parseFeatureFlagArgs", () => {
75
75
  "process.exit",
76
76
  );
77
77
  expect(errorSpy).toHaveBeenCalledWith(
78
- 'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "browser")',
78
+ 'Error: invalid flag key "UPPER". Keys must be kebab-case, e.g. "browser"',
79
79
  );
80
80
 
81
81
  exitSpy.mockRestore();
@@ -38,7 +38,7 @@ export function parseFeatureFlagArgs(args: string[]): {
38
38
 
39
39
  if (!ALLOWED_KEY_RE.test(key)) {
40
40
  console.error(
41
- `Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "browser")`,
41
+ `Error: invalid flag key "${key}". Keys must be kebab-case, e.g. "browser"`,
42
42
  );
43
43
  process.exit(1);
44
44
  }
@@ -429,7 +429,7 @@ export async function refreshGuardianTokenResult(
429
429
  console.warn(
430
430
  `Refusing to refresh the guardian token over an insecure URL (${gatewayUrl}). ` +
431
431
  "The refresh token is only sent over https or a loopback address: " +
432
- "use an https URL (e.g. a tunnel) or connect over loopback.",
432
+ "use an https URL, e.g. a tunnel, or connect over loopback.",
433
433
  );
434
434
  return {
435
435
  ok: false,
@@ -21,6 +21,7 @@ import {
21
21
  setActiveAssistant,
22
22
  } from "./assistant-config.js";
23
23
  import type { AssistantEntry } from "./assistant-config.js";
24
+ import { green, yellow } from "./color.js";
24
25
  import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
25
26
  import {
26
27
  generateLocalSigningKey,
@@ -379,14 +380,18 @@ export async function hatchLocal(
379
380
  ) {
380
381
  const delayMs = 2000 * 2 ** (attempt - 1);
381
382
  reporter.error(
382
- `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
383
+ yellow(
384
+ `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
385
+ ),
383
386
  );
384
387
  await new Promise((r) => setTimeout(r, delayMs));
385
388
  } else {
386
389
  reporter.error(
387
- `⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? " (not retrying — a malformed 200 will not fix itself)" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
388
- ` The assistant is running but guardian-token.json was not written.\n` +
389
- ` Run \`cadet restart ${JSON.stringify(instanceName)} --repair-guardian\` to re-lease a token.`,
390
+ yellow(
391
+ `⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? ", not retrying — a malformed 200 will not fix itself" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
392
+ ` The assistant is running but guardian-token.json was not written.\n` +
393
+ ` Run \`cadet restart ${JSON.stringify(instanceName)} --repair-guardian\` to re-lease a token.`,
394
+ ),
390
395
  );
391
396
  }
392
397
  }
@@ -398,8 +403,10 @@ export async function hatchLocal(
398
403
 
399
404
  if (provider !== undefined && provider !== null && !guardianAccessToken) {
400
405
  reporter.error(
401
- `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
402
- ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
406
+ yellow(
407
+ `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
408
+ ` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
409
+ ),
403
410
  );
404
411
  } else if (provider !== undefined) {
405
412
  await configureHatchProviderApiKey({
@@ -429,7 +436,7 @@ export async function hatchLocal(
429
436
  }
430
437
 
431
438
  reporter.log("");
432
- reporter.log(`✅ Local assistant hatched!`);
439
+ reporter.log(green(`✅ Local assistant hatched!`, process.stdout));
433
440
  printInstanceDetails(
434
441
  {
435
442
  name: instanceName,
@@ -438,7 +445,7 @@ export async function hatchLocal(
438
445
  },
439
446
  (message) => reporter.log(message),
440
447
  );
441
- logHatchNextSteps((message) => reporter.log(message), verbose);
448
+ logHatchNextSteps((message) => reporter.log(message));
442
449
 
443
450
  const result: HatchLocalResult = {
444
451
  assistantId: instanceName,
@@ -1,12 +1,8 @@
1
- export function logHatchNextSteps(
2
- log: (message: string) => void,
3
- verbose = false,
4
- ): void {
1
+ export function logHatchNextSteps(log: (message: string) => void): void {
5
2
  log("Next steps:");
6
- log(" cadet client");
7
- if (verbose) {
8
- log(' cadet message "hello"');
9
- log(" cadet events");
10
- }
3
+ log(" - Run `cadet client` to interact with your cadet via the terminal");
4
+ log(
5
+ " - Run `cadet tunnel` to make your cadet publicly accessible and interact with it via the browser",
6
+ );
11
7
  log("");
12
8
  }
@@ -166,7 +166,7 @@ export async function pollJobUntilDone(
166
166
  throw err;
167
167
  }
168
168
  const msg = err instanceof Error ? err.message : String(err);
169
- console.warn(`${options.label} polling failed, retrying... (${msg})`);
169
+ console.warn(`${options.label} polling failed: ${msg}. Retrying...`);
170
170
  if (Date.now() >= deadline) {
171
171
  throw new Error(
172
172
  `Timed out waiting for ${options.label} after ${Math.round(
package/src/lib/local.ts CHANGED
@@ -937,7 +937,7 @@ async function startDaemonWatchFromSource(
937
937
  }
938
938
  writeFileSync(pidFile, String(daemonPid), "utf-8");
939
939
 
940
- console.log(" Assistant started in watch mode (bun --watch)");
940
+ console.log(" Assistant started in watch mode with bun --watch");
941
941
  return trackDaemonSpawn(child);
942
942
  }
943
943
 
@@ -984,6 +984,41 @@ function resolveGatewayDir(resources?: LocalInstanceResources): string {
984
984
  }
985
985
  }
986
986
 
987
+ /**
988
+ * How to invoke a `gateway <subcommand>` CLI process so it resolves the same
989
+ * workspace AND gateway security dir as the locally spawned gateway server:
990
+ * run the gateway package's CLI entry through bun with the spawn state root
991
+ * as cwd and the instance-pinned CADET_WORKSPACE_DIR / GATEWAY_SECURITY_DIR
992
+ * the server was given (the file-backed pairing store must resolve
993
+ * identically in both processes). Callers pass the resolved assistant
994
+ * entry's `resources` (undefined for the default instance, where both
995
+ * processes fall back to the same cwd-relative paths).
996
+ */
997
+ export function gatewayCliInvocation(resources?: LocalInstanceResources): {
998
+ command: string;
999
+ args: string[];
1000
+ cwd: string;
1001
+ env: Record<string, string | undefined>;
1002
+ } {
1003
+ return {
1004
+ command: resolveBunExecutable(),
1005
+ args: ["run", join(resolveGatewayDir(resources), "src/cli/index.ts")],
1006
+ cwd: spawnStateRoot(),
1007
+ env: {
1008
+ ...process.env,
1009
+ ...(resources
1010
+ ? {
1011
+ CADET_WORKSPACE_DIR: join(resources.instanceDir, "workspace"),
1012
+ GATEWAY_SECURITY_DIR: join(
1013
+ resources.instanceDir,
1014
+ "gateway-security",
1015
+ ),
1016
+ }
1017
+ : {}),
1018
+ },
1019
+ };
1020
+ }
1021
+
987
1022
  function resolveCesDir(resources?: LocalInstanceResources): string {
988
1023
  const runtimeCesDir = localRuntimeCesDir(resources);
989
1024
  if (runtimeCesDir) return runtimeCesDir;
@@ -1118,9 +1153,7 @@ export async function startCes(
1118
1153
  // Source tree / bunx: run the CES entry point via bun.
1119
1154
  const cesDir = runtimeCesDir ?? resolveCesDir(resources);
1120
1155
  const cesEntry = join(cesDir, "src/main.ts");
1121
- const bunArgs = watch
1122
- ? ["--watch", "run", cesEntry]
1123
- : ["run", cesEntry];
1156
+ const bunArgs = watch ? ["--watch", "run", cesEntry] : ["run", cesEntry];
1124
1157
  const cesLogFd = openLogFile("hatch.log");
1125
1158
  ces = spawn(resolveBunExecutable(), bunArgs, {
1126
1159
  cwd: spawnStateRoot(),
@@ -177,8 +177,6 @@ function remoteWebIngressConfig(
177
177
  return {
178
178
  remote: EDGE_SERVED_REMOTE,
179
179
  apiBaseUrl: "/v1",
180
- cloudDisabled: true,
181
- disableCloud: true,
182
180
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
183
181
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
184
182
  ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
@@ -191,7 +189,7 @@ function remoteWebIngressConfig(
191
189
  * fingerprint matches, so this must change whenever the generated index or
192
190
  * nginx template does.
193
191
  */
194
- export const EDGE_TEMPLATE_VERSION = 9;
192
+ export const EDGE_TEMPLATE_VERSION = 10;
195
193
 
196
194
  /**
197
195
  * Stable fingerprint of the SPA config injected into the served index and
@@ -1162,13 +1160,13 @@ export async function ensureTunnelEdge(opts: {
1162
1160
  case "nginx-missing":
1163
1161
  throw new Error(
1164
1162
  "nginx is not installed, so the tunnel edge cannot start. " +
1165
- "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
1163
+ "See https://nginx.org/en/docs/install.html for how to install, " +
1166
1164
  "or point NGINX_BIN at an existing binary.",
1167
1165
  );
1168
1166
  case "web-dist-missing":
1169
1167
  throw new Error(
1170
1168
  "Unable to locate built web assets for the remote web edge. " +
1171
- "Build the SPA (`cd clients/web && VITE_CLOUD_MODE=false bun run build`) " +
1169
+ "Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
1172
1170
  "or install @mycadet/web so its packaged dist directory is available.",
1173
1171
  );
1174
1172
  case "unreachable":
package/src/lib/pair.ts CHANGED
@@ -50,6 +50,6 @@ export function formatWebApproveFailure(
50
50
  return [
51
51
  `${rejection} ${gatewayUrl} (assistant "${assistantReference}", environment "${envName}").`,
52
52
  `Codes are minted by the gateway that serves the pair page, expire after ${CODE_TTL_MINUTES} minutes, and are single-use.`,
53
- "If the pair page came from a different assistant or environment (e.g. the desktop app's), re-run with that environment's CADET_ENVIRONMENT and that assistant's name.",
53
+ "If the pair page came from a different assistant or environment, such as the desktop app's, re-run with that environment's CADET_ENVIRONMENT and that assistant's name.",
54
54
  ].join("\n");
55
55
  }
@@ -112,11 +112,16 @@ export function execWithStdin(
112
112
  export function execOutput(
113
113
  command: string,
114
114
  args: string[],
115
- options: { cwd?: string; timeoutMs?: number } = {},
115
+ options: {
116
+ cwd?: string;
117
+ timeoutMs?: number;
118
+ env?: Record<string, string | undefined>;
119
+ } = {},
116
120
  ): Promise<string> {
117
121
  return new Promise((resolve, reject) => {
118
122
  const child = spawn(command, args, {
119
123
  cwd: options.cwd,
124
+ env: options.env,
120
125
  stdio: ["pipe", "pipe", "pipe"],
121
126
  windowsHide: true,
122
127
  });
@@ -88,7 +88,7 @@ export async function syncCloudAssistants(
88
88
  log?.("Fetching Cadet Cloud assistants…");
89
89
  cloudAssistants = await fetchCloudAssistants(token);
90
90
  log?.(
91
- `Cadet Cloud returned ${cloudAssistants.length} assistant(s): ${cloudAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
91
+ `Cadet Cloud returned ${cloudAssistants.length} ${cloudAssistants.length === 1 ? "assistant" : "assistants"}: ${cloudAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
92
92
  );
93
93
  } catch (err) {
94
94
  const msg = err instanceof Error ? err.message : String(err);
@@ -118,7 +118,7 @@ export async function syncCloudAssistants(
118
118
  );
119
119
  const existingCloudIds = new Set(existingCloudById.keys());
120
120
  log?.(
121
- `Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
121
+ `Lockfile has ${existingCloudIds.size} ${existingCloudIds.size === 1 ? "cloud assistant" : "cloud assistants"}: ${[...existingCloudIds].join(", ") || "(none)"}`,
122
122
  );
123
123
 
124
124
  let added = 0;
@@ -2,6 +2,8 @@ import type { ProviderMeta, ProviderTunnelResult } from "../tunnel.js";
2
2
 
3
3
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
4
4
 
5
+ import { ensureBinaryInstalled } from "../ensure-binary.js";
6
+
5
7
  import { GATEWAY_PORT } from "../constants.js";
6
8
  import {
7
9
  clearIngressUrl,
@@ -138,18 +140,26 @@ export interface RunCloudflareTunnelOptions {
138
140
  export async function runCloudflareTunnel(
139
141
  opts: RunCloudflareTunnelOptions = {},
140
142
  ): Promise<ProviderTunnelResult> {
141
- const version = getCloudflareTunnelVersion();
143
+ const version = await ensureBinaryInstalled({
144
+ binary: "cloudflared",
145
+ purpose: "the cloudflare tunnel",
146
+ packages: {
147
+ brew: ["brew", "install", "cloudflare/cloudflare/cloudflared"],
148
+ winget: [
149
+ "winget",
150
+ "install",
151
+ "--silent",
152
+ "--accept-package-agreements",
153
+ "--accept-source-agreements",
154
+ "Cloudflare.cloudflared",
155
+ ],
156
+ choco: ["choco", "install", "-y", "cloudflared"],
157
+ },
158
+ manualHint:
159
+ "See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ for how to install",
160
+ probe: getCloudflareTunnelVersion,
161
+ });
142
162
  if (!version) {
143
- console.error("Error: cloudflared is not installed.");
144
- console.error("");
145
- console.error("Install cloudflared:");
146
- console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
147
- console.error(" Linux: https://pkg.cloudflare.com/index.html");
148
- console.error(
149
- " Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
150
- );
151
- console.error("");
152
- console.error("No Cloudflare account is required for quick tunnels.");
153
163
  process.exit(1);
154
164
  }
155
165