@vellumai/cli 0.6.2 → 0.6.4
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/bunfig.toml +6 -0
- package/package.json +1 -1
- 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 +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- 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 -57
- package/src/commands/backup.ts +8 -0
- package/src/commands/hatch.ts +5 -28
- package/src/commands/login.ts +178 -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 +124 -12
- package/src/commands/retire.ts +17 -3
- package/src/commands/rollback.ts +32 -33
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +307 -3
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +21 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +54 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +73 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +30 -35
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +261 -25
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
package/src/lib/constants.ts
CHANGED
|
@@ -16,16 +16,6 @@ export const DEFAULT_GATEWAY_PORT = 7830;
|
|
|
16
16
|
export const DEFAULT_QDRANT_PORT = 6333;
|
|
17
17
|
export const DEFAULT_CES_PORT = 8090;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Lockfile candidate filenames, checked in priority order.
|
|
21
|
-
* `.vellum.lock.json` is the current name; `.vellum.lockfile.json` is the
|
|
22
|
-
* legacy name kept for backwards compatibility with older installs.
|
|
23
|
-
*/
|
|
24
|
-
export const LOCKFILE_NAMES = [
|
|
25
|
-
".vellum.lock.json",
|
|
26
|
-
".vellum.lockfile.json",
|
|
27
|
-
] as const;
|
|
28
|
-
|
|
29
19
|
export const VALID_REMOTE_HOSTS = [
|
|
30
20
|
"local",
|
|
31
21
|
"gcp",
|
package/src/lib/docker.ts
CHANGED
|
@@ -39,13 +39,17 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
/** Internal ports exposed by each service's Dockerfile. */
|
|
42
|
-
export const ASSISTANT_INTERNAL_PORT =
|
|
42
|
+
export const ASSISTANT_INTERNAL_PORT = 7821;
|
|
43
43
|
export const GATEWAY_INTERNAL_PORT = 7830;
|
|
44
44
|
|
|
45
45
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
46
46
|
export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
47
47
|
|
|
48
|
+
/** Default memory (GiB) allocated to the Colima VM. */
|
|
49
|
+
const COLIMA_DEFAULT_MEMORY_GIB = 8;
|
|
50
|
+
|
|
48
51
|
/** Directory for user-local binary installs (no sudo required). */
|
|
52
|
+
|
|
49
53
|
const LOCAL_BIN_DIR = join(
|
|
50
54
|
process.env.HOME || process.env.USERPROFILE || ".",
|
|
51
55
|
".local",
|
|
@@ -294,7 +298,11 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
294
298
|
|
|
295
299
|
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
296
300
|
try {
|
|
297
|
-
await exec("colima", [
|
|
301
|
+
await exec("colima", [
|
|
302
|
+
"start",
|
|
303
|
+
"--memory",
|
|
304
|
+
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
305
|
+
]);
|
|
298
306
|
} catch {
|
|
299
307
|
// Colima may fail if a previous VM instance is in a corrupt state.
|
|
300
308
|
// Attempt to delete the stale instance and retry once.
|
|
@@ -311,7 +319,11 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
311
319
|
|
|
312
320
|
try {
|
|
313
321
|
console.log("🔄 Retrying colima start...");
|
|
314
|
-
await exec("colima", [
|
|
322
|
+
await exec("colima", [
|
|
323
|
+
"start",
|
|
324
|
+
"--memory",
|
|
325
|
+
String(COLIMA_DEFAULT_MEMORY_GIB),
|
|
326
|
+
]);
|
|
315
327
|
} catch (retryErr) {
|
|
316
328
|
const message =
|
|
317
329
|
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
@@ -329,6 +341,7 @@ export function dockerResourceNames(instanceName: string) {
|
|
|
329
341
|
assistantContainer: `${instanceName}-assistant`,
|
|
330
342
|
cesContainer: `${instanceName}-credential-executor`,
|
|
331
343
|
cesSecurityVolume: `${instanceName}-ces-sec`,
|
|
344
|
+
dockerdDataVolume: `${instanceName}-dockerd-data`,
|
|
332
345
|
gatewayContainer: `${instanceName}-gateway`,
|
|
333
346
|
gatewaySecurityVolume: `${instanceName}-gateway-sec`,
|
|
334
347
|
network: `${instanceName}-net`,
|
|
@@ -388,6 +401,7 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
388
401
|
res.workspaceVolume,
|
|
389
402
|
res.cesSecurityVolume,
|
|
390
403
|
res.gatewaySecurityVolume,
|
|
404
|
+
res.dockerdDataVolume,
|
|
391
405
|
]) {
|
|
392
406
|
try {
|
|
393
407
|
await exec("docker", ["volume", "rm", vol]);
|
|
@@ -551,19 +565,53 @@ export function serviceDockerRunArgs(opts: {
|
|
|
551
565
|
} = opts;
|
|
552
566
|
return {
|
|
553
567
|
assistant: () => {
|
|
568
|
+
// Run the assistant container in Docker-in-Docker (DinD) mode: the
|
|
569
|
+
// container runs its own `dockerd` so the Meet subsystem can spawn
|
|
570
|
+
// sibling meet-bot containers without needing access to the host's
|
|
571
|
+
// Docker engine. This requires:
|
|
572
|
+
// - `--privileged` so the inner dockerd can manage cgroups, iptables,
|
|
573
|
+
// overlayfs mounts, etc.
|
|
574
|
+
// - A dedicated named volume mounted at `/var/lib/docker` so the
|
|
575
|
+
// inner Docker image cache and container state survive restarts of
|
|
576
|
+
// the assistant container.
|
|
577
|
+
// The host's `/var/run/docker.sock` is intentionally NOT mounted — all
|
|
578
|
+
// Meet-bot spawning happens against the inner dockerd.
|
|
554
579
|
const args: string[] = [
|
|
555
580
|
"run",
|
|
556
581
|
"--init",
|
|
557
582
|
"-d",
|
|
583
|
+
"--privileged",
|
|
558
584
|
"--name",
|
|
559
585
|
res.assistantContainer,
|
|
560
586
|
`--network=${res.network}`,
|
|
561
587
|
"-p",
|
|
562
588
|
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
589
|
+
// Published so the Meet subsystem's sibling bot containers can reach
|
|
590
|
+
// the daemon's internal HTTP API at host.docker.internal:<port>.
|
|
591
|
+
//
|
|
592
|
+
// Published on all host interfaces (no `127.0.0.1:` prefix) because on
|
|
593
|
+
// vanilla Linux Docker, `host.docker.internal:host-gateway` resolves
|
|
594
|
+
// to the Docker bridge gateway IP (e.g. 172.17.0.1), not loopback.
|
|
595
|
+
// Packets from sibling containers arrive at the host's bridge
|
|
596
|
+
// interface, and an iptables DNAT rule keyed on dest=127.0.0.1 would
|
|
597
|
+
// not match — causing connection refused. Docker Desktop (macOS/
|
|
598
|
+
// Windows) still works because its VM proxy forwards to the same
|
|
599
|
+
// published port regardless of the binding address.
|
|
600
|
+
//
|
|
601
|
+
// Security tradeoff: the daemon HTTP API is now reachable from the
|
|
602
|
+
// host's LAN (any device that can hit the host IP on this port).
|
|
603
|
+
// This matches the gateway port's existing posture and is acceptable
|
|
604
|
+
// for single-user self-hosted Docker mode per the Phase 1.8 security
|
|
605
|
+
// note. Managed/multi-tenant deployments are out of scope and would
|
|
606
|
+
// require a different design.
|
|
607
|
+
"-p",
|
|
608
|
+
`${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`,
|
|
563
609
|
"-v",
|
|
564
610
|
`${res.workspaceVolume}:/workspace`,
|
|
565
611
|
"-v",
|
|
566
612
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
613
|
+
"-v",
|
|
614
|
+
`${res.dockerdDataVolume}:/var/lib/docker`,
|
|
567
615
|
"-e",
|
|
568
616
|
"IS_CONTAINERIZED=true",
|
|
569
617
|
"-e",
|
|
@@ -575,6 +623,10 @@ export function serviceDockerRunArgs(opts: {
|
|
|
575
623
|
"-e",
|
|
576
624
|
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
577
625
|
"-e",
|
|
626
|
+
"VELLUM_BACKUP_DIR=/workspace/.backups",
|
|
627
|
+
"-e",
|
|
628
|
+
"VELLUM_BACKUP_KEY_PATH=/workspace/.backup.key",
|
|
629
|
+
"-e",
|
|
578
630
|
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
579
631
|
"-e",
|
|
580
632
|
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
@@ -596,6 +648,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
596
648
|
}
|
|
597
649
|
for (const envVar of [
|
|
598
650
|
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
651
|
+
"VELLUM_ENVIRONMENT",
|
|
599
652
|
"VELLUM_PLATFORM_URL",
|
|
600
653
|
]) {
|
|
601
654
|
if (process.env[envVar]) {
|
|
@@ -644,6 +697,12 @@ export function serviceDockerRunArgs(opts: {
|
|
|
644
697
|
...(opts.bootstrapSecret
|
|
645
698
|
? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
|
|
646
699
|
: []),
|
|
700
|
+
...(process.env.VELLUM_ENVIRONMENT
|
|
701
|
+
? ["-e", `VELLUM_ENVIRONMENT=${process.env.VELLUM_ENVIRONMENT}`]
|
|
702
|
+
: []),
|
|
703
|
+
...(process.env.VELLUM_PLATFORM_URL
|
|
704
|
+
? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
|
|
705
|
+
: []),
|
|
647
706
|
imageTags.gateway,
|
|
648
707
|
],
|
|
649
708
|
"credential-executor": () => [
|
|
@@ -697,6 +756,16 @@ export async function startContainers(
|
|
|
697
756
|
},
|
|
698
757
|
log: (msg: string) => void,
|
|
699
758
|
): Promise<void> {
|
|
759
|
+
// Ensure the inner dockerd's data volume exists before mounting it.
|
|
760
|
+
// For instances hatched on Phase 1.10+, this is created in hatchDocker and
|
|
761
|
+
// is a no-op here. For instances that pre-date Phase 1.10 (DinD) and are
|
|
762
|
+
// upgrading in place, Docker would otherwise auto-create the volume on
|
|
763
|
+
// first `-v` mount without our standard ownership/labeling. Creating it
|
|
764
|
+
// explicitly keeps volume provenance consistent across fresh and upgraded
|
|
765
|
+
// instances. `docker volume create` is idempotent for an existing volume
|
|
766
|
+
// of the same name, so this is safe to run on every start.
|
|
767
|
+
await exec("docker", ["volume", "create", opts.res.dockerdDataVolume]);
|
|
768
|
+
|
|
700
769
|
const runArgs = serviceDockerRunArgs(opts);
|
|
701
770
|
for (const service of SERVICE_START_ORDER) {
|
|
702
771
|
log(`🚀 Starting ${service} container...`);
|
|
@@ -1107,6 +1176,7 @@ export async function hatchDocker(
|
|
|
1107
1176
|
await exec("docker", ["volume", "create", res.workspaceVolume]);
|
|
1108
1177
|
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1109
1178
|
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1179
|
+
await exec("docker", ["volume", "create", res.dockerdDataVolume]);
|
|
1110
1180
|
|
|
1111
1181
|
// Set workspace volume ownership so non-root containers (UID 1001) can write.
|
|
1112
1182
|
await exec("docker", [
|
|
@@ -1162,7 +1232,6 @@ export async function hatchDocker(
|
|
|
1162
1232
|
cloud: "docker",
|
|
1163
1233
|
species,
|
|
1164
1234
|
hatchedAt: new Date().toISOString(),
|
|
1165
|
-
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
1166
1235
|
containerInfo: {
|
|
1167
1236
|
assistantImage: imageTags.assistant,
|
|
1168
1237
|
gatewayImage: imageTags.gateway,
|
|
@@ -0,0 +1,234 @@
|
|
|
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 identical defaults for dev (Phase 5 deferred)", () => {
|
|
204
|
+
expect(getDefaultPorts(dev)).toEqual(getDefaultPorts(prod));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("returns identical defaults for staging", () => {
|
|
208
|
+
const staging: EnvironmentDefinition = {
|
|
209
|
+
name: "staging",
|
|
210
|
+
platformUrl: "https://staging-platform.vellum.ai",
|
|
211
|
+
};
|
|
212
|
+
expect(getDefaultPorts(staging)).toEqual(getDefaultPorts(prod));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("merges env.portsOverride on top of defaults", () => {
|
|
216
|
+
const env: EnvironmentDefinition = {
|
|
217
|
+
...dev,
|
|
218
|
+
portsOverride: { daemon: 9999, gateway: 9998 },
|
|
219
|
+
};
|
|
220
|
+
const ports = getDefaultPorts(env);
|
|
221
|
+
expect(ports.daemon).toBe(9999);
|
|
222
|
+
expect(ports.gateway).toBe(9998);
|
|
223
|
+
expect(ports.qdrant).toBe(6333);
|
|
224
|
+
expect(ports.ces).toBe(8090);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("returns a fresh object — mutations do not affect future calls", () => {
|
|
228
|
+
const first = getDefaultPorts(prod);
|
|
229
|
+
first.daemon = 1;
|
|
230
|
+
const second = getDefaultPorts(prod);
|
|
231
|
+
expect(second.daemon).toBe(7821);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getCurrentEnvironment, getSeed } from "../resolve.js";
|
|
4
|
+
|
|
5
|
+
const ENV_VARS_TO_SAVE = [
|
|
6
|
+
"VELLUM_ENVIRONMENT",
|
|
7
|
+
"VELLUM_PLATFORM_URL",
|
|
8
|
+
"VELLUM_ASSISTANT_PLATFORM_URL",
|
|
9
|
+
"VELLUM_LOCKFILE_DIR",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
describe("getCurrentEnvironment", () => {
|
|
13
|
+
let savedEnv: Record<string, string | undefined>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
savedEnv = {};
|
|
17
|
+
for (const key of ENV_VARS_TO_SAVE) {
|
|
18
|
+
savedEnv[key] = process.env[key];
|
|
19
|
+
delete process.env[key];
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
delete process.env[key];
|
|
27
|
+
} else {
|
|
28
|
+
process.env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns production seed when no override, no env var", () => {
|
|
34
|
+
const env = getCurrentEnvironment();
|
|
35
|
+
expect(env.name).toBe("production");
|
|
36
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns dev seed when VELLUM_ENVIRONMENT=dev", () => {
|
|
40
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
41
|
+
const env = getCurrentEnvironment();
|
|
42
|
+
expect(env.name).toBe("dev");
|
|
43
|
+
expect(env.platformUrl).toBe("https://dev-platform.vellum.ai");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns staging seed when VELLUM_ENVIRONMENT=staging", () => {
|
|
47
|
+
process.env.VELLUM_ENVIRONMENT = "staging";
|
|
48
|
+
expect(getCurrentEnvironment().platformUrl).toBe(
|
|
49
|
+
"https://staging-platform.vellum.ai",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns local seed with localhost URL", () => {
|
|
54
|
+
process.env.VELLUM_ENVIRONMENT = "local";
|
|
55
|
+
expect(getCurrentEnvironment().platformUrl).toBe("http://localhost:8000");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("override argument takes priority over VELLUM_ENVIRONMENT env var", () => {
|
|
59
|
+
process.env.VELLUM_ENVIRONMENT = "staging";
|
|
60
|
+
const env = getCurrentEnvironment("dev");
|
|
61
|
+
expect(env.name).toBe("dev");
|
|
62
|
+
expect(env.platformUrl).toBe("https://dev-platform.vellum.ai");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty override argument falls through to env var", () => {
|
|
66
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
67
|
+
const env = getCurrentEnvironment("");
|
|
68
|
+
expect(env.name).toBe("dev");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("whitespace-only override argument falls through to env var", () => {
|
|
72
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
73
|
+
const env = getCurrentEnvironment(" ");
|
|
74
|
+
expect(env.name).toBe("dev");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("falls back to production seed for unknown env name via override, warns on stderr", () => {
|
|
78
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(
|
|
79
|
+
() => true,
|
|
80
|
+
);
|
|
81
|
+
try {
|
|
82
|
+
const env = getCurrentEnvironment("no-such-env");
|
|
83
|
+
expect(env.name).toBe("production");
|
|
84
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
85
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining('unknown environment "no-such-env"'),
|
|
87
|
+
);
|
|
88
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('falling back to "production"'),
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
stderr.mockRestore();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("falls back to production seed for unknown env name via env var, warns on stderr", () => {
|
|
97
|
+
process.env.VELLUM_ENVIRONMENT = "nope";
|
|
98
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(
|
|
99
|
+
() => true,
|
|
100
|
+
);
|
|
101
|
+
try {
|
|
102
|
+
const env = getCurrentEnvironment();
|
|
103
|
+
expect(env.name).toBe("production");
|
|
104
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
105
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
106
|
+
expect.stringContaining('unknown environment "nope"'),
|
|
107
|
+
);
|
|
108
|
+
} finally {
|
|
109
|
+
stderr.mockRestore();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("VELLUM_ENVIRONMENT=production does not emit a warning", () => {
|
|
114
|
+
process.env.VELLUM_ENVIRONMENT = "production";
|
|
115
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(
|
|
116
|
+
() => true,
|
|
117
|
+
);
|
|
118
|
+
try {
|
|
119
|
+
const env = getCurrentEnvironment();
|
|
120
|
+
expect(env.name).toBe("production");
|
|
121
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
122
|
+
} finally {
|
|
123
|
+
stderr.mockRestore();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("VELLUM_PLATFORM_URL overrides platformUrl on the resolved definition", () => {
|
|
128
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
129
|
+
process.env.VELLUM_PLATFORM_URL = "https://custom.example.com";
|
|
130
|
+
const env = getCurrentEnvironment();
|
|
131
|
+
expect(env.name).toBe("dev");
|
|
132
|
+
expect(env.platformUrl).toBe("https://custom.example.com");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("VELLUM_PLATFORM_URL override does not affect the seed table", () => {
|
|
136
|
+
process.env.VELLUM_PLATFORM_URL = "https://custom.example.com";
|
|
137
|
+
getCurrentEnvironment();
|
|
138
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
139
|
+
const env = getCurrentEnvironment();
|
|
140
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("VELLUM_ASSISTANT_PLATFORM_URL overrides assistantPlatformUrl", () => {
|
|
144
|
+
process.env.VELLUM_ASSISTANT_PLATFORM_URL =
|
|
145
|
+
"http://host.docker.internal:8000";
|
|
146
|
+
const env = getCurrentEnvironment();
|
|
147
|
+
expect(env.assistantPlatformUrl).toBe("http://host.docker.internal:8000");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("VELLUM_ASSISTANT_PLATFORM_URL does not shadow platformUrl", () => {
|
|
151
|
+
process.env.VELLUM_ASSISTANT_PLATFORM_URL = "http://override";
|
|
152
|
+
const env = getCurrentEnvironment();
|
|
153
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("does not auto-materialize a new environment from VELLUM_PLATFORM_URL alone", () => {
|
|
157
|
+
// Unknown env names fall back to production (parity with daemon + Swift).
|
|
158
|
+
// The per-field VELLUM_PLATFORM_URL override is intentionally dropped on
|
|
159
|
+
// the fallback path — fallback returns a pristine production seed so a
|
|
160
|
+
// typo'd env var can't accidentally stitch together a new environment.
|
|
161
|
+
process.env.VELLUM_ENVIRONMENT = "my-custom";
|
|
162
|
+
process.env.VELLUM_PLATFORM_URL = "https://my-custom.example.com";
|
|
163
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(
|
|
164
|
+
() => true,
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
const env = getCurrentEnvironment();
|
|
168
|
+
expect(env.name).toBe("production");
|
|
169
|
+
expect(env.platformUrl).toBe("https://platform.vellum.ai");
|
|
170
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
171
|
+
expect.stringContaining('unknown environment "my-custom"'),
|
|
172
|
+
);
|
|
173
|
+
} finally {
|
|
174
|
+
stderr.mockRestore();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("VELLUM_LOCKFILE_DIR populates lockfileDirOverride on the resolved definition", () => {
|
|
179
|
+
process.env.VELLUM_LOCKFILE_DIR = "/tmp/test-lockfile-dir";
|
|
180
|
+
const env = getCurrentEnvironment();
|
|
181
|
+
expect(env.lockfileDirOverride).toBe("/tmp/test-lockfile-dir");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("lockfileDirOverride is undefined when VELLUM_LOCKFILE_DIR is unset", () => {
|
|
185
|
+
const env = getCurrentEnvironment();
|
|
186
|
+
expect(env.lockfileDirOverride).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("lockfileDirOverride applies to non-prod envs too", () => {
|
|
190
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
191
|
+
process.env.VELLUM_LOCKFILE_DIR = "/tmp/test-lockfile-dir";
|
|
192
|
+
const env = getCurrentEnvironment();
|
|
193
|
+
expect(env.name).toBe("dev");
|
|
194
|
+
expect(env.lockfileDirOverride).toBe("/tmp/test-lockfile-dir");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("getSeed", () => {
|
|
199
|
+
test("returns a definition for a known seed", () => {
|
|
200
|
+
const seed = getSeed("dev");
|
|
201
|
+
expect(seed).toBeDefined();
|
|
202
|
+
expect(seed?.name).toBe("dev");
|
|
203
|
+
expect(seed?.platformUrl).toBe("https://dev-platform.vellum.ai");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("returns undefined for an unknown name", () => {
|
|
207
|
+
expect(getSeed("no-such-env")).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returned seed is a copy — mutations do not affect the table", () => {
|
|
211
|
+
const seed = getSeed("dev");
|
|
212
|
+
if (seed) {
|
|
213
|
+
seed.platformUrl = "mutated";
|
|
214
|
+
}
|
|
215
|
+
const second = getSeed("dev");
|
|
216
|
+
expect(second?.platformUrl).toBe("https://dev-platform.vellum.ai");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("all five canonical seeds exist", () => {
|
|
220
|
+
expect(getSeed("production")).toBeDefined();
|
|
221
|
+
expect(getSeed("staging")).toBeDefined();
|
|
222
|
+
expect(getSeed("test")).toBeDefined();
|
|
223
|
+
expect(getSeed("dev")).toBeDefined();
|
|
224
|
+
expect(getSeed("local")).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
});
|