@vellumai/cli 0.8.7 → 0.8.8-dev.202606052332.17fc8ea

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 (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. package/src/lib/local.ts +20 -6
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared interactive confirmation for destructive CLI commands (retire, unpair,
3
+ * …). Per cli/AGENTS.md, a command that removes assistant state must print the
4
+ * resolved identity and require confirmation, with a `--yes` bypass for
5
+ * automation.
6
+ */
7
+
8
+ /** True only when we can run an interactive raw-mode confirmation prompt. */
9
+ export function canPromptForConfirmation(): boolean {
10
+ return (
11
+ process.stdin.isTTY === true &&
12
+ process.stdout.isTTY === true &&
13
+ typeof process.stdin.setRawMode === "function"
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Show `prompt` and resolve true on Enter, false on Esc/q/Ctrl-C. Restores the
19
+ * prior stdin raw/paused state on exit. Caller must gate on
20
+ * {@link canPromptForConfirmation} first.
21
+ */
22
+ export async function confirmAction(prompt: string): Promise<boolean> {
23
+ const stdin = process.stdin;
24
+ const stdout = process.stdout;
25
+ const wasRaw = stdin.isRaw === true;
26
+ const wasPaused = stdin.isPaused();
27
+
28
+ stdout.write(prompt);
29
+ stdin.setRawMode(true);
30
+ stdin.resume();
31
+
32
+ return await new Promise<boolean>((resolve) => {
33
+ const cleanup = () => {
34
+ stdin.off("data", onData);
35
+ stdin.setRawMode(wasRaw);
36
+ if (wasPaused) {
37
+ stdin.pause();
38
+ }
39
+ stdout.write("\n");
40
+ };
41
+
42
+ const onData = (chunk: Buffer) => {
43
+ const byte = chunk[0];
44
+ if (byte === 13 || byte === 10) {
45
+ cleanup();
46
+ resolve(true);
47
+ return;
48
+ }
49
+ if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
50
+ cleanup();
51
+ resolve(false);
52
+ }
53
+ };
54
+
55
+ stdin.on("data", onData);
56
+ });
57
+ }
package/src/lib/docker.ts CHANGED
@@ -478,6 +478,17 @@ export async function retireDocker(name: string): Promise<void> {
478
478
  }
479
479
  }
480
480
 
481
+ // Future: consider stopping Colima VM when no Docker instances remain.
482
+ // Considerations:
483
+ // - Use loadAllAssistantsAcrossEnvs() instead of loadAllAssistants() to
484
+ // avoid stopping Colima while another VELLUM_ENVIRONMENT still has a
485
+ // running Docker instance.
486
+ // - Track whether Vellum started Colima (vs. the user already had it
487
+ // running for non-Vellum workloads) \u2014 e.g. via a dedicated Colima
488
+ // profile (`colima start --profile vellum`) or a sentinel file.
489
+ // - Only stop if both conditions are met: no cross-env Docker instances
490
+ // AND Vellum owns the Colima lifecycle.
491
+
481
492
  console.log(`\u2705 Docker instance retired.`);
482
493
  }
483
494
 
@@ -1137,7 +1148,20 @@ export async function hatchDocker(
1137
1148
  await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
1138
1149
  } else {
1139
1150
  log(` ↪ pulling ${ref}`);
1140
- await exec("docker", ["pull", ref]);
1151
+ const MAX_PULL_RETRIES = 3;
1152
+ for (let attempt = 1; attempt <= MAX_PULL_RETRIES; attempt++) {
1153
+ try {
1154
+ await exec("docker", ["pull", ref]);
1155
+ break;
1156
+ } catch (err) {
1157
+ if (attempt === MAX_PULL_RETRIES) throw err;
1158
+ const delaySec = 2 ** attempt;
1159
+ log(
1160
+ ` ⚠ pull failed (attempt ${attempt}/${MAX_PULL_RETRIES}), retrying in ${delaySec}s...`,
1161
+ );
1162
+ await new Promise((r) => setTimeout(r, delaySec * 1000));
1163
+ }
1164
+ }
1141
1165
  }
1142
1166
  }
1143
1167
  log("✅ Docker images acquired");
@@ -1,48 +1,27 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- unlinkSync,
6
- writeFileSync,
7
- } from "fs";
8
- import { homedir } from "os";
9
- import { dirname, join } from "path";
1
+ import { mkdirSync, unlinkSync, writeFileSync } from "fs";
2
+ import { dirname } from "path";
10
3
 
11
4
  import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
5
+ import {
6
+ defaultEnvironmentFilePath,
7
+ readDefaultEnvironment as readPersistedDefaultEnvironment,
8
+ } from "@vellumai/local-mode";
12
9
 
13
10
  const DEFAULT_ENVIRONMENT_NAME = "production";
14
11
 
15
- /**
16
- * Path to the user's persisted default environment file.
17
- * Lives at `~/.config/vellum/environment` — a fixed, environment-agnostic
18
- * location so it can be read before the environment is resolved.
19
- */
20
- function getDefaultEnvironmentPath(): string {
21
- const xdgConfig =
22
- process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
23
- return join(xdgConfig, "vellum", "environment");
24
- }
25
-
26
12
  /**
27
13
  * Read the persisted default environment name, if any.
28
14
  * Returns `undefined` if no file exists or the file is empty.
29
15
  */
30
16
  export function readDefaultEnvironment(): string | undefined {
31
- const filePath = getDefaultEnvironmentPath();
32
- try {
33
- if (!existsSync(filePath)) return undefined;
34
- const content = readFileSync(filePath, "utf-8").trim();
35
- return content.length > 0 ? content : undefined;
36
- } catch {
37
- return undefined;
38
- }
17
+ return readPersistedDefaultEnvironment(process.env);
39
18
  }
40
19
 
41
20
  /**
42
21
  * Persist a default environment name to the user config file.
43
22
  */
44
23
  export function writeDefaultEnvironment(name: string): void {
45
- const filePath = getDefaultEnvironmentPath();
24
+ const filePath = defaultEnvironmentFilePath(process.env);
46
25
  mkdirSync(dirname(filePath), { recursive: true });
47
26
  writeFileSync(filePath, name + "\n", "utf-8");
48
27
  }
@@ -51,7 +30,7 @@ export function writeDefaultEnvironment(name: string): void {
51
30
  * Remove the persisted default environment file, falling back to production.
52
31
  */
53
32
  export function clearDefaultEnvironment(): void {
54
- const filePath = getDefaultEnvironmentPath();
33
+ const filePath = defaultEnvironmentFilePath(process.env);
55
34
  try {
56
35
  unlinkSync(filePath);
57
36
  } catch {
@@ -2,11 +2,16 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execSync } from "node:child_process";
3
3
  import {
4
4
  chmodSync,
5
+ closeSync,
5
6
  existsSync,
6
7
  mkdirSync,
8
+ openSync,
7
9
  readFileSync,
10
+ rmdirSync,
8
11
  statSync,
12
+ unlinkSync,
9
13
  writeFileSync,
14
+ writeSync,
10
15
  } from "fs";
11
16
  import { platform } from "os";
12
17
  import { dirname, join } from "path";
@@ -41,6 +46,27 @@ function getGuardianTokenPath(assistantId: string): string {
41
46
  );
42
47
  }
43
48
 
49
+ /**
50
+ * Best-effort removal of an assistant's stored guardian token (used by
51
+ * `vellum unpair` to forget a paired connection). Never throws if the token
52
+ * file or its per-assistant directory is already absent.
53
+ */
54
+ export function deleteGuardianToken(assistantId: string): void {
55
+ const tokenPath = getGuardianTokenPath(assistantId);
56
+ try {
57
+ unlinkSync(tokenPath);
58
+ } catch {
59
+ /* already gone */
60
+ }
61
+ // Clean up the now-empty per-assistant directory; rmdir throws if it still
62
+ // holds other files, in which case we leave it.
63
+ try {
64
+ rmdirSync(dirname(tokenPath));
65
+ } catch {
66
+ /* not empty or absent */
67
+ }
68
+ }
69
+
44
70
  function getPersistedDeviceIdPath(): string {
45
71
  return join(getConfigDir(getCurrentEnvironment()), "device-id");
46
72
  }
@@ -161,33 +187,121 @@ export function saveGuardianToken(
161
187
  chmodSync(tokenPath, 0o600);
162
188
  }
163
189
 
190
+ /** Abort the refresh POST if the gateway is slow/unreachable (it's now on the
191
+ * hot request path, so it must never hang indefinitely). */
192
+ const REFRESH_FETCH_TIMEOUT_MS = 15_000;
193
+ /** Max time to wait for the per-assistant refresh lock before proceeding. */
194
+ const REFRESH_LOCK_WAIT_MS = 10_000;
195
+ /** A lock older than this is treated as stale (holder crashed) and stolen. */
196
+ const REFRESH_LOCK_STALE_MS = 30_000;
197
+ const REFRESH_LOCK_POLL_MS = 100;
198
+
199
+ function getRefreshLockPath(assistantId: string): string {
200
+ return join(dirname(getGuardianTokenPath(assistantId)), "refresh.lock");
201
+ }
202
+
203
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
204
+
205
+ /**
206
+ * Best-effort exclusive cross-process lock for a per-assistant token refresh.
207
+ * Created atomically with `wx`; a stale lock (crashed holder) is stolen.
208
+ * Returns true if acquired, false if it timed out (caller proceeds degraded).
209
+ */
210
+ async function acquireRefreshLock(lockPath: string): Promise<boolean> {
211
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
212
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
213
+ for (;;) {
214
+ try {
215
+ const fd = openSync(lockPath, "wx", 0o600);
216
+ writeSync(fd, String(process.pid));
217
+ closeSync(fd);
218
+ return true;
219
+ } catch (err) {
220
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") return false;
221
+ try {
222
+ if (Date.now() - statSync(lockPath).mtimeMs > REFRESH_LOCK_STALE_MS) {
223
+ unlinkSync(lockPath); // steal a stale lock, then retry
224
+ continue;
225
+ }
226
+ } catch {
227
+ continue; // lock vanished between open and stat — retry
228
+ }
229
+ if (Date.now() >= deadline) return false;
230
+ await delay(REFRESH_LOCK_POLL_MS);
231
+ }
232
+ }
233
+ }
234
+
235
+ function releaseRefreshLock(lockPath: string): void {
236
+ try {
237
+ unlinkSync(lockPath);
238
+ } catch {
239
+ /* already released/stolen */
240
+ }
241
+ }
242
+
164
243
  /**
165
244
  * Call POST /v1/guardian/refresh on the remote gateway to obtain a new
166
245
  * access token using an existing (possibly expired) access token for auth.
167
246
  * Returns the refreshed token data (persisted locally), or null if the
168
247
  * refresh fails (e.g. no stored token, or refresh token itself is expired).
248
+ *
249
+ * Concurrency-safe: the gateway rotates refresh tokens and treats reuse of an
250
+ * already-rotated token as replay (revoking the whole token family), so two
251
+ * processes (e.g. `vellum message` + `vellum events`) refreshing the same
252
+ * stored token at once would self-revoke and force re-pairing. We serialize on
253
+ * a per-assistant lock and, once held, re-read the stored token: if another
254
+ * process already rotated it while we waited, we return that fresh token
255
+ * instead of replaying our now-stale refresh token.
169
256
  */
170
257
  export async function refreshGuardianToken(
171
258
  gatewayUrl: string,
172
259
  assistantId: string,
173
260
  ): Promise<GuardianTokenData | null> {
174
- const tokenData = loadGuardianToken(assistantId);
175
- if (!tokenData) return null;
261
+ const before = loadGuardianToken(assistantId);
262
+ if (!before) return null;
176
263
 
177
264
  // Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
178
265
  // returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
179
- const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
266
+ const refreshExpiry = new Date(before.refreshTokenExpiresAt).getTime();
180
267
  if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
181
268
  return null;
182
269
 
270
+ const lockPath = getRefreshLockPath(assistantId);
271
+ const locked = await acquireRefreshLock(lockPath);
183
272
  try {
273
+ // Re-read under the lock: a concurrent process may have rotated the token
274
+ // while we waited. If the stored refresh token changed, ours is now stale
275
+ // (replaying it would trip reuse-detection) — use the fresh token instead.
276
+ const current = loadGuardianToken(assistantId);
277
+ if (current && current.refreshToken !== before.refreshToken) {
278
+ return current;
279
+ }
280
+
281
+ // We did NOT acquire the lock (another process is likely mid-refresh) and
282
+ // the stored token hasn't been rotated yet. Do NOT call the gateway: our
283
+ // refresh token may be the one the winner is rotating right now, and
284
+ // replaying a rotated token revokes the whole family (forcing re-pair).
285
+ // Give up — the caller surfaces the original 401, and the next attempt
286
+ // picks up the winner's persisted token.
287
+ if (!locked) return null;
288
+
289
+ const tokenData = current ?? before;
290
+
184
291
  const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
185
292
  method: "POST",
186
293
  headers: {
187
294
  "Content-Type": "application/json",
188
295
  Authorization: `Bearer ${tokenData.accessToken}`,
189
296
  },
190
- body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
297
+ body: JSON.stringify({
298
+ refreshToken: tokenData.refreshToken,
299
+ // The refresh token is device-bound; send the device id used at init
300
+ // (falling back to a fresh computation for tokens persisted before the
301
+ // field was stored) so the gateway can verify the binding.
302
+ deviceId: tokenData.deviceId || computeDeviceId(),
303
+ }),
304
+ signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
191
305
  });
192
306
  if (!response.ok) return null;
193
307
 
@@ -212,6 +326,8 @@ export async function refreshGuardianToken(
212
326
  return refreshed;
213
327
  } catch {
214
328
  return null;
329
+ } finally {
330
+ if (locked) releaseRefreshLock(lockPath);
215
331
  }
216
332
  }
217
333
 
package/src/lib/local.ts CHANGED
@@ -230,8 +230,10 @@ function resolveAssistantIndexPath(): string | undefined {
230
230
  }
231
231
 
232
232
  try {
233
- const vellumPkgPath = _require.resolve("vellum/package.json");
234
- const resolved = join(dirname(vellumPkgPath), "src", "index.ts");
233
+ const assistantPkgPath = _require.resolve(
234
+ "@vellumai/assistant/package.json",
235
+ );
236
+ const resolved = join(dirname(assistantPkgPath), "src", "index.ts");
235
237
  if (existsSync(resolved)) {
236
238
  return resolved;
237
239
  }
@@ -416,13 +418,13 @@ async function startDaemonFromSource(
416
418
  writeFileSync(pidFile, "starting", "utf-8");
417
419
 
418
420
  const child = foreground
419
- ? spawn("bun", ["run", daemonMainPath], {
421
+ ? spawn(process.execPath, ["run", daemonMainPath], {
420
422
  stdio: "inherit",
421
423
  env,
422
424
  })
423
425
  : (() => {
424
426
  const daemonLogFd = openLogFile("hatch.log");
425
- const c = spawn("bun", ["run", daemonMainPath], {
427
+ const c = spawn(process.execPath, ["run", daemonMainPath], {
426
428
  detached: true,
427
429
  stdio: ["ignore", "pipe", "pipe"],
428
430
  env,
@@ -486,7 +488,7 @@ async function startDaemonWatchFromSource(
486
488
  writeFileSync(pidFile, "starting", "utf-8");
487
489
 
488
490
  const daemonLogFd = openLogFile("hatch.log");
489
- const child = spawn("bun", ["--watch", "run", mainPath], {
491
+ const child = spawn(process.execPath, ["--watch", "run", mainPath], {
490
492
  detached: true,
491
493
  stdio: ["ignore", "pipe", "pipe"],
492
494
  env,
@@ -514,6 +516,18 @@ function resolveGatewayDir(): string {
514
516
  return sourceDir;
515
517
  }
516
518
 
519
+ // npm-installed: @vellumai/cli and @vellumai/vellum-gateway are siblings
520
+ const npmGatewayDir = join(
521
+ import.meta.dir,
522
+ "..",
523
+ "..",
524
+ "..",
525
+ "vellum-gateway",
526
+ );
527
+ if (isGatewaySourceDir(npmGatewayDir)) {
528
+ return npmGatewayDir;
529
+ }
530
+
517
531
  // Compiled binary: gateway/ bundled adjacent to the CLI executable.
518
532
  const binGateway = join(dirname(process.execPath), "gateway");
519
533
  if (isGatewaySourceDir(binGateway)) {
@@ -1135,7 +1149,7 @@ export async function startGateway(
1135
1149
  ? ["--watch", "run", "src/index.ts", "--vellum-gateway"]
1136
1150
  : ["run", "src/index.ts", "--vellum-gateway"];
1137
1151
  const gwLogFd = openLogFile("hatch.log");
1138
- gateway = spawn("bun", bunArgs, {
1152
+ gateway = spawn(process.execPath, bunArgs, {
1139
1153
  cwd: gatewayDir,
1140
1154
  detached: true,
1141
1155
  stdio: ["ignore", "pipe", "pipe"],