@vellumai/cli 0.6.3 → 0.6.5

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 (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
@@ -64,6 +64,14 @@ export async function backup(): Promise<void> {
64
64
  // Detect topology and route platform assistants through Django export
65
65
  const cloud =
66
66
  entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
67
+
68
+ if (cloud === "apple-container") {
69
+ console.error(
70
+ `Error: '${name}' uses the Apple Containers runtime. Backup is not yet supported for this topology.`,
71
+ );
72
+ process.exit(1);
73
+ }
74
+
67
75
  if (cloud === "vellum") {
68
76
  await backupPlatform(name, outputArg, entry.runtimeUrl);
69
77
  return;
@@ -0,0 +1,186 @@
1
+ import { spawn } from "child_process";
2
+
3
+ import {
4
+ findAssistantByName,
5
+ loadLatestAssistant,
6
+ resolveCloud,
7
+ } from "../lib/assistant-config";
8
+ import { dockerResourceNames } from "../lib/docker";
9
+ import type { ServiceName } from "../lib/docker";
10
+ import { execAppleContainer } from "../lib/exec-apple-container";
11
+ import { sshAppleContainer } from "../lib/ssh-apple-container";
12
+
13
+ const SERVICE_ALIASES: Record<string, ServiceName> = {
14
+ assistant: "assistant",
15
+ "vellum-assistant": "assistant",
16
+ gateway: "gateway",
17
+ "vellum-gateway": "gateway",
18
+ "credential-executor": "credential-executor",
19
+ "vellum-credential-executor": "credential-executor",
20
+ };
21
+
22
+ function normalizeService(raw: string): ServiceName {
23
+ const normalized = SERVICE_ALIASES[raw];
24
+ if (!normalized) {
25
+ console.error(
26
+ `Unknown service '${raw}'. Valid services: assistant, gateway, credential-executor`,
27
+ );
28
+ process.exit(1);
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ function resolveDockerContainer(
34
+ instanceName: string,
35
+ service: ServiceName,
36
+ ): string {
37
+ const res = dockerResourceNames(instanceName);
38
+ switch (service) {
39
+ case "assistant":
40
+ return res.assistantContainer;
41
+ case "gateway":
42
+ return res.gatewayContainer;
43
+ case "credential-executor":
44
+ return res.cesContainer;
45
+ }
46
+ }
47
+
48
+ export async function exec(): Promise<void> {
49
+ const rawArgs = process.argv.slice(3);
50
+
51
+ // Only check for help flags before the -- separator so that
52
+ // `vellum exec -- curl --help` passes through correctly.
53
+ const dashDashIndex = rawArgs.indexOf("--");
54
+ const preArgs =
55
+ dashDashIndex === -1 ? rawArgs : rawArgs.slice(0, dashDashIndex);
56
+
57
+ if (
58
+ preArgs.includes("--help") ||
59
+ preArgs.includes("-h") ||
60
+ rawArgs.length === 0
61
+ ) {
62
+ console.log(
63
+ "Usage: vellum exec [<name>] [--service <svc>] [-it] -- <command...>",
64
+ );
65
+ console.log("");
66
+ console.log("Execute a command inside an assistant's container.");
67
+ console.log("");
68
+ console.log("Arguments:");
69
+ console.log(
70
+ " <name> Name of the assistant (defaults to active)",
71
+ );
72
+ console.log(
73
+ " <command...> Command and arguments to run (after --)",
74
+ );
75
+ console.log("");
76
+ console.log("Options:");
77
+ console.log(
78
+ " --service <svc> Target service (default: assistant)",
79
+ );
80
+ console.log(
81
+ " -it Interactive mode with TTY (like docker exec -it)",
82
+ );
83
+ console.log("");
84
+ console.log("Services:");
85
+ console.log(" assistant (or vellum-assistant)");
86
+ console.log(" gateway (or vellum-gateway)");
87
+ console.log(" credential-executor (or vellum-credential-executor)");
88
+ console.log("");
89
+ console.log("Examples:");
90
+ console.log(" vellum exec -- ls -la /workspace");
91
+ console.log(" vellum exec -- cat /workspace/NOW.md");
92
+ console.log(" vellum exec -it -- /bin/bash");
93
+ console.log(
94
+ " vellum exec --service gateway -- cat /tmp/gateway.log",
95
+ );
96
+ process.exit(0);
97
+ }
98
+
99
+ if (dashDashIndex === -1) {
100
+ console.error(
101
+ "Error: missing '--' separator before command.\n" +
102
+ "Usage: vellum exec [<name>] -- <command...>",
103
+ );
104
+ process.exit(1);
105
+ }
106
+
107
+ const command = rawArgs.slice(dashDashIndex + 1);
108
+
109
+ if (command.length === 0) {
110
+ console.error("Error: no command specified after '--'.");
111
+ process.exit(1);
112
+ }
113
+
114
+ let nameArg: string | undefined;
115
+ let serviceRaw = "assistant";
116
+ let interactive = false;
117
+
118
+ for (let i = 0; i < preArgs.length; i++) {
119
+ if (preArgs[i] === "--service" && preArgs[i + 1]) {
120
+ serviceRaw = preArgs[++i];
121
+ } else if (preArgs[i] === "-it" || preArgs[i] === "-ti") {
122
+ interactive = true;
123
+ } else if (!preArgs[i].startsWith("-")) {
124
+ nameArg = preArgs[i];
125
+ }
126
+ }
127
+
128
+ const service = normalizeService(serviceRaw);
129
+
130
+ const entry = nameArg
131
+ ? findAssistantByName(nameArg)
132
+ : loadLatestAssistant();
133
+
134
+ if (!entry) {
135
+ if (nameArg) {
136
+ console.error(`No assistant instance found with name '${nameArg}'.`);
137
+ } else {
138
+ console.error("No assistant instance found. Run `vellum hatch` first.");
139
+ }
140
+ process.exit(1);
141
+ }
142
+
143
+ const cloud = resolveCloud(entry);
144
+
145
+ if (cloud === "local") {
146
+ console.error(
147
+ "Cannot exec into a local assistant — it runs directly on this machine.",
148
+ );
149
+ process.exit(1);
150
+ }
151
+
152
+ if (cloud === "apple-container") {
153
+ const fullServiceName = `vellum-${service}`;
154
+ if (interactive) {
155
+ await sshAppleContainer(entry, command, fullServiceName);
156
+ } else {
157
+ await execAppleContainer(entry, command, fullServiceName);
158
+ }
159
+ return;
160
+ }
161
+
162
+ if (cloud === "docker") {
163
+ const container = resolveDockerContainer(entry.assistantId, service);
164
+ const dockerArgs = interactive
165
+ ? ["exec", "-it", container, ...command]
166
+ : ["exec", container, ...command];
167
+
168
+ const child = spawn("docker", dockerArgs, { stdio: "inherit" });
169
+ await new Promise<void>((resolve, reject) => {
170
+ child.on("close", (code) => {
171
+ if (code === 0) resolve();
172
+ else {
173
+ process.exitCode = code ?? 1;
174
+ resolve();
175
+ }
176
+ });
177
+ child.on("error", reject);
178
+ });
179
+ return;
180
+ }
181
+
182
+ console.error(
183
+ `Error: 'vellum exec' is not supported for ${cloud} instances.`,
184
+ );
185
+ process.exit(1);
186
+ }
@@ -570,7 +570,7 @@ async function hatchVellumPlatform(): Promise<void> {
570
570
  console.log(" Hatching assistant on Vellum platform...");
571
571
  console.log("");
572
572
 
573
- const result = await hatchAssistant(token);
573
+ const { assistant: result } = await hatchAssistant(token);
574
574
 
575
575
  const platformUrl = getPlatformUrl();
576
576
 
@@ -1,20 +1,143 @@
1
+ import { createServer } from "http";
2
+ import { spawn } from "child_process";
3
+ import { randomBytes } from "crypto";
4
+
5
+ import {
6
+ findAssistantByName,
7
+ getActiveAssistant,
8
+ loadLatestAssistant,
9
+ } from "../lib/assistant-config";
10
+ import { computeDeviceId } from "../lib/guardian-token";
1
11
  import {
2
12
  clearPlatformToken,
13
+ ensureSelfHostedLocalRegistration,
3
14
  fetchCurrentUser,
15
+ fetchOrganizationId,
16
+ getPlatformUrl,
17
+ injectCredentialsIntoAssistant,
18
+ readGatewayCredential,
4
19
  readPlatformToken,
20
+ reprovisionAssistantApiKey,
5
21
  savePlatformToken,
6
22
  } from "../lib/platform-client";
7
23
 
24
+ const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
25
+
26
+ /**
27
+ * Open a URL in the user's default browser.
28
+ */
29
+ function openBrowser(url: string): void {
30
+ const platform = process.platform;
31
+ const cmd =
32
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
33
+ const args =
34
+ platform === "win32"
35
+ ? ["/c", "start", '""', url.replace(/&/g, "^&")]
36
+ : [url];
37
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
38
+ child.on("error", () => {
39
+ // Silently ignore — the user can still copy the URL from the console
40
+ });
41
+ child.unref();
42
+ }
43
+
44
+ /**
45
+ * Start a local HTTP server, open the browser to the platform login page,
46
+ * and wait for the platform to redirect back with the session token.
47
+ */
48
+ function browserLogin(platformUrl: string): Promise<string> {
49
+ return new Promise((resolve, reject) => {
50
+ const state = randomBytes(32).toString("hex");
51
+
52
+ const server = createServer((req, res) => {
53
+ const url = new URL(req.url ?? "/", `http://localhost`);
54
+
55
+ if (url.pathname !== "/callback") {
56
+ res.writeHead(404, { "Content-Type": "text/plain" });
57
+ res.end("Not found");
58
+ return;
59
+ }
60
+
61
+ const receivedState = url.searchParams.get("state");
62
+ const sessionToken = url.searchParams.get("session_token");
63
+
64
+ if (receivedState !== state) {
65
+ res.writeHead(400, { "Content-Type": "text/html" });
66
+ res.end(
67
+ "<html><body><h2>Login failed</h2><p>State mismatch. Please try again.</p></body></html>",
68
+ );
69
+ cleanup("State mismatch — possible CSRF attack.");
70
+ return;
71
+ }
72
+
73
+ if (!sessionToken) {
74
+ res.writeHead(400, { "Content-Type": "text/html" });
75
+ res.end(
76
+ "<html><body><h2>Login failed</h2><p>No session token received. Please try again.</p></body></html>",
77
+ );
78
+ cleanup("No session token received from platform.");
79
+ return;
80
+ }
81
+
82
+ res.writeHead(200, { "Content-Type": "text/html" });
83
+ res.end(
84
+ "<html><body><h2>Login successful!</h2><p>You can close this window and return to your terminal.</p></body></html>",
85
+ );
86
+ cleanup(null, sessionToken);
87
+ });
88
+
89
+ const timeout = setTimeout(() => {
90
+ cleanup("Login timed out. Please try again.");
91
+ }, LOGIN_TIMEOUT_MS);
92
+
93
+ function cleanup(error: string | null, token?: string): void {
94
+ clearTimeout(timeout);
95
+ server.close();
96
+ if (error) {
97
+ reject(new Error(error));
98
+ } else if (token) {
99
+ resolve(token);
100
+ } else {
101
+ reject(new Error("Unknown error during login."));
102
+ }
103
+ }
104
+
105
+ server.on("error", (err) => cleanup(err.message));
106
+ server.listen(0, "127.0.0.1", () => {
107
+ const addr = server.address();
108
+ if (!addr || typeof addr === "string") {
109
+ cleanup("Failed to start local server.");
110
+ return;
111
+ }
112
+
113
+ const port = addr.port;
114
+ const returnTo = `/accounts/cli/callback?port=${port}&state=${state}`;
115
+ const loginUrl = `${platformUrl}/account/login?returnTo=${encodeURIComponent(returnTo)}`;
116
+
117
+ console.log("Opening browser for login...");
118
+ console.log(`If the browser doesn't open, visit: ${loginUrl}`);
119
+ openBrowser(loginUrl);
120
+ });
121
+ });
122
+ }
123
+
8
124
  export async function login(): Promise<void> {
9
125
  const args = process.argv.slice(3);
10
126
 
11
127
  if (args.includes("--help") || args.includes("-h")) {
12
- console.log("Usage: vellum login --token <session-token>");
128
+ console.log("Usage: vellum login [--token <session-token>]");
13
129
  console.log("");
14
130
  console.log("Log in to the Vellum platform.");
15
131
  console.log("");
132
+ console.log("By default, opens a browser window for authentication.");
133
+ console.log("Alternatively, pass a session token directly with --token.");
134
+ console.log("");
16
135
  console.log("Options:");
17
136
  console.log(" --token <token> Session token from the Vellum platform");
137
+ console.log("");
138
+ console.log("Examples:");
139
+ console.log(" vellum login");
140
+ console.log(" vellum login --token <session-token>");
18
141
  process.exit(0);
19
142
  }
20
143
 
@@ -31,14 +154,15 @@ export async function login(): Promise<void> {
31
154
  }
32
155
  }
33
156
 
157
+ // If no --token flag, use browser-based login
34
158
  if (!token) {
35
- console.error("Usage: vellum login --token <session-token>");
36
- console.error("");
37
- console.error("To get your session token:");
38
- console.error(" 1. Log in to the Vellum platform in your browser");
39
- console.error(" 2. Open Developer Tools Application Cookies");
40
- console.error(" 3. Copy the value of the session token");
41
- process.exit(1);
159
+ const platformUrl = getPlatformUrl();
160
+ try {
161
+ token = await browserLogin(platformUrl);
162
+ } catch (error) {
163
+ console.error(`❌ ${error instanceof Error ? error.message : error}`);
164
+ process.exit(1);
165
+ }
42
166
  }
43
167
 
44
168
  console.log("Validating token...");
@@ -47,6 +171,82 @@ export async function login(): Promise<void> {
47
171
  const user = await fetchCurrentUser(token);
48
172
  savePlatformToken(token);
49
173
  console.log(`✅ Logged in as ${user.email}`);
174
+
175
+ // Register the local assistant with the platform (non-fatal).
176
+ // Mirrors the desktop app's LocalAssistantBootstrapService flow.
177
+ try {
178
+ const activeName = getActiveAssistant();
179
+ const entry = activeName
180
+ ? findAssistantByName(activeName)
181
+ : loadLatestAssistant();
182
+
183
+ // Skip managed ("vellum") assistants — they are handled by the platform.
184
+ if (entry && entry.cloud !== "vellum") {
185
+ const orgId = await fetchOrganizationId(token);
186
+ const clientInstallationId = computeDeviceId();
187
+ const registration = await ensureSelfHostedLocalRegistration(
188
+ token,
189
+ orgId,
190
+ clientInstallationId,
191
+ entry.assistantId,
192
+ "cli",
193
+ );
194
+ console.log(
195
+ `Registered assistant: ${registration.assistant.name} (${registration.assistant.id})`,
196
+ );
197
+
198
+ // Resolve the API key to inject, mirroring the macOS app's
199
+ // LocalAssistantBootstrapService 3-step flow:
200
+ // 1. Use fresh key from registration (first-time only)
201
+ // 2. Use existing key from the daemon's credential store
202
+ // 3. Reprovision (rotate) as a last resort — this revokes the
203
+ // old key server-side, so we only do it when the gateway
204
+ // confirms no key exists (not when it's merely unreachable).
205
+ let assistantApiKey = registration.assistant_api_key;
206
+ if (!assistantApiKey) {
207
+ const cached = await readGatewayCredential(
208
+ entry.runtimeUrl,
209
+ "vellum:assistant_api_key",
210
+ entry.bearerToken,
211
+ );
212
+ if (cached.value) {
213
+ assistantApiKey = cached.value;
214
+ } else if (!cached.unreachable) {
215
+ console.log("No API key available locally — reprovisioning...");
216
+ const reprovision = await reprovisionAssistantApiKey(
217
+ token,
218
+ orgId,
219
+ clientInstallationId,
220
+ entry.assistantId,
221
+ "cli",
222
+ );
223
+ assistantApiKey = reprovision.provisioning.assistant_api_key;
224
+ }
225
+ }
226
+
227
+ // Inject credentials into the running assistant via the gateway,
228
+ // mirroring the desktop app's LocalAssistantBootstrapService flow.
229
+ const allInjected = await injectCredentialsIntoAssistant({
230
+ gatewayUrl: entry.runtimeUrl,
231
+ bearerToken: entry.bearerToken,
232
+ assistantApiKey,
233
+ platformAssistantId: registration.assistant.id,
234
+ platformBaseUrl: getPlatformUrl(),
235
+ organizationId: orgId,
236
+ userId: user.id,
237
+ webhookSecret: registration.webhook_secret,
238
+ });
239
+ if (allInjected) {
240
+ console.log("Injected platform credentials into assistant.");
241
+ } else {
242
+ console.warn(
243
+ "Some credentials could not be injected into the assistant.",
244
+ );
245
+ }
246
+ }
247
+ } catch {
248
+ // Non-fatal — login succeeded even if registration fails
249
+ }
50
250
  } catch (error) {
51
251
  console.error(
52
252
  `❌ Login failed: ${error instanceof Error ? error.message : error}`,
@@ -81,7 +281,7 @@ export async function whoami(): Promise<void> {
81
281
 
82
282
  const token = readPlatformToken();
83
283
  if (!token) {
84
- console.error("Not logged in. Run `vellum login --token <token>` first.");
284
+ console.error("Not logged in. Run `vellum login` first.");
85
285
  process.exit(1);
86
286
  }
87
287