@vellumai/cli 0.8.4 → 0.8.6

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 (43) hide show
  1. package/AGENTS.md +17 -1
  2. package/knip.json +2 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/api-key-check.test.ts +78 -0
  5. package/src/__tests__/backup.test.ts +38 -0
  6. package/src/__tests__/recover.test.ts +307 -0
  7. package/src/__tests__/retire.test.ts +241 -0
  8. package/src/__tests__/wake.test.ts +215 -0
  9. package/src/commands/backup.ts +2 -0
  10. package/src/commands/client.ts +62 -32
  11. package/src/commands/flags.ts +197 -0
  12. package/src/commands/gateway/token.ts +73 -0
  13. package/src/commands/gateway.ts +29 -0
  14. package/src/commands/logs.ts +6 -18
  15. package/src/commands/ps.ts +41 -41
  16. package/src/commands/recover.ts +47 -9
  17. package/src/commands/restore.ts +8 -1
  18. package/src/commands/retire.ts +145 -55
  19. package/src/commands/roadmap.ts +449 -0
  20. package/src/commands/rollback.ts +2 -14
  21. package/src/commands/ssh.ts +5 -24
  22. package/src/commands/teleport.ts +34 -26
  23. package/src/commands/upgrade.ts +8 -16
  24. package/src/commands/wake.ts +68 -45
  25. package/src/index.ts +9 -0
  26. package/src/lib/__tests__/port-allocator.test.ts +117 -0
  27. package/src/lib/__tests__/step-runner.test.ts +133 -0
  28. package/src/lib/api-key-check.ts +40 -0
  29. package/src/lib/assistant-config.ts +13 -0
  30. package/src/lib/config-utils.ts +24 -3
  31. package/src/lib/docker.ts +72 -8
  32. package/src/lib/hatch-local.ts +15 -2
  33. package/src/lib/http-client.ts +1 -3
  34. package/src/lib/local.ts +173 -292
  35. package/src/lib/orphan-detection.ts +9 -5
  36. package/src/lib/pgrep.ts +5 -1
  37. package/src/lib/platform-client.ts +97 -49
  38. package/src/lib/port-allocator.ts +93 -0
  39. package/src/lib/process.ts +109 -39
  40. package/src/lib/statefulset.ts +0 -10
  41. package/src/lib/step-runner.ts +102 -9
  42. package/src/lib/sync-cloud-assistants.ts +17 -0
  43. package/src/shared/provider-env-vars.ts +1 -0
@@ -7,9 +7,10 @@ import {
7
7
  findAssistantByName,
8
8
  getActiveAssistant,
9
9
  loadAllAssistants,
10
+ resolveCloud,
10
11
  saveAssistantEntry,
12
+ type AssistantEntry,
11
13
  } from "../lib/assistant-config";
12
- import type { AssistantEntry } from "../lib/assistant-config";
13
14
  import {
14
15
  captureImageRefs,
15
16
  GATEWAY_INTERNAL_PORT,
@@ -145,19 +146,6 @@ function parseArgs(): UpgradeArgs {
145
146
  return { name, version, latest, prepare, finalize };
146
147
  }
147
148
 
148
- function resolveCloud(entry: AssistantEntry): string {
149
- if (entry.cloud) {
150
- return entry.cloud;
151
- }
152
- if (entry.project) {
153
- return "gcp";
154
- }
155
- if (entry.sshUser) {
156
- return "custom";
157
- }
158
- return "local";
159
- }
160
-
161
149
  /**
162
150
  * Resolve which assistant to target for the upgrade command. Priority:
163
151
  * 1. Explicit name argument
@@ -512,7 +500,10 @@ async function upgradeDocker(
512
500
  } else {
513
501
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
514
502
 
515
- const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-upgrade-failure`);
503
+ const logDir = await captureUpgradeFailureLogs(
504
+ res,
505
+ `${instanceName}-upgrade-failure`,
506
+ );
516
507
  if (logDir) {
517
508
  console.log(`📋 Container logs saved to: ${logDir}`);
518
509
  }
@@ -938,7 +929,8 @@ async function resolveLatestAndMaybeSelfUpdate(
938
929
  );
939
930
  if (installResult.error || installResult.status !== 0) {
940
931
  const detail =
941
- installResult.error?.message ?? `exited with code ${installResult.status}`;
932
+ installResult.error?.message ??
933
+ `exited with code ${installResult.status}`;
942
934
  console.error(`\n❌ CLI self-update failed: ${detail}`);
943
935
  emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
944
936
  process.exit(1);
@@ -8,7 +8,7 @@ import {
8
8
  } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
10
  import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
11
- import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
11
+ import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
12
12
  import {
13
13
  generateLocalSigningKey,
14
14
  isAssistantWatchModeAvailable,
@@ -85,36 +85,26 @@ export async function wake(): Promise<void> {
85
85
 
86
86
  const pidFile = getDaemonPidPath(resources);
87
87
 
88
- // Check if daemon is already running
89
88
  let daemonRunning = false;
90
- if (existsSync(pidFile)) {
91
- const pidStr = readFileSync(pidFile, "utf-8").trim();
92
- const pid = parseInt(pidStr, 10);
93
- if (!isNaN(pid)) {
94
- try {
95
- process.kill(pid, 0);
96
- daemonRunning = true;
97
- if (watch) {
98
- // Restart in watch mode but only if source files are available.
99
- // Watch mode requires bun --watch with .ts sources; packaged desktop
100
- // builds only have a compiled binary. Stopping the daemon without a
101
- // viable watch-mode path would leave the user with no running assistant.
102
- if (!isAssistantWatchModeAvailable()) {
103
- console.log(
104
- `Assistant running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
105
- );
106
- } else {
107
- console.log(
108
- `Assistant running (pid ${pid}) — restarting in watch mode...`,
109
- );
110
- await stopProcessByPidFile(pidFile, "assistant");
111
- daemonRunning = false;
112
- }
113
- } else {
114
- console.log(`Assistant already running (pid ${pid}).`);
115
- }
116
- } catch {
117
- // Process not alive, will start below
89
+ const daemonState = await resolveProcessState(
90
+ pidFile,
91
+ resources.daemonPort,
92
+ "Assistant",
93
+ );
94
+ if (daemonState.status === "healthy") {
95
+ if (watch && isAssistantWatchModeAvailable()) {
96
+ console.log(
97
+ `Assistant running (pid ${daemonState.pid})restarting in watch mode...`,
98
+ );
99
+ await stopProcessByPidFile(pidFile, "assistant");
100
+ } else {
101
+ daemonRunning = true;
102
+ if (watch) {
103
+ console.log(
104
+ `Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
105
+ );
106
+ } else {
107
+ console.log(`Assistant already running (pid ${daemonState.pid}).`);
118
108
  }
119
109
  }
120
110
  }
@@ -153,6 +143,15 @@ export async function wake(): Promise<void> {
153
143
  saveAssistantEntry(entry);
154
144
  }
155
145
 
146
+ let bootstrapSecret = entry.guardianBootstrapSecret;
147
+ let bootstrapSecretBackfilled = false;
148
+ if (!bootstrapSecret) {
149
+ bootstrapSecret = generateLocalSigningKey();
150
+ entry.guardianBootstrapSecret = bootstrapSecret;
151
+ saveAssistantEntry(entry);
152
+ bootstrapSecretBackfilled = true;
153
+ }
154
+
156
155
  if (!daemonRunning) {
157
156
  await startLocalDaemon(watch, resources, { foreground, signingKey });
158
157
  }
@@ -161,26 +160,47 @@ export async function wake(): Promise<void> {
161
160
  {
162
161
  const vellumDir = join(resources.instanceDir, ".vellum");
163
162
  const gatewayPidFile = join(vellumDir, "gateway.pid");
164
- const { alive, pid } = isProcessAlive(gatewayPidFile);
165
- if (alive) {
166
- if (watch) {
167
- // Guard gateway restart separately: check gateway source availability.
168
- if (!isGatewayWatchModeAvailable()) {
163
+ const gatewayState = await resolveProcessState(
164
+ gatewayPidFile,
165
+ resources.gatewayPort,
166
+ "Gateway",
167
+ );
168
+ const gatewayAlive = gatewayState.status === "healthy";
169
+ const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
170
+ if (needsRestart) {
171
+ const restartWithWatch = watch && isGatewayWatchModeAvailable();
172
+ if (restartWithWatch) {
173
+ console.log(
174
+ `Gateway running (pid ${gatewayState.pid}) — restarting to apply bootstrap secret...`,
175
+ );
176
+ } else {
177
+ console.log(
178
+ `Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
179
+ );
180
+ }
181
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
182
+ await startGateway(restartWithWatch, resources, {
183
+ signingKey,
184
+ bootstrapSecret,
185
+ });
186
+ } else if (gatewayAlive) {
187
+ if (watch && isGatewayWatchModeAvailable()) {
188
+ console.log(
189
+ `Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
190
+ );
191
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
192
+ await startGateway(watch, resources, { signingKey, bootstrapSecret });
193
+ } else {
194
+ if (watch) {
169
195
  console.log(
170
- `Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
196
+ `Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
171
197
  );
172
198
  } else {
173
- console.log(
174
- `Gateway running (pid ${pid}) — restarting in watch mode...`,
175
- );
176
- await stopProcessByPidFile(gatewayPidFile, "gateway");
177
- await startGateway(watch, resources, { signingKey });
199
+ console.log(`Gateway already running (pid ${gatewayState.pid}).`);
178
200
  }
179
- } else {
180
- console.log(`Gateway already running (pid ${pid}).`);
181
201
  }
182
202
  } else {
183
- await startGateway(watch, resources, { signingKey });
203
+ await startGateway(watch, resources, { signingKey, bootstrapSecret });
184
204
  }
185
205
  }
186
206
 
@@ -196,7 +216,10 @@ export async function wake(): Promise<void> {
196
216
 
197
217
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
198
218
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
199
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort, workspaceDir);
219
+ const ngrokChild = await maybeStartNgrokTunnel(
220
+ resources.gatewayPort,
221
+ workspaceDir,
222
+ );
200
223
  if (ngrokChild?.pid) {
201
224
  const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
202
225
  writeFileSync(ngrokPidFile, String(ngrokChild.pid));
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { client } from "./commands/client";
7
7
  import { env } from "./commands/env";
8
8
  import { events } from "./commands/events";
9
9
  import { exec } from "./commands/exec";
10
+ import { flags } from "./commands/flags";
11
+ import { gateway } from "./commands/gateway";
10
12
  import { hatch } from "./commands/hatch";
11
13
  import { login, logout, whoami } from "./commands/login";
12
14
  import { logs } from "./commands/logs";
@@ -14,6 +16,7 @@ import { message } from "./commands/message";
14
16
  import { ps } from "./commands/ps";
15
17
  import { recover } from "./commands/recover";
16
18
  import { restore } from "./commands/restore";
19
+ import { roadmap } from "./commands/roadmap";
17
20
  import { retire } from "./commands/retire";
18
21
  import { rollback } from "./commands/rollback";
19
22
  import { setup } from "./commands/setup";
@@ -36,6 +39,8 @@ const commands = {
36
39
  env,
37
40
  events,
38
41
  exec,
42
+ flags,
43
+ gateway,
39
44
  hatch,
40
45
  login,
41
46
  logout,
@@ -45,6 +50,7 @@ const commands = {
45
50
  recover,
46
51
  restore,
47
52
  retire,
53
+ roadmap,
48
54
  rollback,
49
55
  setup,
50
56
  sleep,
@@ -70,6 +76,8 @@ function printHelp(): void {
70
76
  console.log(" env Manage the default CLI environment");
71
77
  console.log(" events Stream events from a running assistant");
72
78
  console.log(" exec Execute a command inside an assistant's container");
79
+ console.log(" flags Show and toggle feature flags");
80
+ console.log(" gateway Gateway management commands");
73
81
  console.log(" hatch Create a new assistant instance");
74
82
  console.log(" logs View logs from an assistant instance");
75
83
  console.log(" login Log in to the Vellum platform");
@@ -83,6 +91,7 @@ function printHelp(): void {
83
91
  " restore Restore data (and optionally version) from a .vbundle backup",
84
92
  );
85
93
  console.log(" retire Delete an assistant instance");
94
+ console.log(" roadmap Manage roadmap items");
86
95
  console.log(" rollback Roll back an assistant to a previous version");
87
96
  console.log(" setup Configure API keys interactively");
88
97
  console.log(" sleep Stop the assistant process");
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "net";
3
+
4
+ import { findOpenPort } from "../port-allocator.js";
5
+
6
+ const HOST = "127.0.0.1";
7
+
8
+ async function bindBlocker(port: number, host: string = HOST): Promise<Server> {
9
+ return new Promise((resolve, reject) => {
10
+ const server = createServer();
11
+ server.once("error", reject);
12
+ server.once("listening", () => resolve(server));
13
+ server.listen(port, host);
14
+ });
15
+ }
16
+
17
+ async function closeServer(server: Server): Promise<void> {
18
+ return new Promise((resolve, reject) => {
19
+ server.close((err) => (err ? reject(err) : resolve()));
20
+ });
21
+ }
22
+
23
+ async function getEphemeralPort(): Promise<number> {
24
+ const server = await new Promise<Server>((resolve, reject) => {
25
+ const s = createServer();
26
+ s.once("error", reject);
27
+ s.once("listening", () => resolve(s));
28
+ s.listen(0, HOST);
29
+ });
30
+ const addr = server.address();
31
+ if (!addr || typeof addr === "string" || addr.port == null) {
32
+ throw new Error("Could not obtain ephemeral port");
33
+ }
34
+ const port = addr.port;
35
+ await closeServer(server);
36
+ return port;
37
+ }
38
+
39
+ describe("findOpenPort", () => {
40
+ test("returns the preferred port when it is free", async () => {
41
+ const port = await getEphemeralPort();
42
+ const result = await findOpenPort(port, { host: HOST });
43
+ expect(result).toBe(port);
44
+ });
45
+
46
+ test("walks past an in-use port and returns the next free one", async () => {
47
+ const blocked = await getEphemeralPort();
48
+ const blocker = await bindBlocker(blocked);
49
+ try {
50
+ const result = await findOpenPort(blocked, { host: HOST });
51
+ expect(result).toBeGreaterThan(blocked);
52
+ expect(result).toBeLessThanOrEqual(blocked + 50);
53
+ } finally {
54
+ await closeServer(blocker);
55
+ }
56
+ });
57
+
58
+ test("walks past two consecutive in-use ports", async () => {
59
+ const first = await getEphemeralPort();
60
+ const blockerA = await bindBlocker(first);
61
+ let blockerB: Server | null = null;
62
+ try {
63
+ // Best-effort grab of the next consecutive port; if the kernel
64
+ // handed it to someone else just before we got here, that's still a
65
+ // valid "two consecutive blockers" scenario for the walk.
66
+ try {
67
+ blockerB = await bindBlocker(first + 1);
68
+ } catch {
69
+ blockerB = null;
70
+ }
71
+ const result = await findOpenPort(first, { host: HOST });
72
+ expect(result).toBeGreaterThan(first + (blockerB ? 1 : 0));
73
+ } finally {
74
+ await closeServer(blockerA);
75
+ if (blockerB) await closeServer(blockerB);
76
+ }
77
+ });
78
+
79
+ test("throws when the entire requested window is in use", async () => {
80
+ const blocked = await getEphemeralPort();
81
+ const blocker = await bindBlocker(blocked);
82
+ try {
83
+ await expect(
84
+ findOpenPort(blocked, { host: HOST, maxAttempts: 1 }),
85
+ ).rejects.toThrow(/no open port/i);
86
+ } finally {
87
+ await closeServer(blocker);
88
+ }
89
+ });
90
+
91
+ test("rejects non-integer or out-of-range preferred port", async () => {
92
+ await expect(findOpenPort(0, { host: HOST })).rejects.toThrow(
93
+ /not a valid TCP port/i,
94
+ );
95
+ await expect(findOpenPort(65536, { host: HOST })).rejects.toThrow(
96
+ /not a valid TCP port/i,
97
+ );
98
+ await expect(findOpenPort(1.5, { host: HOST })).rejects.toThrow(
99
+ /not a valid TCP port/i,
100
+ );
101
+ });
102
+
103
+ test("rejects non-positive maxAttempts", async () => {
104
+ await expect(
105
+ findOpenPort(20100, { host: HOST, maxAttempts: 0 }),
106
+ ).rejects.toThrow(/maxAttempts/i);
107
+ });
108
+
109
+ test("does not leak the probe port — port is rebindable after resolution", async () => {
110
+ const port = await getEphemeralPort();
111
+ const found = await findOpenPort(port, { host: HOST });
112
+ expect(found).toBe(port);
113
+ // If the probe leaked a listener on `port`, this would throw EADDRINUSE.
114
+ const reuse = await bindBlocker(found);
115
+ await closeServer(reuse);
116
+ });
117
+ });
@@ -0,0 +1,133 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { describe, expect, it } from "bun:test";
6
+
7
+ import {
8
+ buildExecErrorMessage,
9
+ exec,
10
+ execOutput,
11
+ execWithStdin,
12
+ } from "../step-runner";
13
+
14
+ describe("buildExecErrorMessage", () => {
15
+ it("omits the argv from the header so secrets in args can't leak", () => {
16
+ // Realistic shape — docker hatch invocations pass `-e <NAME>=<val>`
17
+ // flags inline. If we ever regress and put argv in the header, this
18
+ // assertion catches it immediately.
19
+ const msg = buildExecErrorMessage("docker", 125, "stderr text", "");
20
+ expect(msg).not.toContain("ANTHROPIC_API_KEY");
21
+ expect(msg).not.toContain("OPENAI_API_KEY");
22
+ expect(msg.startsWith("docker exited with code 125")).toBe(true);
23
+ });
24
+
25
+ it("appends stderr below the header when present", () => {
26
+ const msg = buildExecErrorMessage("docker", 125, " bind failed\n", "");
27
+ expect(msg).toBe("docker exited with code 125\nbind failed");
28
+ });
29
+
30
+ it("appends stdout when stderr is empty", () => {
31
+ const msg = buildExecErrorMessage("docker", 1, "", "stdout-only\n");
32
+ expect(msg).toBe("docker exited with code 1\nstdout-only");
33
+ });
34
+
35
+ it("appends both streams joined by newline when both present", () => {
36
+ const msg = buildExecErrorMessage("docker", 1, "stderr-line", "stdout-line");
37
+ expect(msg).toBe("docker exited with code 1\nstderr-line\nstdout-line");
38
+ });
39
+
40
+ it("collapses an empty output to just the header", () => {
41
+ const msg = buildExecErrorMessage("docker", 1, " ", "\n");
42
+ expect(msg).toBe("docker exited with code 1");
43
+ });
44
+
45
+ it("handles a null exit code (signal-terminated child)", () => {
46
+ const msg = buildExecErrorMessage("docker", null, "killed", "");
47
+ expect(msg).toBe("docker exited with an unknown code\nkilled");
48
+ });
49
+ });
50
+
51
+ describe("exec — secret leak regression", () => {
52
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
53
+ // The classic hatch failure shape: docker invoked with several
54
+ // -e flags, exiting non-zero. Without the fix, args.join(" ")
55
+ // would put `-e ANTHROPIC_API_KEY=sk-ant-…` into err.message.
56
+ const fakeSecret = "sk-ant-this-should-never-appear-in-logs";
57
+ try {
58
+ await exec("sh", [
59
+ "-c",
60
+ `echo "bind for 0.0.0.0:20100 failed: port is already allocated" 1>&2 && exit 125`,
61
+ "-e",
62
+ `ANTHROPIC_API_KEY=${fakeSecret}`,
63
+ ]);
64
+ throw new Error("exec should have rejected");
65
+ } catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ expect(message).not.toContain(fakeSecret);
68
+ expect(message).not.toContain("ANTHROPIC_API_KEY");
69
+ expect(message).toContain("sh exited with code 125");
70
+ expect(message).toContain("port is already allocated");
71
+ }
72
+ });
73
+ });
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
+
114
+ describe("execOutput — secret leak regression", () => {
115
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
116
+ const fakeSecret = "sk-openai-leak-canary";
117
+ try {
118
+ await execOutput("sh", [
119
+ "-c",
120
+ `echo "no such container" 1>&2 && exit 1`,
121
+ "-e",
122
+ `OPENAI_API_KEY=${fakeSecret}`,
123
+ ]);
124
+ throw new Error("execOutput should have rejected");
125
+ } catch (err) {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ expect(message).not.toContain(fakeSecret);
128
+ expect(message).not.toContain("OPENAI_API_KEY");
129
+ expect(message).toContain("sh exited with code 1");
130
+ expect(message).toContain("no such container");
131
+ }
132
+ });
133
+ });
@@ -0,0 +1,40 @@
1
+ import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
2
+
3
+ export interface ApiKeyCheckResult {
4
+ hasKey: boolean;
5
+ }
6
+
7
+ /**
8
+ * Returns true when a key value is a real credential rather than a placeholder.
9
+ *
10
+ * .env.example ships values like `sk-ant-...`, `sk-...`, and `...` to show
11
+ * where credentials go. Any value containing `...` or that is empty is treated
12
+ * as a placeholder that the user has not replaced yet.
13
+ */
14
+ function isPlaceholder(value: string | undefined): boolean {
15
+ if (!value || value.trim() === "") return true;
16
+ if (value.includes("...")) return true;
17
+ return false;
18
+ }
19
+
20
+ /**
21
+ * Check whether at least one LLM provider API key is configured in the
22
+ * current process environment.
23
+ *
24
+ * The CLI's job is to spawn the daemon and pass configuration via environment
25
+ * variables — it does not read from the .vellum/ directory (see AGENTS.md).
26
+ * Checking process.env is sufficient: the daemon forwards whatever is set
27
+ * in the environment, so exporting a key before running `vellum hatch` is
28
+ * the correct way to supply it.
29
+ *
30
+ * Uses the canonical LLM provider env-var catalog so the list stays in sync
31
+ * automatically as new providers are added.
32
+ */
33
+ export function checkProviderApiKey(): ApiKeyCheckResult {
34
+ for (const envVar of Object.values(LLM_PROVIDER_ENV_VAR_NAMES)) {
35
+ if (!isPlaceholder(process.env[envVar])) {
36
+ return { hasKey: true };
37
+ }
38
+ }
39
+ return { hasKey: false };
40
+ }
@@ -559,6 +559,19 @@ export function resolveCloud(entry: AssistantEntry): string {
559
559
  return "local";
560
560
  }
561
561
 
562
+ /**
563
+ * Extract the hostname from a URL string. Falls back to stripping the scheme
564
+ * and taking the hostname portion if URL parsing fails.
565
+ */
566
+ export function extractHostFromUrl(url: string): string {
567
+ try {
568
+ const parsed = new URL(url);
569
+ return parsed.hostname;
570
+ } catch {
571
+ return url.replace(/^https?:\/\//, "").split(":")[0];
572
+ }
573
+ }
574
+
562
575
  export function saveAssistantEntry(entry: AssistantEntry): void {
563
576
  const entries = readAssistants().filter(
564
577
  (e) => e.assistantId !== entry.assistantId,
@@ -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>,