@vellumai/cli 0.10.11-dev.202607221636.b1b80c3 → 0.10.11-dev.202607221815.530ef8d

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.11-dev.202607221636.b1b80c3",
3
+ "version": "0.10.11-dev.202607221815.530ef8d",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { closeSync, existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  import {
@@ -61,6 +61,8 @@ import { tuiLog } from "../lib/tui-log";
61
61
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
62
62
  import { probePort } from "../lib/port-probe.js";
63
63
  import { openBrowser } from "../lib/open-browser";
64
+ import { isCompiledCli } from "../lib/local.js";
65
+ import { getLogDir, openLogFile, resetLogFile } from "../lib/xdg-log.js";
64
66
 
65
67
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
66
68
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
@@ -93,6 +95,10 @@ interface ParsedArgs {
93
95
  disablePlatform: boolean;
94
96
  /** Auto-open the web interface in the default browser (--interface web only). */
95
97
  openBrowser: boolean;
98
+ /** Explicit web server port (--interface web only). Binds strictly — no scan. */
99
+ webPort?: number;
100
+ /** Run the web server as a detached background process (--interface web only). */
101
+ background: boolean;
96
102
  }
97
103
 
98
104
  function readAssistantName(entry: AssistantEntry | null): string | undefined {
@@ -138,9 +144,12 @@ export function parseArgs(): ParsedArgs {
138
144
  "-i",
139
145
  "--token",
140
146
  "-t",
147
+ "--port",
141
148
  ]);
142
149
  // Auto-open the web interface in the browser by default; --no-open opts out.
143
150
  let openBrowserPref = true;
151
+ let webPort: number | undefined;
152
+ let background = false;
144
153
  const flagArgs: string[] = [];
145
154
  for (let i = 0; i < args.length; i++) {
146
155
  const arg = args[i];
@@ -151,6 +160,19 @@ export function parseArgs(): ParsedArgs {
151
160
  disablePlatform = true;
152
161
  } else if (arg === "--no-open") {
153
162
  openBrowserPref = false;
163
+ } else if (arg === "--background") {
164
+ background = true;
165
+ } else if (arg === "--port") {
166
+ const value = args[++i];
167
+ const parsed =
168
+ value === undefined ? Number.NaN : Number.parseInt(value, 10);
169
+ if (String(parsed) !== value || parsed < 1 || parsed > 65535) {
170
+ console.error(
171
+ `Invalid --port '${value ?? ""}'. Expected an integer between 1 and 65535.`,
172
+ );
173
+ process.exit(1);
174
+ }
175
+ webPort = parsed;
154
176
  } else if (
155
177
  (arg === "--url" ||
156
178
  arg === "-u" ||
@@ -259,6 +281,17 @@ export function parseArgs(): ParsedArgs {
259
281
  }
260
282
  }
261
283
 
284
+ if (interfaceId !== WEB_INTERFACE_ID) {
285
+ if (webPort !== undefined) {
286
+ console.error("--port requires --interface web.");
287
+ process.exit(1);
288
+ }
289
+ if (background) {
290
+ console.error("--background requires --interface web.");
291
+ process.exit(1);
292
+ }
293
+ }
294
+
262
295
  return {
263
296
  runtimeUrl: normalizeRuntimeUrl(runtimeUrl),
264
297
  assistantId,
@@ -272,6 +305,8 @@ export function parseArgs(): ParsedArgs {
272
305
  parsedFlagOverrides,
273
306
  disablePlatform,
274
307
  openBrowser: openBrowserPref,
308
+ webPort,
309
+ background,
275
310
  };
276
311
  }
277
312
 
@@ -292,6 +327,12 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
292
327
  -a, --assistant-id <id> Assistant ID
293
328
  -i, --interface <id> Interface identifier: cli (default) or web
294
329
  --no-open Don't auto-open the browser (--interface web)
330
+ --port <port> Web server port, 1-65535 (--interface web).
331
+ Errors if the port is taken. Default: 3000,
332
+ scanning upward when busy.
333
+ --background Run the web server as a background process
334
+ (--interface web). Prints the URL, PID, and log
335
+ path, then returns to the shell.
295
336
  --flag <key=value> Feature flag override (repeatable, kebab-case key)
296
337
  --disable-platform Suppress all outbound platform API calls
297
338
  -h, --help Show this help message
@@ -311,6 +352,9 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
311
352
  # Ephemeral: connect to another machine's assistant with a paired token
312
353
  # (no lockfile entry, nothing persisted):
313
354
  vellum client --url https://your-tunnel.example --token <jwt>
355
+
356
+ # Web interface on a fixed port, detached from the shell:
357
+ vellum client --interface web --port 4000 --background
314
358
  `);
315
359
  }
316
360
 
@@ -775,12 +819,12 @@ function tryBindLoopback(
775
819
  * Never binds wildcard interfaces (`0.0.0.0`/`::`): the server exposes
776
820
  * `/__local/*` control endpoints, so it must stay loopback-only.
777
821
  */
778
- function serveLoopback(preferredPort: number, fetchHandler: WebFetchHandler) {
779
- for (
780
- let port = preferredPort;
781
- port < preferredPort + WEB_PORT_SCAN_LIMIT;
782
- port++
783
- ) {
822
+ function serveLoopback(
823
+ preferredPort: number,
824
+ fetchHandler: WebFetchHandler,
825
+ scanLimit = WEB_PORT_SCAN_LIMIT,
826
+ ) {
827
+ for (let port = preferredPort; port < preferredPort + scanLimit; port++) {
784
828
  const primary = tryBindLoopback(port, "127.0.0.1", fetchHandler);
785
829
  if (!primary) continue;
786
830
 
@@ -804,10 +848,21 @@ function serveLoopback(preferredPort: number, fetchHandler: WebFetchHandler) {
804
848
  }
805
849
  }
806
850
  throw new Error(
807
- `Could not bind a free loopback port in [${preferredPort}, ${preferredPort + WEB_PORT_SCAN_LIMIT - 1}]`,
851
+ scanLimit === 1
852
+ ? `Port ${preferredPort} is already in use`
853
+ : `Could not bind a free loopback port in [${preferredPort}, ${preferredPort + scanLimit - 1}]`,
808
854
  );
809
855
  }
810
856
 
857
+ /** True when neither loopback family has a listener on `port`. */
858
+ async function isDualLoopbackPortFree(port: number): Promise<boolean> {
859
+ const [busyV4, busyV6] = await Promise.all([
860
+ probePort(port, "127.0.0.1"),
861
+ probePort(port, "::1"),
862
+ ]);
863
+ return !busyV4 && !busyV6;
864
+ }
865
+
811
866
  /**
812
867
  * Find the first port at/above `preferred` with nothing listening on either
813
868
  * loopback family. Used for the Vite dev server, which binds the port itself
@@ -816,15 +871,29 @@ function serveLoopback(preferredPort: number, fetchHandler: WebFetchHandler) {
816
871
  */
817
872
  async function findFreeDualLoopbackPort(preferred: number): Promise<number> {
818
873
  for (let port = preferred; port < preferred + WEB_PORT_SCAN_LIMIT; port++) {
819
- const [busyV4, busyV6] = await Promise.all([
820
- probePort(port, "127.0.0.1"),
821
- probePort(port, "::1"),
822
- ]);
823
- if (!busyV4 && !busyV6) return port;
874
+ if (await isDualLoopbackPortFree(port)) {
875
+ return port;
876
+ }
824
877
  }
825
878
  return preferred;
826
879
  }
827
880
 
881
+ /**
882
+ * Resolve the port for a probe-based web server launch: an explicit --port
883
+ * must be free (exit 1 otherwise — strict, no silent move), the default picks
884
+ * the first free port at/above 3000.
885
+ */
886
+ async function resolveWebPort(webPort: number | undefined): Promise<number> {
887
+ if (webPort === undefined) {
888
+ return findFreeDualLoopbackPort(3000);
889
+ }
890
+ if (!(await isDualLoopbackPortFree(webPort))) {
891
+ console.error(`Port ${webPort} is already in use`);
892
+ process.exit(1);
893
+ }
894
+ return webPort;
895
+ }
896
+
828
897
  /**
829
898
  * Open `url` in the browser once `port` is accepting connections, polling for
830
899
  * up to ~10s. Used for the Vite dev server, which binds the port asynchronously
@@ -840,11 +909,118 @@ async function openBrowserWhenReady(url: string, port: number): Promise<void> {
840
909
  }
841
910
  }
842
911
 
912
+ const WEB_BACKGROUND_LOG_FILE = "client-web.log";
913
+ // Generous cap so a cold Vite dev-server boot (dependency optimization) still
914
+ // counts as a successful start.
915
+ const WEB_BACKGROUND_START_TIMEOUT_MS = 30_000;
916
+
917
+ /**
918
+ * Launch `vellum client --interface web` as a detached background process.
919
+ *
920
+ * The port is resolved up front (explicit --port must be free; otherwise the
921
+ * first free port at/above 3000) and pinned via `--port` on the child so the
922
+ * URL printed here is the one the child binds. The child's stdout/stderr go to
923
+ * `<xdg-log-dir>/client-web.log` — same detach idiom as the nginx/ngrok
924
+ * spawns. Success is only reported once the child is accepting connections on
925
+ * the port; an early child exit (e.g. missing @vellumai/web assets) or a
926
+ * startup timeout fails with a pointer at the log file.
927
+ */
928
+ async function spawnBackgroundWebInterface(
929
+ webPort: number | undefined,
930
+ ): Promise<void> {
931
+ const port = await resolveWebPort(webPort);
932
+
933
+ // Rebuild the argv without --background, pinning the resolved port.
934
+ const childArgs: string[] = ["client"];
935
+ const rawArgs = process.argv.slice(3);
936
+ for (let i = 0; i < rawArgs.length; i++) {
937
+ const arg = rawArgs[i];
938
+ if (arg === "--background") {
939
+ continue;
940
+ }
941
+ // A dangling --port can't reach here — parseArgs already rejected it.
942
+ if (arg === "--port") {
943
+ i++;
944
+ continue;
945
+ }
946
+ childArgs.push(arg);
947
+ }
948
+ childArgs.push("--port", String(port));
949
+
950
+ // A compiled binary re-invokes itself; under plain bun (source tree, npm
951
+ // install) the entry script is argv[1].
952
+ const spawnArgs = isCompiledCli()
953
+ ? childArgs
954
+ : [process.argv[1], ...childArgs];
955
+
956
+ resetLogFile(WEB_BACKGROUND_LOG_FILE);
957
+ const fd = openLogFile(WEB_BACKGROUND_LOG_FILE);
958
+ const child = spawn(process.execPath, spawnArgs, {
959
+ detached: true,
960
+ stdio: ["ignore", fd, fd],
961
+ });
962
+ if (typeof fd === "number") {
963
+ closeSync(fd);
964
+ }
965
+ child.unref();
966
+
967
+ const logPath = path.join(getLogDir(), WEB_BACKGROUND_LOG_FILE);
968
+
969
+ // Don't report success until the child is actually serving: watch for an
970
+ // early exit (e.g. missing @vellumai/web assets, port lost to the TOCTOU
971
+ // window) and poll the port until it accepts connections.
972
+ let exit: { code: number | null } | undefined;
973
+ child.on("error", () => {
974
+ exit = { code: null };
975
+ });
976
+ child.on("exit", (code) => {
977
+ exit = { code };
978
+ });
979
+
980
+ const deadline = Date.now() + WEB_BACKGROUND_START_TIMEOUT_MS;
981
+ let listening = false;
982
+ while (Date.now() < deadline && !exit) {
983
+ if (await probePort(port, "127.0.0.1")) {
984
+ listening = true;
985
+ break;
986
+ }
987
+ await new Promise((resolve) => setTimeout(resolve, 200));
988
+ }
989
+
990
+ if (exit) {
991
+ console.error(
992
+ `Web interface exited during startup${exit.code !== null ? ` (exit code ${exit.code})` : ""}. Logs: ${logPath}`,
993
+ );
994
+ process.exit(1);
995
+ }
996
+ if (!listening) {
997
+ // Kill the detached child (its whole process group — the Vite path spawns
998
+ // grandchildren) so a slow startup can't bind the port and linger after
999
+ // we've reported failure.
1000
+ if (child.pid !== undefined) {
1001
+ try {
1002
+ process.kill(-child.pid, "SIGTERM");
1003
+ } catch {
1004
+ child.kill("SIGTERM");
1005
+ }
1006
+ }
1007
+ console.error(
1008
+ `Web interface did not start listening on port ${port} within ${WEB_BACKGROUND_START_TIMEOUT_MS / 1000}s; terminated it. Logs: ${logPath}`,
1009
+ );
1010
+ process.exit(1);
1011
+ }
1012
+
1013
+ console.log(`Vellum web interface: http://localhost:${port}${SPA_BASE}`);
1014
+ console.log(`Running in background (pid ${child.pid}). Logs: ${logPath}`);
1015
+ console.log(`Stop with: kill ${child.pid}`);
1016
+ }
1017
+
843
1018
  async function runWebInterface(
844
1019
  flagEnvVars: Record<string, string>,
845
1020
  parsedFlagOverrides: Record<string, boolean | string>,
846
1021
  disablePlatform: boolean,
847
1022
  openInBrowser: boolean,
1023
+ webPort: number | undefined,
848
1024
  ): Promise<void> {
849
1025
  // Propagate flag env vars so child processes (e.g. hatch from the web UI) inherit them.
850
1026
  Object.assign(process.env, flagEnvVars);
@@ -858,6 +1034,7 @@ async function runWebInterface(
858
1034
  flagEnvVars,
859
1035
  disablePlatform,
860
1036
  openInBrowser,
1037
+ webPort,
861
1038
  );
862
1039
  }
863
1040
 
@@ -1001,9 +1178,23 @@ async function runWebInterface(
1001
1178
  return new Response("Not Found", { status: 404 });
1002
1179
  };
1003
1180
 
1004
- const { port, servers } = serveLoopback(3000, fetchHandler);
1005
- if (port !== 3000) {
1006
- console.log(`Port 3000 in use; using ${port}.`);
1181
+ // An explicit --port binds strictly (no scan) so the user gets the port they
1182
+ // asked for or a clear error.
1183
+ const preferredPort = webPort ?? 3000;
1184
+ let bound: ReturnType<typeof serveLoopback>;
1185
+ try {
1186
+ bound = serveLoopback(
1187
+ preferredPort,
1188
+ fetchHandler,
1189
+ webPort !== undefined ? 1 : WEB_PORT_SCAN_LIMIT,
1190
+ );
1191
+ } catch (err) {
1192
+ console.error(err instanceof Error ? err.message : String(err));
1193
+ process.exit(1);
1194
+ }
1195
+ const { port, servers } = bound;
1196
+ if (port !== preferredPort) {
1197
+ console.log(`Port ${preferredPort} in use; using ${port}.`);
1007
1198
  }
1008
1199
  // Advertise `localhost` (not `127.0.0.1`) so the app origin matches the host
1009
1200
  // the platform hardcodes in its loopback callback. We bind both loopback
@@ -1027,6 +1218,7 @@ async function runViteDevServer(
1027
1218
  flagEnvVars: Record<string, string>,
1028
1219
  disablePlatform: boolean,
1029
1220
  openInBrowser: boolean,
1221
+ webPort: number | undefined,
1030
1222
  ): Promise<void> {
1031
1223
  const platformUrl = getPlatformUrl();
1032
1224
 
@@ -1039,8 +1231,9 @@ async function runViteDevServer(
1039
1231
  // Auto-pick a free port (Vite uses strictPort) so a running `vel up` stack
1040
1232
  // on :3000 doesn't wedge dev. The loopback callback port follows
1041
1233
  // window.location.port, so a non-3000 port propagates automatically.
1042
- const port = await findFreeDualLoopbackPort(3000);
1043
- if (port !== 3000) {
1234
+ // An explicit --port is strict: error rather than silently moving.
1235
+ const port = await resolveWebPort(webPort);
1236
+ if (webPort === undefined && port !== 3000) {
1044
1237
  console.log(`Port 3000 in use; using ${port}.`);
1045
1238
  }
1046
1239
 
@@ -1139,6 +1332,8 @@ export async function client(): Promise<void> {
1139
1332
  parsedFlagOverrides,
1140
1333
  disablePlatform,
1141
1334
  openBrowser: openInBrowser,
1335
+ webPort,
1336
+ background,
1142
1337
  } = parseArgs();
1143
1338
 
1144
1339
  if (disablePlatform) {
@@ -1146,11 +1341,16 @@ export async function client(): Promise<void> {
1146
1341
  }
1147
1342
 
1148
1343
  if (interfaceId === WEB_INTERFACE_ID) {
1344
+ if (background) {
1345
+ await spawnBackgroundWebInterface(webPort);
1346
+ return;
1347
+ }
1149
1348
  await runWebInterface(
1150
1349
  flagEnvVars,
1151
1350
  parsedFlagOverrides,
1152
1351
  disablePlatform,
1153
1352
  openInBrowser,
1353
+ webPort,
1154
1354
  );
1155
1355
  return;
1156
1356
  }
package/src/lib/local.ts CHANGED
@@ -105,7 +105,7 @@ function hasLocalRuntimeComponents(installDir: string): boolean {
105
105
  * (`assistant`, `credential-executor`) collide with app-bundle binary names
106
106
  * and point at whatever version happens to be installed globally.
107
107
  */
108
- function isCompiledCli(): boolean {
108
+ export function isCompiledCli(): boolean {
109
109
  const execBase = basename(process.execPath);
110
110
  return (
111
111
  execBase !== "bun" && execBase !== "bunx" && !execBase.startsWith("bun-")