@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356

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 (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
4
+
5
+ describe("consoleLifecycleReporter", () => {
6
+ const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
7
+ let stdoutWriteSpy: ReturnType<typeof spyOn>;
8
+
9
+ beforeEach(() => {
10
+ stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
11
+ () => true,
12
+ );
13
+ });
14
+
15
+ afterEach(() => {
16
+ stdoutWriteSpy.mockRestore();
17
+ if (originalDesktopApp === undefined) {
18
+ delete process.env.VELLUM_DESKTOP_APP;
19
+ } else {
20
+ process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
21
+ }
22
+ });
23
+
24
+ test("routes log/warn/error to the matching console methods", () => {
25
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
26
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
27
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
28
+
29
+ consoleLifecycleReporter.log("hello");
30
+ consoleLifecycleReporter.warn("careful");
31
+ consoleLifecycleReporter.error("boom");
32
+
33
+ expect(logSpy).toHaveBeenCalledWith("hello");
34
+ expect(warnSpy).toHaveBeenCalledWith("careful");
35
+ expect(errorSpy).toHaveBeenCalledWith("boom");
36
+
37
+ logSpy.mockRestore();
38
+ warnSpy.mockRestore();
39
+ errorSpy.mockRestore();
40
+ });
41
+
42
+ test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
43
+ process.env.VELLUM_DESKTOP_APP = "1";
44
+
45
+ consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
46
+
47
+ expect(stdoutWriteSpy).toHaveBeenCalledWith(
48
+ `HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
49
+ );
50
+ });
51
+
52
+ test("suppresses progress output when not running under the desktop app", () => {
53
+ delete process.env.VELLUM_DESKTOP_APP;
54
+
55
+ consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
56
+
57
+ expect(stdoutWriteSpy).not.toHaveBeenCalled();
58
+ });
59
+ });
@@ -1,6 +1,15 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+
1
5
  import { describe, expect, it } from "bun:test";
2
6
 
3
- import { buildExecErrorMessage, exec, execOutput } from "../step-runner";
7
+ import {
8
+ buildExecErrorMessage,
9
+ exec,
10
+ execOutput,
11
+ execWithStdin,
12
+ } from "../step-runner";
4
13
 
5
14
  describe("buildExecErrorMessage", () => {
6
15
  it("omits the argv from the header so secrets in args can't leak", () => {
@@ -63,6 +72,45 @@ describe("exec — secret leak regression", () => {
63
72
  });
64
73
  });
65
74
 
75
+ describe("execWithStdin — pipes input + no secret leak in errors", () => {
76
+ it("writes the supplied input to the child's stdin", async () => {
77
+ // Use sh `cat > path` to capture stdin to a real file we can inspect.
78
+ // Mirrors the Docker-hatch overlay-staging call site shape.
79
+ const workDir = mkdtempSync(join(tmpdir(), "step-runner-stdin-"));
80
+ const dest = join(workDir, "captured.txt");
81
+ try {
82
+ const payload = '{"hello":"world"}\n';
83
+ await execWithStdin("sh", ["-c", `cat > ${dest}`], payload);
84
+ expect(readFileSync(dest, "utf-8")).toBe(payload);
85
+ } finally {
86
+ rmSync(workDir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
91
+ const fakeSecret = "sk-anthropic-stdin-canary";
92
+ try {
93
+ await execWithStdin(
94
+ "sh",
95
+ [
96
+ "-c",
97
+ 'echo "permission denied while trying to connect to docker daemon" 1>&2 && exit 1',
98
+ "-e",
99
+ `ANTHROPIC_API_KEY=${fakeSecret}`,
100
+ ],
101
+ "",
102
+ );
103
+ throw new Error("execWithStdin should have rejected");
104
+ } catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ expect(message).not.toContain(fakeSecret);
107
+ expect(message).not.toContain("ANTHROPIC_API_KEY");
108
+ expect(message).toContain("sh exited with code 1");
109
+ expect(message).toContain("permission denied");
110
+ }
111
+ });
112
+ });
113
+
66
114
  describe("execOutput — secret leak regression", () => {
67
115
  it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
68
116
  const fakeSecret = "sk-openai-leak-canary";
@@ -12,11 +12,9 @@
12
12
  * ```
13
13
  */
14
14
 
15
- import {
16
- resolveAssistant,
17
- } from "./assistant-config.js";
15
+ import { resolveAssistant } from "./assistant-config.js";
18
16
  import { GATEWAY_PORT } from "./constants.js";
19
- import { loadGuardianToken } from "./guardian-token.js";
17
+ import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
20
18
 
21
19
  const DEFAULT_TIMEOUT_MS = 30_000;
22
20
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -45,7 +43,8 @@ export class AssistantClient {
45
43
  readonly runtimeUrl: string;
46
44
 
47
45
  private readonly _assistantId: string;
48
- private readonly token: string | undefined;
46
+ /** Mutable: a 401 on the guardian path refreshes this in place (see request). */
47
+ private token: string | undefined;
49
48
  /** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
50
49
  private readonly isSessionAuth: boolean;
51
50
  private readonly orgId: string | undefined;
@@ -176,45 +175,67 @@ export class AssistantClient {
176
175
  ? `?${new URLSearchParams(opts.query).toString()}`
177
176
  : "";
178
177
  const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
179
-
180
- const headers: Record<string, string> = { ...opts?.headers };
181
- if (this.token) {
182
- if (this.isSessionAuth) {
183
- headers["X-Session-Token"] ??= this.token;
184
- } else {
185
- headers["Authorization"] ??= `Bearer ${this.token}`;
186
- }
187
- }
188
- if (this.orgId) {
189
- headers["Vellum-Organization-Id"] ??= this.orgId;
190
- }
191
- if (body !== undefined) {
192
- headers["Content-Type"] = "application/json";
193
- }
194
-
195
178
  const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
196
179
 
197
- if (opts?.signal) {
180
+ // Headers are built per-attempt so a refreshed token is picked up on retry.
181
+ const buildHeaders = (): Record<string, string> => {
182
+ const headers: Record<string, string> = { ...opts?.headers };
183
+ if (this.token) {
184
+ if (this.isSessionAuth) {
185
+ headers["X-Session-Token"] ??= this.token;
186
+ } else {
187
+ headers["Authorization"] ??= `Bearer ${this.token}`;
188
+ }
189
+ }
190
+ if (this.orgId) {
191
+ headers["Vellum-Organization-Id"] ??= this.orgId;
192
+ }
193
+ if (body !== undefined) {
194
+ headers["Content-Type"] = "application/json";
195
+ }
196
+ return headers;
197
+ };
198
+
199
+ const doFetch = (): Promise<Response> => {
200
+ const headers = buildHeaders();
201
+ if (opts?.signal) {
202
+ return fetch(url, {
203
+ method,
204
+ headers,
205
+ body: jsonBody,
206
+ signal: opts.signal,
207
+ });
208
+ }
209
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
210
+ const controller = new AbortController();
211
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
198
212
  return fetch(url, {
199
- method,
200
- headers,
201
- body: jsonBody,
202
- signal: opts.signal,
203
- });
204
- }
205
-
206
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
207
- const controller = new AbortController();
208
- const timeoutId = setTimeout(() => controller.abort(), timeout);
209
- try {
210
- return await fetch(url, {
211
213
  method,
212
214
  headers,
213
215
  body: jsonBody,
214
216
  signal: controller.signal,
215
- });
216
- } finally {
217
- clearTimeout(timeoutId);
217
+ }).finally(() => clearTimeout(timeoutId));
218
+ };
219
+
220
+ const response = await doFetch();
221
+
222
+ // Reactive auto-refresh: a paired/local guardian access token that has
223
+ // expired comes back 401. Refresh it once via the stored refresh credential
224
+ // and retry. Self-gating — refreshGuardianToken returns null unless a usable
225
+ // refresh token is stored, so ephemeral (`--token`) and access-only sessions
226
+ // just see the original 401. The platform session-auth path is never
227
+ // refreshed here (its token is managed by the Vellum platform).
228
+ if (response.status === 401 && !this.isSessionAuth) {
229
+ const refreshed = await refreshGuardianToken(
230
+ this.runtimeUrl,
231
+ this._assistantId,
232
+ );
233
+ if (refreshed?.accessToken) {
234
+ this.token = refreshed.accessToken;
235
+ return doFetch();
236
+ }
218
237
  }
238
+
239
+ return response;
219
240
  }
220
241
  }
@@ -10,6 +10,8 @@ import {
10
10
  import { homedir } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
14
+
13
15
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
14
16
  import {
15
17
  getDefaultPorts,
@@ -18,8 +20,6 @@ import {
18
20
  getMultiInstanceDir,
19
21
  } from "./environments/paths.js";
20
22
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
- import { SEEDS } from "./environments/seeds.js";
22
- import type { EnvironmentDefinition } from "./environments/types.js";
23
23
  import { probePort } from "./port-probe.js";
24
24
 
25
25
  /**
@@ -76,7 +76,19 @@ export interface AssistantEntry {
76
76
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
77
77
  localUrl?: string;
78
78
  bearerToken?: string;
79
+ /** Deployment topology / how the assistant is reached. Known values:
80
+ * `"local"` (on-machine daemon), `"docker"` (local container),
81
+ * `"apple-container"` (macOS-app-managed container), `"vellum"`
82
+ * (platform-managed, uses the X-Session-Token auth path), `"gcp"` / `"aws"`
83
+ * / `"custom"` (remote, SSH-managed), and `"paired"` (a remote assistant
84
+ * paired from another machine — reached via a bearer guardian token at
85
+ * `runtimeUrl`; has no local process, container, or `resources`).
86
+ * Kept as a free `string` (not a union) for forward-compatibility. */
79
87
  cloud: string;
88
+ /** True when this entry was registered via `vellum connect import` (a remote
89
+ * pairing). Set alongside `cloud: "paired"`; also backs the re-import /
90
+ * overwrite guard in connect import. */
91
+ paired?: boolean;
80
92
  instanceId?: string;
81
93
  namespace?: string;
82
94
  project?: string;
@@ -559,6 +571,19 @@ export function resolveCloud(entry: AssistantEntry): string {
559
571
  return "local";
560
572
  }
561
573
 
574
+ /**
575
+ * Extract the hostname from a URL string. Falls back to stripping the scheme
576
+ * and taking the hostname portion if URL parsing fails.
577
+ */
578
+ export function extractHostFromUrl(url: string): string {
579
+ try {
580
+ const parsed = new URL(url);
581
+ return parsed.hostname;
582
+ } catch {
583
+ return url.replace(/^https?:\/\//, "").split(":")[0];
584
+ }
585
+ }
586
+
562
587
  export function saveAssistantEntry(entry: AssistantEntry): void {
563
588
  const entries = readAssistants().filter(
564
589
  (e) => e.assistantId !== entry.assistantId,
@@ -618,7 +643,7 @@ export async function allocateLocalResources(
618
643
 
619
644
  // Env-aware bases: non-prod envs sit in their own 1000-port window so
620
645
  // running prod and staging assistants side-by-side doesn't collide. See
621
- // `environments/seeds.ts:portBlock` for the layout.
646
+ // the `@vellumai/environments` `portBlock` layout.
622
647
  const basePorts = getDefaultPorts(env);
623
648
  const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
624
649
  const gatewayPort = await findAvailablePort(basePorts.gateway, [
@@ -0,0 +1,276 @@
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { GATEWAY_PORT } from "./constants.js";
7
+
8
+ // ── Workspace config helpers (mirrors the pattern in ngrok.ts) ───────────────
9
+
10
+ function getDefaultWorkspaceDir(): string {
11
+ return (
12
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
13
+ join(homedir(), ".vellum", "workspace")
14
+ );
15
+ }
16
+
17
+ function getConfigPath(workspaceDir: string): string {
18
+ return join(workspaceDir, "config.json");
19
+ }
20
+
21
+ function loadRawConfig(workspaceDir: string): Record<string, unknown> {
22
+ const configPath = getConfigPath(workspaceDir);
23
+ if (!existsSync(configPath)) return {};
24
+ return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
25
+ string,
26
+ unknown
27
+ >;
28
+ }
29
+
30
+ function saveRawConfig(
31
+ workspaceDir: string,
32
+ config: Record<string, unknown>,
33
+ ): void {
34
+ const configPath = getConfigPath(workspaceDir);
35
+ const dir = dirname(configPath);
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
38
+ }
39
+
40
+ function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
41
+ const config = loadRawConfig(workspaceDir);
42
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
43
+ ingress.publicBaseUrl = publicUrl;
44
+ ingress.enabled = true;
45
+ config.ingress = ingress;
46
+ saveRawConfig(workspaceDir, config);
47
+ }
48
+
49
+ function clearIngressUrl(workspaceDir: string): void {
50
+ const config = loadRawConfig(workspaceDir);
51
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
52
+ delete ingress.publicBaseUrl;
53
+ config.ingress = ingress;
54
+ saveRawConfig(workspaceDir, config);
55
+ }
56
+
57
+ // ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
58
+
59
+ const CLOUDFLARED_TIMEOUT_MS = 30_000;
60
+
61
+ // Quick-tunnel hostnames follow the pattern <word>-<word>-<word>.trycloudflare.com
62
+ const QUICK_TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
63
+
64
+ /**
65
+ * Check whether cloudflared is installed and on PATH.
66
+ * Returns the version string if found, null otherwise.
67
+ */
68
+ export function getCloudflareTunnelVersion(): string | null {
69
+ try {
70
+ const output = execFileSync("cloudflared", ["version"], {
71
+ encoding: "utf-8",
72
+ timeout: 5_000,
73
+ stdio: ["ignore", "pipe", "ignore"],
74
+ });
75
+ return output.trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Spawn a cloudflared quick-tunnel process forwarding HTTP traffic to
83
+ * `targetPort`. The child process writes its public URL to stderr during
84
+ * startup — use {@link waitForCloudflareTunnelUrl} to extract it.
85
+ */
86
+ export function startCloudflareTunnelProcess(targetPort: number): ChildProcess {
87
+ return spawn(
88
+ "cloudflared",
89
+ ["tunnel", "--url", `http://localhost:${targetPort}`, "--no-autoupdate"],
90
+ // Keep stdio as pipes so we can parse the URL from output.
91
+ { stdio: ["ignore", "pipe", "pipe"] },
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Listen to a running cloudflared process's stdout/stderr and resolve with
97
+ * the public quick-tunnel URL once cloudflared prints it.
98
+ *
99
+ * cloudflared emits a line containing the trycloudflare.com URL during
100
+ * startup — typically within 5–15 seconds on a normal internet connection.
101
+ *
102
+ * Rejects when:
103
+ * - The URL does not appear within `timeoutMs`.
104
+ * - The child process exits before the URL is found.
105
+ */
106
+ export function waitForCloudflareTunnelUrl(
107
+ child: ChildProcess,
108
+ timeoutMs: number = CLOUDFLARED_TIMEOUT_MS,
109
+ ): Promise<string> {
110
+ return new Promise((resolve, reject) => {
111
+ const timer = setTimeout(() => {
112
+ reject(
113
+ new Error(
114
+ `cloudflared tunnel URL did not appear within ${timeoutMs / 1000}s. ` +
115
+ `Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:8080' manually.`,
116
+ ),
117
+ );
118
+ }, timeoutMs);
119
+
120
+ let resolved = false;
121
+
122
+ function scanLine(line: string): void {
123
+ if (resolved) return;
124
+ const match = QUICK_TUNNEL_URL_RE.exec(line);
125
+ if (match) {
126
+ resolved = true;
127
+ clearTimeout(timer);
128
+ resolve(match[0]);
129
+ }
130
+ }
131
+
132
+ // Buffer incomplete lines across chunks
133
+ let stdoutBuf = "";
134
+ let stderrBuf = "";
135
+
136
+ child.stdout?.on("data", (chunk: Buffer) => {
137
+ stdoutBuf += chunk.toString();
138
+ const lines = stdoutBuf.split("\n");
139
+ stdoutBuf = lines.pop() ?? "";
140
+ for (const line of lines) scanLine(line);
141
+ });
142
+
143
+ child.stderr?.on("data", (chunk: Buffer) => {
144
+ stderrBuf += chunk.toString();
145
+ const lines = stderrBuf.split("\n");
146
+ stderrBuf = lines.pop() ?? "";
147
+ for (const line of lines) scanLine(line);
148
+ });
149
+
150
+ child.on("exit", (code) => {
151
+ if (resolved) return;
152
+ clearTimeout(timer);
153
+ reject(
154
+ new Error(
155
+ `cloudflared exited with code ${code ?? "unknown"} before the tunnel URL appeared.`,
156
+ ),
157
+ );
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Run the cloudflared quick-tunnel workflow:
164
+ * 1. Verify cloudflared is installed.
165
+ * 2. Start a quick tunnel pointing at the gateway port.
166
+ * 3. Parse the public URL from cloudflared output.
167
+ * 4. Persist the URL to the workspace config as the ingress base URL.
168
+ * 5. Block until the process exits or the user presses Ctrl+C.
169
+ * 6. Clear the ingress URL from config on exit.
170
+ *
171
+ * No Cloudflare account is required — quick tunnels are free and ephemeral.
172
+ */
173
+ export interface RunCloudflareTunnelOptions {
174
+ /** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
175
+ port?: number;
176
+ /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
177
+ workspaceDir?: string;
178
+ }
179
+
180
+ export async function runCloudflareTunnel(
181
+ opts: RunCloudflareTunnelOptions = {},
182
+ ): Promise<void> {
183
+ const version = getCloudflareTunnelVersion();
184
+ if (!version) {
185
+ console.error("Error: cloudflared is not installed.");
186
+ console.error("");
187
+ console.error("Install cloudflared:");
188
+ console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
189
+ console.error(" Linux: https://pkg.cloudflare.com/index.html");
190
+ console.error(
191
+ " Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
192
+ );
193
+ console.error("");
194
+ console.error("No Cloudflare account is required for quick tunnels.");
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(`Using ${version}`);
199
+
200
+ const port = opts.port ?? GATEWAY_PORT;
201
+ const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
202
+
203
+ console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
204
+ console.log("No Cloudflare account required — quick tunnels are free.");
205
+ console.log("");
206
+
207
+ let publicUrl: string | undefined;
208
+ const child = startCloudflareTunnelProcess(port);
209
+
210
+ const cleanup = (): void => {
211
+ if (!child.killed) child.kill("SIGTERM");
212
+ if (publicUrl) {
213
+ console.log("\nClearing ingress URL from config...");
214
+ clearIngressUrl(workspaceDir);
215
+ }
216
+ };
217
+
218
+ process.on("SIGINT", () => {
219
+ cleanup();
220
+ process.exit(0);
221
+ });
222
+ process.on("SIGTERM", () => {
223
+ cleanup();
224
+ process.exit(0);
225
+ });
226
+
227
+ child.on("error", (err: Error) => {
228
+ console.error(`cloudflared process error: ${err.message}`);
229
+ process.exit(1);
230
+ });
231
+
232
+ child.on("exit", (code) => {
233
+ // Always clear the saved ingress URL when the tunnel process ends so
234
+ // webhook integrations don't keep hitting a dead endpoint.
235
+ if (publicUrl !== undefined) {
236
+ clearIngressUrl(workspaceDir);
237
+ }
238
+ if (code !== null && code !== 0) {
239
+ console.error(`\ncloudflared exited with code ${code}.`);
240
+ process.exit(1);
241
+ }
242
+ });
243
+
244
+ // Forward cloudflared output to the console so the user can see startup
245
+ // progress and any authentication errors.
246
+ child.stdout?.on("data", (data: Buffer) => {
247
+ const line = data.toString().trim();
248
+ if (line) console.log(`[cloudflared] ${line}`);
249
+ });
250
+ child.stderr?.on("data", (data: Buffer) => {
251
+ const line = data.toString().trim();
252
+ if (line) console.log(`[cloudflared] ${line}`);
253
+ });
254
+
255
+ try {
256
+ publicUrl = await waitForCloudflareTunnelUrl(child);
257
+ } catch (err) {
258
+ cleanup();
259
+ throw err;
260
+ }
261
+
262
+ console.log("");
263
+ console.log(`Tunnel established: ${publicUrl}`);
264
+ console.log(`Forwarding to: localhost:${port}`);
265
+ console.log("");
266
+
267
+ saveIngressUrl(workspaceDir, publicUrl);
268
+ console.log("Ingress URL saved to config.");
269
+ console.log("");
270
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
271
+
272
+ // Keep running until cloudflared exits (e.g., network error or user Ctrl+C)
273
+ await new Promise<void>((resolve) => {
274
+ child.on("exit", () => resolve());
275
+ });
276
+ }
@@ -35,6 +35,21 @@ export function buildNestedConfig(
35
35
  /**
36
36
  * Ensure hatch always provides enough initial LLM config for the assistant to
37
37
  * detect a fresh off-platform hatch and seed BYOK profiles.
38
+ *
39
+ * @deprecated Part of the workspace-config overlay path — a CLI→Assistant
40
+ * side channel that bypasses the Assistant's public APIs and has no
41
+ * equivalent in web/desktop. Two replacement paths are on the table:
42
+ *
43
+ * 1. Post-hatch API calls — the CLI calls public Assistant routes after
44
+ * boot (`POST /v1/secrets`, plus a small read-only endpoint that
45
+ * returns the canonical inference-profile templates so the CLI can
46
+ * PATCH them in). See the closed alternatives in PR #32061 and
47
+ * PR #32131 for the shape this would take.
48
+ * 2. Move inference-profile seeds out of workspace config and into
49
+ * Assistant code, so there is nothing for the CLI to inject in the
50
+ * first place.
51
+ *
52
+ * Either path removes the need for this helper.
38
53
  */
39
54
  export function buildHatchConfigValues(
40
55
  configValues: Record<string, string>,
@@ -52,15 +67,21 @@ export function buildHatchConfigValues(
52
67
 
53
68
  /**
54
69
  * Write arbitrary key-value pairs to a temporary JSON file and return its
55
- * path. The caller passes this path to the daemon via the
56
- * VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH env var so the daemon can merge the
57
- * values into its workspace config on first boot.
70
+ * path. The caller is responsible for getting the file to the daemon for
71
+ * the local hatch flow that means setting `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH`
72
+ * on the daemon process; for the Docker hatch flow the caller stages the
73
+ * file into the workspace volume so the rename-after-consume step in
74
+ * `mergeDefaultWorkspaceConfig` is a same-filesystem rename.
58
75
  *
59
76
  * Keys use dot-notation to address nested fields. For example:
60
77
  * "llm.default.provider" → {llm: {default: {provider: ...}}}
61
78
  * "llm.default.model" → {llm: {default: {model: ...}}}
62
79
  *
63
80
  * Returns undefined when configValues is empty (nothing to write).
81
+ *
82
+ * @deprecated See {@link buildHatchConfigValues} for the replacement
83
+ * direction. This overlay path is a CLI→Assistant side channel and will be
84
+ * removed once one of the documented replacements lands.
64
85
  */
65
86
  export function writeInitialConfig(
66
87
  configValues: Record<string, string>,
@@ -0,0 +1,57 @@
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
4
+ * resolved identity and require confirmation, with a `--yes` bypass for
5
+ * automation.
6
+ */
7
+
8
+ /** True only when we can run an interactive raw-mode confirmation prompt. */
9
+ export function canPromptForConfirmation(): boolean {
10
+ return (
11
+ process.stdin.isTTY === true &&
12
+ process.stdout.isTTY === true &&
13
+ typeof process.stdin.setRawMode === "function"
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Show `prompt` and resolve true on Enter, false on Esc/q/Ctrl-C. Restores the
19
+ * prior stdin raw/paused state on exit. Caller must gate on
20
+ * {@link canPromptForConfirmation} first.
21
+ */
22
+ export async function confirmAction(prompt: string): Promise<boolean> {
23
+ const stdin = process.stdin;
24
+ const stdout = process.stdout;
25
+ const wasRaw = stdin.isRaw === true;
26
+ const wasPaused = stdin.isPaused();
27
+
28
+ stdout.write(prompt);
29
+ stdin.setRawMode(true);
30
+ stdin.resume();
31
+
32
+ return await new Promise<boolean>((resolve) => {
33
+ const cleanup = () => {
34
+ stdin.off("data", onData);
35
+ stdin.setRawMode(wasRaw);
36
+ if (wasPaused) {
37
+ stdin.pause();
38
+ }
39
+ stdout.write("\n");
40
+ };
41
+
42
+ const onData = (chunk: Buffer) => {
43
+ const byte = chunk[0];
44
+ if (byte === 13 || byte === 10) {
45
+ cleanup();
46
+ resolve(true);
47
+ return;
48
+ }
49
+ if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
50
+ cleanup();
51
+ resolve(false);
52
+ }
53
+ };
54
+
55
+ stdin.on("data", onData);
56
+ });
57
+ }