@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
@@ -0,0 +1,241 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import {
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ import type { AssistantEntry } from "../lib/assistant-config.js";
22
+ import { loadAllAssistants } from "../lib/assistant-config.js";
23
+ import * as retireLocalModule from "../lib/retire-local.js";
24
+
25
+ const testDir = mkdtempSync(join(tmpdir(), "cli-retire-test-"));
26
+ const originalArgv = [...process.argv];
27
+ const originalExit = process.exit;
28
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
29
+ const originalStdinIsTTY = process.stdin.isTTY;
30
+ const originalStdoutIsTTY = process.stdout.isTTY;
31
+ const originalStdinIsRaw = process.stdin.isRaw;
32
+ const originalSetRawMode = process.stdin.setRawMode;
33
+ const originalStdoutWrite = process.stdout.write;
34
+ const realRetireLocalModule = { ...retireLocalModule };
35
+
36
+ const retireLocalMock = mock(async () => {});
37
+
38
+ mock.module("../lib/retire-local.js", () => ({
39
+ ...realRetireLocalModule,
40
+ retireLocal: retireLocalMock,
41
+ }));
42
+
43
+ import { retire } from "../commands/retire.js";
44
+
45
+ let consoleLogSpy: ReturnType<typeof spyOn>;
46
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
47
+
48
+ function makeEntry(
49
+ assistantId: string,
50
+ extra: Partial<AssistantEntry> = {},
51
+ ): AssistantEntry {
52
+ return {
53
+ assistantId,
54
+ runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
55
+ cloud: "local",
56
+ resources: {
57
+ instanceDir: join(testDir, assistantId),
58
+ daemonPort: 7801,
59
+ gatewayPort: 7831,
60
+ qdrantPort: 6334,
61
+ cesPort: 7790,
62
+ },
63
+ ...extra,
64
+ };
65
+ }
66
+
67
+ function writeLockfile(entries: AssistantEntry[]): void {
68
+ mkdirSync(testDir, { recursive: true });
69
+ writeFileSync(
70
+ join(testDir, ".vellum.lock.json"),
71
+ JSON.stringify({ assistants: entries }, null, 2) + "\n",
72
+ );
73
+ }
74
+
75
+ function readLockfile(): string {
76
+ return readFileSync(join(testDir, ".vellum.lock.json"), "utf-8");
77
+ }
78
+
79
+ function setTerminalMode(isTTY: boolean): void {
80
+ Object.defineProperty(process.stdin, "isTTY", {
81
+ configurable: true,
82
+ value: isTTY,
83
+ });
84
+ Object.defineProperty(process.stdout, "isTTY", {
85
+ configurable: true,
86
+ value: isTTY,
87
+ });
88
+ }
89
+
90
+ function setInteractiveTerminal(): void {
91
+ setTerminalMode(true);
92
+ Object.defineProperty(process.stdin, "isRaw", {
93
+ configurable: true,
94
+ value: false,
95
+ });
96
+ Object.defineProperty(process.stdin, "setRawMode", {
97
+ configurable: true,
98
+ value: mock(() => process.stdin),
99
+ });
100
+ process.stdout.write = (() => true) as typeof process.stdout.write;
101
+ }
102
+
103
+ function restoreTerminal(): void {
104
+ Object.defineProperty(process.stdin, "isTTY", {
105
+ configurable: true,
106
+ value: originalStdinIsTTY,
107
+ });
108
+ Object.defineProperty(process.stdout, "isTTY", {
109
+ configurable: true,
110
+ value: originalStdoutIsTTY,
111
+ });
112
+ Object.defineProperty(process.stdin, "isRaw", {
113
+ configurable: true,
114
+ value: originalStdinIsRaw,
115
+ });
116
+ Object.defineProperty(process.stdin, "setRawMode", {
117
+ configurable: true,
118
+ value: originalSetRawMode,
119
+ });
120
+ process.stdout.write = originalStdoutWrite;
121
+ }
122
+
123
+ describe("vellum retire", () => {
124
+ beforeEach(() => {
125
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
126
+ rmSync(join(testDir, ".vellum.lock.json"), { force: true });
127
+ process.argv = ["bun", "vellum", "retire"];
128
+ process.exit = ((code?: number) => {
129
+ throw new Error(`process.exit:${code}`);
130
+ }) as typeof process.exit;
131
+ retireLocalMock.mockReset();
132
+ retireLocalMock.mockResolvedValue(undefined);
133
+ setTerminalMode(false);
134
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
135
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
136
+ });
137
+
138
+ afterEach(() => {
139
+ process.argv = originalArgv;
140
+ process.exit = originalExit;
141
+ restoreTerminal();
142
+ consoleLogSpy.mockRestore();
143
+ consoleErrorSpy.mockRestore();
144
+ });
145
+
146
+ afterAll(() => {
147
+ mock.module("../lib/retire-local.js", () => realRetireLocalModule);
148
+ if (originalLockfileDir === undefined) {
149
+ delete process.env.VELLUM_LOCKFILE_DIR;
150
+ } else {
151
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
152
+ }
153
+ rmSync(testDir, { recursive: true, force: true });
154
+ });
155
+
156
+ test("--yes retires by unquoted display name and removes by assistant ID", async () => {
157
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
158
+ writeLockfile([entry]);
159
+ process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
160
+
161
+ await retire();
162
+
163
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
164
+ expect(loadAllAssistants()).toEqual([]);
165
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
166
+ expect(output).toContain("Name: Example Assistant");
167
+ expect(output).toContain("ID: assistant-1");
168
+ expect(output).toContain(
169
+ "Removed Example Assistant (assistant-1) from config.",
170
+ );
171
+ });
172
+
173
+ test("non-interactive retire without --yes fails before deleting", async () => {
174
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
175
+ writeLockfile([entry]);
176
+ const before = readLockfile();
177
+ process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
178
+
179
+ await expect(retire()).rejects.toThrow("process.exit:1");
180
+
181
+ expect(retireLocalMock).not.toHaveBeenCalled();
182
+ expect(readLockfile()).toBe(before);
183
+ const output = consoleErrorSpy.mock.calls.flat().join("\n");
184
+ expect(output).toContain("Refusing to retire without confirmation");
185
+ expect(output).toContain("--yes");
186
+ });
187
+
188
+ test("interactive cancel leaves the assistant untouched", async () => {
189
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
190
+ writeLockfile([entry]);
191
+ const before = readLockfile();
192
+ setInteractiveTerminal();
193
+ process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
194
+
195
+ const pending = retire();
196
+ queueMicrotask(() => {
197
+ process.stdin.emit("data", Buffer.from("q"));
198
+ });
199
+
200
+ await expect(pending).rejects.toThrow("process.exit:1");
201
+ expect(retireLocalMock).not.toHaveBeenCalled();
202
+ expect(readLockfile()).toBe(before);
203
+ expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
204
+ "Retire cancelled.",
205
+ );
206
+ });
207
+
208
+ test("interactive confirmation retires the assistant", async () => {
209
+ const entry = makeEntry("assistant-1", { name: "Example Assistant" });
210
+ writeLockfile([entry]);
211
+ setInteractiveTerminal();
212
+ process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
213
+
214
+ const pending = retire();
215
+ queueMicrotask(() => {
216
+ process.stdin.emit("data", Buffer.from([13]));
217
+ });
218
+
219
+ await pending;
220
+ expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
221
+ expect(loadAllAssistants()).toEqual([]);
222
+ });
223
+
224
+ test("ambiguous display names fail before deleting", async () => {
225
+ writeLockfile([
226
+ makeEntry("assistant-1", { name: "Example Assistant" }),
227
+ makeEntry("assistant-2", { name: "Example Assistant" }),
228
+ ]);
229
+ const before = readLockfile();
230
+ process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
231
+
232
+ await expect(retire()).rejects.toThrow("process.exit:1");
233
+
234
+ expect(retireLocalMock).not.toHaveBeenCalled();
235
+ expect(readLockfile()).toBe(before);
236
+ const output = consoleErrorSpy.mock.calls.flat().join("\n");
237
+ expect(output).toContain("Multiple assistants match 'Example Assistant'");
238
+ expect(output).toContain("assistant-1");
239
+ expect(output).toContain("assistant-2");
240
+ });
241
+ });
@@ -0,0 +1,215 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import * as assistantConfig from "../lib/assistant-config.js";
16
+ import * as docker from "../lib/docker.js";
17
+ import * as guardianToken from "../lib/guardian-token.js";
18
+ import * as local from "../lib/local.js";
19
+ import * as ngrok from "../lib/ngrok.js";
20
+ import * as processLib from "../lib/process.js";
21
+ import type { AssistantEntry } from "../lib/assistant-config.js";
22
+
23
+ const realAssistantConfig = { ...assistantConfig };
24
+ const realDocker = { ...docker };
25
+ const realGuardianToken = { ...guardianToken };
26
+ const realLocal = { ...local };
27
+ const realNgrok = { ...ngrok };
28
+ const realProcessLib = { ...processLib };
29
+
30
+ const resolveTargetAssistantMock = mock<
31
+ typeof assistantConfig.resolveTargetAssistant
32
+ >();
33
+ const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
34
+ () => {},
35
+ );
36
+ const getDaemonPidPathMock = mock<typeof assistantConfig.getDaemonPidPath>(
37
+ (resources) => join(resources!.instanceDir, ".vellum", "daemon.pid"),
38
+ );
39
+
40
+ mock.module("../lib/assistant-config.js", () => ({
41
+ ...realAssistantConfig,
42
+ resolveTargetAssistant: resolveTargetAssistantMock,
43
+ saveAssistantEntry: saveAssistantEntryMock,
44
+ getDaemonPidPath: getDaemonPidPathMock,
45
+ }));
46
+
47
+ const dockerResourceNamesMock = mock<typeof docker.dockerResourceNames>(
48
+ realDocker.dockerResourceNames,
49
+ );
50
+ const wakeContainersMock = mock<typeof docker.wakeContainers>(async () => {});
51
+
52
+ mock.module("../lib/docker.js", () => ({
53
+ ...realDocker,
54
+ dockerResourceNames: dockerResourceNamesMock,
55
+ wakeContainers: wakeContainersMock,
56
+ }));
57
+
58
+ const seedGuardianTokenFromSiblingEnvMock = mock<
59
+ typeof guardianToken.seedGuardianTokenFromSiblingEnv
60
+ >(() => false);
61
+
62
+ mock.module("../lib/guardian-token.js", () => ({
63
+ ...realGuardianToken,
64
+ seedGuardianTokenFromSiblingEnv: seedGuardianTokenFromSiblingEnvMock,
65
+ }));
66
+
67
+ const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
68
+ async (_pidFile, _port, label) => ({
69
+ status: "healthy",
70
+ pid: label === "Gateway" ? 456 : 123,
71
+ }),
72
+ );
73
+ const stopProcessByPidFileMock = mock<typeof processLib.stopProcessByPidFile>(
74
+ async () => true,
75
+ );
76
+
77
+ mock.module("../lib/process", () => ({
78
+ ...realProcessLib,
79
+ resolveProcessState: resolveProcessStateMock,
80
+ stopProcessByPidFile: stopProcessByPidFileMock,
81
+ }));
82
+
83
+ const generateLocalSigningKeyMock = mock<typeof local.generateLocalSigningKey>(
84
+ () => "generated-bootstrap-secret",
85
+ );
86
+ const isAssistantWatchModeAvailableMock = mock<
87
+ typeof local.isAssistantWatchModeAvailable
88
+ >(() => false);
89
+ const isGatewayWatchModeAvailableMock = mock<
90
+ typeof local.isGatewayWatchModeAvailable
91
+ >(() => false);
92
+ const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(async () => {});
93
+ const startGatewayMock = mock<typeof local.startGateway>(
94
+ async () => "http://127.0.0.1:7830",
95
+ );
96
+
97
+ mock.module("../lib/local", () => ({
98
+ ...realLocal,
99
+ generateLocalSigningKey: generateLocalSigningKeyMock,
100
+ isAssistantWatchModeAvailable: isAssistantWatchModeAvailableMock,
101
+ isGatewayWatchModeAvailable: isGatewayWatchModeAvailableMock,
102
+ startLocalDaemon: startLocalDaemonMock,
103
+ startGateway: startGatewayMock,
104
+ }));
105
+
106
+ const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
107
+ async () => null,
108
+ );
109
+
110
+ mock.module("../lib/ngrok", () => ({
111
+ ...realNgrok,
112
+ maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
113
+ }));
114
+
115
+ const { wake } = await import("../commands/wake.js");
116
+
117
+ let tempDir: string;
118
+ let originalArgv: string[];
119
+ let logSpy: ReturnType<typeof spyOn>;
120
+
121
+ function makeLocalEntry(): AssistantEntry {
122
+ tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
123
+ mkdirSync(join(tempDir, ".vellum"), { recursive: true });
124
+ return {
125
+ assistantId: "local-assistant",
126
+ runtimeUrl: "http://127.0.0.1:7830",
127
+ cloud: "local",
128
+ resources: {
129
+ instanceDir: tempDir,
130
+ daemonPort: 7821,
131
+ gatewayPort: 7830,
132
+ qdrantPort: 6333,
133
+ cesPort: 7822,
134
+ signingKey: "existing-signing-key",
135
+ },
136
+ };
137
+ }
138
+
139
+ beforeEach(() => {
140
+ originalArgv = [...process.argv];
141
+ tempDir = "";
142
+ process.argv = ["bun", "vellum", "wake", "--watch", "local-assistant"];
143
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
144
+
145
+ const entry = makeLocalEntry();
146
+ resolveTargetAssistantMock.mockReset();
147
+ resolveTargetAssistantMock.mockReturnValue(entry);
148
+ saveAssistantEntryMock.mockReset();
149
+ getDaemonPidPathMock.mockReset();
150
+ getDaemonPidPathMock.mockImplementation((resources) =>
151
+ join(resources!.instanceDir, ".vellum", "daemon.pid"),
152
+ );
153
+ resolveProcessStateMock.mockReset();
154
+ resolveProcessStateMock.mockImplementation(async (_pidFile, _port, label) => ({
155
+ status: "healthy",
156
+ pid: label === "Gateway" ? 456 : 123,
157
+ }));
158
+ stopProcessByPidFileMock.mockReset();
159
+ stopProcessByPidFileMock.mockResolvedValue(true);
160
+ generateLocalSigningKeyMock.mockReset();
161
+ generateLocalSigningKeyMock.mockReturnValue("generated-bootstrap-secret");
162
+ isAssistantWatchModeAvailableMock.mockReset();
163
+ isAssistantWatchModeAvailableMock.mockReturnValue(false);
164
+ isGatewayWatchModeAvailableMock.mockReset();
165
+ isGatewayWatchModeAvailableMock.mockReturnValue(false);
166
+ startLocalDaemonMock.mockReset();
167
+ startLocalDaemonMock.mockResolvedValue(undefined);
168
+ startGatewayMock.mockReset();
169
+ startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
170
+ seedGuardianTokenFromSiblingEnvMock.mockReset();
171
+ seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
172
+ maybeStartNgrokTunnelMock.mockReset();
173
+ maybeStartNgrokTunnelMock.mockResolvedValue(null);
174
+ });
175
+
176
+ afterEach(() => {
177
+ process.argv = originalArgv;
178
+ logSpy.mockRestore();
179
+ if (tempDir) {
180
+ rmSync(tempDir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ afterAll(() => {
185
+ mock.module("../lib/assistant-config.js", () => realAssistantConfig);
186
+ mock.module("../lib/docker.js", () => realDocker);
187
+ mock.module("../lib/guardian-token.js", () => realGuardianToken);
188
+ mock.module("../lib/process", () => realProcessLib);
189
+ mock.module("../lib/local", () => realLocal);
190
+ mock.module("../lib/ngrok", () => realNgrok);
191
+ });
192
+
193
+ describe("vellum wake", () => {
194
+ test("restarts a running gateway without watch mode when backfilling the bootstrap secret", async () => {
195
+ await wake();
196
+
197
+ expect(saveAssistantEntryMock).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ guardianBootstrapSecret: "generated-bootstrap-secret",
200
+ }),
201
+ );
202
+ expect(stopProcessByPidFileMock).toHaveBeenCalledWith(
203
+ join(tempDir, ".vellum", "gateway.pid"),
204
+ "gateway",
205
+ );
206
+ expect(startGatewayMock).toHaveBeenCalledWith(
207
+ false,
208
+ expect.objectContaining({ instanceDir: tempDir }),
209
+ {
210
+ signingKey: "existing-signing-key",
211
+ bootstrapSecret: "generated-bootstrap-secret",
212
+ },
213
+ );
214
+ });
215
+ });
@@ -93,6 +93,7 @@ export async function backup(): Promise<void> {
93
93
  const freshToken = await leaseGuardianToken(
94
94
  entry.runtimeUrl,
95
95
  entry.assistantId,
96
+ entry.guardianBootstrapSecret,
96
97
  );
97
98
  accessToken = freshToken.accessToken;
98
99
  } catch (err) {
@@ -129,6 +130,7 @@ export async function backup(): Promise<void> {
129
130
  const freshToken = await leaseGuardianToken(
130
131
  entry.runtimeUrl,
131
132
  entry.assistantId,
133
+ entry.guardianBootstrapSecret,
132
134
  );
133
135
  refreshedToken = freshToken.accessToken;
134
136
  } catch {
@@ -278,18 +278,29 @@ async function maybeHydratePlatformAssistantName(
278
278
  }
279
279
  }
280
280
 
281
+ const SPA_BASE = "/assistant/";
282
+
281
283
  /**
282
- * Walk up from this file's location to find a sibling `clients/web` package.
284
+ * Locate the pre-built @vellumai/web dist directory.
283
285
  *
284
- * Returns the absolute path to its directory, or null when not found —
285
- * e.g. when the CLI is installed via npm/bunx, where the `clients/web`
286
- * source isn't shipped alongside `@vellumai/cli`. For now we treat the
287
- * `--interface web` path as source-checkout-only.
286
+ * Resolution order:
287
+ * 1. npm-installed package require.resolve('@vellumai/web/package.json')
288
+ * 2. Source checkout walk up from cli/ to find apps/web/dist/
288
289
  */
289
- function findClientsWebDir(): string | null {
290
+ function findWebDistDir(): string | null {
291
+ try {
292
+ const pkgPath = require.resolve("@vellumai/web/package.json");
293
+ const distDir = path.join(path.dirname(pkgPath), "dist");
294
+ if (existsSync(path.join(distDir, "index.html"))) {
295
+ return distDir;
296
+ }
297
+ } catch {
298
+ // Package not installed; try source checkout.
299
+ }
300
+
290
301
  let dir = import.meta.dir;
291
302
  for (let depth = 0; depth < 8; depth++) {
292
- const candidate = path.join(dir, "clients", "web", "package.json");
303
+ const candidate = path.join(dir, "apps", "web", "dist", "index.html");
293
304
  if (existsSync(candidate)) {
294
305
  return path.dirname(candidate);
295
306
  }
@@ -300,42 +311,61 @@ function findClientsWebDir(): string | null {
300
311
  return null;
301
312
  }
302
313
 
303
- /**
304
- * Spawn the `clients/web` package's `local` script and proxy its lifecycle.
305
- *
306
- * The web client is deliberately not declared as a dependency of `@vellumai/cli`:
307
- * the CLI is published, the web package is not. Locating it on disk and
308
- * shelling out keeps the two packages independent.
309
- */
310
314
  async function runWebInterface(): Promise<void> {
311
- const webDir = findClientsWebDir();
312
- if (!webDir) {
315
+ const distDir = findWebDistDir();
316
+ if (!distDir) {
313
317
  console.error(
314
318
  `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
315
- `clients/web. This interface currently requires running ` +
316
- `vellum from a source checkout of vellum-assistant.`,
319
+ `@vellumai/web assets.\n\n` +
320
+ ` npm/bunx install: npm install @vellumai/web\n` +
321
+ ` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
317
322
  );
318
323
  process.exit(1);
319
324
  }
320
325
 
321
- const child = Bun.spawn({
322
- cmd: ["bun", "run", "local"],
323
- cwd: webDir,
324
- stdio: ["inherit", "inherit", "inherit"],
326
+ const indexHtml = await Bun.file(path.join(distDir, "index.html")).text();
327
+
328
+ const server = Bun.serve({
329
+ port: 3000,
330
+ hostname: "127.0.0.1",
331
+ fetch: async (req) => {
332
+ const url = new URL(req.url);
333
+ const { pathname } = url;
334
+
335
+ if (pathname === "/") {
336
+ return Response.redirect(SPA_BASE, 302);
337
+ }
338
+
339
+ if (pathname.startsWith(SPA_BASE)) {
340
+ const relPath = pathname.slice(SPA_BASE.length);
341
+ if (relPath) {
342
+ const filePath = path.join(distDir, relPath);
343
+ const file = Bun.file(filePath);
344
+ if (await file.exists()) {
345
+ return new Response(file);
346
+ }
347
+ }
348
+ return new Response(indexHtml, {
349
+ headers: { "Content-Type": "text/html; charset=utf-8" },
350
+ });
351
+ }
352
+
353
+ return new Response("Not Found", { status: 404 });
354
+ },
325
355
  });
326
356
 
327
- const forward = (signal: "SIGINT" | "SIGTERM"): void => {
328
- try {
329
- child.kill(signal);
330
- } catch {
331
- // Child already exited; nothing to forward.
332
- }
357
+ console.log(
358
+ `Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
359
+ );
360
+
361
+ const shutdown = (): void => {
362
+ server.stop();
363
+ process.exit(0);
333
364
  };
334
- process.on("SIGINT", () => forward("SIGINT"));
335
- process.on("SIGTERM", () => forward("SIGTERM"));
365
+ process.on("SIGINT", shutdown);
366
+ process.on("SIGTERM", shutdown);
336
367
 
337
- const exitCode = await child.exited;
338
- process.exit(typeof exitCode === "number" ? exitCode : 0);
368
+ await new Promise(() => {});
339
369
  }
340
370
 
341
371
  export async function client(): Promise<void> {