@vellumai/cli 0.8.5 → 0.8.7
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/AGENTS.md +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +471 -30
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +3 -23
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-config.ts +16 -3
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +57 -7
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/commands/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { hostname } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -23,10 +24,27 @@ import {
|
|
|
23
24
|
WEB_INTERFACE_ID,
|
|
24
25
|
getClientRegistrationHeaders,
|
|
25
26
|
} from "../lib/client-identity";
|
|
27
|
+
import {
|
|
28
|
+
getLockfileData,
|
|
29
|
+
upsertLockfileAssistant,
|
|
30
|
+
replacePlatformAssistants,
|
|
31
|
+
runHatch,
|
|
32
|
+
runRetire,
|
|
33
|
+
getGuardianAccessToken,
|
|
34
|
+
parseGatewayUrl,
|
|
35
|
+
readAllowedGatewayPorts,
|
|
36
|
+
isLoopbackAddr,
|
|
37
|
+
resolveDevCliInvocation,
|
|
38
|
+
resolveLockfilePaths,
|
|
39
|
+
resolveConfigDir,
|
|
40
|
+
type CliInvocation,
|
|
41
|
+
} from "@vellumai/local-mode";
|
|
26
42
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
27
43
|
import {
|
|
28
44
|
fetchOrganizationId,
|
|
29
45
|
fetchPlatformAssistants,
|
|
46
|
+
getPlatformUrl,
|
|
47
|
+
getWebUrl,
|
|
30
48
|
readPlatformToken,
|
|
31
49
|
} from "../lib/platform-client";
|
|
32
50
|
import { tuiLog } from "../lib/tui-log";
|
|
@@ -278,18 +296,29 @@ async function maybeHydratePlatformAssistantName(
|
|
|
278
296
|
}
|
|
279
297
|
}
|
|
280
298
|
|
|
299
|
+
const SPA_BASE = "/assistant/";
|
|
300
|
+
|
|
281
301
|
/**
|
|
282
|
-
*
|
|
302
|
+
* Locate the pre-built @vellumai/web dist directory.
|
|
283
303
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* `--interface web` path as source-checkout-only.
|
|
304
|
+
* Resolution order:
|
|
305
|
+
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
306
|
+
* 2. Source checkout — walk up from cli/ to find apps/web/dist/
|
|
288
307
|
*/
|
|
289
|
-
function
|
|
308
|
+
function findWebDistDir(): string | null {
|
|
309
|
+
try {
|
|
310
|
+
const pkgPath = require.resolve("@vellumai/web/package.json");
|
|
311
|
+
const distDir = path.join(path.dirname(pkgPath), "dist");
|
|
312
|
+
if (existsSync(path.join(distDir, "index.html"))) {
|
|
313
|
+
return distDir;
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// Package not installed; try source checkout.
|
|
317
|
+
}
|
|
318
|
+
|
|
290
319
|
let dir = import.meta.dir;
|
|
291
320
|
for (let depth = 0; depth < 8; depth++) {
|
|
292
|
-
const candidate = path.join(dir, "
|
|
321
|
+
const candidate = path.join(dir, "apps", "web", "dist", "index.html");
|
|
293
322
|
if (existsSync(candidate)) {
|
|
294
323
|
return path.dirname(candidate);
|
|
295
324
|
}
|
|
@@ -301,41 +330,453 @@ function findClientsWebDir(): string | null {
|
|
|
301
330
|
}
|
|
302
331
|
|
|
303
332
|
/**
|
|
304
|
-
*
|
|
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.
|
|
333
|
+
* Locate the apps/web source directory for running the Vite dev server.
|
|
334
|
+
* Only works from a source checkout (not npm-installed).
|
|
309
335
|
*/
|
|
336
|
+
function findWebSourceDir(): string | null {
|
|
337
|
+
let dir = import.meta.dir;
|
|
338
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
339
|
+
const candidate = path.join(dir, "apps", "web", "vite.config.ts");
|
|
340
|
+
if (existsSync(candidate)) {
|
|
341
|
+
return path.dirname(candidate);
|
|
342
|
+
}
|
|
343
|
+
const parent = path.dirname(dir);
|
|
344
|
+
if (parent === dir) break;
|
|
345
|
+
dir = parent;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
|
|
351
|
+
const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
|
|
352
|
+
const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
|
|
353
|
+
const GUARDIAN_TOKEN_PATTERN =
|
|
354
|
+
/^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
|
|
355
|
+
|
|
356
|
+
function getEnvRecord(): Record<string, string> {
|
|
357
|
+
const result: Record<string, string> = {};
|
|
358
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
359
|
+
if (v !== undefined) result[k] = v;
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const _localEnv = getEnvRecord();
|
|
365
|
+
const _lockfilePaths = resolveLockfilePaths(_localEnv);
|
|
366
|
+
const _configDir = resolveConfigDir(_localEnv);
|
|
367
|
+
const _baseDir = getBaseDir();
|
|
368
|
+
|
|
369
|
+
async function handleLocalEndpoints(
|
|
370
|
+
req: Request,
|
|
371
|
+
url: URL,
|
|
372
|
+
server: { requestIP(req: Request): { address: string } | null },
|
|
373
|
+
): Promise<Response | null> {
|
|
374
|
+
const { pathname } = url;
|
|
375
|
+
const lockfilePaths = _lockfilePaths;
|
|
376
|
+
const configDir = _configDir;
|
|
377
|
+
|
|
378
|
+
// Check if this is a __local or __gateway route before enforcing loopback.
|
|
379
|
+
const isLocalRoute =
|
|
380
|
+
LOCKFILE_PATTERN.test(pathname) ||
|
|
381
|
+
HATCH_PATTERN.test(pathname) ||
|
|
382
|
+
RETIRE_PATTERN.test(pathname) ||
|
|
383
|
+
GUARDIAN_TOKEN_PATTERN.test(pathname) ||
|
|
384
|
+
parseGatewayUrl(pathname).match;
|
|
385
|
+
|
|
386
|
+
if (!isLocalRoute) return null;
|
|
387
|
+
|
|
388
|
+
// All __local and __gateway endpoints are restricted to loopback clients.
|
|
389
|
+
const peer = server.requestIP(req)?.address ?? "";
|
|
390
|
+
if (!isLoopbackAddr(peer)) {
|
|
391
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Lockfile
|
|
395
|
+
if (LOCKFILE_PATTERN.test(pathname)) {
|
|
396
|
+
if (req.method === "GET") {
|
|
397
|
+
const result = getLockfileData(lockfilePaths);
|
|
398
|
+
if (result.ok) {
|
|
399
|
+
return Response.json(result.data);
|
|
400
|
+
}
|
|
401
|
+
return new Response(null, { status: result.status });
|
|
402
|
+
}
|
|
403
|
+
if (req.method === "POST") {
|
|
404
|
+
let body: Record<string, unknown>;
|
|
405
|
+
try {
|
|
406
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
407
|
+
} catch {
|
|
408
|
+
return Response.json(
|
|
409
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
410
|
+
{ status: 400 },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
let result;
|
|
414
|
+
if (body.syncPlatform && Array.isArray(body.platformAssistants)) {
|
|
415
|
+
result = replacePlatformAssistants(
|
|
416
|
+
lockfilePaths,
|
|
417
|
+
body.platformAssistants as Array<Record<string, unknown>>,
|
|
418
|
+
);
|
|
419
|
+
} else {
|
|
420
|
+
result = upsertLockfileAssistant(
|
|
421
|
+
lockfilePaths,
|
|
422
|
+
body.assistant as Record<string, unknown>,
|
|
423
|
+
body.activeAssistant as string | undefined,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return Response.json(result, { status: result.ok ? 200 : result.status });
|
|
427
|
+
}
|
|
428
|
+
return new Response(null, { status: 405 });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Hatch
|
|
432
|
+
if (HATCH_PATTERN.test(pathname)) {
|
|
433
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
434
|
+
|
|
435
|
+
let species = "vellum";
|
|
436
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
437
|
+
if (contentType.includes("json")) {
|
|
438
|
+
try {
|
|
439
|
+
const body = (await req.json()) as { species?: string };
|
|
440
|
+
if (body.species) species = body.species;
|
|
441
|
+
} catch {
|
|
442
|
+
return Response.json(
|
|
443
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
444
|
+
{ status: 400 },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let invocation: CliInvocation;
|
|
450
|
+
try {
|
|
451
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return Response.json(
|
|
454
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
455
|
+
{ status: 500 },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const result = await runHatch(invocation, species);
|
|
460
|
+
if (result.ok) {
|
|
461
|
+
return Response.json({ ok: true, assistantId: result.assistantId });
|
|
462
|
+
}
|
|
463
|
+
return Response.json(
|
|
464
|
+
{ ok: false, error: result.error },
|
|
465
|
+
{ status: result.status },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Retire
|
|
470
|
+
if (RETIRE_PATTERN.test(pathname)) {
|
|
471
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
472
|
+
|
|
473
|
+
let assistantId: string | undefined;
|
|
474
|
+
try {
|
|
475
|
+
const body = (await req.json()) as { assistantId?: string };
|
|
476
|
+
assistantId = body.assistantId;
|
|
477
|
+
} catch {
|
|
478
|
+
return Response.json(
|
|
479
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
480
|
+
{ status: 400 },
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!assistantId) {
|
|
485
|
+
return Response.json(
|
|
486
|
+
{ ok: false, error: "Missing assistantId" },
|
|
487
|
+
{ status: 400 },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let invocation: CliInvocation;
|
|
492
|
+
try {
|
|
493
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
return Response.json(
|
|
496
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
497
|
+
{ status: 500 },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const result = await runRetire(invocation, assistantId);
|
|
502
|
+
if (result.ok) {
|
|
503
|
+
return Response.json({ ok: true });
|
|
504
|
+
}
|
|
505
|
+
return Response.json(
|
|
506
|
+
{ ok: false, error: result.error },
|
|
507
|
+
{ status: result.status },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Guardian token
|
|
512
|
+
const guardianMatch = pathname.match(GUARDIAN_TOKEN_PATTERN);
|
|
513
|
+
if (guardianMatch) {
|
|
514
|
+
if (req.method !== "GET") return new Response(null, { status: 405 });
|
|
515
|
+
|
|
516
|
+
const assistantId = decodeURIComponent(guardianMatch[1]!);
|
|
517
|
+
|
|
518
|
+
let invocation: CliInvocation;
|
|
519
|
+
try {
|
|
520
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
return Response.json(
|
|
523
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
524
|
+
{ status: 500 },
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const result = await getGuardianAccessToken(
|
|
529
|
+
assistantId,
|
|
530
|
+
configDir,
|
|
531
|
+
invocation,
|
|
532
|
+
true,
|
|
533
|
+
_localEnv,
|
|
534
|
+
);
|
|
535
|
+
if (result.ok) {
|
|
536
|
+
return Response.json({ accessToken: result.accessToken });
|
|
537
|
+
}
|
|
538
|
+
return Response.json({ error: result.error }, { status: result.status });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Gateway proxy
|
|
542
|
+
const gatewayResult = parseGatewayUrl(pathname);
|
|
543
|
+
if (gatewayResult.match) {
|
|
544
|
+
if (!gatewayResult.valid) {
|
|
545
|
+
return new Response("Port must be between 1024 and 65535", {
|
|
546
|
+
status: 400,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const { target: gatewayTarget } = gatewayResult;
|
|
550
|
+
const allowedPorts = readAllowedGatewayPorts(lockfilePaths);
|
|
551
|
+
if (!allowedPorts.has(gatewayTarget.port)) {
|
|
552
|
+
return new Response("Gateway port is not active in lockfile", {
|
|
553
|
+
status: 403,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
|
|
557
|
+
const headers = new Headers(req.headers);
|
|
558
|
+
headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
562
|
+
const proxyRes = await fetch(targetUrl, {
|
|
563
|
+
method: req.method,
|
|
564
|
+
headers,
|
|
565
|
+
body: hasBody ? req.body : undefined,
|
|
566
|
+
redirect: "manual",
|
|
567
|
+
});
|
|
568
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
569
|
+
resHeaders.delete("transfer-encoding");
|
|
570
|
+
return new Response(proxyRes.body, {
|
|
571
|
+
status: proxyRes.status,
|
|
572
|
+
statusText: proxyRes.statusText,
|
|
573
|
+
headers: resHeaders,
|
|
574
|
+
});
|
|
575
|
+
} catch {
|
|
576
|
+
return new Response("Gateway proxy error", { status: 502 });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function getBaseDir(): string {
|
|
584
|
+
let dir = import.meta.dir;
|
|
585
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
586
|
+
if (existsSync(path.join(dir, "cli", "src", "index.ts"))) {
|
|
587
|
+
return dir;
|
|
588
|
+
}
|
|
589
|
+
const pkgPath = path.join(dir, "package.json");
|
|
590
|
+
if (existsSync(pkgPath)) {
|
|
591
|
+
return dir;
|
|
592
|
+
}
|
|
593
|
+
const parent = path.dirname(dir);
|
|
594
|
+
if (parent === dir) break;
|
|
595
|
+
dir = parent;
|
|
596
|
+
}
|
|
597
|
+
return path.resolve(import.meta.dir, "..", "..", "..");
|
|
598
|
+
}
|
|
599
|
+
|
|
310
600
|
async function runWebInterface(): Promise<void> {
|
|
311
|
-
|
|
312
|
-
|
|
601
|
+
// Prefer Vite dev server in source checkouts for full local-mode support
|
|
602
|
+
// (HMR, __local endpoints, gateway proxy).
|
|
603
|
+
const webSourceDir = findWebSourceDir();
|
|
604
|
+
if (webSourceDir) {
|
|
605
|
+
return runViteDevServer(webSourceDir);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const distDir = findWebDistDir();
|
|
609
|
+
if (!distDir) {
|
|
313
610
|
console.error(
|
|
314
611
|
`${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
|
|
315
|
-
|
|
316
|
-
`
|
|
612
|
+
`@vellumai/web assets.\n\n` +
|
|
613
|
+
` npm/bunx install: npm install @vellumai/web\n` +
|
|
614
|
+
` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
|
|
317
615
|
);
|
|
318
616
|
process.exit(1);
|
|
319
617
|
}
|
|
320
618
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
619
|
+
const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
|
|
620
|
+
const platformUrl = getPlatformUrl();
|
|
621
|
+
const webUrl = getWebUrl();
|
|
622
|
+
const configJson = JSON.stringify({ webUrl, platformUrl });
|
|
623
|
+
const indexHtml = rawIndexHtml.replace(
|
|
624
|
+
"</head>",
|
|
625
|
+
`<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const server = Bun.serve({
|
|
629
|
+
port: 3000,
|
|
630
|
+
hostname: "127.0.0.1",
|
|
631
|
+
fetch: async (req) => {
|
|
632
|
+
const url = new URL(req.url);
|
|
633
|
+
const { pathname } = url;
|
|
634
|
+
|
|
635
|
+
if (pathname === "/" || pathname === "/assistant") {
|
|
636
|
+
return Response.redirect(SPA_BASE, 302);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Loopback auth: the platform redirects here after login with
|
|
640
|
+
// ?state=...&session_token=... — forward into the SPA.
|
|
641
|
+
if (pathname === "/callback") {
|
|
642
|
+
return Response.redirect(
|
|
643
|
+
`/account/platform-callback${url.search}`,
|
|
644
|
+
302,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Expose environment config to the SPA.
|
|
649
|
+
if (pathname === "/assistant/__config" || pathname === "/__config") {
|
|
650
|
+
return new Response(configJson, {
|
|
651
|
+
headers: { "Content-Type": "application/json" },
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
|
|
656
|
+
const localResponse = await handleLocalEndpoints(req, url, server);
|
|
657
|
+
if (localResponse) return localResponse;
|
|
658
|
+
|
|
659
|
+
// Reverse-proxy platform API requests.
|
|
660
|
+
if (
|
|
661
|
+
pathname.startsWith("/v1/") ||
|
|
662
|
+
pathname.startsWith("/_allauth/") ||
|
|
663
|
+
pathname.startsWith("/accounts/")
|
|
664
|
+
) {
|
|
665
|
+
const target = new URL(pathname + url.search, platformUrl);
|
|
666
|
+
const headers = new Headers(req.headers);
|
|
667
|
+
headers.set("Host", new URL(platformUrl).host);
|
|
668
|
+
headers.delete("Origin");
|
|
669
|
+
headers.delete("Referer");
|
|
670
|
+
|
|
671
|
+
// Forward the session token — the loopback flow stores it in
|
|
672
|
+
// the browser cookie jar for localhost, but the platform backend
|
|
673
|
+
// expects it on its own domain. Set both the Cookie (for Django
|
|
674
|
+
// session middleware / allauth) and X-Session-Token (for DRF
|
|
675
|
+
// views that accept header-based auth).
|
|
676
|
+
const sessionToken = /sessionid=([^;]+)/.exec(
|
|
677
|
+
req.headers.get("Cookie") ?? "",
|
|
678
|
+
)?.[1];
|
|
679
|
+
if (sessionToken) {
|
|
680
|
+
headers.set(
|
|
681
|
+
"Cookie",
|
|
682
|
+
`sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
|
|
683
|
+
);
|
|
684
|
+
headers.set("X-Session-Token", sessionToken);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
689
|
+
const body = hasBody ? await req.arrayBuffer() : undefined;
|
|
690
|
+
const proxyRes = await fetch(target.toString(), {
|
|
691
|
+
method: req.method,
|
|
692
|
+
headers,
|
|
693
|
+
body,
|
|
694
|
+
redirect: "manual",
|
|
695
|
+
});
|
|
696
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
697
|
+
resHeaders.delete("transfer-encoding");
|
|
698
|
+
return new Response(proxyRes.body, {
|
|
699
|
+
status: proxyRes.status,
|
|
700
|
+
statusText: proxyRes.statusText,
|
|
701
|
+
headers: resHeaders,
|
|
702
|
+
});
|
|
703
|
+
} catch (err) {
|
|
704
|
+
return new Response(
|
|
705
|
+
JSON.stringify({ error: `Platform proxy error: ${err}` }),
|
|
706
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (pathname.startsWith(SPA_BASE)) {
|
|
712
|
+
const relPath = pathname.slice(SPA_BASE.length);
|
|
713
|
+
if (relPath) {
|
|
714
|
+
const filePath = path.join(distDir, relPath);
|
|
715
|
+
const file = Bun.file(filePath);
|
|
716
|
+
if (await file.exists()) {
|
|
717
|
+
return new Response(file);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return new Response(indexHtml, {
|
|
721
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// SPA fallback for /account/* routes (login, callback, etc.)
|
|
726
|
+
if (pathname.startsWith("/account/")) {
|
|
727
|
+
return new Response(indexHtml, {
|
|
728
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return new Response("Not Found", { status: 404 });
|
|
733
|
+
},
|
|
325
734
|
});
|
|
326
735
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
736
|
+
console.log(
|
|
737
|
+
`Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
const shutdown = (): void => {
|
|
741
|
+
server.stop();
|
|
742
|
+
process.exit(0);
|
|
743
|
+
};
|
|
744
|
+
process.on("SIGINT", shutdown);
|
|
745
|
+
process.on("SIGTERM", shutdown);
|
|
746
|
+
|
|
747
|
+
await new Promise(() => {});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function runViteDevServer(webSourceDir: string): Promise<void> {
|
|
751
|
+
const platformUrl = getPlatformUrl();
|
|
752
|
+
|
|
753
|
+
const child = spawn("bun", ["run", "dev"], {
|
|
754
|
+
cwd: webSourceDir,
|
|
755
|
+
stdio: "inherit",
|
|
756
|
+
env: {
|
|
757
|
+
...process.env,
|
|
758
|
+
VITE_PLATFORM_MODE: "false",
|
|
759
|
+
API_PROXY_TARGET: platformUrl,
|
|
760
|
+
VELLUM_WEB_URL: getWebUrl(),
|
|
761
|
+
VELLUM_PLATFORM_URL: platformUrl,
|
|
762
|
+
PORT: "3000",
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const shutdown = (): void => {
|
|
767
|
+
child.kill();
|
|
768
|
+
process.exit(0);
|
|
333
769
|
};
|
|
334
|
-
process.on("SIGINT",
|
|
335
|
-
process.on("SIGTERM",
|
|
770
|
+
process.on("SIGINT", shutdown);
|
|
771
|
+
process.on("SIGTERM", shutdown);
|
|
336
772
|
|
|
337
|
-
|
|
338
|
-
|
|
773
|
+
await new Promise<void>((_, reject) => {
|
|
774
|
+
child.on("exit", (code) => {
|
|
775
|
+
if (code !== 0) {
|
|
776
|
+
reject(new Error(`Vite dev server exited with code ${code}`));
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
});
|
|
339
780
|
}
|
|
340
781
|
|
|
341
782
|
export async function client(): Promise<void> {
|
package/src/commands/env.ts
CHANGED