@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.
- package/AGENTS.md +3 -11
- package/README.md +49 -0
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +591 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +597 -37
- package/src/commands/backup.ts +149 -70
- package/src/commands/client.ts +56 -14
- package/src/commands/events.ts +3 -0
- package/src/commands/exec.ts +34 -12
- package/src/commands/hatch.ts +3 -7
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +32 -47
- package/src/commands/setup.ts +38 -73
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +148 -34
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +114 -7
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +65 -129
- package/src/index.ts +2 -13
- package/src/lib/__tests__/docker.test.ts +50 -32
- package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
- package/src/lib/__tests__/runtime-url.test.ts +125 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +18 -26
- package/src/lib/assistant-config.ts +34 -41
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker-statefulset.ts +381 -0
- package/src/lib/docker.ts +8 -247
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +162 -28
- package/src/lib/local.ts +35 -64
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +97 -221
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +52 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/upgrade-lifecycle.ts +65 -0
- package/src/lib/xdg-log.ts +10 -4
- 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
|
-
|
|
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 `
|
|
32
|
-
* sent via the `
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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["
|
|
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
|
-
*
|
|
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
|
|
348
|
+
* 3. Sole lockfile entry (any cloud)
|
|
350
349
|
*/
|
|
351
|
-
export function
|
|
350
|
+
export function resolveAssistant(nameArg?: string): AssistantEntry | null {
|
|
352
351
|
if (nameArg) {
|
|
353
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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 (
|
|
373
|
-
console.error(
|
|
381
|
+
if (nameArg) {
|
|
382
|
+
console.error(`No assistant found with name '${nameArg}'.`);
|
|
374
383
|
} else {
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
}
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { dirname, join } from "path";
|
|
11
11
|
|
|
12
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
|
41
|
-
return
|
|
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
|
|
70
|
-
// after a container restart that
|
|
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
|
-
|
|
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
|
|
145
|
-
// after a container restart that
|
|
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
|
-
|
|
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: {
|
package/src/lib/cli-error.ts
CHANGED
|
@@ -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
|
|
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
|
|