@vellumai/cli 0.7.0 → 0.7.2

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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { AssistantEntry } from "../assistant-config.js";
4
+ import {
5
+ resolveRuntimeMigrationUrl,
6
+ resolveRuntimeUrl,
7
+ } from "../runtime-url.js";
8
+
9
+ function makeEntry(
10
+ overrides: Partial<AssistantEntry> & {
11
+ cloud: string;
12
+ runtimeUrl: string;
13
+ assistantId: string;
14
+ },
15
+ ): Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> {
16
+ return {
17
+ cloud: overrides.cloud,
18
+ runtimeUrl: overrides.runtimeUrl,
19
+ assistantId: overrides.assistantId,
20
+ };
21
+ }
22
+
23
+ describe("resolveRuntimeMigrationUrl", () => {
24
+ test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
25
+ const entry = makeEntry({
26
+ cloud: "local",
27
+ runtimeUrl: "http://localhost:7821",
28
+ assistantId: "ast-local-1",
29
+ });
30
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
31
+ "http://localhost:7821/v1/migrations/export-to-gcs",
32
+ );
33
+ expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
34
+ "http://localhost:7821/v1/migrations/import-from-gcs",
35
+ );
36
+ expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
37
+ "http://localhost:7821/v1/migrations/jobs/job-abc",
38
+ );
39
+ });
40
+
41
+ test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
42
+ const entry = makeEntry({
43
+ cloud: "docker",
44
+ runtimeUrl: "http://localhost:7831",
45
+ assistantId: "ast-docker-1",
46
+ });
47
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
48
+ "http://localhost:7831/v1/migrations/export-to-gcs",
49
+ );
50
+ });
51
+
52
+ test("vellum (platform-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
53
+ const entry = makeEntry({
54
+ cloud: "vellum",
55
+ runtimeUrl: "https://platform.vellum.ai",
56
+ assistantId: "11111111-2222-3333-4444-555555555555",
57
+ });
58
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
59
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
60
+ );
61
+ expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
62
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
63
+ );
64
+ expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
65
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
66
+ );
67
+ });
68
+
69
+ test("dev platform URL still routes through the wildcard prefix", () => {
70
+ const entry = makeEntry({
71
+ cloud: "vellum",
72
+ runtimeUrl: "https://dev-platform.vellum.ai",
73
+ assistantId: "ast-dev-1",
74
+ });
75
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
76
+ "https://dev-platform.vellum.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
77
+ );
78
+ });
79
+
80
+ test("a non-vellum, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
81
+ const entry = makeEntry({
82
+ cloud: "gcp",
83
+ runtimeUrl: "http://10.0.0.5:7821",
84
+ assistantId: "ast-gcp-1",
85
+ });
86
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
87
+ "http://10.0.0.5:7821/v1/migrations/export-to-gcs",
88
+ );
89
+ });
90
+ });
91
+
92
+ describe("resolveRuntimeUrl", () => {
93
+ test("local cloud uses gateway-loopback /v1/<subpath>", () => {
94
+ const entry = makeEntry({
95
+ cloud: "local",
96
+ runtimeUrl: "http://localhost:7821",
97
+ assistantId: "ast-local-1",
98
+ });
99
+ expect(resolveRuntimeUrl(entry, "identity")).toBe(
100
+ "http://localhost:7821/v1/identity",
101
+ );
102
+ });
103
+
104
+ test("docker cloud uses gateway-loopback /v1/<subpath>", () => {
105
+ const entry = makeEntry({
106
+ cloud: "docker",
107
+ runtimeUrl: "http://localhost:7831",
108
+ assistantId: "ast-docker-1",
109
+ });
110
+ expect(resolveRuntimeUrl(entry, "identity")).toBe(
111
+ "http://localhost:7831/v1/identity",
112
+ );
113
+ });
114
+
115
+ test("vellum cloud uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
116
+ const entry = makeEntry({
117
+ cloud: "vellum",
118
+ runtimeUrl: "https://platform.vellum.ai",
119
+ assistantId: "11111111-2222-3333-4444-555555555555",
120
+ });
121
+ expect(resolveRuntimeUrl(entry, "identity")).toBe(
122
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/identity",
123
+ );
124
+ });
125
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ parseSentinelOutput,
5
+ stripAnsi,
6
+ } from "../terminal-session.js";
7
+
8
+ const START = "__VELLUM_EXEC_START_1234__";
9
+ const END = "__VELLUM_EXEC_END_1234__";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // stripAnsi
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe("stripAnsi", () => {
16
+ test("removes SGR color codes", () => {
17
+ expect(stripAnsi("\x1b[32mINFO\x1b[39m hello")).toBe("INFO hello");
18
+ });
19
+
20
+ test("removes OSC title sequences", () => {
21
+ expect(stripAnsi("\x1b]0;title\x07prompt$ ")).toBe("prompt$ ");
22
+ });
23
+
24
+ test("removes carriage returns", () => {
25
+ expect(stripAnsi("line1\r\nline2\r\n")).toBe("line1\nline2\n");
26
+ });
27
+
28
+ test("removes bracket-paste mode escapes", () => {
29
+ expect(stripAnsi("\x1b[?2004hroot$ ")).toBe("root$ ");
30
+ });
31
+
32
+ test("removes charset designator sequences", () => {
33
+ expect(stripAnsi("\x1b(Bhello")).toBe("hello");
34
+ });
35
+
36
+ test("passes through plain text unchanged", () => {
37
+ expect(stripAnsi("just plain text")).toBe("just plain text");
38
+ });
39
+
40
+ test("handles mixed ANSI sequences", () => {
41
+ const raw =
42
+ "\x1b[?2004hroot:/workspace$ \r\x1b[K\rroot:/workspace$ echo hello\r\nhello\r\n";
43
+ const clean = stripAnsi(raw);
44
+ expect(clean).not.toContain("\x1b");
45
+ expect(clean).not.toContain("\r");
46
+ expect(clean).toContain("hello");
47
+ });
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // parseSentinelOutput
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("parseSentinelOutput", () => {
55
+ test("extracts output between sentinels", () => {
56
+ const cleaned = [
57
+ `echo '${START}'; ls; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
58
+ START,
59
+ "file1.txt",
60
+ "file2.txt",
61
+ END,
62
+ "__VELLUM_EXIT_0",
63
+ ].join("\n");
64
+
65
+ const result = parseSentinelOutput(cleaned, START, END);
66
+ expect(result.output).toBe("file1.txt\nfile2.txt");
67
+ expect(result.exitCode).toBe(0);
68
+ });
69
+
70
+ test("extracts non-zero exit code", () => {
71
+ const cleaned = [
72
+ `echo '${START}'; false; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
73
+ START,
74
+ END,
75
+ "__VELLUM_EXIT_1",
76
+ ].join("\n");
77
+
78
+ const result = parseSentinelOutput(cleaned, START, END);
79
+ expect(result.output).toBe("");
80
+ expect(result.exitCode).toBe(1);
81
+ });
82
+
83
+ test("handles exit code 127 (command not found)", () => {
84
+ const cleaned = [
85
+ START,
86
+ "bash: nosuchcmd: command not found",
87
+ END,
88
+ "__VELLUM_EXIT_127",
89
+ ].join("\n");
90
+
91
+ const result = parseSentinelOutput(cleaned, START, END);
92
+ expect(result.output).toBe("bash: nosuchcmd: command not found");
93
+ expect(result.exitCode).toBe(127);
94
+ });
95
+
96
+ test("uses last start sentinel (skips command echo)", () => {
97
+ // The command echo contains the sentinel text, then the actual output
98
+ // sentinel comes later. Parser must pick the last START, not the echo.
99
+ const cleaned = [
100
+ `root$ echo '${START}'; mycommand; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
101
+ START,
102
+ "real output here",
103
+ END,
104
+ "__VELLUM_EXIT_0",
105
+ ].join("\n");
106
+
107
+ const result = parseSentinelOutput(cleaned, START, END);
108
+ expect(result.output).toBe("real output here");
109
+ expect(result.exitCode).toBe(0);
110
+ });
111
+
112
+ test("regression: end sentinel in echo before start sentinel in output", () => {
113
+ // This was the original bug: backward search found END in the echo
114
+ // (line 0) before START in the output (line 1), giving endIdx < startIdx.
115
+ const cleaned = [
116
+ `echo '${START}'; cmd; echo '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
117
+ START,
118
+ "[INFO] Running clawhub command",
119
+ ' args: ["search"]',
120
+ ' cwd: "/workspace"',
121
+ ].join("\n");
122
+
123
+ // No end sentinel in actual output yet (stream was cut short in old code)
124
+ const result = parseSentinelOutput(cleaned, START, END);
125
+ // Should still return the partial output (no end sentinel → take everything)
126
+ expect(result.output).toContain("[INFO] Running clawhub command");
127
+ expect(result.output).toContain('cwd: "/workspace"');
128
+ });
129
+
130
+ test("handles multiline output with special characters", () => {
131
+ const cleaned = [
132
+ START,
133
+ "📤 Resend Email Setup [installed]",
134
+ " ID: resend-setup",
135
+ ' Set up and send emails via a user-provided Resend account (BYO email provider)',
136
+ "",
137
+ "Community registry (1):",
138
+ "",
139
+ " resend-setup [installed]",
140
+ END,
141
+ "__VELLUM_EXIT_0",
142
+ ].join("\n");
143
+
144
+ const result = parseSentinelOutput(cleaned, START, END);
145
+ expect(result.output).toContain("📤 Resend Email Setup");
146
+ expect(result.output).toContain("Community registry (1):");
147
+ expect(result.exitCode).toBe(0);
148
+ });
149
+
150
+ test("returns empty output and exit code 0 when no sentinels found", () => {
151
+ const cleaned = "just some random output\nwith no sentinels\n";
152
+ const result = parseSentinelOutput(cleaned, START, END);
153
+ // Falls back to entire output (trimmed)
154
+ expect(result.output).toBe(
155
+ "just some random output\nwith no sentinels",
156
+ );
157
+ expect(result.exitCode).toBe(0);
158
+ });
159
+
160
+ test("handles output with only start sentinel (no end)", () => {
161
+ const cleaned = [
162
+ START,
163
+ "partial output",
164
+ "more output",
165
+ ].join("\n");
166
+
167
+ const result = parseSentinelOutput(cleaned, START, END);
168
+ expect(result.output).toBe("partial output\nmore output");
169
+ expect(result.exitCode).toBe(0);
170
+ });
171
+
172
+ test("handles real-world verbose trace structure", () => {
173
+ // Simulates the full cleaned output from a real exec session
174
+ const cleaned = [
175
+ "root:/workspace$ root:/workspace$ " +
176
+ `echo '${START}'; 'assistant' 'skills' 'search' 'resend-setup'; __ec=$?; echo ` +
177
+ ` '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
178
+ START,
179
+ "[13:06:38.851] INFO (761 on pod-0): [clawhub] Running clawhub command",
180
+ ' args: [',
181
+ ' "search",',
182
+ ' "resend-setup",',
183
+ ' "--limit",',
184
+ ' "10"',
185
+ " ]",
186
+ ' cwd: "/workspace"',
187
+ "Bundled & installed skills (1):",
188
+ "",
189
+ " 📤 Resend Email Setup [installed]",
190
+ " ID: resend-setup",
191
+ "",
192
+ END,
193
+ "__VELLUM_EXIT_0",
194
+ ].join("\n");
195
+
196
+ const result = parseSentinelOutput(cleaned, START, END);
197
+ expect(result.output).toContain("Bundled & installed skills (1):");
198
+ expect(result.output).toContain("📤 Resend Email Setup [installed]");
199
+ expect(result.output).toContain("[clawhub] Running clawhub command");
200
+ expect(result.exitCode).toBe(0);
201
+ });
202
+ });
@@ -13,9 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
- findAssistantByName,
17
- getActiveAssistant,
18
- loadLatestAssistant,
16
+ resolveAssistant,
19
17
  } from "./assistant-config.js";
20
18
  import { GATEWAY_PORT } from "./constants.js";
21
19
  import { loadGuardianToken } from "./guardian-token.js";
@@ -28,8 +26,8 @@ export interface AssistantClientOpts {
28
26
  /**
29
27
  * When provided alongside `orgId`, the client authenticates with a
30
28
  * session token instead of a guardian token. The session token is
31
- * sent as `Authorization: Bearer <sessionToken>` and the org id is
32
- * sent via the `X-Vellum-Org-Id` header.
29
+ * sent as `X-Session-Token: <sessionToken>` and the org id is
30
+ * sent via the `Vellum-Organization-Id` header.
33
31
  */
34
32
  sessionToken?: string;
35
33
  /** Required when `sessionToken` is provided. */
@@ -48,6 +46,8 @@ export class AssistantClient {
48
46
 
49
47
  private readonly _assistantId: string;
50
48
  private readonly token: string | undefined;
49
+ /** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
50
+ private readonly isSessionAuth: boolean;
51
51
  private readonly orgId: string | undefined;
52
52
 
53
53
  /**
@@ -58,27 +58,13 @@ export class AssistantClient {
58
58
  * @throws If no matching assistant is found.
59
59
  */
60
60
  constructor(opts?: AssistantClientOpts) {
61
- const nameOrId = opts?.assistantId;
62
- let entry = nameOrId ? findAssistantByName(nameOrId) : null;
63
-
64
- if (nameOrId && !entry) {
65
- throw new Error(`No assistant found with name '${nameOrId}'.`);
66
- }
67
-
68
- if (!entry) {
69
- const active = getActiveAssistant();
70
- if (active) {
71
- entry = findAssistantByName(active);
72
- }
73
- }
74
-
75
- if (!entry) {
76
- entry = loadLatestAssistant();
77
- }
61
+ const entry = resolveAssistant(opts?.assistantId);
78
62
 
79
63
  if (!entry) {
80
64
  throw new Error(
81
- "No assistant found. Hatch one first with 'vellum hatch'.",
65
+ opts?.assistantId
66
+ ? `No assistant found with name '${opts.assistantId}'.`
67
+ : "No assistant found. Hatch one first with 'vellum hatch'.",
82
68
  );
83
69
  }
84
70
 
@@ -90,12 +76,14 @@ export class AssistantClient {
90
76
  this._assistantId = entry.assistantId;
91
77
 
92
78
  if (opts?.sessionToken) {
93
- // Platform assistant: use session token + org id header.
79
+ // Platform assistant: use X-Session-Token + Vellum-Organization-Id.
94
80
  this.token = opts.sessionToken;
81
+ this.isSessionAuth = true;
95
82
  this.orgId = opts.orgId;
96
83
  } else {
97
84
  this.token =
98
85
  loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
86
+ this.isSessionAuth = false;
99
87
  this.orgId = undefined;
100
88
  }
101
89
  }
@@ -191,10 +179,14 @@ export class AssistantClient {
191
179
 
192
180
  const headers: Record<string, string> = { ...opts?.headers };
193
181
  if (this.token) {
194
- headers["Authorization"] ??= `Bearer ${this.token}`;
182
+ if (this.isSessionAuth) {
183
+ headers["X-Session-Token"] ??= this.token;
184
+ } else {
185
+ headers["Authorization"] ??= `Bearer ${this.token}`;
186
+ }
195
187
  }
196
188
  if (this.orgId) {
197
- headers["X-Vellum-Org-Id"] ??= this.orgId;
189
+ headers["Vellum-Organization-Id"] ??= this.orgId;
198
190
  }
199
191
  if (body !== undefined) {
200
192
  headers["Content-Type"] = "application/json";
@@ -108,10 +108,6 @@ interface LockfileData {
108
108
  [key: string]: unknown;
109
109
  }
110
110
 
111
- export function getBaseDir(): string {
112
- return process.env.BASE_DATA_DIR?.trim() || homedir();
113
- }
114
-
115
111
  /**
116
112
  * Derive the daemon PID file path from a resources object. The PID file
117
113
  * lives inside the instance's workspace directory. When no resources are
@@ -343,19 +339,17 @@ export function setActiveAssistant(assistantId: string): void {
343
339
  }
344
340
 
345
341
  /**
346
- * Resolve which assistant to target for a command. Priority:
342
+ * Best-effort resolution of the target assistant. Returns null when no
343
+ * match is found — callers decide how to handle the absence.
344
+ *
345
+ * Priority:
347
346
  * 1. Explicit name argument
348
347
  * 2. Active assistant set via `vellum use`
349
- * 3. Sole local assistant (when exactly one exists)
348
+ * 3. Sole lockfile entry (any cloud)
350
349
  */
351
- export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
350
+ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
352
351
  if (nameArg) {
353
- const entry = findAssistantByName(nameArg);
354
- if (!entry) {
355
- console.error(`No assistant found with name '${nameArg}'.`);
356
- process.exit(1);
357
- }
358
- return entry;
352
+ return findAssistantByName(nameArg);
359
353
  }
360
354
 
361
355
  const active = getActiveAssistant();
@@ -366,15 +360,35 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
366
360
  }
367
361
 
368
362
  const all = readAssistants();
369
- const locals = all.filter((e) => e.cloud === "local");
370
- if (locals.length === 1) return locals[0];
363
+ if (all.length === 1) return all[0];
364
+
365
+ return null;
366
+ }
367
+
368
+ /**
369
+ * Resolve which assistant to target for a command, exiting the process
370
+ * with a user-facing error when resolution fails.
371
+ *
372
+ * Priority:
373
+ * 1. Explicit name argument
374
+ * 2. Active assistant set via `vellum use`
375
+ * 3. Sole lockfile entry (any cloud)
376
+ */
377
+ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
378
+ const entry = resolveAssistant(nameArg);
379
+ if (entry) return entry;
371
380
 
372
- if (locals.length === 0) {
373
- console.error("No local assistant found. Run 'vellum hatch local' first.");
381
+ if (nameArg) {
382
+ console.error(`No assistant found with name '${nameArg}'.`);
374
383
  } else {
375
- console.error(
376
- `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
377
- );
384
+ const all = readAssistants();
385
+ if (all.length === 0) {
386
+ console.error("No assistant found. Run 'vellum hatch' first.");
387
+ } else {
388
+ console.error(
389
+ `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
390
+ );
391
+ }
378
392
  }
379
393
  process.exit(1);
380
394
  }
@@ -494,25 +508,4 @@ export function getLockfilePlatformBaseUrl(): string | undefined {
494
508
  return undefined;
495
509
  }
496
510
 
497
- /**
498
- * Read the assistant config file and sync client-relevant values to the
499
- * lockfile. This lets external tools (e.g. vel) discover the platform URL
500
- * without importing the assistant config schema.
501
- */
502
- export function syncConfigToLockfile(): void {
503
- const configPath = join(getBaseDir(), ".vellum", "workspace", "config.json");
504
- if (!existsSync(configPath)) return;
505
511
 
506
- try {
507
- const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
508
- string,
509
- unknown
510
- >;
511
- const platform = raw.platform as Record<string, unknown> | undefined;
512
- const data = readLockfile();
513
- data.platformBaseUrl = (platform?.baseUrl as string) || undefined;
514
- writeLockfile(data);
515
- } catch {
516
- // Config file unreadable — skip sync
517
- }
518
- }
@@ -9,7 +9,10 @@ import {
9
9
  import { homedir } from "os";
10
10
  import { dirname, join } from "path";
11
11
 
12
- import { loadGuardianToken, leaseGuardianToken } from "./guardian-token.js";
12
+ import {
13
+ loadGuardianToken,
14
+ refreshGuardianToken,
15
+ } from "./guardian-token.js";
13
16
 
14
17
  /** Default backup directory following XDG convention */
15
18
  export function getBackupsDir(): string {
@@ -25,20 +28,25 @@ export function formatSize(bytes: number): string {
25
28
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
26
29
  }
27
30
 
28
- /** Obtain a valid guardian access token (cached or fresh lease) */
31
+ /**
32
+ * Obtain a valid guardian access token.
33
+ *
34
+ * Resolution order:
35
+ * 1. Cached token that is not yet expired — use as-is.
36
+ * 2. Cached token with a valid refresh token — call /v1/guardian/refresh.
37
+ * 3. No usable token — return null so callers can skip the backup gracefully
38
+ * rather than hitting /v1/guardian/init (which 403s on bootstrapped instances).
39
+ */
29
40
  async function getGuardianAccessToken(
30
41
  runtimeUrl: string,
31
42
  assistantId: string,
32
- forceRefresh?: boolean,
33
- ): Promise<string> {
34
- if (!forceRefresh) {
35
- const tokenData = loadGuardianToken(assistantId);
36
- if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
37
- return tokenData.accessToken;
38
- }
43
+ ): Promise<string | null> {
44
+ const tokenData = loadGuardianToken(assistantId);
45
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
46
+ return tokenData.accessToken;
39
47
  }
40
- const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
41
- return freshToken.accessToken;
48
+ const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
49
+ return refreshed?.accessToken ?? null;
42
50
  }
43
51
 
44
52
  /**
@@ -53,6 +61,10 @@ export async function createBackup(
53
61
  ): Promise<string | null> {
54
62
  try {
55
63
  let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
64
+ if (!accessToken) {
65
+ console.warn("Warning: backup skipped — no valid guardian token available");
66
+ return null;
67
+ }
56
68
 
57
69
  let response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
58
70
  method: "POST",
@@ -66,10 +78,15 @@ export async function createBackup(
66
78
  signal: AbortSignal.timeout(120_000),
67
79
  });
68
80
 
69
- // Retry once with a fresh token on 401 — the cached token may be stale
70
- // after a container restart that generated a new gateway signing key.
81
+ // Retry once with a refreshed token on 401 — the cached token may be
82
+ // stale after a container restart that regenerated the gateway signing key.
71
83
  if (response.status === 401) {
72
- accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
84
+ const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
85
+ if (!refreshed) {
86
+ console.warn(`Warning: backup export failed (401) and token refresh failed`);
87
+ return null;
88
+ }
89
+ accessToken = refreshed.accessToken;
73
90
  response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
74
91
  method: "POST",
75
92
  headers: {
@@ -130,6 +147,10 @@ export async function restoreBackup(
130
147
 
131
148
  const bundleData = readFileSync(backupPath);
132
149
  let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
150
+ if (!accessToken) {
151
+ console.warn("Warning: restore skipped — no valid guardian token available");
152
+ return false;
153
+ }
133
154
 
134
155
  let response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
135
156
  method: "POST",
@@ -141,10 +162,15 @@ export async function restoreBackup(
141
162
  signal: AbortSignal.timeout(120_000),
142
163
  });
143
164
 
144
- // Retry once with a fresh token on 401 — the cached token may be stale
145
- // after a container restart that generated a new gateway signing key.
165
+ // Retry once with a refreshed token on 401 — the cached token may be
166
+ // stale after a container restart that regenerated the gateway signing key.
146
167
  if (response.status === 401) {
147
- accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
168
+ const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
169
+ if (!refreshed) {
170
+ console.warn(`Warning: restore failed (401) and token refresh failed`);
171
+ return false;
172
+ }
173
+ accessToken = refreshed.accessToken;
148
174
  response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
149
175
  method: "POST",
150
176
  headers: {
@@ -9,6 +9,7 @@
9
9
 
10
10
  /** Known error categories emitted by CLI commands. */
11
11
  export type CliErrorCategory =
12
+ | "CLI_UPDATE_FAILED"
12
13
  | "DOCKER_NOT_RUNNING"
13
14
  | "IMAGE_PULL_FAILED"
14
15
  | "MISSING_VERSION"
@@ -2,7 +2,7 @@
2
2
  * Stable per-install client identity for the CLI.
3
3
  *
4
4
  * Generates a UUID on first use and persists it to
5
- * `~/.config/vellum/client-id` so the daemon's ClientRegistry can
5
+ * `~/.config/vellum/client-id` so the daemon's event hub can
6
6
  * track this terminal across SSE reconnects and CLI restarts.
7
7
  */
8
8