@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 +1 -1
- package/src/commands/client.ts +218 -18
- package/src/lib/local.ts +1 -1
package/package.json
CHANGED
package/src/commands/client.ts
CHANGED
|
@@ -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(
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
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-")
|