@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getDefaultPorts } from "../paths.js";
|
|
4
|
+
import { SEEDS } from "../seeds.js";
|
|
5
|
+
|
|
6
|
+
describe("SEEDS port blocks", () => {
|
|
7
|
+
test("production uses the legacy (pre-MVP) port layout", () => {
|
|
8
|
+
const ports = getDefaultPorts(SEEDS.production!);
|
|
9
|
+
expect(ports).toEqual({
|
|
10
|
+
daemon: 7821,
|
|
11
|
+
gateway: 7830,
|
|
12
|
+
qdrant: 6333,
|
|
13
|
+
ces: 8090,
|
|
14
|
+
outboundProxy: 8080,
|
|
15
|
+
tcp: 8765,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test.each([
|
|
20
|
+
["staging", 17000],
|
|
21
|
+
["dev", 18000],
|
|
22
|
+
["test", 19000],
|
|
23
|
+
["local", 20000],
|
|
24
|
+
] as const)("%s block starts at %i with 100-apart services", (name, base) => {
|
|
25
|
+
const ports = getDefaultPorts(SEEDS[name]!);
|
|
26
|
+
expect(ports).toEqual({
|
|
27
|
+
daemon: base,
|
|
28
|
+
gateway: base + 100,
|
|
29
|
+
qdrant: base + 200,
|
|
30
|
+
ces: base + 300,
|
|
31
|
+
outboundProxy: base + 400,
|
|
32
|
+
tcp: base + 500,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("non-prod blocks are disjoint across environments", () => {
|
|
37
|
+
// 100 instances per service is the scan headroom in findAvailablePort,
|
|
38
|
+
// so a block "occupies" base…base+599 from daemon through tcp. Verify no
|
|
39
|
+
// two blocks overlap for any service.
|
|
40
|
+
const blocks = (["staging", "dev", "test", "local"] as const).map(
|
|
41
|
+
(name) => ({
|
|
42
|
+
name,
|
|
43
|
+
ports: getDefaultPorts(SEEDS[name]!),
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const allPorts = new Set<number>();
|
|
47
|
+
for (const { name, ports } of blocks) {
|
|
48
|
+
for (const port of Object.values(ports)) {
|
|
49
|
+
// Within each block, each service has 100 slots (base…base+99).
|
|
50
|
+
for (let offset = 0; offset < 100; offset++) {
|
|
51
|
+
const p = port + offset;
|
|
52
|
+
if (allPorts.has(p)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`port ${p} (in ${name}'s block) overlaps another env's block`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
allPorts.add(p);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("non-prod blocks sit below Linux's default ephemeral range (32768)", () => {
|
|
64
|
+
for (const name of ["staging", "dev", "test", "local"] as const) {
|
|
65
|
+
const ports = getDefaultPorts(SEEDS[name]!);
|
|
66
|
+
for (const port of Object.values(ports)) {
|
|
67
|
+
// Max port we'll ever scan to is base+99 for daemon/gateway/etc.
|
|
68
|
+
expect(port + 99).toBeLessThan(32768);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
import type { EnvironmentDefinition, PortMap } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const PRODUCTION_ENVIRONMENT_NAME = "production";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Production lockfile filenames in priority order. The current name is
|
|
10
|
+
* `.vellum.lock.json`; `.vellum.lockfile.json` is the legacy name kept for
|
|
11
|
+
* backward compatibility with installs that predate the rename.
|
|
12
|
+
*/
|
|
13
|
+
const PRODUCTION_LOCKFILE_NAMES = [
|
|
14
|
+
".vellum.lock.json",
|
|
15
|
+
".vellum.lockfile.json",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PORTS: Readonly<PortMap> = {
|
|
19
|
+
daemon: 7821,
|
|
20
|
+
gateway: 7830,
|
|
21
|
+
qdrant: 6333,
|
|
22
|
+
ces: 8090,
|
|
23
|
+
outboundProxy: 8080,
|
|
24
|
+
tcp: 8765,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Config directory for an environment.
|
|
29
|
+
* Production preserves the existing `~/.config/vellum/` location;
|
|
30
|
+
* non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/`.
|
|
31
|
+
*/
|
|
32
|
+
export function getConfigDir(env: EnvironmentDefinition): string {
|
|
33
|
+
if (env.configDirOverride) return env.configDirOverride;
|
|
34
|
+
if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
|
|
35
|
+
return join(xdgConfigHome(), "vellum");
|
|
36
|
+
}
|
|
37
|
+
return join(xdgConfigHome(), `vellum-${env.name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lockfile candidate paths for an environment, in priority order.
|
|
42
|
+
*
|
|
43
|
+
* For production, returns both the current `.vellum.lock.json` and the
|
|
44
|
+
* legacy `.vellum.lockfile.json` so read-side callers can fall back to the
|
|
45
|
+
* legacy filename on installs that predate the rename. Non-production
|
|
46
|
+
* environments are new and have a single canonical path under the env-scoped
|
|
47
|
+
* XDG config directory.
|
|
48
|
+
*
|
|
49
|
+
* Read-side callers should iterate this array and use the first existing
|
|
50
|
+
* file (matching `cli/src/lib/assistant-config.ts:readLockfile`). Write-side
|
|
51
|
+
* callers should use {@link getLockfilePath}, which returns the first
|
|
52
|
+
* (canonical) entry.
|
|
53
|
+
*
|
|
54
|
+
* `env.lockfileDirOverride` (populated by the resolver from
|
|
55
|
+
* `VELLUM_LOCKFILE_DIR`) overrides the directory the lockfile lives in for
|
|
56
|
+
* both production and non-production environments.
|
|
57
|
+
*/
|
|
58
|
+
export function getLockfilePaths(env: EnvironmentDefinition): string[] {
|
|
59
|
+
if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
|
|
60
|
+
const dir = env.lockfileDirOverride ?? homedir();
|
|
61
|
+
return PRODUCTION_LOCKFILE_NAMES.map((name) => join(dir, name));
|
|
62
|
+
}
|
|
63
|
+
const dir = env.lockfileDirOverride ?? getConfigDir(env);
|
|
64
|
+
return [join(dir, "lockfile.json")];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical lockfile path for writes. For production this is the current
|
|
69
|
+
* `.vellum.lock.json` (legacy reads handled by {@link getLockfilePaths}).
|
|
70
|
+
*/
|
|
71
|
+
export function getLockfilePath(env: EnvironmentDefinition): string {
|
|
72
|
+
return getLockfilePaths(env)[0]!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Multi-instance root directory for an environment. Production uses
|
|
77
|
+
* `~/.local/share/vellum/assistants/` — the convention already in
|
|
78
|
+
* `cli/src/lib/assistant-config.ts`. Non-production environments use
|
|
79
|
+
* `~/.local/share/vellum-<env>/assistants/`.
|
|
80
|
+
*/
|
|
81
|
+
export function getMultiInstanceDir(env: EnvironmentDefinition): string {
|
|
82
|
+
if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
|
|
83
|
+
return join(xdgDataHome(), "vellum", "assistants");
|
|
84
|
+
}
|
|
85
|
+
return join(xdgDataHome(), `vellum-${env.name}`, "assistants");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Default port set for an environment.
|
|
90
|
+
* Seed entries for non-prod environments come with separate port ranges
|
|
91
|
+
* to avoid collisions in multi-env / multi-instance setups.
|
|
92
|
+
* Longer term, consider allocating ports dynamically at hatch/wake time.
|
|
93
|
+
*/
|
|
94
|
+
export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
|
|
95
|
+
return {
|
|
96
|
+
...DEFAULT_PORTS,
|
|
97
|
+
...(env.portsOverride ?? {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function xdgDataHome(): string {
|
|
102
|
+
return (
|
|
103
|
+
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function xdgConfigHome(): string {
|
|
108
|
+
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
109
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { SEEDS } from "./seeds.js";
|
|
2
|
+
import type { EnvironmentDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_ENVIRONMENT_NAME = "production";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Look up a seed entry by name. Returns `undefined` if no seed matches.
|
|
8
|
+
* Callers that need the full resolution stack (env-var overrides, default
|
|
9
|
+
* fallback, error on unknown) should use {@link getCurrentEnvironment}
|
|
10
|
+
* instead. The returned definition is a shallow copy so mutations by the
|
|
11
|
+
* caller don't leak back into the seed table.
|
|
12
|
+
*/
|
|
13
|
+
export function getSeed(name: string): EnvironmentDefinition | undefined {
|
|
14
|
+
const seed = SEEDS[name];
|
|
15
|
+
if (!seed) return undefined;
|
|
16
|
+
return { ...seed };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the current environment definition.
|
|
21
|
+
*
|
|
22
|
+
* Priority:
|
|
23
|
+
* 1. `override` argument (from a `--environment` CLI flag, when wired)
|
|
24
|
+
* 2. `VELLUM_ENVIRONMENT` env var
|
|
25
|
+
* 3. (future) user context file
|
|
26
|
+
* 4. Default: `production`
|
|
27
|
+
*
|
|
28
|
+
* Per-field env-var overrides are honored on the resolved definition as
|
|
29
|
+
* ad-hoc escape hatches (they do not materialize new environments):
|
|
30
|
+
* - `VELLUM_PLATFORM_URL` overrides `platformUrl`
|
|
31
|
+
* - `VELLUM_ASSISTANT_PLATFORM_URL` overrides `assistantPlatformUrl`
|
|
32
|
+
* - `VELLUM_LOCKFILE_DIR` overrides `lockfileDirOverride` (legacy e2e
|
|
33
|
+
* test hook)
|
|
34
|
+
*
|
|
35
|
+
* This function should be the single entrypoint for environment resolution.
|
|
36
|
+
* No other code should drive off `VELLUM_ENVIRONMENT` directly.
|
|
37
|
+
*/
|
|
38
|
+
export function getCurrentEnvironment(
|
|
39
|
+
override?: string,
|
|
40
|
+
): EnvironmentDefinition {
|
|
41
|
+
const name = resolveEnvironmentName(override);
|
|
42
|
+
const seed = SEEDS[name];
|
|
43
|
+
if (!seed) {
|
|
44
|
+
if (name !== DEFAULT_ENVIRONMENT_NAME) {
|
|
45
|
+
// Warn on stderr instead of throwing, to match the silent-fallback
|
|
46
|
+
// behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName
|
|
47
|
+
// and clients/shared/App/VellumEnvironment.swift:current. Those two
|
|
48
|
+
// silently fall back to production; the CLI should agree so all three
|
|
49
|
+
// writers don't end up in disjoint states on a typo.
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
`warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
|
|
52
|
+
`Add it to cli/src/lib/environments/seeds.ts and rebuild if this was intentional.\n`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
|
|
56
|
+
if (!fallback) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`fatal: default environment "${DEFAULT_ENVIRONMENT_NAME}" missing from seed table — this is a build error`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { ...fallback };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const resolved: EnvironmentDefinition = { ...seed };
|
|
65
|
+
|
|
66
|
+
const platformUrlOverride = process.env.VELLUM_PLATFORM_URL?.trim();
|
|
67
|
+
if (platformUrlOverride) {
|
|
68
|
+
resolved.platformUrl = platformUrlOverride;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const assistantPlatformUrlOverride =
|
|
72
|
+
process.env.VELLUM_ASSISTANT_PLATFORM_URL?.trim();
|
|
73
|
+
if (assistantPlatformUrlOverride) {
|
|
74
|
+
resolved.assistantPlatformUrl = assistantPlatformUrlOverride;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lockfileDirOverride = process.env.VELLUM_LOCKFILE_DIR?.trim();
|
|
78
|
+
if (lockfileDirOverride) {
|
|
79
|
+
resolved.lockfileDirOverride = lockfileDirOverride;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveEnvironmentName(override: string | undefined): string {
|
|
86
|
+
const trimmedOverride = override?.trim();
|
|
87
|
+
if (trimmedOverride && trimmedOverride.length > 0) {
|
|
88
|
+
return trimmedOverride;
|
|
89
|
+
}
|
|
90
|
+
const envVar = process.env.VELLUM_ENVIRONMENT?.trim();
|
|
91
|
+
if (envVar && envVar.length > 0) {
|
|
92
|
+
return envVar;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return DEFAULT_ENVIRONMENT_NAME;
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { EnvironmentDefinition, PortMap } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-prod port blocks. Each environment gets a 1000-port window in the
|
|
5
|
+
* 17000–21000 band. Within a block, services are spaced 100 apart so up to
|
|
6
|
+
* 100 assistants can coexist without the scan (`findAvailablePort`) running
|
|
7
|
+
* one service's range into the next. Band chosen to sit below Linux's
|
|
8
|
+
* default ephemeral start (32768) and macOS's (49152), and away from the
|
|
9
|
+
* 3000/5000/8000/9000 dev-tool swamp. Production keeps its legacy,
|
|
10
|
+
* non-contiguous port set (7821/7830/6333/8090/8080/8765): cross-env
|
|
11
|
+
* collision is the only problem this change targets, prod is unaffected
|
|
12
|
+
* because only one env's assistants compete on a given machine, and
|
|
13
|
+
* churning it would leave existing hatches on 7821 while new ones
|
|
14
|
+
* allocated elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
function portBlock(base: number): PortMap {
|
|
17
|
+
return {
|
|
18
|
+
daemon: base,
|
|
19
|
+
gateway: base + 100,
|
|
20
|
+
qdrant: base + 200,
|
|
21
|
+
ces: base + 300,
|
|
22
|
+
outboundProxy: base + 400,
|
|
23
|
+
tcp: base + 500,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Built-in environment definitions. Mirrors Swift's
|
|
29
|
+
* `clients/macos/vellum-assistant/App/VellumEnvironment.swift` enum and is
|
|
30
|
+
* the TS-side source of truth for the set of known environment names.
|
|
31
|
+
* Two other TS sites duplicate the name list:
|
|
32
|
+
* - `assistant/src/util/platform.ts` (`KNOWN_ENVIRONMENTS`)
|
|
33
|
+
* - `clients/chrome-extension/native-host/src/lockfile.ts`
|
|
34
|
+
* (`NON_PRODUCTION_ENVIRONMENTS`, excludes `production`)
|
|
35
|
+
* Drift between these three sites is caught at test time by
|
|
36
|
+
* `cli/src/__tests__/env-drift.test.ts`. Fast follow: hoist the shared
|
|
37
|
+
* list into a `packages/environments` package so all three sites import
|
|
38
|
+
* from one place.
|
|
39
|
+
*
|
|
40
|
+
* Custom environments via a user config file are a future phase — see the
|
|
41
|
+
* "Coexisting environments" design doc. Until then, a call site that needs a
|
|
42
|
+
* new environment must add it here and rebuild.
|
|
43
|
+
*/
|
|
44
|
+
export const SEEDS: Record<string, EnvironmentDefinition> = {
|
|
45
|
+
production: {
|
|
46
|
+
name: "production",
|
|
47
|
+
platformUrl: "https://platform.vellum.ai",
|
|
48
|
+
},
|
|
49
|
+
staging: {
|
|
50
|
+
name: "staging",
|
|
51
|
+
platformUrl: "https://staging-platform.vellum.ai",
|
|
52
|
+
portsOverride: portBlock(17000),
|
|
53
|
+
},
|
|
54
|
+
test: {
|
|
55
|
+
name: "test",
|
|
56
|
+
// Non-functional URL — used only by unit tests for URL resolution, never
|
|
57
|
+
// hit in production.
|
|
58
|
+
platformUrl: "https://test-platform.vellum.ai",
|
|
59
|
+
portsOverride: portBlock(19000),
|
|
60
|
+
},
|
|
61
|
+
dev: {
|
|
62
|
+
name: "dev",
|
|
63
|
+
platformUrl: "https://dev-platform.vellum.ai",
|
|
64
|
+
portsOverride: portBlock(18000),
|
|
65
|
+
},
|
|
66
|
+
local: {
|
|
67
|
+
name: "local",
|
|
68
|
+
platformUrl: "http://localhost:8000",
|
|
69
|
+
// assistantPlatformUrl: "http://host.docker.internal:8000",
|
|
70
|
+
// ^ uncomment this once dockerized hatch path is live.
|
|
71
|
+
// The assistant runs in a different network namespace than the host.
|
|
72
|
+
portsOverride: portBlock(20000),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment type definitions. Environments are deployment targets with
|
|
3
|
+
* their own platform backend and their own isolated on-host state. See the
|
|
4
|
+
* "Coexisting environments" design doc for the full model.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-service default port set. Phase 5 (per-environment port offsets) is
|
|
9
|
+
* deferred from MVP, so today every environment uses the same port set. The
|
|
10
|
+
* shape exists so the rest of the stack can call `getDefaultPorts(env)` and
|
|
11
|
+
* gain per-env offsets later without changing any call sites.
|
|
12
|
+
*/
|
|
13
|
+
export interface PortMap {
|
|
14
|
+
daemon: number;
|
|
15
|
+
gateway: number;
|
|
16
|
+
qdrant: number;
|
|
17
|
+
ces: number;
|
|
18
|
+
outboundProxy: number;
|
|
19
|
+
tcp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A resolved environment definition. Required fields are `name` and
|
|
24
|
+
* `platformUrl`. All other fields are optional and declared upfront — new
|
|
25
|
+
* fields are additive, never breaking. `name` is intentionally typed as
|
|
26
|
+
* `string` (not `keyof SEEDS`) so custom environments can be represented by
|
|
27
|
+
* future layers (user config file, ad-hoc env vars, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export interface EnvironmentDefinition {
|
|
30
|
+
name: string;
|
|
31
|
+
platformUrl: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Override for the platform URL the assistant process itself uses. Only
|
|
35
|
+
* differs from `platformUrl` when the assistant runs in a different network
|
|
36
|
+
* namespace than the host (e.g. Docker on macOS, where the host's localhost
|
|
37
|
+
* is reached via `host.docker.internal`). Falls back to `platformUrl` when
|
|
38
|
+
* unset.
|
|
39
|
+
*/
|
|
40
|
+
assistantPlatformUrl?: string;
|
|
41
|
+
|
|
42
|
+
/** Human-readable label for UI surfaces. */
|
|
43
|
+
displayName?: string;
|
|
44
|
+
|
|
45
|
+
/** Hint for UI surfaces that want to tint or badge their display. */
|
|
46
|
+
tintColor?: string;
|
|
47
|
+
|
|
48
|
+
/** Per-service port overrides merged on top of defaults. */
|
|
49
|
+
portsOverride?: Partial<PortMap>;
|
|
50
|
+
|
|
51
|
+
/** Override for the XDG config directory. */
|
|
52
|
+
configDirOverride?: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Override for the directory containing the lockfile. Populated by the
|
|
56
|
+
* resolver from `VELLUM_LOCKFILE_DIR` (an existing e2e test escape hatch)
|
|
57
|
+
* so path helpers don't read env vars directly.
|
|
58
|
+
*/
|
|
59
|
+
lockfileDirOverride?: string;
|
|
60
|
+
}
|