@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.
Files changed (72) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +21 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  14. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  15. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  16. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  17. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  18. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  19. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  20. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  21. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  22. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  23. package/package.json +12 -1
  24. package/src/__tests__/backup.test.ts +38 -0
  25. package/src/__tests__/env-drift.test.ts +32 -44
  26. package/src/__tests__/flags.test.ts +248 -0
  27. package/src/__tests__/multi-local.test.ts +1 -1
  28. package/src/__tests__/orphan-detection.test.ts +8 -6
  29. package/src/__tests__/recover.test.ts +307 -0
  30. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  31. package/src/__tests__/wake.test.ts +215 -0
  32. package/src/commands/backup.ts +2 -0
  33. package/src/commands/client.ts +471 -30
  34. package/src/commands/env.ts +1 -1
  35. package/src/commands/flags.ts +269 -0
  36. package/src/commands/gateway/token.ts +73 -0
  37. package/src/commands/gateway.ts +29 -0
  38. package/src/commands/logs.ts +6 -18
  39. package/src/commands/ps.ts +41 -41
  40. package/src/commands/recover.ts +47 -9
  41. package/src/commands/restore.ts +8 -1
  42. package/src/commands/retire.ts +3 -23
  43. package/src/commands/rollback.ts +2 -14
  44. package/src/commands/ssh.ts +5 -24
  45. package/src/commands/teleport.ts +34 -26
  46. package/src/commands/upgrade.ts +8 -16
  47. package/src/commands/wake.ts +68 -45
  48. package/src/components/DefaultMainScreen.tsx +16 -1
  49. package/src/index.ts +6 -0
  50. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  51. package/src/lib/__tests__/step-runner.test.ts +49 -1
  52. package/src/lib/assistant-config.ts +16 -3
  53. package/src/lib/config-utils.ts +24 -3
  54. package/src/lib/docker.ts +57 -7
  55. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  56. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  57. package/src/lib/environments/paths.ts +1 -1
  58. package/src/lib/environments/resolve.ts +2 -5
  59. package/src/lib/guardian-token.ts +12 -5
  60. package/src/lib/hatch-local.ts +75 -33
  61. package/src/lib/http-client.ts +1 -3
  62. package/src/lib/lifecycle-reporter.ts +31 -0
  63. package/src/lib/local.ts +173 -292
  64. package/src/lib/orphan-detection.ts +9 -5
  65. package/src/lib/pgrep.ts +5 -1
  66. package/src/lib/platform-client.ts +97 -49
  67. package/src/lib/process.ts +109 -39
  68. package/src/lib/retire-local.ts +28 -14
  69. package/src/lib/segments-to-plain-text.ts +35 -0
  70. package/src/lib/step-runner.ts +67 -7
  71. package/src/lib/sync-cloud-assistants.ts +17 -0
  72. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -2,64 +2,52 @@ import { describe, expect, test } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- import { SEEDS } from "../lib/environments/seeds.js";
5
+ import { SEEDS } from "@vellumai/environments";
6
6
 
7
- // Drift guard for the TypeScript sites that hardcode the set of known
8
- // environment names:
7
+ // Drift guard between the two language-level sources of truth for the set of
8
+ // known environment names:
9
9
  //
10
- // 1. cli/src/lib/environments/seeds.ts — SEEDS record (source of truth)
11
- // 2. assistant/src/util/platform.ts KNOWN_ENVIRONMENTS set
10
+ // 1. packages/environments/src/seeds.ts — SEEDS record (TS source of truth)
11
+ // 2. clients/shared/App/VellumEnvironment.swift Swift `VellumEnvironment` enum
12
12
  //
13
- // Cross-package relative imports don't work here: assistant's tsconfig
14
- // restricts `include` to its own src tree. So this test parses the literal
15
- // set out of the external file and asserts it agrees with CLI's SEEDS.
16
- //
17
- // FOLLOW-UP: split the env name list into a shared `packages/environments`
18
- // package (mirroring `packages/service-contracts`, `credential-storage`) so
19
- // both sites can `import { KNOWN_ENVIRONMENTS }` from one place and this
20
- // drift guard becomes a compile-time check. Planned alongside CLI-driven
21
- // context support — see the "Environments" design doc.
13
+ // The Swift client can't import the TypeScript package, so the two lists are
14
+ // maintained independently and must be kept in lockstep by hand. This test
15
+ // parses the enum cases out of the Swift source and asserts they agree with
16
+ // SEEDS. Adding an environment means updating both sites.
22
17
 
23
18
  const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
24
- const ASSISTANT_PLATFORM = join(
19
+ const SWIFT_ENVIRONMENT = join(
25
20
  REPO_ROOT,
26
- "assistant",
27
- "src",
28
- "util",
29
- "platform.ts",
21
+ "clients",
22
+ "shared",
23
+ "App",
24
+ "VellumEnvironment.swift",
30
25
  );
31
26
 
32
27
  /**
33
- * Extract the string literals from a Set constructor body in a TS source
34
- * file. Looks for `<setName>: ReadonlySet<string> = new Set([ ... ])` and
35
- * pulls out every `"..."` entry within the array. The match is anchored to
36
- * the `setName` to avoid picking up unrelated sets that happen to live in
37
- * the same file.
28
+ * Extract the case names declared in the `VellumEnvironment` enum. Matches
29
+ * standalone `case <name>` declaration lines (one identifier, nothing else),
30
+ * which is the enum's own declaration syntax. Switch-statement arms like
31
+ * `case .local:` carry a leading dot and a trailing colon, so they're
32
+ * excluded — the match is anchored to a bare identifier at end of line.
38
33
  */
39
- function extractSetLiterals(source: string, setName: string): string[] {
40
- const pattern = new RegExp(
41
- `${setName}\\s*:\\s*ReadonlySet<string>\\s*=\\s*new Set\\(\\[([^\\]]*)\\]`,
42
- "m",
43
- );
44
- const match = source.match(pattern);
45
- if (!match) {
46
- throw new Error(
47
- `Could not find Set literal for ${setName}. Update the drift-guard regex in env-drift.test.ts.`,
48
- );
34
+ function extractSwiftEnumCases(source: string): string[] {
35
+ const names: string[] = [];
36
+ for (const line of source.split("\n")) {
37
+ const match = line.match(/^\s*case\s+([a-zA-Z][a-zA-Z0-9]*)\s*$/);
38
+ if (match) names.push(match[1]!);
49
39
  }
50
- const body = match[1];
51
- const literals = body.match(/"([^"]+)"/g) ?? [];
52
- return literals.map((lit) => lit.slice(1, -1));
40
+ return names;
53
41
  }
54
42
 
55
- describe("KNOWN_ENVIRONMENTS drift guard (TS-side)", () => {
43
+ describe("environment name drift guard (TS ↔ Swift)", () => {
56
44
  const seedNames = new Set(Object.keys(SEEDS));
57
45
 
58
- test("assistant/src/util/platform.ts KNOWN_ENVIRONMENTS matches CLI SEEDS", () => {
59
- const source = readFileSync(ASSISTANT_PLATFORM, "utf8");
60
- const assistantNames = new Set(
61
- extractSetLiterals(source, "KNOWN_ENVIRONMENTS"),
62
- );
63
- expect([...assistantNames].sort()).toEqual([...seedNames].sort());
46
+ test("clients/shared/App/VellumEnvironment.swift matches SEEDS", () => {
47
+ const source = readFileSync(SWIFT_ENVIRONMENT, "utf8");
48
+ const swiftNames = new Set(extractSwiftEnumCases(source));
49
+
50
+ expect(swiftNames.size).toBeGreaterThan(0);
51
+ expect([...swiftNames].sort()).toEqual([...seedNames].sort());
64
52
  });
65
53
  });
@@ -0,0 +1,248 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { type AssistantEntry } from "../lib/assistant-config.js";
15
+ import { flags } from "../commands/flags.js";
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), "cli-flags-test-"));
18
+ const originalArgv = [...process.argv];
19
+ const originalExit = process.exit;
20
+ const originalFetch = globalThis.fetch;
21
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
22
+
23
+ let consoleLogSpy: ReturnType<typeof spyOn>;
24
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
25
+ let fetchCalls: Array<{ url: string; method: string }>;
26
+
27
+ function makeEntry(
28
+ assistantId: string,
29
+ extra: Partial<AssistantEntry> = {},
30
+ ): AssistantEntry {
31
+ return {
32
+ assistantId,
33
+ runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
34
+ cloud: "local",
35
+ ...extra,
36
+ };
37
+ }
38
+
39
+ function writeLockfile(
40
+ entries: AssistantEntry[],
41
+ activeAssistant?: string,
42
+ ): void {
43
+ mkdirSync(testDir, { recursive: true });
44
+ writeFileSync(
45
+ join(testDir, ".vellum.lock.json"),
46
+ JSON.stringify(
47
+ {
48
+ assistants: entries,
49
+ ...(activeAssistant ? { activeAssistant } : {}),
50
+ },
51
+ null,
52
+ 2,
53
+ ),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Build a Response stub that callers shape per subcommand. `setFlag` needs
59
+ * a 200 OK with the gateway's updated flag payload; `getFlag`/`listFlags`
60
+ * need a flag list. Body content is the minimal valid shape — the tests
61
+ * exercise URL routing, not response parsing.
62
+ */
63
+ function jsonResponse(body: unknown, status = 200): Response {
64
+ return new Response(JSON.stringify(body), {
65
+ status,
66
+ headers: { "content-type": "application/json" },
67
+ });
68
+ }
69
+
70
+ describe("vellum flags --assistant routing", () => {
71
+ beforeEach(() => {
72
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
73
+ rmSync(join(testDir, ".vellum.lock.json"), { force: true });
74
+ fetchCalls = [];
75
+ // Capture every outgoing fetch and respond with a stub matching the
76
+ // subcommand's expected shape. The URL is what the test asserts on.
77
+ globalThis.fetch = (async (
78
+ input: RequestInfo | URL,
79
+ init?: RequestInit,
80
+ ) => {
81
+ const url = typeof input === "string" ? input : input.toString();
82
+ const method = init?.method ?? "GET";
83
+ fetchCalls.push({ url, method });
84
+ if (method === "PATCH") {
85
+ return jsonResponse({
86
+ key: "external-plugins",
87
+ enabled: true,
88
+ defaultEnabled: false,
89
+ label: "External Plugins",
90
+ description: "test",
91
+ });
92
+ }
93
+ return jsonResponse({ flags: [] });
94
+ }) as typeof globalThis.fetch;
95
+ process.exit = ((code?: number) => {
96
+ throw new Error(`process.exit:${code}`);
97
+ }) as typeof process.exit;
98
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
99
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
100
+ });
101
+
102
+ afterEach(() => {
103
+ process.argv = originalArgv;
104
+ process.exit = originalExit;
105
+ globalThis.fetch = originalFetch;
106
+ consoleLogSpy.mockRestore();
107
+ consoleErrorSpy.mockRestore();
108
+ });
109
+
110
+ afterAll(() => {
111
+ if (originalLockfileDir === undefined) {
112
+ delete process.env.VELLUM_LOCKFILE_DIR;
113
+ } else {
114
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
115
+ }
116
+ rmSync(testDir, { recursive: true, force: true });
117
+ });
118
+
119
+ test("set --assistant <id> routes to the explicit instance's runtime URL, not the active one", async () => {
120
+ // Two assistants on different ports. The active one is "alice"; the
121
+ // explicit --assistant target is "bob". A correct routing impl hits
122
+ // bob's URL — a regression that silently uses the active assistant
123
+ // would hit alice's URL.
124
+ writeLockfile(
125
+ [
126
+ makeEntry("alice-1", { name: "Alice" }),
127
+ makeEntry("bob-2", { name: "Bob" }),
128
+ ],
129
+ "alice-1",
130
+ );
131
+ process.argv = [
132
+ "bun",
133
+ "vellum",
134
+ "flags",
135
+ "set",
136
+ "external-plugins",
137
+ "true",
138
+ "--assistant",
139
+ "Bob",
140
+ ];
141
+
142
+ await flags();
143
+
144
+ expect(fetchCalls.length).toBe(1);
145
+ expect(fetchCalls[0].method).toBe("PATCH");
146
+ // bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
147
+ expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
148
+ expect(fetchCalls[0].url).toContain(
149
+ "/v1/assistants/bob-2/feature-flags/external-plugins",
150
+ );
151
+ });
152
+
153
+ test("set --assistant <id> placed BEFORE positional args still parses correctly", async () => {
154
+ // Eval harness composes `vellum flags set <key> <value> --assistant <id>`
155
+ // but human users might write `--assistant <id> set <key> <value>`.
156
+ // The extractor strips --assistant from anywhere in argv so positional
157
+ // parsing downstream sees the same shape either way.
158
+ writeLockfile([
159
+ makeEntry("alice-1", { name: "Alice" }),
160
+ makeEntry("bob-2", { name: "Bob" }),
161
+ ]);
162
+ process.argv = [
163
+ "bun",
164
+ "vellum",
165
+ "flags",
166
+ "--assistant",
167
+ "Bob",
168
+ "set",
169
+ "external-plugins",
170
+ "true",
171
+ ];
172
+
173
+ await flags();
174
+
175
+ expect(fetchCalls.length).toBe(1);
176
+ expect(fetchCalls[0].url).toContain(
177
+ "/v1/assistants/bob-2/feature-flags/external-plugins",
178
+ );
179
+ });
180
+
181
+ test("set without --assistant uses the active assistant", async () => {
182
+ // Backwards-compat: behavior unchanged for invocations that don't
183
+ // pass --assistant. The active assistant ("alice-1") wins.
184
+ writeLockfile(
185
+ [
186
+ makeEntry("alice-1", { name: "Alice" }),
187
+ makeEntry("bob-2", { name: "Bob" }),
188
+ ],
189
+ "alice-1",
190
+ );
191
+ process.argv = [
192
+ "bun",
193
+ "vellum",
194
+ "flags",
195
+ "set",
196
+ "external-plugins",
197
+ "true",
198
+ ];
199
+
200
+ await flags();
201
+
202
+ expect(fetchCalls.length).toBe(1);
203
+ // alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
204
+ expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
205
+ expect(fetchCalls[0].url).toContain(
206
+ "/v1/assistants/alice-1/feature-flags/external-plugins",
207
+ );
208
+ });
209
+
210
+ test("set --assistant <name> exits with a lookup error when no assistant matches", async () => {
211
+ writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
212
+ process.argv = [
213
+ "bun",
214
+ "vellum",
215
+ "flags",
216
+ "set",
217
+ "external-plugins",
218
+ "true",
219
+ "--assistant",
220
+ "Ghost",
221
+ ];
222
+
223
+ // The Error thrown by createClient propagates out of flags().
224
+ // No fetch should ever fire because lookup fails before the
225
+ // AssistantClient is constructed.
226
+ await expect(flags()).rejects.toThrow(/Ghost/);
227
+ expect(fetchCalls.length).toBe(0);
228
+ });
229
+
230
+ test("--assistant without a value exits via the explicit missing-value branch", async () => {
231
+ writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
232
+ process.argv = [
233
+ "bun",
234
+ "vellum",
235
+ "flags",
236
+ "set",
237
+ "external-plugins",
238
+ "true",
239
+ "--assistant",
240
+ ];
241
+
242
+ await expect(flags()).rejects.toThrow(/process\.exit:1/);
243
+ expect(consoleErrorSpy.mock.calls.flat().join("\n")).toContain(
244
+ "Missing value for --assistant <name>",
245
+ );
246
+ expect(fetchCalls.length).toBe(0);
247
+ });
248
+ });
@@ -156,7 +156,7 @@ describe("multi-local", () => {
156
156
 
157
157
  test("allocation picks env-specific port bases for non-prod envs", async () => {
158
158
  // Each non-prod env sits in its own 1000-port window (see
159
- // environments/seeds.ts). Hatching under VELLUM_ENVIRONMENT=dev should
159
+ // @vellumai/environments seeds). Hatching under VELLUM_ENVIRONMENT=dev should
160
160
  // produce ports in the dev block (18000+), not the production defaults.
161
161
  const prevEnv = process.env.VELLUM_ENVIRONMENT;
162
162
  const prevXdg = process.env.XDG_DATA_HOME;
@@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import type { EnvironmentDefinition } from "@vellumai/environments";
7
+
6
8
  // Point lockfile operations at a temp directory before importing anything that
7
9
  // would otherwise resolve real on-host paths.
8
10
  const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
@@ -16,7 +18,6 @@ import {
16
18
  loadAllAssistantsAcrossEnvs,
17
19
  type AssistantEntry,
18
20
  } from "../lib/assistant-config.js";
19
- import type { EnvironmentDefinition } from "../lib/environments/types.js";
20
21
 
21
22
  afterAll(() => {
22
23
  rmSync(testDir, { recursive: true, force: true });
@@ -74,11 +75,12 @@ describe("getKnownPidsFromAssistants", () => {
74
75
  });
75
76
 
76
77
  test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
77
- const entry = makeLocalEntry(
78
- "alpha",
79
- join(perTestDir, "alpha"),
80
- { daemon: "100", gateway: "200", qdrant: "300", embed: "400" },
81
- );
78
+ const entry = makeLocalEntry("alpha", join(perTestDir, "alpha"), {
79
+ daemon: "100",
80
+ gateway: "200",
81
+ qdrant: "300",
82
+ embed: "400",
83
+ });
82
84
  const pids = getKnownPidsFromAssistants([entry]);
83
85
  expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
84
86
  });