@vellumai/cli 0.6.3 → 0.6.5
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 +12 -2
- package/README.md +3 -3
- package/bun.lock +17 -17
- package/bunfig.toml +6 -0
- package/package.json +18 -18
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +225 -0
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +90 -13
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -56
- package/src/commands/backup.ts +8 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +209 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +8 -0
- package/src/commands/retire.ts +16 -9
- package/src/commands/rollback.ts +32 -33
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +253 -1
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +25 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +168 -0
- package/src/lib/assistant-config.ts +82 -108
- package/src/lib/aws.ts +12 -1
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +158 -8
- package/src/lib/environments/__tests__/paths.test.ts +228 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +109 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +74 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +71 -10
- package/src/lib/hatch-local.ts +44 -23
- package/src/lib/local.ts +47 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +354 -24
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/ssh-apple-container.ts +166 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
- package/src/shared/provider-env-vars.ts +30 -6
package/src/lib/docker.ts
CHANGED
|
@@ -6,6 +6,13 @@ import { dirname, join } from "path";
|
|
|
6
6
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
7
7
|
import cliPkg from "../../package.json";
|
|
8
8
|
|
|
9
|
+
// Pulled from skills/ — the Meet avatar device-path default is owned by the
|
|
10
|
+
// meet-join skill; importing here keeps the CLI's Docker wiring locked to the
|
|
11
|
+
// same value the bot and config schema use. The shared module is deliberately
|
|
12
|
+
// zero-dep so this import cannot drag unrelated surface into the compiled CLI
|
|
13
|
+
// binary.
|
|
14
|
+
import { AVATAR_DEVICE_PATH_DEFAULT } from "../../../skills/meet-join/shared/avatar-device-path.js";
|
|
15
|
+
|
|
9
16
|
import {
|
|
10
17
|
findAssistantByName,
|
|
11
18
|
saveAssistantEntry,
|
|
@@ -13,9 +20,10 @@ import {
|
|
|
13
20
|
} from "./assistant-config";
|
|
14
21
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
22
|
import { writeInitialConfig } from "./config-utils";
|
|
16
|
-
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
17
23
|
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
18
24
|
import type { Species } from "./constants";
|
|
25
|
+
import { getDefaultPorts } from "./environments/paths.js";
|
|
26
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
19
27
|
import { leaseGuardianToken } from "./guardian-token";
|
|
20
28
|
import { isVellumProcess, stopProcess } from "./process";
|
|
21
29
|
import { generateInstanceName } from "./random-name";
|
|
@@ -39,13 +47,75 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
|
|
|
39
47
|
};
|
|
40
48
|
|
|
41
49
|
/** Internal ports exposed by each service's Dockerfile. */
|
|
42
|
-
export const ASSISTANT_INTERNAL_PORT =
|
|
50
|
+
export const ASSISTANT_INTERNAL_PORT = 7821;
|
|
43
51
|
export const GATEWAY_INTERNAL_PORT = 7830;
|
|
44
52
|
|
|
45
53
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
46
54
|
export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Default virtual-camera device path when the Meet avatar feature is
|
|
58
|
+
* enabled. Re-exports the shared
|
|
59
|
+
* {@link ../../../skills/meet-join/shared/avatar-device-path.js AVATAR_DEVICE_PATH_DEFAULT}
|
|
60
|
+
* so the CLI's device-passthrough wiring cannot drift from the bot's
|
|
61
|
+
* Chrome-flag wiring or the workspace config default. Matches the
|
|
62
|
+
* `video_nr=10` value in the README's host-setup section
|
|
63
|
+
* (`skills/meet-join/bot/README.md`). Operators can override the path by
|
|
64
|
+
* setting `VELLUM_MEET_AVATAR_DEVICE` to something other than the default
|
|
65
|
+
* (e.g. `/dev/video11` if a different `video_nr` was used).
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_MEET_AVATAR_DEVICE_PATH = AVATAR_DEVICE_PATH_DEFAULT;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Env-var opt-in for bind-mounting the v4l2loopback virtual-camera device
|
|
71
|
+
* into the assistant container. Set to a truthy value (`1`, `true`, `yes`)
|
|
72
|
+
* to enable; unset or falsy disables the passthrough entirely.
|
|
73
|
+
*
|
|
74
|
+
* Kept as an env-var rather than a config-schema field because the Meet
|
|
75
|
+
* avatar config schema lands in a later PR (PR 5 of the phase-4 plan) —
|
|
76
|
+
* threading the config through the CLI's boot flow now would force a
|
|
77
|
+
* forward dependency. Once the schema lands, the CLI can either keep this
|
|
78
|
+
* env-var as a pre-config override or move the opt-in into the workspace
|
|
79
|
+
* config.
|
|
80
|
+
*/
|
|
81
|
+
export const MEET_AVATAR_ENV_VAR = "VELLUM_MEET_AVATAR";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Override for the virtual-camera device path. Defaults to
|
|
85
|
+
* {@link DEFAULT_MEET_AVATAR_DEVICE_PATH}.
|
|
86
|
+
*/
|
|
87
|
+
export const MEET_AVATAR_DEVICE_ENV_VAR = "VELLUM_MEET_AVATAR_DEVICE";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the Meet avatar device path to pass through to the assistant
|
|
91
|
+
* container, or `null` if the feature is not opted into. Exported so tests
|
|
92
|
+
* can assert against the env-var parsing without reaching into the shell.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveMeetAvatarDevicePath(
|
|
95
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
96
|
+
): string | null {
|
|
97
|
+
const flag = env[MEET_AVATAR_ENV_VAR];
|
|
98
|
+
if (!flag) return null;
|
|
99
|
+
const normalized = flag.trim().toLowerCase();
|
|
100
|
+
if (
|
|
101
|
+
normalized === "" ||
|
|
102
|
+
normalized === "0" ||
|
|
103
|
+
normalized === "false" ||
|
|
104
|
+
normalized === "no"
|
|
105
|
+
) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const override = env[MEET_AVATAR_DEVICE_ENV_VAR];
|
|
109
|
+
return override && override.length > 0
|
|
110
|
+
? override
|
|
111
|
+
: DEFAULT_MEET_AVATAR_DEVICE_PATH;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Default memory (GiB) allocated to the Colima VM. */
|
|
115
|
+
const COLIMA_DEFAULT_MEMORY_GIB = 8;
|
|
116
|
+
|
|
48
117
|
/** Directory for user-local binary installs (no sudo required). */
|
|
118
|
+
|
|
49
119
|
const LOCAL_BIN_DIR = join(
|
|
50
120
|
process.env.HOME || process.env.USERPROFILE || ".",
|
|
51
121
|
".local",
|
|
@@ -294,7 +364,11 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
294
364
|
|
|
295
365
|
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
296
366
|
try {
|
|
297
|
-
await exec("colima", [
|
|
367
|
+
await exec("colima", [
|
|
368
|
+
"start",
|
|
369
|
+
"--memory",
|
|
370
|
+
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
371
|
+
]);
|
|
298
372
|
} catch {
|
|
299
373
|
// Colima may fail if a previous VM instance is in a corrupt state.
|
|
300
374
|
// Attempt to delete the stale instance and retry once.
|
|
@@ -311,7 +385,11 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
311
385
|
|
|
312
386
|
try {
|
|
313
387
|
console.log("🔄 Retrying colima start...");
|
|
314
|
-
await exec("colima", [
|
|
388
|
+
await exec("colima", [
|
|
389
|
+
"start",
|
|
390
|
+
"--memory",
|
|
391
|
+
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
392
|
+
]);
|
|
315
393
|
} catch (retryErr) {
|
|
316
394
|
const message =
|
|
317
395
|
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
@@ -329,6 +407,7 @@ export function dockerResourceNames(instanceName: string) {
|
|
|
329
407
|
assistantContainer: `${instanceName}-assistant`,
|
|
330
408
|
cesContainer: `${instanceName}-credential-executor`,
|
|
331
409
|
cesSecurityVolume: `${instanceName}-ces-sec`,
|
|
410
|
+
dockerdDataVolume: `${instanceName}-dockerd-data`,
|
|
332
411
|
gatewayContainer: `${instanceName}-gateway`,
|
|
333
412
|
gatewaySecurityVolume: `${instanceName}-gateway-sec`,
|
|
334
413
|
network: `${instanceName}-net`,
|
|
@@ -388,6 +467,7 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
388
467
|
res.workspaceVolume,
|
|
389
468
|
res.cesSecurityVolume,
|
|
390
469
|
res.gatewaySecurityVolume,
|
|
470
|
+
res.dockerdDataVolume,
|
|
391
471
|
]) {
|
|
392
472
|
try {
|
|
393
473
|
await exec("docker", ["volume", "rm", vol]);
|
|
@@ -501,8 +581,8 @@ function serviceImageConfigs(
|
|
|
501
581
|
tag: imageTags["credential-executor"],
|
|
502
582
|
},
|
|
503
583
|
gateway: {
|
|
504
|
-
context:
|
|
505
|
-
dockerfile: "Dockerfile",
|
|
584
|
+
context: repoRoot,
|
|
585
|
+
dockerfile: "gateway/Dockerfile",
|
|
506
586
|
tag: imageTags.gateway,
|
|
507
587
|
},
|
|
508
588
|
};
|
|
@@ -551,19 +631,53 @@ export function serviceDockerRunArgs(opts: {
|
|
|
551
631
|
} = opts;
|
|
552
632
|
return {
|
|
553
633
|
assistant: () => {
|
|
634
|
+
// Run the assistant container in Docker-in-Docker (DinD) mode: the
|
|
635
|
+
// container runs its own `dockerd` so the Meet subsystem can spawn
|
|
636
|
+
// sibling meet-bot containers without needing access to the host's
|
|
637
|
+
// Docker engine. This requires:
|
|
638
|
+
// - `--privileged` so the inner dockerd can manage cgroups, iptables,
|
|
639
|
+
// overlayfs mounts, etc.
|
|
640
|
+
// - A dedicated named volume mounted at `/var/lib/docker` so the
|
|
641
|
+
// inner Docker image cache and container state survive restarts of
|
|
642
|
+
// the assistant container.
|
|
643
|
+
// The host's `/var/run/docker.sock` is intentionally NOT mounted — all
|
|
644
|
+
// Meet-bot spawning happens against the inner dockerd.
|
|
554
645
|
const args: string[] = [
|
|
555
646
|
"run",
|
|
556
647
|
"--init",
|
|
557
648
|
"-d",
|
|
649
|
+
"--privileged",
|
|
558
650
|
"--name",
|
|
559
651
|
res.assistantContainer,
|
|
560
652
|
`--network=${res.network}`,
|
|
561
653
|
"-p",
|
|
562
654
|
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
655
|
+
// Published so the Meet subsystem's sibling bot containers can reach
|
|
656
|
+
// the daemon's internal HTTP API at host.docker.internal:<port>.
|
|
657
|
+
//
|
|
658
|
+
// Published on all host interfaces (no `127.0.0.1:` prefix) because on
|
|
659
|
+
// vanilla Linux Docker, `host.docker.internal:host-gateway` resolves
|
|
660
|
+
// to the Docker bridge gateway IP (e.g. 172.17.0.1), not loopback.
|
|
661
|
+
// Packets from sibling containers arrive at the host's bridge
|
|
662
|
+
// interface, and an iptables DNAT rule keyed on dest=127.0.0.1 would
|
|
663
|
+
// not match — causing connection refused. Docker Desktop (macOS/
|
|
664
|
+
// Windows) still works because its VM proxy forwards to the same
|
|
665
|
+
// published port regardless of the binding address.
|
|
666
|
+
//
|
|
667
|
+
// Security tradeoff: the daemon HTTP API is now reachable from the
|
|
668
|
+
// host's LAN (any device that can hit the host IP on this port).
|
|
669
|
+
// This matches the gateway port's existing posture and is acceptable
|
|
670
|
+
// for single-user self-hosted Docker mode per the Phase 1.8 security
|
|
671
|
+
// note. Managed/multi-tenant deployments are out of scope and would
|
|
672
|
+
// require a different design.
|
|
673
|
+
"-p",
|
|
674
|
+
`${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`,
|
|
563
675
|
"-v",
|
|
564
676
|
`${res.workspaceVolume}:/workspace`,
|
|
565
677
|
"-v",
|
|
566
678
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
679
|
+
"-v",
|
|
680
|
+
`${res.dockerdDataVolume}:/var/lib/docker`,
|
|
567
681
|
"-e",
|
|
568
682
|
"IS_CONTAINERIZED=true",
|
|
569
683
|
"-e",
|
|
@@ -575,6 +689,10 @@ export function serviceDockerRunArgs(opts: {
|
|
|
575
689
|
"-e",
|
|
576
690
|
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
577
691
|
"-e",
|
|
692
|
+
"VELLUM_BACKUP_DIR=/workspace/.backups",
|
|
693
|
+
"-e",
|
|
694
|
+
"VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
|
|
695
|
+
"-e",
|
|
578
696
|
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
579
697
|
"-e",
|
|
580
698
|
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
@@ -596,6 +714,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
596
714
|
}
|
|
597
715
|
for (const envVar of [
|
|
598
716
|
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
717
|
+
"VELLUM_ENVIRONMENT",
|
|
599
718
|
"VELLUM_PLATFORM_URL",
|
|
600
719
|
]) {
|
|
601
720
|
if (process.env[envVar]) {
|
|
@@ -607,6 +726,24 @@ export function serviceDockerRunArgs(opts: {
|
|
|
607
726
|
args.push("-e", `${key}=${value}`);
|
|
608
727
|
}
|
|
609
728
|
}
|
|
729
|
+
// Optional Meet avatar (v4l2loopback) passthrough. When
|
|
730
|
+
// `VELLUM_MEET_AVATAR=1` is set in the caller's environment, bind the
|
|
731
|
+
// virtual-camera device node (default `/dev/video10`) into the
|
|
732
|
+
// assistant container so the nested `dockerd` can in turn pass it
|
|
733
|
+
// through to the Meet-bot container. The daemon-side equivalent of
|
|
734
|
+
// this opt-in lives on `DockerRunner.run()`'s `avatarDevicePath`
|
|
735
|
+
// option (see `skills/meet-join/daemon/docker-runner.ts`).
|
|
736
|
+
const avatarDevice = resolveMeetAvatarDevicePath();
|
|
737
|
+
if (avatarDevice) {
|
|
738
|
+
args.push(
|
|
739
|
+
"--device",
|
|
740
|
+
`${avatarDevice}:${avatarDevice}`,
|
|
741
|
+
"-e",
|
|
742
|
+
`${MEET_AVATAR_ENV_VAR}=1`,
|
|
743
|
+
"-e",
|
|
744
|
+
`${MEET_AVATAR_DEVICE_ENV_VAR}=${avatarDevice}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
610
747
|
args.push(imageTags.assistant);
|
|
611
748
|
return args;
|
|
612
749
|
},
|
|
@@ -644,6 +781,9 @@ export function serviceDockerRunArgs(opts: {
|
|
|
644
781
|
...(opts.bootstrapSecret
|
|
645
782
|
? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
|
|
646
783
|
: []),
|
|
784
|
+
...(process.env.VELLUM_ENVIRONMENT
|
|
785
|
+
? ["-e", `VELLUM_ENVIRONMENT=${process.env.VELLUM_ENVIRONMENT}`]
|
|
786
|
+
: []),
|
|
647
787
|
...(process.env.VELLUM_PLATFORM_URL
|
|
648
788
|
? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
|
|
649
789
|
: []),
|
|
@@ -700,6 +840,16 @@ export async function startContainers(
|
|
|
700
840
|
},
|
|
701
841
|
log: (msg: string) => void,
|
|
702
842
|
): Promise<void> {
|
|
843
|
+
// Ensure the inner dockerd's data volume exists before mounting it.
|
|
844
|
+
// For instances hatched on Phase 1.10+, this is created in hatchDocker and
|
|
845
|
+
// is a no-op here. For instances that pre-date Phase 1.10 (DinD) and are
|
|
846
|
+
// upgrading in place, Docker would otherwise auto-create the volume on
|
|
847
|
+
// first `-v` mount without our standard ownership/labeling. Creating it
|
|
848
|
+
// explicitly keeps volume provenance consistent across fresh and upgraded
|
|
849
|
+
// instances. `docker volume create` is idempotent for an existing volume
|
|
850
|
+
// of the same name, so this is safe to run on every start.
|
|
851
|
+
await exec("docker", ["volume", "create", opts.res.dockerdDataVolume]);
|
|
852
|
+
|
|
703
853
|
const runArgs = serviceDockerRunArgs(opts);
|
|
704
854
|
for (const service of SERVICE_START_ORDER) {
|
|
705
855
|
log(`🚀 Starting ${service} container...`);
|
|
@@ -1008,7 +1158,7 @@ export async function hatchDocker(
|
|
|
1008
1158
|
await ensureDockerInstalled();
|
|
1009
1159
|
|
|
1010
1160
|
const instanceName = generateInstanceName(species, name);
|
|
1011
|
-
const gatewayPort =
|
|
1161
|
+
const gatewayPort = getDefaultPorts(getCurrentEnvironment()).gateway;
|
|
1012
1162
|
|
|
1013
1163
|
const imageTags: Record<ServiceName, string> = {
|
|
1014
1164
|
assistant: "",
|
|
@@ -1110,6 +1260,7 @@ export async function hatchDocker(
|
|
|
1110
1260
|
await exec("docker", ["volume", "create", res.workspaceVolume]);
|
|
1111
1261
|
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1112
1262
|
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1263
|
+
await exec("docker", ["volume", "create", res.dockerdDataVolume]);
|
|
1113
1264
|
|
|
1114
1265
|
// Set workspace volume ownership so non-root containers (UID 1001) can write.
|
|
1115
1266
|
await exec("docker", [
|
|
@@ -1165,7 +1316,6 @@ export async function hatchDocker(
|
|
|
1165
1316
|
cloud: "docker",
|
|
1166
1317
|
species,
|
|
1167
1318
|
hatchedAt: new Date().toISOString(),
|
|
1168
|
-
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
1169
1319
|
containerInfo: {
|
|
1170
1320
|
assistantImage: imageTags.assistant,
|
|
1171
1321
|
gatewayImage: imageTags.gateway,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const TEST_HOME = "/test/home";
|
|
5
|
+
|
|
6
|
+
// Mock homedir() so the helpers return predictable paths regardless of who
|
|
7
|
+
// is running the test. `os.homedir()` is read once per process on Bun/Node
|
|
8
|
+
// and does not reflect later $HOME changes, so setting process.env.HOME at
|
|
9
|
+
// test time does not work — module mocking is the recommended pattern (see
|
|
10
|
+
// cli/src/__tests__/multi-local.test.ts).
|
|
11
|
+
const realOs = await import("node:os");
|
|
12
|
+
mock.module("node:os", () => ({
|
|
13
|
+
...realOs,
|
|
14
|
+
homedir: () => TEST_HOME,
|
|
15
|
+
}));
|
|
16
|
+
mock.module("os", () => ({
|
|
17
|
+
...realOs,
|
|
18
|
+
homedir: () => TEST_HOME,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Imports that depend on the mocked `os` module must come after the
|
|
22
|
+
// mock.module() calls above.
|
|
23
|
+
const {
|
|
24
|
+
getConfigDir,
|
|
25
|
+
getDefaultPorts,
|
|
26
|
+
getLockfilePath,
|
|
27
|
+
getLockfilePaths,
|
|
28
|
+
getMultiInstanceDir,
|
|
29
|
+
} = await import("../paths.js");
|
|
30
|
+
type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
|
|
31
|
+
|
|
32
|
+
const prod: EnvironmentDefinition = {
|
|
33
|
+
name: "production",
|
|
34
|
+
platformUrl: "https://platform.vellum.ai",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const dev: EnvironmentDefinition = {
|
|
38
|
+
name: "dev",
|
|
39
|
+
platformUrl: "https://dev-platform.vellum.ai",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] as const;
|
|
43
|
+
|
|
44
|
+
describe("path helpers", () => {
|
|
45
|
+
let savedEnv: Record<string, string | undefined>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
savedEnv = {};
|
|
49
|
+
for (const key of XDG_ENV_VARS) {
|
|
50
|
+
savedEnv[key] = process.env[key];
|
|
51
|
+
delete process.env[key];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
delete process.env[key];
|
|
59
|
+
} else {
|
|
60
|
+
process.env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("getConfigDir", () => {
|
|
66
|
+
test("production returns ~/.config/vellum/", () => {
|
|
67
|
+
expect(getConfigDir(prod)).toBe(join(TEST_HOME, ".config", "vellum"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("dev returns ~/.config/vellum-dev/", () => {
|
|
71
|
+
expect(getConfigDir(dev)).toBe(join(TEST_HOME, ".config", "vellum-dev"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("respects XDG_CONFIG_HOME for non-prod envs", () => {
|
|
75
|
+
process.env.XDG_CONFIG_HOME = "/custom/config";
|
|
76
|
+
expect(getConfigDir(dev)).toBe("/custom/config/vellum-dev");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("respects XDG_CONFIG_HOME for production too", () => {
|
|
80
|
+
// Production's XDG config dir already follows XDG conventions, so the
|
|
81
|
+
// standard XDG override applies.
|
|
82
|
+
process.env.XDG_CONFIG_HOME = "/custom/config";
|
|
83
|
+
expect(getConfigDir(prod)).toBe("/custom/config/vellum");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("respects env.configDirOverride", () => {
|
|
87
|
+
const env: EnvironmentDefinition = {
|
|
88
|
+
...dev,
|
|
89
|
+
configDirOverride: "/tmp/cfg",
|
|
90
|
+
};
|
|
91
|
+
expect(getConfigDir(env)).toBe("/tmp/cfg");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("getLockfilePath", () => {
|
|
96
|
+
test("production returns ~/.vellum.lock.json", () => {
|
|
97
|
+
expect(getLockfilePath(prod)).toBe(join(TEST_HOME, ".vellum.lock.json"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("dev returns ~/.config/vellum-dev/lockfile.json", () => {
|
|
101
|
+
expect(getLockfilePath(dev)).toBe(
|
|
102
|
+
join(TEST_HOME, ".config", "vellum-dev", "lockfile.json"),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("non-prod respects configDirOverride", () => {
|
|
107
|
+
const env: EnvironmentDefinition = {
|
|
108
|
+
...dev,
|
|
109
|
+
configDirOverride: "/tmp/cfg",
|
|
110
|
+
};
|
|
111
|
+
expect(getLockfilePath(env)).toBe("/tmp/cfg/lockfile.json");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("production respects lockfileDirOverride", () => {
|
|
115
|
+
const env: EnvironmentDefinition = {
|
|
116
|
+
...prod,
|
|
117
|
+
lockfileDirOverride: "/tmp/lock",
|
|
118
|
+
};
|
|
119
|
+
expect(getLockfilePath(env)).toBe("/tmp/lock/.vellum.lock.json");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("non-prod respects lockfileDirOverride (overrides configDir)", () => {
|
|
123
|
+
const env: EnvironmentDefinition = {
|
|
124
|
+
...dev,
|
|
125
|
+
configDirOverride: "/tmp/cfg",
|
|
126
|
+
lockfileDirOverride: "/tmp/lock",
|
|
127
|
+
};
|
|
128
|
+
expect(getLockfilePath(env)).toBe("/tmp/lock/lockfile.json");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("getLockfilePaths", () => {
|
|
133
|
+
test("production returns both current and legacy filenames in priority order", () => {
|
|
134
|
+
expect(getLockfilePaths(prod)).toEqual([
|
|
135
|
+
join(TEST_HOME, ".vellum.lock.json"),
|
|
136
|
+
join(TEST_HOME, ".vellum.lockfile.json"),
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("non-prod returns a single canonical path", () => {
|
|
141
|
+
expect(getLockfilePaths(dev)).toEqual([
|
|
142
|
+
join(TEST_HOME, ".config", "vellum-dev", "lockfile.json"),
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("production with lockfileDirOverride applies to both candidates", () => {
|
|
147
|
+
const env: EnvironmentDefinition = {
|
|
148
|
+
...prod,
|
|
149
|
+
lockfileDirOverride: "/tmp/lock",
|
|
150
|
+
};
|
|
151
|
+
expect(getLockfilePaths(env)).toEqual([
|
|
152
|
+
"/tmp/lock/.vellum.lock.json",
|
|
153
|
+
"/tmp/lock/.vellum.lockfile.json",
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("non-prod with lockfileDirOverride overrides the config dir", () => {
|
|
158
|
+
const env: EnvironmentDefinition = {
|
|
159
|
+
...dev,
|
|
160
|
+
lockfileDirOverride: "/tmp/lock",
|
|
161
|
+
};
|
|
162
|
+
expect(getLockfilePaths(env)).toEqual(["/tmp/lock/lockfile.json"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("getLockfilePath returns the first entry from getLockfilePaths", () => {
|
|
166
|
+
expect(getLockfilePath(prod)).toBe(getLockfilePaths(prod)[0]);
|
|
167
|
+
expect(getLockfilePath(dev)).toBe(getLockfilePaths(dev)[0]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("getMultiInstanceDir", () => {
|
|
172
|
+
test("production returns ~/.local/share/vellum/assistants", () => {
|
|
173
|
+
expect(getMultiInstanceDir(prod)).toBe(
|
|
174
|
+
join(TEST_HOME, ".local", "share", "vellum", "assistants"),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("dev returns ~/.local/share/vellum-dev/assistants", () => {
|
|
179
|
+
expect(getMultiInstanceDir(dev)).toBe(
|
|
180
|
+
join(TEST_HOME, ".local", "share", "vellum-dev", "assistants"),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("respects XDG_DATA_HOME", () => {
|
|
185
|
+
process.env.XDG_DATA_HOME = "/custom/data";
|
|
186
|
+
expect(getMultiInstanceDir(dev)).toBe(
|
|
187
|
+
"/custom/data/vellum-dev/assistants",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("getDefaultPorts", () => {
|
|
193
|
+
test("returns production defaults for production", () => {
|
|
194
|
+
const ports = getDefaultPorts(prod);
|
|
195
|
+
expect(ports.daemon).toBe(7821);
|
|
196
|
+
expect(ports.gateway).toBe(7830);
|
|
197
|
+
expect(ports.qdrant).toBe(6333);
|
|
198
|
+
expect(ports.ces).toBe(8090);
|
|
199
|
+
expect(ports.outboundProxy).toBe(8080);
|
|
200
|
+
expect(ports.tcp).toBe(8765);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns base defaults for a bare env with no portsOverride", () => {
|
|
204
|
+
// Bare env literal (no portsOverride) falls through to DEFAULT_PORTS.
|
|
205
|
+
// Real non-prod seeds populate portsOverride — see seeds.test cases.
|
|
206
|
+
expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("merges env.portsOverride on top of defaults", () => {
|
|
210
|
+
const env: EnvironmentDefinition = {
|
|
211
|
+
...dev,
|
|
212
|
+
portsOverride: { daemon: 9999, gateway: 9998 },
|
|
213
|
+
};
|
|
214
|
+
const ports = getDefaultPorts(env);
|
|
215
|
+
expect(ports.daemon).toBe(9999);
|
|
216
|
+
expect(ports.gateway).toBe(9998);
|
|
217
|
+
expect(ports.qdrant).toBe(6333);
|
|
218
|
+
expect(ports.ces).toBe(8090);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("returns a fresh object — mutations do not affect future calls", () => {
|
|
222
|
+
const first = getDefaultPorts(prod);
|
|
223
|
+
first.daemon = 1;
|
|
224
|
+
const second = getDefaultPorts(prod);
|
|
225
|
+
expect(second.daemon).toBe(7821);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|