@vellumai/cli 0.10.0-dev.202606230105.081b3b9 → 0.10.0-dev.202606230530.bc0f32e
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.
|
@@ -69,6 +69,32 @@ describe("isActiveAssistant", () => {
|
|
|
69
69
|
expect(isActiveAssistant([lockfilePath], "active")).toBe(true);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("returns true for the sole assistant when activeAssistant is empty", () => {
|
|
73
|
+
const dir = makeTempDir();
|
|
74
|
+
const lockfilePath = path.join(dir, "lockfile.json");
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
lockfilePath,
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
assistants: [{ assistantId: "only" }],
|
|
79
|
+
activeAssistant: null,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(isActiveAssistant([lockfilePath], "only")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns true for the sole assistant when activeAssistant is stale", () => {
|
|
86
|
+
const dir = makeTempDir();
|
|
87
|
+
const lockfilePath = path.join(dir, "lockfile.json");
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
lockfilePath,
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
assistants: [{ assistantId: "only" }],
|
|
92
|
+
activeAssistant: "missing",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
expect(isActiveAssistant([lockfilePath], "only")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
72
98
|
test("returns false for a non-active assistant", () => {
|
|
73
99
|
const dir = makeTempDir();
|
|
74
100
|
const lockfilePath = path.join(dir, "lockfile.json");
|
|
@@ -94,8 +94,15 @@ export function isActiveAssistant(
|
|
|
94
94
|
): boolean {
|
|
95
95
|
for (const candidate of lockfilePaths) {
|
|
96
96
|
try {
|
|
97
|
-
const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<
|
|
98
|
-
|
|
97
|
+
const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<
|
|
98
|
+
string,
|
|
99
|
+
unknown
|
|
100
|
+
>;
|
|
101
|
+
if (data.activeAssistant === assistantId) return true;
|
|
102
|
+
const assistants = data.assistants;
|
|
103
|
+
if (!Array.isArray(assistants) || assistants.length !== 1) return false;
|
|
104
|
+
const [onlyAssistant] = assistants as Array<Record<string, unknown>>;
|
|
105
|
+
return onlyAssistant?.assistantId === assistantId;
|
|
99
106
|
} catch {
|
|
100
107
|
continue;
|
|
101
108
|
}
|
package/package.json
CHANGED
package/src/commands/client.ts
CHANGED
|
@@ -54,9 +54,12 @@ import {
|
|
|
54
54
|
getPlatformUrl,
|
|
55
55
|
getWebUrl,
|
|
56
56
|
readPlatformToken,
|
|
57
|
+
savePlatformToken,
|
|
58
|
+
clearPlatformToken,
|
|
57
59
|
} from "../lib/platform-client";
|
|
58
60
|
import { tuiLog } from "../lib/tui-log";
|
|
59
61
|
import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
62
|
+
import { probePort } from "../lib/port-probe.js";
|
|
60
63
|
|
|
61
64
|
const SUPPORTED_INTERFACES = ["cli", "web"] as const;
|
|
62
65
|
type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
|
|
@@ -101,8 +104,10 @@ export function parseArgs(): ParsedArgs {
|
|
|
101
104
|
const { envVars: cliFlagVars, remaining: argsWithoutFlags } =
|
|
102
105
|
parseFeatureFlagArgs(process.argv.slice(3));
|
|
103
106
|
const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
|
|
104
|
-
const disablePlatformAmbient =
|
|
105
|
-
|
|
107
|
+
const disablePlatformAmbient =
|
|
108
|
+
process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
|
|
109
|
+
let disablePlatform =
|
|
110
|
+
disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
|
|
106
111
|
const args = argsWithoutFlags;
|
|
107
112
|
|
|
108
113
|
// Build parsedFlagOverrides from the extracted env vars:
|
|
@@ -389,6 +394,31 @@ const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
|
|
|
389
394
|
const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
|
|
390
395
|
const GUARDIAN_TOKEN_PATTERN =
|
|
391
396
|
/^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
|
|
397
|
+
const PLATFORM_SESSION_PATTERN =
|
|
398
|
+
/^(?:\/assistant)?\/__local\/platform-session$/;
|
|
399
|
+
|
|
400
|
+
// The loopback platform session token. Persisted via the same store the CLI
|
|
401
|
+
// uses (so `vellum client` restarts and CLI logins stay in sync), cached here
|
|
402
|
+
// to keep it off the per-request proxy path. Set only after the SPA validates
|
|
403
|
+
// the loopback `state`, so an unsolicited /callback can't fixate a session.
|
|
404
|
+
let platformSessionToken: string | null | undefined;
|
|
405
|
+
function currentPlatformToken(): string | null {
|
|
406
|
+
if (platformSessionToken === undefined) {
|
|
407
|
+
platformSessionToken = readPlatformToken();
|
|
408
|
+
}
|
|
409
|
+
return platformSessionToken;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Whether to attach the platform credential to a proxied request. Only
|
|
413
|
+
// same-origin (SPA) traffic qualifies — a cross-site page must not be able to
|
|
414
|
+
// use the local proxy as a confused deputy for authenticated platform calls.
|
|
415
|
+
// Cross-origin fetches always send an Origin; `Sec-Fetch-Site` is a belt-and-
|
|
416
|
+
// braces check for browsers that send it.
|
|
417
|
+
function isSameOriginRequest(req: Request): boolean {
|
|
418
|
+
if (!originIsAllowed(req.headers.get("origin") ?? undefined)) return false;
|
|
419
|
+
const site = req.headers.get("sec-fetch-site");
|
|
420
|
+
return !site || site === "same-origin" || site === "none";
|
|
421
|
+
}
|
|
392
422
|
|
|
393
423
|
function getEnvRecord(): Record<string, string> {
|
|
394
424
|
const result: Record<string, string> = {};
|
|
@@ -418,6 +448,7 @@ async function handleLocalEndpoints(
|
|
|
418
448
|
HATCH_PATTERN.test(pathname) ||
|
|
419
449
|
RETIRE_PATTERN.test(pathname) ||
|
|
420
450
|
GUARDIAN_TOKEN_PATTERN.test(pathname) ||
|
|
451
|
+
PLATFORM_SESSION_PATTERN.test(pathname) ||
|
|
421
452
|
parseGatewayUrl(pathname).match;
|
|
422
453
|
|
|
423
454
|
if (!isLocalRoute) return null;
|
|
@@ -435,6 +466,33 @@ async function handleLocalEndpoints(
|
|
|
435
466
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
436
467
|
}
|
|
437
468
|
|
|
469
|
+
// Platform session: the SPA hands over the loopback token here (after it has
|
|
470
|
+
// validated the `state` nonce) so the proxy below can authenticate to the
|
|
471
|
+
// platform. The browser never holds a session cookie.
|
|
472
|
+
if (PLATFORM_SESSION_PATTERN.test(pathname)) {
|
|
473
|
+
if (req.method === "DELETE") {
|
|
474
|
+
clearPlatformToken();
|
|
475
|
+
platformSessionToken = null;
|
|
476
|
+
return Response.json({ ok: true });
|
|
477
|
+
}
|
|
478
|
+
if (req.method === "POST") {
|
|
479
|
+
const body = (await req.json().catch(() => null)) as {
|
|
480
|
+
token?: unknown;
|
|
481
|
+
} | null;
|
|
482
|
+
const token = body?.token;
|
|
483
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9]+$/.test(token)) {
|
|
484
|
+
return Response.json(
|
|
485
|
+
{ ok: false, error: "Invalid token" },
|
|
486
|
+
{ status: 400 },
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
savePlatformToken(token);
|
|
490
|
+
platformSessionToken = token;
|
|
491
|
+
return Response.json({ ok: true });
|
|
492
|
+
}
|
|
493
|
+
return new Response(null, { status: 405 });
|
|
494
|
+
}
|
|
495
|
+
|
|
438
496
|
// Lockfile
|
|
439
497
|
if (LOCKFILE_PATTERN.test(pathname)) {
|
|
440
498
|
if (req.method === "GET") {
|
|
@@ -658,6 +716,106 @@ function getBaseDir(): string {
|
|
|
658
716
|
return path.resolve(import.meta.dir, "..", "..", "..");
|
|
659
717
|
}
|
|
660
718
|
|
|
719
|
+
// Just the slice of a Bun server `fetchHandler` needs — matches the structural
|
|
720
|
+
// arg `handleLocalEndpoints` accepts, so Bun's `Server` is assignable to it.
|
|
721
|
+
type RequestPeerServer = {
|
|
722
|
+
requestIP(req: Request): { address: string } | null;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const WEB_PORT_SCAN_LIMIT = 50;
|
|
726
|
+
|
|
727
|
+
type WebFetchHandler = (
|
|
728
|
+
req: Request,
|
|
729
|
+
server: RequestPeerServer,
|
|
730
|
+
) => Promise<Response>;
|
|
731
|
+
|
|
732
|
+
function isAddrInUse(err: unknown): boolean {
|
|
733
|
+
const e = err as { code?: string; message?: string } | undefined;
|
|
734
|
+
return (
|
|
735
|
+
e?.code === "EADDRINUSE" ||
|
|
736
|
+
/EADDRINUSE|address already in use/i.test(e?.message ?? "")
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Bind one loopback family; returns the server, or null when the port is in
|
|
741
|
+
// use. Server type is inferred from `Bun.serve` (avoids a generic mismatch).
|
|
742
|
+
function tryBindLoopback(
|
|
743
|
+
port: number,
|
|
744
|
+
hostname: string,
|
|
745
|
+
fetchHandler: WebFetchHandler,
|
|
746
|
+
) {
|
|
747
|
+
try {
|
|
748
|
+
return Bun.serve({ port, hostname, fetch: fetchHandler });
|
|
749
|
+
} catch (err) {
|
|
750
|
+
if (isAddrInUse(err)) return null;
|
|
751
|
+
throw err;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Bind the local web server on BOTH loopback families (`127.0.0.1` and `::1`)
|
|
757
|
+
* so the app can be reached at `http://localhost:<port>` regardless of whether
|
|
758
|
+
* the browser resolves `localhost` to IPv4 or IPv6 — matching the host the
|
|
759
|
+
* platform hardcodes in its loopback login callback.
|
|
760
|
+
*
|
|
761
|
+
* IPv4 is mandatory. IPv6 is best-effort: if `::1` is already taken (e.g. the
|
|
762
|
+
* local platform's `vel up` edge-proxy owns `[::]:<port>`), the port is
|
|
763
|
+
* contested and we advance — otherwise `localhost` would resolve to that other
|
|
764
|
+
* server. If IPv6 is simply unavailable on the host, we proceed IPv4-only.
|
|
765
|
+
*
|
|
766
|
+
* Never binds wildcard interfaces (`0.0.0.0`/`::`): the server exposes
|
|
767
|
+
* `/__local/*` control endpoints, so it must stay loopback-only.
|
|
768
|
+
*/
|
|
769
|
+
function serveLoopback(preferredPort: number, fetchHandler: WebFetchHandler) {
|
|
770
|
+
for (
|
|
771
|
+
let port = preferredPort;
|
|
772
|
+
port < preferredPort + WEB_PORT_SCAN_LIMIT;
|
|
773
|
+
port++
|
|
774
|
+
) {
|
|
775
|
+
const primary = tryBindLoopback(port, "127.0.0.1", fetchHandler);
|
|
776
|
+
if (!primary) continue;
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const secondary = Bun.serve({
|
|
780
|
+
port,
|
|
781
|
+
hostname: "::1",
|
|
782
|
+
fetch: fetchHandler,
|
|
783
|
+
});
|
|
784
|
+
return { port, servers: [primary, secondary] };
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (isAddrInUse(err)) {
|
|
787
|
+
// `::1` is contested (e.g. `vel up`) — move ports so `localhost`
|
|
788
|
+
// doesn't resolve to that other server.
|
|
789
|
+
primary.stop(true);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
// IPv6 unavailable (e.g. EADDRNOTAVAIL) — IPv4-only is acceptable since
|
|
793
|
+
// `localhost` then resolves to 127.0.0.1 anyway.
|
|
794
|
+
return { port, servers: [primary] };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
throw new Error(
|
|
798
|
+
`Could not bind a free loopback port in [${preferredPort}, ${preferredPort + WEB_PORT_SCAN_LIMIT - 1}]`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Find the first port at/above `preferred` with nothing listening on either
|
|
804
|
+
* loopback family. Used for the Vite dev server, which binds the port itself
|
|
805
|
+
* (via the `PORT` env). Connect-probe based, so there's a small TOCTOU window
|
|
806
|
+
* before Vite binds — acceptable for dev.
|
|
807
|
+
*/
|
|
808
|
+
async function findFreeDualLoopbackPort(preferred: number): Promise<number> {
|
|
809
|
+
for (let port = preferred; port < preferred + WEB_PORT_SCAN_LIMIT; port++) {
|
|
810
|
+
const [busyV4, busyV6] = await Promise.all([
|
|
811
|
+
probePort(port, "127.0.0.1"),
|
|
812
|
+
probePort(port, "::1"),
|
|
813
|
+
]);
|
|
814
|
+
if (!busyV4 && !busyV6) return port;
|
|
815
|
+
}
|
|
816
|
+
return preferred;
|
|
817
|
+
}
|
|
818
|
+
|
|
661
819
|
async function runWebInterface(
|
|
662
820
|
flagEnvVars: Record<string, string>,
|
|
663
821
|
parsedFlagOverrides: Record<string, boolean | string>,
|
|
@@ -699,120 +857,118 @@ async function runWebInterface(
|
|
|
699
857
|
`<script>window.__VELLUM_CONFIG__=${configJson}${flagOverridesSnippet}</script></head>`,
|
|
700
858
|
);
|
|
701
859
|
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
fetch: async (req) => {
|
|
706
|
-
const url = new URL(req.url);
|
|
707
|
-
const { pathname } = url;
|
|
860
|
+
const fetchHandler: WebFetchHandler = async (req, server) => {
|
|
861
|
+
const url = new URL(req.url);
|
|
862
|
+
const { pathname } = url;
|
|
708
863
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
864
|
+
if (pathname === "/" || pathname === "/assistant") {
|
|
865
|
+
return Response.redirect(SPA_BASE, 302);
|
|
866
|
+
}
|
|
712
867
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
);
|
|
720
|
-
}
|
|
868
|
+
// Loopback auth: the platform redirects here after login with
|
|
869
|
+
// ?state=...&session_token=... — forward into the SPA, which validates the
|
|
870
|
+
// `state` nonce before registering the token via /__local/platform-session.
|
|
871
|
+
if (pathname === "/callback") {
|
|
872
|
+
return Response.redirect(`/account/platform-callback${url.search}`, 302);
|
|
873
|
+
}
|
|
721
874
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
875
|
+
// Expose environment config to the SPA.
|
|
876
|
+
if (pathname === "/assistant/__config" || pathname === "/__config") {
|
|
877
|
+
return new Response(configJson, {
|
|
878
|
+
headers: { "Content-Type": "application/json" },
|
|
879
|
+
});
|
|
880
|
+
}
|
|
728
881
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
// Reverse-proxy platform API requests.
|
|
734
|
-
if (
|
|
735
|
-
pathname.startsWith("/v1/") ||
|
|
736
|
-
pathname.startsWith("/_allauth/") ||
|
|
737
|
-
pathname.startsWith("/accounts/")
|
|
738
|
-
) {
|
|
739
|
-
const target = new URL(pathname + url.search, platformUrl);
|
|
740
|
-
const headers = new Headers(req.headers);
|
|
741
|
-
headers.set("Host", new URL(platformUrl).host);
|
|
742
|
-
headers.delete("Origin");
|
|
743
|
-
headers.delete("Referer");
|
|
744
|
-
|
|
745
|
-
// Forward the session token — the loopback flow stores it in
|
|
746
|
-
// the browser cookie jar for localhost, but the platform backend
|
|
747
|
-
// expects it on its own domain. Set both the Cookie (for Django
|
|
748
|
-
// session middleware / allauth) and X-Session-Token (for DRF
|
|
749
|
-
// views that accept header-based auth).
|
|
750
|
-
const sessionToken = /sessionid=([^;]+)/.exec(
|
|
751
|
-
req.headers.get("Cookie") ?? "",
|
|
752
|
-
)?.[1];
|
|
753
|
-
if (sessionToken) {
|
|
754
|
-
headers.set(
|
|
755
|
-
"Cookie",
|
|
756
|
-
`sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
|
|
757
|
-
);
|
|
758
|
-
headers.set("X-Session-Token", sessionToken);
|
|
759
|
-
}
|
|
882
|
+
// __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
|
|
883
|
+
const localResponse = await handleLocalEndpoints(req, url, server);
|
|
884
|
+
if (localResponse) return localResponse;
|
|
760
885
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
886
|
+
// Reverse-proxy platform API requests.
|
|
887
|
+
if (
|
|
888
|
+
pathname.startsWith("/v1/") ||
|
|
889
|
+
pathname.startsWith("/_allauth/") ||
|
|
890
|
+
pathname.startsWith("/accounts/")
|
|
891
|
+
) {
|
|
892
|
+
const target = new URL(pathname + url.search, platformUrl);
|
|
893
|
+
const headers = new Headers(req.headers);
|
|
894
|
+
headers.set("Host", new URL(platformUrl).host);
|
|
895
|
+
headers.delete("Origin");
|
|
896
|
+
headers.delete("Referer");
|
|
897
|
+
|
|
898
|
+
// Authenticate with the loopback session token the SPA registered. The
|
|
899
|
+
// platform expects it both as the Django session cookie and as
|
|
900
|
+
// X-Session-Token (for DRF views that accept header-based auth). Only
|
|
901
|
+
// same-origin SPA traffic gets the credential — never a cross-site caller.
|
|
902
|
+
const sessionToken = isSameOriginRequest(req)
|
|
903
|
+
? currentPlatformToken()
|
|
904
|
+
: null;
|
|
905
|
+
if (sessionToken) {
|
|
906
|
+
headers.set(
|
|
907
|
+
"Cookie",
|
|
908
|
+
`sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
|
|
909
|
+
);
|
|
910
|
+
headers.set("X-Session-Token", sessionToken);
|
|
783
911
|
}
|
|
784
912
|
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
794
|
-
return new Response(indexHtml, {
|
|
795
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
913
|
+
try {
|
|
914
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
915
|
+
const body = hasBody ? await req.arrayBuffer() : undefined;
|
|
916
|
+
const proxyRes = await loopbackSafeFetch(target.toString(), {
|
|
917
|
+
method: req.method,
|
|
918
|
+
headers,
|
|
919
|
+
body,
|
|
920
|
+
redirect: "manual",
|
|
796
921
|
});
|
|
922
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
923
|
+
resHeaders.delete("transfer-encoding");
|
|
924
|
+
return new Response(proxyRes.body, {
|
|
925
|
+
status: proxyRes.status,
|
|
926
|
+
statusText: proxyRes.statusText,
|
|
927
|
+
headers: resHeaders,
|
|
928
|
+
});
|
|
929
|
+
} catch (err) {
|
|
930
|
+
return new Response(
|
|
931
|
+
JSON.stringify({ error: `Platform proxy error: ${err}` }),
|
|
932
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
933
|
+
);
|
|
797
934
|
}
|
|
935
|
+
}
|
|
798
936
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
937
|
+
if (pathname.startsWith(SPA_BASE)) {
|
|
938
|
+
const relPath = pathname.slice(SPA_BASE.length);
|
|
939
|
+
if (relPath) {
|
|
940
|
+
const filePath = path.join(distDir, relPath);
|
|
941
|
+
const file = Bun.file(filePath);
|
|
942
|
+
if (await file.exists()) {
|
|
943
|
+
return new Response(file);
|
|
944
|
+
}
|
|
804
945
|
}
|
|
946
|
+
return new Response(indexHtml, {
|
|
947
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
948
|
+
});
|
|
949
|
+
}
|
|
805
950
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
951
|
+
// SPA fallback for /account/* routes (login, callback, etc.)
|
|
952
|
+
if (pathname.startsWith("/account/")) {
|
|
953
|
+
return new Response(indexHtml, {
|
|
954
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
955
|
+
});
|
|
956
|
+
}
|
|
809
957
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
958
|
+
return new Response("Not Found", { status: 404 });
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const { port, servers } = serveLoopback(3000, fetchHandler);
|
|
962
|
+
if (port !== 3000) {
|
|
963
|
+
console.log(`Port 3000 in use; using ${port}.`);
|
|
964
|
+
}
|
|
965
|
+
// Advertise `localhost` (not `127.0.0.1`) so the app origin matches the host
|
|
966
|
+
// the platform hardcodes in its loopback callback. We bind both loopback
|
|
967
|
+
// families above so `localhost` reaches us whichever one it resolves to.
|
|
968
|
+
console.log(`Vellum web interface: http://localhost:${port}${SPA_BASE}`);
|
|
813
969
|
|
|
814
970
|
const shutdown = (): void => {
|
|
815
|
-
server.stop();
|
|
971
|
+
for (const server of servers) server.stop();
|
|
816
972
|
process.exit(0);
|
|
817
973
|
};
|
|
818
974
|
process.on("SIGINT", shutdown);
|
|
@@ -834,6 +990,14 @@ async function runViteDevServer(
|
|
|
834
990
|
viteFlagVars[`VITE_${envName}`] = value;
|
|
835
991
|
}
|
|
836
992
|
|
|
993
|
+
// Auto-pick a free port (Vite uses strictPort) so a running `vel up` stack
|
|
994
|
+
// on :3000 doesn't wedge dev. The loopback callback port follows
|
|
995
|
+
// window.location.port, so a non-3000 port propagates automatically.
|
|
996
|
+
const port = await findFreeDualLoopbackPort(3000);
|
|
997
|
+
if (port !== 3000) {
|
|
998
|
+
console.log(`Port 3000 in use; using ${port}.`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
837
1001
|
const child = spawn("bun", ["run", "dev"], {
|
|
838
1002
|
cwd: webSourceDir,
|
|
839
1003
|
stdio: "inherit",
|
|
@@ -846,7 +1010,7 @@ async function runViteDevServer(
|
|
|
846
1010
|
API_PROXY_TARGET: platformUrl,
|
|
847
1011
|
VELLUM_WEB_URL: getWebUrl(),
|
|
848
1012
|
VELLUM_PLATFORM_URL: platformUrl,
|
|
849
|
-
PORT:
|
|
1013
|
+
PORT: String(port),
|
|
850
1014
|
},
|
|
851
1015
|
});
|
|
852
1016
|
|
package/src/commands/flags.ts
CHANGED
|
@@ -92,7 +92,7 @@ function printHelp(): void {
|
|
|
92
92
|
" $ vellum flags # list flags for active assistant",
|
|
93
93
|
);
|
|
94
94
|
console.log(
|
|
95
|
-
" $ vellum flags get
|
|
95
|
+
" $ vellum flags get voice-mode # inspect one flag",
|
|
96
96
|
);
|
|
97
97
|
console.log(
|
|
98
98
|
" $ vellum flags set voice-mode true # enable a flag",
|