@vellumai/cli 0.9.0-staging.2 → 0.9.1-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.
package/AGENTS.md CHANGED
@@ -63,7 +63,7 @@ The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.
63
63
 
64
64
  For example, the signing key used for JWT auth between the daemon and gateway is persisted in the lockfile (`resources.signingKey`) so that client actor tokens survive daemon/gateway restarts. On first start (or when the key is missing), the CLI generates a new key via `generateLocalSigningKey()` in `lib/local.ts`, saves it to the lockfile entry, and passes it to both `startLocalDaemon` and `startGateway` as the `ACTOR_TOKEN_SIGNING_KEY` env var. The CLI does **not** read or write to the `.vellum/` directory for signing keys — it uses the lockfile instead.
65
65
 
66
- **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Swift clients, the Electron main process, the host-mode assistant, and the CLI (see `clients/shared/App/Auth/DeviceIdStore.swift` and `apps/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
66
+ **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Electron main process, the host-mode assistant, and the CLI (see `clients/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
67
67
 
68
68
  ## Process liveness
69
69
 
package/README.md CHANGED
@@ -4,7 +4,7 @@ CLI tools for provisioning and managing Vellum assistant instances.
4
4
 
5
5
  ## Installation
6
6
 
7
- This package is used internally by the [`vel`](https://github.com/vellum-ai/vellum-assistant-platform/tree/main/vel) CLI. You typically don't need to install it directly.
7
+ This package is used internally by the `vel` CLI. You typically don't need to install it directly.
8
8
 
9
9
  To run it standalone with [Bun](https://bun.sh):
10
10
 
@@ -25,11 +25,8 @@ function portBlock(base: number): PortMap {
25
25
  }
26
26
 
27
27
  /**
28
- * Built-in environment definitions and the TS-side source of truth for the
29
- * set of known environment names. The Swift client mirrors this list in
30
- * `clients/macos/vellum-assistant/App/VellumEnvironment.swift`; since Swift
31
- * can't import TypeScript, drift between the two is caught at test time by
32
- * `cli/src/__tests__/env-drift.test.ts`.
28
+ * Built-in environment definitions and the source of truth for the
29
+ * set of known environment names.
33
30
  *
34
31
  * Custom environments via a user config file are a future phase — see the
35
32
  * "Coexisting environments" design doc. Until then, a call site that needs a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.9.0-staging.2",
3
+ "version": "0.9.1-staging.1",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -245,6 +245,69 @@ describe("vellum backup <local>: guardian bootstrap secret", () => {
245
245
  });
246
246
  });
247
247
 
248
+ describe("vellum backup: --export-timeout flag", () => {
249
+ test("rejects a non-numeric value before any lookup", async () => {
250
+ setArgv("my-local", "--export-timeout", "soon");
251
+
252
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
253
+ () => undefined,
254
+ );
255
+ try {
256
+ await expect(backup()).rejects.toThrow("process.exit:1");
257
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
258
+ expect.stringContaining("--export-timeout must be a positive number"),
259
+ );
260
+ expect(findAssistantByNameMock).not.toHaveBeenCalled();
261
+ } finally {
262
+ consoleErrorSpy.mockRestore();
263
+ }
264
+ });
265
+
266
+ test("rejects a non-positive value", async () => {
267
+ setArgv("my-local", "--export-timeout", "0");
268
+
269
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
270
+ () => undefined,
271
+ );
272
+ try {
273
+ await expect(backup()).rejects.toThrow("process.exit:1");
274
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
275
+ expect.stringContaining("--export-timeout must be a positive number"),
276
+ );
277
+ } finally {
278
+ consoleErrorSpy.mockRestore();
279
+ }
280
+ });
281
+
282
+ test("accepts a valid override and completes the local export", async () => {
283
+ const localEntry = {
284
+ assistantId: "local-assistant",
285
+ runtimeUrl: "http://127.0.0.1:7830",
286
+ cloud: "local",
287
+ guardianBootstrapSecret: "bootstrap-secret-value",
288
+ } satisfies assistantConfig.AssistantEntry;
289
+ findAssistantByNameMock.mockReturnValue(localEntry);
290
+ setArgv(
291
+ "my-local",
292
+ "--export-timeout",
293
+ "600",
294
+ "--output",
295
+ "/tmp/local-backup.vbundle",
296
+ );
297
+
298
+ globalThis.fetch = mock(async () => {
299
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
300
+ }) as unknown as typeof globalThis.fetch;
301
+
302
+ await backup();
303
+
304
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
305
+ expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
306
+ "/tmp/local-backup.vbundle",
307
+ );
308
+ });
309
+ });
310
+
248
311
  describe("vellum backup <platform-managed>: GCS happy path", () => {
249
312
  test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
250
313
  findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
@@ -84,10 +84,10 @@ describe("buildAuthorizeUrl", () => {
84
84
  });
85
85
 
86
86
  test("login hint is forwarded", () => {
87
- // generic-examples:ignore-next-line reason: test fixture for URL encoding, not a real email
88
- const url = new URL(buildAuthorizeUrl({ ...base, loginHint: "a@b.co" }));
89
- // generic-examples:ignore-next-line — reason: test fixture for URL encoding, not a real email
90
- expect(url.searchParams.get("login_hint")).toBe("a@b.co");
87
+ const url = new URL(
88
+ buildAuthorizeUrl({ ...base, loginHint: "user@example.com" }),
89
+ );
90
+ expect(url.searchParams.get("login_hint")).toBe("user@example.com");
91
91
 
92
92
  const noHint = new URL(buildAuthorizeUrl(base));
93
93
  expect(noHint.searchParams.has("login_hint")).toBe(false);
@@ -18,23 +18,32 @@ import {
18
18
  } from "../lib/platform-client.js";
19
19
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
20
20
 
21
+ // Default timeout for the runtime-direct export request. Overridable via
22
+ // --export-timeout.
23
+ const DEFAULT_EXPORT_TIMEOUT_MS = 300_000;
24
+
21
25
  export async function backup(): Promise<void> {
22
26
  const args = process.argv.slice(3);
23
27
 
24
28
  if (args.includes("--help") || args.includes("-h")) {
25
- console.log("Usage: vellum backup <name> [--output <path>]");
29
+ console.log(
30
+ "Usage: vellum backup <name> [--output <path>] [--export-timeout <seconds>]",
31
+ );
26
32
  console.log("");
27
33
  console.log(
28
34
  "Export a backup of a running assistant as a .vbundle archive.",
29
35
  );
30
36
  console.log("");
31
37
  console.log("Arguments:");
32
- console.log(" <name> Name of the assistant to back up");
38
+ console.log(" <name> Name of the assistant to back up");
33
39
  console.log("");
34
40
  console.log("Options:");
35
- console.log(" --output <path> Path to save the .vbundle file");
41
+ console.log(" --output <path> Path to save the .vbundle file");
36
42
  console.log(
37
- " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
43
+ " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
44
+ );
45
+ console.log(
46
+ ` --export-timeout <secs> Export request timeout in seconds (default: ${DEFAULT_EXPORT_TIMEOUT_MS / 1000})`,
38
47
  );
39
48
  console.log("");
40
49
  console.log("Examples:");
@@ -42,21 +51,33 @@ export async function backup(): Promise<void> {
42
51
  console.log(
43
52
  " vellum backup my-assistant --output ~/Desktop/backup.vbundle",
44
53
  );
54
+ console.log(" vellum backup my-assistant --export-timeout 600");
45
55
  process.exit(0);
46
56
  }
47
57
 
48
58
  const name = args[0];
49
59
  if (!name || name.startsWith("-")) {
50
- console.error("Usage: vellum backup <name> [--output <path>]");
60
+ console.error(
61
+ "Usage: vellum backup <name> [--output <path>] [--export-timeout <seconds>]",
62
+ );
51
63
  process.exit(1);
52
64
  }
53
65
 
54
- // Parse --output flag
66
+ // Parse flags
55
67
  let outputArg: string | undefined;
68
+ let exportTimeoutMs = DEFAULT_EXPORT_TIMEOUT_MS;
56
69
  for (let i = 1; i < args.length; i++) {
57
70
  if (args[i] === "--output" && args[i + 1]) {
58
71
  outputArg = args[i + 1];
59
- break;
72
+ } else if (args[i] === "--export-timeout" && args[i + 1]) {
73
+ const seconds = Number(args[i + 1]);
74
+ if (!Number.isFinite(seconds) || seconds <= 0) {
75
+ console.error(
76
+ `Error: --export-timeout must be a positive number of seconds (got '${args[i + 1]}').`,
77
+ );
78
+ process.exit(1);
79
+ }
80
+ exportTimeoutMs = seconds * 1000;
60
81
  }
61
82
  }
62
83
 
@@ -120,7 +141,7 @@ export async function backup(): Promise<void> {
120
141
  "Content-Type": "application/json",
121
142
  },
122
143
  body: JSON.stringify({ description: "CLI backup" }),
123
- signal: AbortSignal.timeout(120_000),
144
+ signal: AbortSignal.timeout(exportTimeoutMs),
124
145
  });
125
146
 
126
147
  // Retry once with a fresh token on 401 — the cached token may be stale
@@ -146,13 +167,15 @@ export async function backup(): Promise<void> {
146
167
  "Content-Type": "application/json",
147
168
  },
148
169
  body: JSON.stringify({ description: "CLI backup" }),
149
- signal: AbortSignal.timeout(120_000),
170
+ signal: AbortSignal.timeout(exportTimeoutMs),
150
171
  });
151
172
  }
152
173
  }
153
174
  } catch (err) {
154
175
  if (err instanceof Error && err.name === "TimeoutError") {
155
- console.error("Error: Export request timed out after 2 minutes.");
176
+ console.error(
177
+ `Error: Export request timed out after ${exportTimeoutMs / 1000} seconds.`,
178
+ );
156
179
  process.exit(1);
157
180
  }
158
181
  const msg = err instanceof Error ? err.message : String(err);
@@ -340,7 +340,7 @@ const SPA_BASE = "/assistant/";
340
340
  *
341
341
  * Resolution order:
342
342
  * 1. npm-installed package — require.resolve('@vellumai/web/package.json')
343
- * 2. Source checkout — walk up from cli/ to find apps/web/dist/
343
+ * 2. Source checkout — walk up from cli/ to find clients/web/dist/
344
344
  */
345
345
  function findWebDistDir(): string | null {
346
346
  try {
@@ -355,7 +355,7 @@ function findWebDistDir(): string | null {
355
355
 
356
356
  let dir = import.meta.dir;
357
357
  for (let depth = 0; depth < 8; depth++) {
358
- const candidate = path.join(dir, "apps", "web", "dist", "index.html");
358
+ const candidate = path.join(dir, "clients", "web", "dist", "index.html");
359
359
  if (existsSync(candidate)) {
360
360
  return path.dirname(candidate);
361
361
  }
@@ -367,13 +367,13 @@ function findWebDistDir(): string | null {
367
367
  }
368
368
 
369
369
  /**
370
- * Locate the apps/web source directory for running the Vite dev server.
370
+ * Locate the clients/web source directory for running the Vite dev server.
371
371
  * Only works from a source checkout (not npm-installed).
372
372
  */
373
373
  function findWebSourceDir(): string | null {
374
374
  let dir = import.meta.dir;
375
375
  for (let depth = 0; depth < 8; depth++) {
376
- const candidate = path.join(dir, "apps", "web", "vite.config.ts");
376
+ const candidate = path.join(dir, "clients", "web", "vite.config.ts");
377
377
  if (existsSync(candidate)) {
378
378
  return path.dirname(candidate);
379
379
  }
@@ -679,7 +679,7 @@ async function runWebInterface(
679
679
  `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
680
680
  `@vellumai/web assets.\n\n` +
681
681
  ` npm/bunx install: npm install @vellumai/web\n` +
682
- ` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
682
+ ` source checkout: cd clients/web && VITE_PLATFORM_MODE=false bun run build`,
683
683
  );
684
684
  process.exit(1);
685
685
  }
@@ -195,7 +195,7 @@ async function up(target: NginxIngressTarget): Promise<void> {
195
195
  );
196
196
  console.error("");
197
197
  console.error("Build the SPA first:");
198
- console.error(" cd apps/web && VITE_PLATFORM_MODE=false bun run build");
198
+ console.error(" cd clients/web && VITE_PLATFORM_MODE=false bun run build");
199
199
  console.error("");
200
200
  console.error(
201
201
  "Or install @vellumai/web so its packaged dist directory is available.",
@@ -318,7 +318,7 @@ async function retireInner(): Promise<void> {
318
318
  console.log(`Removed ${formatAssistantReference(entry)} from config.`);
319
319
 
320
320
  // When no assistants remain, remove the dock-display-name sentinel so
321
- // the next build.sh run falls back to "Vellum" instead of using the
321
+ // the dock label falls back to "Vellum" instead of using the
322
322
  // retired assistant's name.
323
323
  if (loadAllAssistants().length === 0) {
324
324
  const dockLabelFile = join(
@@ -1012,7 +1012,7 @@ async function upgradePlatform(
1012
1012
  }
1013
1013
 
1014
1014
  /**
1015
- * Pre-upgrade steps for Sparkle (macOS app) lifecycle.
1015
+ * Pre-upgrade steps for the macOS app upgrade lifecycle.
1016
1016
  * Runs the pre-update orchestration without actually swapping containers:
1017
1017
  * broadcasts SSE events, creates a workspace commit, creates a backup,
1018
1018
  * prunes old backups, and outputs the backup path.
@@ -1035,7 +1035,7 @@ async function upgradePrepare(
1035
1035
  await commitWorkspaceViaGateway(
1036
1036
  entry.runtimeUrl,
1037
1037
  entry.assistantId,
1038
- `[sparkle-update] Starting: ${currentVersion} → ${targetVersion}`,
1038
+ `[assistant-upgrade] Starting: ${currentVersion} → ${targetVersion}`,
1039
1039
  );
1040
1040
 
1041
1041
  // 3. Progress: saving backup
@@ -1070,7 +1070,7 @@ async function upgradePrepare(
1070
1070
  }
1071
1071
 
1072
1072
  /**
1073
- * Post-upgrade steps for Sparkle (macOS app) lifecycle.
1073
+ * Post-upgrade steps for the macOS app upgrade lifecycle.
1074
1074
  * Called after the app has been replaced and the daemon is back up.
1075
1075
  * Broadcasts a "complete" SSE event and creates a workspace commit.
1076
1076
  */
@@ -1103,7 +1103,7 @@ async function upgradeFinalize(
1103
1103
  await commitWorkspaceViaGateway(
1104
1104
  entry.runtimeUrl,
1105
1105
  entry.assistantId,
1106
- `[sparkle-update] Complete: ${fromVersion} → ${currentVersion}\n\nresult: success`,
1106
+ `[assistant-upgrade] Complete: ${fromVersion} → ${currentVersion}\n\nresult: success`,
1107
1107
  );
1108
1108
  }
1109
1109
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host device ID resolver. Resolution order: `VELLUM_DEVICE_ID` env var,
3
3
  * then `device.json`. Production uses the machine-wide shared
4
- * `~/.vellum/device.json`, matching Electron (`apps/macos/src/main/device-id.ts`)
4
+ * `~/.vellum/device.json`, matching Electron (`clients/macos/src/main/device-id.ts`)
5
5
  * and Swift (`VellumPaths.deviceIdFile`); non-production uses
6
6
  * `<configDir>/device.json`.
7
7
  *
@@ -87,10 +87,9 @@ export function getCurrentEnvironment(
87
87
  if (!seed) {
88
88
  if (name !== DEFAULT_ENVIRONMENT_NAME) {
89
89
  // Warn on stderr instead of throwing, to match the silent-fallback
90
- // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName
91
- // and clients/shared/App/VellumEnvironment.swift:current. Those two
92
- // silently fall back to production; the CLI should agree so all three
93
- // writers don't end up in disjoint states on a typo.
90
+ // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName,
91
+ // which silently falls back to production; the CLI agrees so neither
92
+ // writer ends up in a disjoint state on a typo.
94
93
  process.stderr.write(
95
94
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
96
95
  `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
package/src/lib/local.ts CHANGED
@@ -1018,7 +1018,7 @@ export async function startLocalDaemon(
1018
1018
  let daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1019
1019
 
1020
1020
  // Dev fallback: if the bundled daemon did not become ready in time,
1021
- // fall back to source daemon startup so local `./build.sh run` still works.
1021
+ // fall back to source daemon startup so local source runs still work.
1022
1022
  if (!daemonReady) {
1023
1023
  const assistantIndex = resolveAssistantIndexPath();
1024
1024
  if (assistantIndex) {
@@ -85,7 +85,7 @@ function saveRawConfig(
85
85
  *
86
86
  * Resolution order:
87
87
  * 1. npm-installed package — require.resolve('@vellumai/web/package.json')
88
- * 2. Source checkout — walk up from cli/ to find apps/web/dist/
88
+ * 2. Source checkout — walk up from cli/ to find clients/web/dist/
89
89
  */
90
90
  export function findWebDistDir(): string | null {
91
91
  try {
@@ -100,7 +100,7 @@ export function findWebDistDir(): string | null {
100
100
 
101
101
  let dir = import.meta.dir;
102
102
  for (let depth = 0; depth < 8; depth++) {
103
- const candidate = join(dir, "apps", "web", "dist", "index.html");
103
+ const candidate = join(dir, "clients", "web", "dist", "index.html");
104
104
  if (existsSync(candidate)) {
105
105
  return dirname(candidate);
106
106
  }
@@ -1,53 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
-
5
- import { SEEDS } from "@vellumai/environments";
6
-
7
- // Drift guard between the two language-level sources of truth for the set of
8
- // known environment names:
9
- //
10
- // 1. packages/environments/src/seeds.ts — SEEDS record (TS source of truth)
11
- // 2. clients/shared/App/VellumEnvironment.swift — Swift `VellumEnvironment` enum
12
- //
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.
17
-
18
- const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
19
- const SWIFT_ENVIRONMENT = join(
20
- REPO_ROOT,
21
- "clients",
22
- "shared",
23
- "App",
24
- "VellumEnvironment.swift",
25
- );
26
-
27
- /**
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.
33
- */
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]!);
39
- }
40
- return names;
41
- }
42
-
43
- describe("environment name drift guard (TS ↔ Swift)", () => {
44
- const seedNames = new Set(Object.keys(SEEDS));
45
-
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());
52
- });
53
- });