@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a

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 (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseLockfile, type Lockfile } from "./lockfile-contract";
4
+
5
+ describe("parseLockfile", () => {
6
+ test("passes through a fully-populated lockfile", () => {
7
+ const raw = {
8
+ activeAssistant: "asst_1",
9
+ assistants: [
10
+ {
11
+ assistantId: "asst_1",
12
+ name: "Alice",
13
+ cloud: "local",
14
+ runtimeUrl: "http://127.0.0.1:7777",
15
+ species: "vellum",
16
+ hatchedAt: "2026-01-01T00:00:00.000Z",
17
+ resources: { gatewayPort: 7777, daemonPort: 7778 },
18
+ },
19
+ ],
20
+ };
21
+
22
+ expect(parseLockfile(raw)).toEqual(raw as Lockfile);
23
+ });
24
+
25
+ test("keeps entries missing only optional fields", () => {
26
+ const raw = {
27
+ activeAssistant: null,
28
+ assistants: [
29
+ { assistantId: "asst_1", cloud: "vellum", runtimeUrl: "https://x" },
30
+ ],
31
+ };
32
+
33
+ const parsed = parseLockfile(raw);
34
+ expect(parsed.assistants).toHaveLength(1);
35
+ expect(parsed.assistants[0]).toEqual({
36
+ assistantId: "asst_1",
37
+ cloud: "vellum",
38
+ runtimeUrl: "https://x",
39
+ });
40
+ });
41
+
42
+ test("salvages a legacy entry that has only an assistantId", () => {
43
+ // The oldest persisted entries carry just the identity; the CLI fills the
44
+ // rest in lazily. assistantId is the only required field, so the entry
45
+ // must survive intact rather than being discarded.
46
+ const parsed = parseLockfile({
47
+ activeAssistant: "asst_1",
48
+ assistants: [{ assistantId: "asst_1" }],
49
+ });
50
+ expect(parsed.assistants).toEqual([{ assistantId: "asst_1" }]);
51
+ });
52
+
53
+ test("salvages an entry whose cloud and runtimeUrl are absent", () => {
54
+ // Older CLI builds predate the `cloud` field and persisted the runtime URL
55
+ // under a different key (`localUrl`), so a real on-disk entry can lack both
56
+ // modeled fields. It must still be returned (the macOS↔CLI skew window).
57
+ const parsed = parseLockfile({
58
+ activeAssistant: null,
59
+ assistants: [{ assistantId: "asst_1", localUrl: "http://127.0.0.1:7777" }],
60
+ });
61
+ expect(parsed.assistants).toEqual([{ assistantId: "asst_1" }]);
62
+ });
63
+
64
+ test("drops malformed entries but salvages valid siblings", () => {
65
+ const raw = {
66
+ activeAssistant: "asst_ok",
67
+ assistants: [
68
+ { assistantId: "asst_ok", cloud: "local", runtimeUrl: "http://a" },
69
+ { cloud: "local", runtimeUrl: "http://b" }, // missing assistantId
70
+ { assistantId: 42, cloud: "local", runtimeUrl: "http://c" }, // wrong type
71
+ "not-an-object",
72
+ ],
73
+ };
74
+
75
+ const parsed = parseLockfile(raw);
76
+ expect(parsed.assistants).toEqual([
77
+ { assistantId: "asst_ok", cloud: "local", runtimeUrl: "http://a" },
78
+ ]);
79
+ expect(parsed.activeAssistant).toBe("asst_ok");
80
+ });
81
+
82
+ test("accepts (does not reject) entries with unknown fields from a newer writer", () => {
83
+ const raw = {
84
+ activeAssistant: "asst_1",
85
+ schemaVersion: 99, // unknown top-level field from a newer writer
86
+ assistants: [
87
+ {
88
+ assistantId: "asst_1",
89
+ cloud: "local",
90
+ runtimeUrl: "http://a",
91
+ futureField: { nested: true }, // unknown entry field
92
+ },
93
+ ],
94
+ };
95
+
96
+ // A newer writer's extra fields must never make an older reader reject the
97
+ // entry. The entry survives with its modeled fields; the wire value is the
98
+ // validated shape (extra fields are stripped from it — the on-disk file
99
+ // keeps them, see lockfile.test.ts).
100
+ const parsed = parseLockfile(raw);
101
+ expect(parsed.assistants).toEqual([
102
+ { assistantId: "asst_1", cloud: "local", runtimeUrl: "http://a" },
103
+ ]);
104
+ expect(parsed.activeAssistant).toBe("asst_1");
105
+ });
106
+
107
+ test("defaults missing or non-array assistants to an empty list", () => {
108
+ expect(parseLockfile({}).assistants).toEqual([]);
109
+ expect(parseLockfile({ assistants: "nope" }).assistants).toEqual([]);
110
+ });
111
+
112
+ test("coerces a non-string activeAssistant to null", () => {
113
+ expect(parseLockfile({ assistants: [], activeAssistant: 7 }).activeAssistant).toBeNull();
114
+ expect(parseLockfile({ assistants: [] }).activeAssistant).toBeNull();
115
+ });
116
+
117
+ test("returns the empty lockfile for non-object input", () => {
118
+ const empty: Lockfile = { assistants: [], activeAssistant: null };
119
+ expect(parseLockfile(null)).toEqual(empty);
120
+ expect(parseLockfile(undefined)).toEqual(empty);
121
+ expect(parseLockfile("[]")).toEqual(empty);
122
+ expect(parseLockfile(123)).toEqual(empty);
123
+ });
124
+
125
+ test("drops a mistyped optional field but keeps the entry", () => {
126
+ // assistantId is the only required field. A mistyped optional field (here
127
+ // cloud / runtimeUrl) is dropped from the result, but the entry survives on
128
+ // the strength of its identity rather than being discarded wholesale.
129
+ const raw = {
130
+ assistants: [
131
+ { assistantId: "asst_1", cloud: 7, runtimeUrl: "http://a" }, // cloud not a string
132
+ { assistantId: "asst_2", cloud: "local", runtimeUrl: 7 }, // runtimeUrl not a string
133
+ ],
134
+ activeAssistant: null,
135
+ };
136
+ expect(parseLockfile(raw).assistants).toEqual([
137
+ { assistantId: "asst_1", runtimeUrl: "http://a" },
138
+ { assistantId: "asst_2", cloud: "local" },
139
+ ]);
140
+ });
141
+
142
+ test("drops an entry only when its assistantId is missing or mistyped", () => {
143
+ const raw = {
144
+ assistants: [
145
+ { cloud: "local", runtimeUrl: "http://a" }, // no assistantId
146
+ { assistantId: 42, cloud: "local" }, // assistantId not a string
147
+ ],
148
+ activeAssistant: null,
149
+ };
150
+ expect(parseLockfile(raw).assistants).toEqual([]);
151
+ });
152
+
153
+ test("drops a resources object missing its numeric ports", () => {
154
+ const raw = {
155
+ assistants: [
156
+ {
157
+ assistantId: "asst_1",
158
+ cloud: "local",
159
+ runtimeUrl: "http://a",
160
+ resources: { gatewayPort: "7777", daemonPort: 7778 },
161
+ },
162
+ ],
163
+ activeAssistant: null,
164
+ };
165
+ const [assistant] = parseLockfile(raw).assistants;
166
+ expect(assistant).toEqual({
167
+ assistantId: "asst_1",
168
+ cloud: "local",
169
+ runtimeUrl: "http://a",
170
+ });
171
+ expect(assistant?.resources).toBeUndefined();
172
+ });
173
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The lockfile wire contract — the types and the parser that validates them —
3
+ * kept in its own module so consumers can import the contract without pulling
4
+ * in the host I/O graph (`node:fs`, the CLI resolver, the environments
5
+ * package). The renderer relies on that separation: it imports these types
6
+ * type-only and must not transitively reference Node built-ins.
7
+ *
8
+ * Validation is hand-written rather than schema-library-based on purpose: this
9
+ * package is inlined from source by three different bundler hosts (the CLI, the
10
+ * Electron main process, and the web dev server) and deliberately carries no
11
+ * third-party runtime dependencies (enforced by `__tests__/package-boundary`).
12
+ * The contract is small enough that a typed parser is clearer than a dependency.
13
+ *
14
+ * The lockfile is written by the Vellum CLI and read across the macOS↔CLI
15
+ * boundary, which do not release in lockstep. The parser is therefore
16
+ * permissive about unknown fields: it reads only the modeled fields below and
17
+ * ignores everything else, so a newer writer's extra fields never fail an
18
+ * older reader. Unknown fields are preserved on disk by the write path (see
19
+ * `lockfile.ts`); the validated value returned to callers carries only the
20
+ * modeled shape.
21
+ *
22
+ * `assistantId` is the only required field — it is the entry's identity (the
23
+ * key every reader and the write path look entries up by) and the one field
24
+ * the CLI always writes. Everything else is optional on disk: older entries
25
+ * predate the `cloud` field, the runtime URL has historically been persisted
26
+ * under a different key (`localUrl`), and resource ports are only present for
27
+ * multi-instance local setups. The parser therefore salvages any entry that
28
+ * has a string `assistantId` and copies the remaining modeled fields only when
29
+ * they are present and well-typed, rather than discarding an otherwise-usable
30
+ * assistant because an optional field is missing or malformed.
31
+ */
32
+
33
+ export interface LocalAssistantResources {
34
+ gatewayPort: number;
35
+ daemonPort: number;
36
+ }
37
+
38
+ export interface LockfileAssistant {
39
+ assistantId: string;
40
+ name?: string;
41
+ cloud?: string;
42
+ runtimeUrl?: string;
43
+ species?: string;
44
+ hatchedAt?: string;
45
+ resources?: LocalAssistantResources;
46
+ }
47
+
48
+ export interface Lockfile {
49
+ assistants: LockfileAssistant[];
50
+ activeAssistant: string | null;
51
+ }
52
+
53
+ /**
54
+ * Renderer-facing result of a lockfile write, returned across the host bridge.
55
+ * Carries no HTTP-style `status` — the hosts map that away because the
56
+ * renderer surfaces failures by message alone.
57
+ */
58
+ export type LockfileWriteResult =
59
+ | { ok: true; lockfile: Lockfile }
60
+ | { ok: false; error: string };
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function parseResources(value: unknown): LocalAssistantResources | undefined {
67
+ if (!isRecord(value)) return undefined;
68
+ if (typeof value.gatewayPort !== "number") return undefined;
69
+ if (typeof value.daemonPort !== "number") return undefined;
70
+ return { gatewayPort: value.gatewayPort, daemonPort: value.daemonPort };
71
+ }
72
+
73
+ /**
74
+ * Validate a single assistant entry. Returns `null` only when the entry lacks a
75
+ * string `assistantId` (its identity); any other entry is salvaged. Each
76
+ * optional field is copied only when present and well-typed — a missing or
77
+ * mistyped optional field is dropped from the result without discarding the
78
+ * entry — and unknown fields are ignored.
79
+ */
80
+ function parseAssistant(value: unknown): LockfileAssistant | null {
81
+ if (!isRecord(value)) return null;
82
+ if (typeof value.assistantId !== "string") return null;
83
+
84
+ const assistant: LockfileAssistant = { assistantId: value.assistantId };
85
+ if (typeof value.name === "string") assistant.name = value.name;
86
+ if (typeof value.cloud === "string") assistant.cloud = value.cloud;
87
+ if (typeof value.runtimeUrl === "string") assistant.runtimeUrl = value.runtimeUrl;
88
+ if (typeof value.species === "string") assistant.species = value.species;
89
+ if (typeof value.hatchedAt === "string") assistant.hatchedAt = value.hatchedAt;
90
+ const resources = parseResources(value.resources);
91
+ if (resources) assistant.resources = resources;
92
+ return assistant;
93
+ }
94
+
95
+ /**
96
+ * Coerce parsed JSON into a validated `Lockfile`. Total and non-throwing:
97
+ * individual assistant entries that fail validation are dropped (so one
98
+ * malformed entry can't discard the whole file), a missing/non-array
99
+ * `assistants` becomes `[]`, and a non-string `activeAssistant` becomes
100
+ * `null`. Callers pass the result through unchanged; the only failure modes a
101
+ * host surfaces are I/O and JSON-parse errors, handled by `getLockfileData`.
102
+ */
103
+ export function parseLockfile(raw: unknown): Lockfile {
104
+ const root = isRecord(raw) ? raw : {};
105
+ const rawAssistants = Array.isArray(root.assistants) ? root.assistants : [];
106
+ const assistants: LockfileAssistant[] = [];
107
+ for (const entry of rawAssistants) {
108
+ const parsed = parseAssistant(entry);
109
+ if (parsed) assistants.push(parsed);
110
+ }
111
+ const activeAssistant =
112
+ typeof root.activeAssistant === "string" ? root.activeAssistant : null;
113
+ return { assistants, activeAssistant };
114
+ }
@@ -0,0 +1,235 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ getLockfileData,
8
+ replacePlatformAssistants,
9
+ upsertLockfileAssistant,
10
+ } from "./lockfile";
11
+
12
+ let dir: string;
13
+ let lockfilePath: string;
14
+
15
+ beforeEach(() => {
16
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "lockfile-test-"));
17
+ lockfilePath = path.join(dir, "lockfile.json");
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ function writeOnDisk(value: unknown): void {
25
+ fs.writeFileSync(lockfilePath, JSON.stringify(value, null, 2));
26
+ }
27
+
28
+ function readOnDisk(): Record<string, unknown> {
29
+ return JSON.parse(fs.readFileSync(lockfilePath, "utf-8")) as Record<
30
+ string,
31
+ unknown
32
+ >;
33
+ }
34
+
35
+ describe("getLockfileData", () => {
36
+ test("returns the empty lockfile when no file exists", () => {
37
+ const result = getLockfileData([lockfilePath]);
38
+ expect(result).toEqual({
39
+ ok: true,
40
+ data: { assistants: [], activeAssistant: null },
41
+ });
42
+ });
43
+
44
+ test("validates and salvages a partially-malformed file", () => {
45
+ writeOnDisk({
46
+ activeAssistant: "asst_ok",
47
+ assistants: [
48
+ { assistantId: "asst_ok", cloud: "local", runtimeUrl: "http://a" },
49
+ { cloud: "local", runtimeUrl: "http://b" }, // missing assistantId
50
+ ],
51
+ });
52
+
53
+ const result = getLockfileData([lockfilePath]);
54
+ expect(result.ok).toBe(true);
55
+ if (result.ok) {
56
+ expect(result.data.assistants).toEqual([
57
+ { assistantId: "asst_ok", cloud: "local", runtimeUrl: "http://a" },
58
+ ]);
59
+ }
60
+ });
61
+
62
+ test("salvages a legacy entry that predates cloud/runtimeUrl", () => {
63
+ // An entry written by an older CLI: no `cloud`, and the runtime URL stored
64
+ // under the legacy `localUrl` key rather than `runtimeUrl`. Only the
65
+ // identity is guaranteed, so the entry must still be returned (the modeled
66
+ // fields it lacks are simply absent on the wire value).
67
+ writeOnDisk({
68
+ activeAssistant: "asst_legacy",
69
+ assistants: [
70
+ { assistantId: "asst_legacy", localUrl: "http://127.0.0.1:7777" },
71
+ ],
72
+ });
73
+
74
+ const result = getLockfileData([lockfilePath]);
75
+ expect(result.ok).toBe(true);
76
+ if (result.ok) {
77
+ expect(result.data.assistants).toEqual([{ assistantId: "asst_legacy" }]);
78
+ expect(result.data.activeAssistant).toBe("asst_legacy");
79
+ }
80
+ });
81
+
82
+ test("fails with status 500 on malformed JSON", () => {
83
+ fs.writeFileSync(lockfilePath, "{ not json");
84
+ const result = getLockfileData([lockfilePath]);
85
+ expect(result).toEqual({ ok: false, status: 500 });
86
+ });
87
+ });
88
+
89
+ describe("upsertLockfileAssistant", () => {
90
+ test("rejects an assistant with no id", () => {
91
+ const result = upsertLockfileAssistant(
92
+ [lockfilePath],
93
+ { cloud: "local" },
94
+ undefined,
95
+ );
96
+ expect(result).toEqual({
97
+ ok: false,
98
+ status: 400,
99
+ error: "Missing assistant.assistantId",
100
+ });
101
+ });
102
+
103
+ test("preserves unknown on-disk fields written by a newer client", () => {
104
+ // A newer writer added a top-level field and per-entry fields this build
105
+ // does not model. Upserting an unrelated assistant must not drop them.
106
+ writeOnDisk({
107
+ schemaVersion: 99,
108
+ activeAssistant: "asst_old",
109
+ assistants: [
110
+ {
111
+ assistantId: "asst_old",
112
+ cloud: "vellum",
113
+ runtimeUrl: "http://old",
114
+ futureField: "keep-me",
115
+ },
116
+ ],
117
+ });
118
+
119
+ const result = upsertLockfileAssistant(
120
+ [lockfilePath],
121
+ { assistantId: "asst_new", cloud: "local", runtimeUrl: "http://new" },
122
+ "asst_new",
123
+ );
124
+
125
+ expect(result.ok).toBe(true);
126
+
127
+ const onDisk = readOnDisk();
128
+ expect(onDisk.schemaVersion).toBe(99);
129
+ const assistants = onDisk.assistants as Array<Record<string, unknown>>;
130
+ const old = assistants.find((a) => a.assistantId === "asst_old");
131
+ expect(old?.futureField).toBe("keep-me");
132
+
133
+ // The returned wire value is the validated shape (unknown fields stripped).
134
+ if (result.ok) {
135
+ expect(result.lockfile.activeAssistant).toBe("asst_new");
136
+ const wireOld = result.lockfile.assistants.find(
137
+ (a) => a.assistantId === "asst_old",
138
+ );
139
+ expect(wireOld).toEqual({
140
+ assistantId: "asst_old",
141
+ cloud: "vellum",
142
+ runtimeUrl: "http://old",
143
+ });
144
+ }
145
+ });
146
+
147
+ test("merges fields into an existing entry", () => {
148
+ writeOnDisk({
149
+ activeAssistant: null,
150
+ assistants: [
151
+ { assistantId: "asst_1", cloud: "local", runtimeUrl: "http://a" },
152
+ ],
153
+ });
154
+
155
+ upsertLockfileAssistant(
156
+ [lockfilePath],
157
+ { assistantId: "asst_1", name: "Renamed" },
158
+ undefined,
159
+ );
160
+
161
+ const assistants = readOnDisk().assistants as Array<
162
+ Record<string, unknown>
163
+ >;
164
+ expect(assistants).toHaveLength(1);
165
+ expect(assistants[0]).toMatchObject({
166
+ assistantId: "asst_1",
167
+ cloud: "local",
168
+ runtimeUrl: "http://a",
169
+ name: "Renamed",
170
+ });
171
+ });
172
+ });
173
+
174
+ describe("replacePlatformAssistants", () => {
175
+ test("replaces platform assistants while keeping local ones and unknown fields", () => {
176
+ writeOnDisk({
177
+ schemaVersion: 99,
178
+ activeAssistant: "asst_local",
179
+ assistants: [
180
+ { assistantId: "asst_local", cloud: "local", runtimeUrl: "http://l" },
181
+ {
182
+ assistantId: "asst_old_platform",
183
+ cloud: "vellum",
184
+ runtimeUrl: "http://op",
185
+ },
186
+ ],
187
+ });
188
+
189
+ const result = replacePlatformAssistants(
190
+ [lockfilePath],
191
+ [
192
+ {
193
+ assistantId: "asst_new_platform",
194
+ cloud: "vellum",
195
+ runtimeUrl: "http://np",
196
+ },
197
+ ],
198
+ );
199
+
200
+ expect(result.ok).toBe(true);
201
+
202
+ const onDisk = readOnDisk();
203
+ expect(onDisk.schemaVersion).toBe(99);
204
+ const ids = (onDisk.assistants as Array<Record<string, unknown>>).map(
205
+ (a) => a.assistantId,
206
+ );
207
+ expect(ids).toEqual(["asst_local", "asst_new_platform"]);
208
+ });
209
+
210
+ test("clears activeAssistant when the active id no longer exists", () => {
211
+ writeOnDisk({
212
+ activeAssistant: "asst_old_platform",
213
+ assistants: [
214
+ {
215
+ assistantId: "asst_old_platform",
216
+ cloud: "vellum",
217
+ runtimeUrl: "http://op",
218
+ },
219
+ ],
220
+ });
221
+
222
+ replacePlatformAssistants(
223
+ [lockfilePath],
224
+ [
225
+ {
226
+ assistantId: "asst_new_platform",
227
+ cloud: "vellum",
228
+ runtimeUrl: "http://np",
229
+ },
230
+ ],
231
+ );
232
+
233
+ expect(readOnDisk().activeAssistant).toBeNull();
234
+ });
235
+ });
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { parseLockfile, type Lockfile } from "./lockfile-contract";
5
+ import { stripSensitiveFields } from "./util";
6
+
7
+ export type LockfileResult =
8
+ | { ok: true; data: Lockfile }
9
+ | { ok: false; status: number; error?: string };
10
+
11
+ export function getLockfileData(lockfilePaths: string[]): LockfileResult {
12
+ let raw: string | undefined;
13
+ for (const candidate of lockfilePaths) {
14
+ try {
15
+ raw = fs.readFileSync(candidate, "utf-8");
16
+ break;
17
+ } catch (err: unknown) {
18
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
19
+ return { ok: false, status: 500 };
20
+ }
21
+ }
22
+ }
23
+
24
+ if (!raw) {
25
+ return { ok: true, data: { assistants: [], activeAssistant: null } };
26
+ }
27
+
28
+ let data: Record<string, unknown>;
29
+ try {
30
+ data = JSON.parse(raw) as Record<string, unknown>;
31
+ } catch {
32
+ return { ok: false, status: 500 };
33
+ }
34
+ stripSensitiveFields(data);
35
+ return { ok: true, data: parseLockfile(data) };
36
+ }
37
+
38
+ export type WriteResult =
39
+ | { ok: true; lockfile: Lockfile }
40
+ | { ok: false; status: number; error: string };
41
+
42
+ export function upsertLockfileAssistant(
43
+ lockfilePaths: string[],
44
+ assistant: Record<string, unknown>,
45
+ activeAssistant: string | undefined,
46
+ ): WriteResult {
47
+ if (!assistant || typeof assistant.assistantId !== "string") {
48
+ return { ok: false, status: 400, error: "Missing assistant.assistantId" };
49
+ }
50
+
51
+ let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
52
+ for (const candidate of lockfilePaths) {
53
+ try {
54
+ lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
55
+ break;
56
+ } catch {
57
+ // continue
58
+ }
59
+ }
60
+
61
+ const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
62
+ const existingIdx = assistants.findIndex(
63
+ (a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
64
+ );
65
+ if (existingIdx >= 0) {
66
+ assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
67
+ } else {
68
+ assistants.push(assistant);
69
+ }
70
+ lockfile.assistants = assistants;
71
+ if (activeAssistant !== undefined) {
72
+ lockfile.activeAssistant = activeAssistant;
73
+ }
74
+
75
+ const writePath = lockfilePaths[0]!;
76
+ try {
77
+ const dir = path.dirname(writePath);
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ const tmp = `${writePath}.tmp.${process.pid}`;
80
+ fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
81
+ fs.renameSync(tmp, writePath);
82
+ } catch (err) {
83
+ return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
84
+ }
85
+
86
+ const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
87
+ stripSensitiveFields(stripped);
88
+ return { ok: true, lockfile: parseLockfile(stripped) };
89
+ }
90
+
91
+ export function replacePlatformAssistants(
92
+ lockfilePaths: string[],
93
+ platformAssistants: Array<Record<string, unknown>>,
94
+ ): WriteResult {
95
+ let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
96
+ for (const candidate of lockfilePaths) {
97
+ try {
98
+ lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
99
+ break;
100
+ } catch {
101
+ // continue
102
+ }
103
+ }
104
+
105
+ const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
106
+ const local = existing.filter(
107
+ (a: Record<string, unknown>) => a?.cloud !== "vellum",
108
+ );
109
+ lockfile.assistants = [...local, ...platformAssistants];
110
+
111
+ const active = lockfile.activeAssistant as string | null;
112
+ if (active) {
113
+ const stillExists = (lockfile.assistants as Array<Record<string, unknown>>).some(
114
+ (a) => a.assistantId === active,
115
+ );
116
+ if (!stillExists) lockfile.activeAssistant = null;
117
+ }
118
+
119
+ const writePath = lockfilePaths[0]!;
120
+ try {
121
+ const dir = path.dirname(writePath);
122
+ fs.mkdirSync(dir, { recursive: true });
123
+ const tmp = `${writePath}.tmp.${process.pid}`;
124
+ fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
125
+ fs.renameSync(tmp, writePath);
126
+ } catch (err) {
127
+ return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
128
+ }
129
+
130
+ const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
131
+ stripSensitiveFields(stripped);
132
+ return { ok: true, lockfile: parseLockfile(stripped) };
133
+ }