@vellumai/cli 0.10.12-dev.202607250047.75b9ec6 → 0.10.12-staging.1

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.
@@ -1,101 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
-
6
- import {
7
- crossEnvironmentAssistantHint,
8
- detectOtherEnvironmentAssistants,
9
- } from "../detect.js";
10
- import { getSeed } from "../resolve.js";
11
-
12
- const prod = getSeed("production")!;
13
-
14
- /** Create `<XDG_DATA_HOME>/vellum-<env>/assistants/<id>/` for a non-prod env. */
15
- function seedAssistantDir(dataHome: string, envName: string, id: string): void {
16
- mkdirSync(join(dataHome, `vellum-${envName}`, "assistants", id), {
17
- recursive: true,
18
- });
19
- }
20
-
21
- describe("cross-environment assistant detection", () => {
22
- let dataHome: string;
23
- let prevXdg: string | undefined;
24
- let prevEnv: string | undefined;
25
-
26
- beforeEach(() => {
27
- prevXdg = process.env.XDG_DATA_HOME;
28
- prevEnv = process.env.VELLUM_ENVIRONMENT;
29
- dataHome = mkdtempSync(join(tmpdir(), "cli-detect-xdg-"));
30
- process.env.XDG_DATA_HOME = dataHome;
31
- // Pin the current env so the hint's "this CLI targets '<env>'" copy and
32
- // the current-env skip are deterministic regardless of machine config.
33
- process.env.VELLUM_ENVIRONMENT = "production";
34
- });
35
-
36
- afterEach(() => {
37
- if (prevXdg === undefined) {
38
- delete process.env.XDG_DATA_HOME;
39
- } else {
40
- process.env.XDG_DATA_HOME = prevXdg;
41
- }
42
- if (prevEnv === undefined) {
43
- delete process.env.VELLUM_ENVIRONMENT;
44
- } else {
45
- process.env.VELLUM_ENVIRONMENT = prevEnv;
46
- }
47
- rmSync(dataHome, { recursive: true, force: true });
48
- });
49
-
50
- test("finds an env whose data dir holds an assistant subdirectory", () => {
51
- seedAssistantDir(dataHome, "dev", "dev-bot");
52
- expect(detectOtherEnvironmentAssistants(prod)).toContain("dev");
53
- });
54
-
55
- test("reports every other env that holds assistants", () => {
56
- seedAssistantDir(dataHome, "dev", "dev-bot");
57
- seedAssistantDir(dataHome, "staging", "staging-bot");
58
- const others = detectOtherEnvironmentAssistants(prod);
59
- expect(others).toContain("dev");
60
- expect(others).toContain("staging");
61
- });
62
-
63
- test("skips the current environment even when it holds assistants", () => {
64
- // production's own multi-instance dir (no env suffix).
65
- mkdirSync(join(dataHome, "vellum", "assistants", "prod-bot"), {
66
- recursive: true,
67
- });
68
- expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
69
- });
70
-
71
- test("ignores an env dir that holds no assistant subdirectory", () => {
72
- mkdirSync(join(dataHome, "vellum-dev", "assistants"), { recursive: true });
73
- expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
74
- });
75
-
76
- test("returns empty when no env data dirs exist", () => {
77
- expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
78
- });
79
-
80
- test("swallows fs errors (data root is a file, not a dir) and returns empty", () => {
81
- const filePath = join(dataHome, "not-a-dir");
82
- writeFileSync(filePath, "x");
83
- process.env.XDG_DATA_HOME = filePath;
84
- expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
85
- expect(crossEnvironmentAssistantHint()).toBeNull();
86
- });
87
-
88
- test("hint names the env, the current env, and both fixes", () => {
89
- seedAssistantDir(dataHome, "dev", "dev-bot");
90
- const hint = crossEnvironmentAssistantHint();
91
- expect(hint).not.toBeNull();
92
- expect(hint).toContain("'dev'");
93
- expect(hint).toContain("This CLI targets 'production'");
94
- expect(hint).toContain("Install vellum Command");
95
- expect(hint).toContain("VELLUM_ENVIRONMENT=dev");
96
- });
97
-
98
- test("hint is null when no other env has assistants", () => {
99
- expect(crossEnvironmentAssistantHint()).toBeNull();
100
- });
101
- });
@@ -1,79 +0,0 @@
1
- import { readdirSync } from "fs";
2
-
3
- import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
4
-
5
- import { getMultiInstanceDir } from "./paths.js";
6
- import { getCurrentEnvironment } from "./resolve.js";
7
-
8
- /**
9
- * Names of OTHER environments (every seed except `currentEnv`) whose
10
- * multi-instance data dir exists and holds at least one assistant
11
- * subdirectory.
12
- *
13
- * Each environment has its own on-host data layout — see
14
- * {@link getMultiInstanceDir}. A user who hatched via a dev/staging desktop
15
- * app but runs the production npm CLI (or vice versa) targets a different
16
- * layout than the app did, so the assistant the app created is invisible to
17
- * this CLI. This cheap filesystem scan surfaces that split so callers can
18
- * point the user at the environment that actually holds their assistants.
19
- *
20
- * Never throws: a missing or unreadable dir just means that environment has no
21
- * discoverable assistants, so it is skipped. Returns an empty array on any
22
- * error.
23
- */
24
- export function detectOtherEnvironmentAssistants(
25
- currentEnv: EnvironmentDefinition,
26
- ): string[] {
27
- const found: string[] = [];
28
- for (const env of Object.values(SEEDS)) {
29
- if (env.name === currentEnv.name) {
30
- continue;
31
- }
32
- try {
33
- const hasAssistant = readdirSync(getMultiInstanceDir(env), {
34
- withFileTypes: true,
35
- }).some((dirent) => dirent.isDirectory());
36
- if (hasAssistant) {
37
- found.push(env.name);
38
- }
39
- } catch {
40
- // Missing or unreadable dir — this environment has no discoverable
41
- // assistants. Skip it.
42
- }
43
- }
44
- return found;
45
- }
46
-
47
- /**
48
- * One-line hint appended to "no assistant found" and "gateway unreachable"
49
- * errors when another environment's data dir holds assistants. A first-time
50
- * user who hatched via a dev/staging desktop app while running the production
51
- * npm CLI otherwise gets a bare failure with no clue the two tools target
52
- * different environments.
53
- *
54
- * Returns null when nothing is found so callers can append unconditionally:
55
- * `message + (crossEnvironmentAssistantHint() ?? "")`. Detection failure never
56
- * propagates — the caller's original error must survive intact.
57
- */
58
- export function crossEnvironmentAssistantHint(): string | null {
59
- let currentEnv: EnvironmentDefinition;
60
- let others: string[];
61
- try {
62
- currentEnv = getCurrentEnvironment();
63
- others = detectOtherEnvironmentAssistants(currentEnv);
64
- } catch {
65
- return null;
66
- }
67
- if (others.length === 0) {
68
- return null;
69
- }
70
-
71
- const quoted = others.map((name) => `'${name}'`).join(", ");
72
- const noun = others.length > 1 ? "environments" : "environment";
73
- return (
74
- `\n\nFound assistants in the ${quoted} ${noun} (managed by a dev/staging ` +
75
- `desktop app). This CLI targets '${currentEnv.name}'. Use the desktop ` +
76
- `app's 'Install vellum Command…' menu item to get a matching CLI, or ` +
77
- `set VELLUM_ENVIRONMENT=${others[0]}.`
78
- );
79
- }
@@ -1,322 +0,0 @@
1
- import { execFileSync } from "child_process";
2
- import { createHash, randomUUID } from "crypto";
3
- import {
4
- closeSync,
5
- mkdirSync,
6
- openSync,
7
- readFileSync,
8
- statSync,
9
- unlinkSync,
10
- utimesSync,
11
- writeFileSync,
12
- } from "fs";
13
- import { hostname, userInfo } from "os";
14
- import { dirname, join } from "path";
15
-
16
- const DEFAULT_TIMEOUT_MS = 240_000;
17
- const DEFAULT_RETRY_MS = 100;
18
- const MAX_LOCK_AGE_MS = DEFAULT_TIMEOUT_MS - 60_000;
19
- const HEARTBEAT_INTERVAL_MS = 30_000;
20
- const LIVE_OWNER_RECOVERY_GRACE_MS = 10_000;
21
-
22
- interface LocalHatchLockOptions {
23
- lockPath?: string;
24
- liveOwnerRecoveryGraceMs?: number;
25
- timeoutMs?: number;
26
- retryMs?: number;
27
- }
28
-
29
- interface LockOwner {
30
- pid: number;
31
- processStartedAt?: string;
32
- token: string;
33
- }
34
-
35
- interface LockSnapshot {
36
- owner?: LockOwner;
37
- signature: string;
38
- }
39
-
40
- export function resolveLocalHatchLockPath(): string {
41
- const machineKey = createHash("sha256")
42
- .update(hostname())
43
- .digest("hex")
44
- .slice(0, 12);
45
- return join(
46
- userInfo().homedir,
47
- ".cache",
48
- "vellum",
49
- "runtime",
50
- machineKey,
51
- "local-hatch.lock",
52
- );
53
- }
54
-
55
- function processIsAlive(pid: number): boolean {
56
- try {
57
- process.kill(pid, 0);
58
- return true;
59
- } catch (error) {
60
- return (error as NodeJS.ErrnoException).code === "EPERM";
61
- }
62
- }
63
-
64
- function processStartedAt(pid: number): string | undefined {
65
- try {
66
- const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
67
- encoding: "utf-8",
68
- stdio: ["ignore", "pipe", "ignore"],
69
- timeout: 1_000,
70
- }).trim();
71
- return value || undefined;
72
- } catch {
73
- return undefined;
74
- }
75
- }
76
-
77
- function parseOwner(raw: string): LockOwner | undefined {
78
- try {
79
- const parsed = JSON.parse(raw) as {
80
- pid?: unknown;
81
- processStartedAt?: unknown;
82
- token?: unknown;
83
- };
84
- if (
85
- typeof parsed.pid === "number" &&
86
- Number.isInteger(parsed.pid) &&
87
- parsed.pid > 0 &&
88
- typeof parsed.token === "string" &&
89
- parsed.token.length > 0
90
- ) {
91
- return {
92
- pid: parsed.pid,
93
- processStartedAt:
94
- typeof parsed.processStartedAt === "string" &&
95
- parsed.processStartedAt.length > 0
96
- ? parsed.processStartedAt
97
- : undefined,
98
- token: parsed.token,
99
- };
100
- }
101
- } catch {}
102
- return undefined;
103
- }
104
-
105
- function readSnapshot(path: string): LockSnapshot | undefined {
106
- try {
107
- const raw = readFileSync(path, "utf-8");
108
- return {
109
- owner: parseOwner(raw),
110
- signature: createHash("sha256").update(raw).digest("hex"),
111
- };
112
- } catch {
113
- return undefined;
114
- }
115
- }
116
-
117
- function readOwner(path: string): LockOwner | undefined {
118
- return readSnapshot(path)?.owner;
119
- }
120
-
121
- function fileAgeMs(path: string): number | undefined {
122
- try {
123
- return Date.now() - statSync(path).mtimeMs;
124
- } catch {
125
- return undefined;
126
- }
127
- }
128
-
129
- const expiredLiveOwners = new Map<string, number>();
130
-
131
- function ownerIsActive(
132
- path: string,
133
- owner: LockOwner | undefined,
134
- liveOwnerRecoveryGraceMs: number,
135
- ): boolean {
136
- const ageMs = fileAgeMs(path);
137
- if (!owner) {
138
- return ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS;
139
- }
140
- if (!processIsAlive(owner.pid)) {
141
- return false;
142
- }
143
- if (owner.processStartedAt) {
144
- const currentStartedAt = processStartedAt(owner.pid);
145
- if (currentStartedAt) {
146
- expiredLiveOwners.delete(owner.token);
147
- return currentStartedAt === owner.processStartedAt;
148
- }
149
- }
150
- if (ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS) {
151
- expiredLiveOwners.delete(owner.token);
152
- return true;
153
- }
154
-
155
- const firstExpiredAt = expiredLiveOwners.get(owner.token) ?? Date.now();
156
- expiredLiveOwners.set(owner.token, firstExpiredAt);
157
- if (Date.now() - firstExpiredAt < liveOwnerRecoveryGraceMs) {
158
- return true;
159
- }
160
- return false;
161
- }
162
-
163
- function writeOwnerFile(path: string, owner: LockOwner): void {
164
- const fd = openSync(path, "wx", 0o600);
165
- try {
166
- writeFileSync(fd, JSON.stringify(owner) + "\n");
167
- } finally {
168
- closeSync(fd);
169
- }
170
- }
171
-
172
- function acquireRecoveryClaim(
173
- lockPath: string,
174
- staleSignature: string,
175
- owner: LockOwner,
176
- liveOwnerRecoveryGraceMs: number,
177
- ): (() => void) | undefined {
178
- const recoveryKey = staleSignature.slice(0, 16);
179
- for (let generation = 0; generation < 100; generation++) {
180
- const claimPath = `${lockPath}.recovery-${recoveryKey}-${generation}`;
181
- try {
182
- writeOwnerFile(claimPath, owner);
183
- return () => releaseLock(claimPath, owner.token);
184
- } catch (error) {
185
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
186
- throw error;
187
- }
188
- if (
189
- ownerIsActive(claimPath, readOwner(claimPath), liveOwnerRecoveryGraceMs)
190
- ) {
191
- return undefined;
192
- }
193
- }
194
- }
195
- return undefined;
196
- }
197
-
198
- function removeStaleLock(
199
- lockPath: string,
200
- recoveryOwner: LockOwner,
201
- liveOwnerRecoveryGraceMs: number,
202
- ): boolean {
203
- const staleSnapshot = readSnapshot(lockPath);
204
- if (!staleSnapshot) {
205
- return true;
206
- }
207
- if (ownerIsActive(lockPath, staleSnapshot.owner, liveOwnerRecoveryGraceMs)) {
208
- return false;
209
- }
210
-
211
- const releaseRecoveryClaim = acquireRecoveryClaim(
212
- lockPath,
213
- staleSnapshot.signature,
214
- recoveryOwner,
215
- liveOwnerRecoveryGraceMs,
216
- );
217
- if (!releaseRecoveryClaim) {
218
- return false;
219
- }
220
-
221
- try {
222
- const currentSnapshot = readSnapshot(lockPath);
223
- if (!currentSnapshot) {
224
- return true;
225
- }
226
- if (currentSnapshot.signature !== staleSnapshot.signature) {
227
- return false;
228
- }
229
- if (
230
- ownerIsActive(lockPath, currentSnapshot.owner, liveOwnerRecoveryGraceMs)
231
- ) {
232
- return false;
233
- }
234
- try {
235
- unlinkSync(lockPath);
236
- return true;
237
- } catch (error) {
238
- return (error as NodeJS.ErrnoException).code === "ENOENT";
239
- }
240
- } finally {
241
- releaseRecoveryClaim();
242
- }
243
- }
244
-
245
- function releaseLock(lockPath: string, token: string): void {
246
- if (readOwner(lockPath)?.token !== token) {
247
- return;
248
- }
249
- try {
250
- unlinkSync(lockPath);
251
- } catch (error) {
252
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
253
- throw error;
254
- }
255
- }
256
- }
257
-
258
- function refreshLock(lockPath: string, token: string): void {
259
- if (readOwner(lockPath)?.token !== token) {
260
- return;
261
- }
262
- try {
263
- const now = new Date();
264
- utimesSync(lockPath, now, now);
265
- } catch {}
266
- }
267
-
268
- /**
269
- * Serialize local assistant startup across CLI processes. Port availability is
270
- * machine-wide even when callers use different lockfile or XDG directories,
271
- * so allocation must remain exclusive until the new services have bound their
272
- * ports and the assistant entry has been persisted.
273
- */
274
- export async function withLocalHatchLock<T>(
275
- action: () => Promise<T>,
276
- options: LocalHatchLockOptions = {},
277
- ): Promise<T> {
278
- const lockPath = options.lockPath ?? resolveLocalHatchLockPath();
279
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
280
- const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
281
- const liveOwnerRecoveryGraceMs =
282
- options.liveOwnerRecoveryGraceMs ?? LIVE_OWNER_RECOVERY_GRACE_MS;
283
- const deadline = Date.now() + timeoutMs;
284
- const owner: LockOwner = {
285
- pid: process.pid,
286
- processStartedAt: processStartedAt(process.pid),
287
- token: `${process.pid}-${randomUUID()}`,
288
- };
289
-
290
- mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
291
-
292
- while (true) {
293
- try {
294
- writeOwnerFile(lockPath, owner);
295
- } catch (error) {
296
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
297
- throw error;
298
- }
299
- if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
300
- continue;
301
- }
302
- if (Date.now() >= deadline) {
303
- throw new Error(
304
- `Timed out waiting for another local assistant to finish starting (${lockPath})`,
305
- );
306
- }
307
- await new Promise((resolve) => setTimeout(resolve, retryMs));
308
- continue;
309
- }
310
-
311
- const heartbeat = setInterval(
312
- () => refreshLock(lockPath, owner.token),
313
- HEARTBEAT_INTERVAL_MS,
314
- );
315
- try {
316
- return await action();
317
- } finally {
318
- clearInterval(heartbeat);
319
- releaseLock(lockPath, owner.token);
320
- }
321
- }
322
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * Shared tail for errors where this CLI doesn't recognize an option or provider
3
- * the user named. The name may be valid in a newer release, so point the user
4
- * at the self-update path before they conclude the feature doesn't exist.
5
- *
6
- * `bun install -g vellum@latest` refreshes the `vellum` binary directly — the
7
- * mechanism the installer and the CLI's own self-update use. (`vellum upgrade`
8
- * targets the assistant runtime, not the CLI binary, for local assistants.)
9
- */
10
- export const STALE_CLI_UPDATE_HINT =
11
- "your CLI may be out of date — update it with `bun install -g vellum@latest` and retry.";