@vellumai/cli 0.6.6 → 0.7.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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -11,19 +11,17 @@
11
11
  * vellum terminal list — List tmux sessions
12
12
  */
13
13
 
14
- import {
15
- findAssistantByName,
16
- loadLatestAssistant,
17
- resolveCloud,
18
- } from "../lib/assistant-config.js";
19
- import { getPlatformUrl, readPlatformToken } from "../lib/platform-client.js";
20
14
  import {
21
15
  closeTerminalSession,
22
16
  createTerminalSession,
23
- resizeTerminalSession,
24
17
  sendTerminalInput,
25
18
  subscribeTerminalEvents,
26
19
  } from "../lib/terminal-client.js";
20
+ import {
21
+ interactiveSession,
22
+ resolveManagedAssistant,
23
+ } from "../lib/terminal-session.js";
24
+ import type { ResolvedManagedAssistant } from "../lib/terminal-session.js";
27
25
 
28
26
  // ---------------------------------------------------------------------------
29
27
  // Helpers
@@ -60,223 +58,13 @@ function printHelp(): void {
60
58
  console.log(" vellum terminal --assistant my-assistant");
61
59
  }
62
60
 
63
- interface ResolvedAssistant {
64
- assistantId: string;
65
- token: string;
66
- platformUrl: string;
67
- }
68
-
69
- function resolveAssistant(nameArg?: string): ResolvedAssistant {
70
- const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
71
-
72
- if (!entry) {
73
- if (nameArg) {
74
- console.error(`No assistant instance found with name '${nameArg}'.`);
75
- } else {
76
- console.error("No assistant instance found. Run `vellum hatch` first.");
77
- }
78
- process.exit(1);
79
- }
80
-
81
- const cloud = resolveCloud(entry);
82
- if (cloud !== "vellum") {
83
- if (cloud === "local") {
84
- console.error(
85
- "This assistant runs locally on your machine. You can access it directly.",
86
- );
87
- } else if (cloud === "docker") {
88
- console.error(
89
- `Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
90
- );
91
- } else {
92
- console.error(
93
- `'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
94
- );
95
- }
96
- process.exit(1);
97
- }
98
-
99
- const token = readPlatformToken();
100
- if (!token) {
101
- console.error(
102
- "Not logged in. Run `vellum login` first to authenticate with the platform.",
103
- );
104
- process.exit(1);
105
- }
106
-
107
- return {
108
- assistantId: entry.assistantId,
109
- token,
110
- platformUrl: getPlatformUrl(),
111
- };
112
- }
113
-
114
- // ---------------------------------------------------------------------------
115
- // Interactive session
116
- // ---------------------------------------------------------------------------
117
-
118
- async function interactiveSession(
119
- assistant: ResolvedAssistant,
120
- initialCommand?: string,
121
- ): Promise<void> {
122
- const cols = process.stdout.columns || 80;
123
- const rows = process.stdout.rows || 24;
124
-
125
- console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
126
-
127
- const { session_id: sessionId } = await createTerminalSession(
128
- assistant.token,
129
- assistant.assistantId,
130
- cols,
131
- rows,
132
- assistant.platformUrl,
133
- );
134
-
135
- // --- TTY raw mode setup ---
136
- const wasRaw = process.stdin.isRaw;
137
- if (process.stdin.isTTY) {
138
- process.stdin.setRawMode(true);
139
- }
140
- process.stdin.resume();
141
- process.stdin.setEncoding("utf-8");
142
-
143
- // Abort controller for the SSE stream
144
- const abortController = new AbortController();
145
- let exiting = false;
146
-
147
- // --- Cleanup function (idempotent) ---
148
- async function cleanup(): Promise<void> {
149
- if (exiting) return;
150
- exiting = true;
151
-
152
- // Restore tty
153
- if (process.stdin.isTTY) {
154
- process.stdin.setRawMode(wasRaw ?? false);
155
- }
156
- process.stdin.pause();
157
-
158
- // Abort SSE stream
159
- abortController.abort();
160
-
161
- // Close remote session (best-effort)
162
- try {
163
- await closeTerminalSession(
164
- assistant.token,
165
- assistant.assistantId,
166
- sessionId,
167
- assistant.platformUrl,
168
- );
169
- } catch {
170
- // Best-effort cleanup
171
- }
172
- }
173
-
174
- // --- Signal handlers ---
175
- const onSigInt = () => {
176
- cleanup().then(() => process.exit(0));
177
- };
178
- const onSigTerm = () => {
179
- cleanup().then(() => process.exit(0));
180
- };
181
- process.on("SIGINT", onSigInt);
182
- process.on("SIGTERM", onSigTerm);
183
-
184
- // --- SIGWINCH (terminal resize) ---
185
- const onResize = () => {
186
- const newCols = process.stdout.columns || 80;
187
- const newRows = process.stdout.rows || 24;
188
- resizeTerminalSession(
189
- assistant.token,
190
- assistant.assistantId,
191
- sessionId,
192
- newCols,
193
- newRows,
194
- assistant.platformUrl,
195
- ).catch(() => {
196
- // Resize failures are non-fatal
197
- });
198
- };
199
- process.stdout.on("resize", onResize);
200
-
201
- // --- Input: stdin → remote ---
202
- let inputBuffer = "";
203
- let inputTimer: ReturnType<typeof setTimeout> | null = null;
204
- const INPUT_DEBOUNCE_MS = 30;
205
-
206
- function flushInput(): void {
207
- if (inputBuffer.length === 0) return;
208
- const data = inputBuffer;
209
- inputBuffer = "";
210
- sendTerminalInput(
211
- assistant.token,
212
- assistant.assistantId,
213
- sessionId,
214
- data,
215
- assistant.platformUrl,
216
- ).catch((err) => {
217
- if (!exiting) {
218
- console.error(`\r\nInput error: ${err.message}\r\n`);
219
- }
220
- });
221
- }
222
-
223
- process.stdin.on("data", (chunk: string) => {
224
- if (exiting) return;
225
- inputBuffer += chunk;
226
- if (inputTimer) clearTimeout(inputTimer);
227
- inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
228
- });
229
-
230
- // --- Send initial command (for `attach` subcommand) ---
231
- if (initialCommand) {
232
- // Brief delay to let the shell initialize
233
- await new Promise((resolve) => setTimeout(resolve, 300));
234
- await sendTerminalInput(
235
- assistant.token,
236
- assistant.assistantId,
237
- sessionId,
238
- initialCommand + "\r",
239
- assistant.platformUrl,
240
- );
241
- }
242
-
243
- // --- Output: remote SSE → stdout ---
244
- try {
245
- for await (const event of subscribeTerminalEvents(
246
- assistant.token,
247
- assistant.assistantId,
248
- sessionId,
249
- assistant.platformUrl,
250
- abortController.signal,
251
- )) {
252
- if (exiting) break;
253
- // Decode base64 output and write raw bytes to stdout
254
- const bytes = Buffer.from(event.data, "base64");
255
- process.stdout.write(bytes);
256
- }
257
- } catch (err) {
258
- if (!exiting) {
259
- const msg = err instanceof Error ? err.message : String(err);
260
- // AbortError is expected on cleanup
261
- if (!msg.includes("abort")) {
262
- console.error(`\r\nConnection lost: ${msg}\r\n`);
263
- }
264
- }
265
- } finally {
266
- await cleanup();
267
-
268
- // Remove listeners
269
- process.off("SIGINT", onSigInt);
270
- process.off("SIGTERM", onSigTerm);
271
- process.stdout.off("resize", onResize);
272
- }
273
- }
274
-
275
61
  // ---------------------------------------------------------------------------
276
62
  // List tmux sessions
277
63
  // ---------------------------------------------------------------------------
278
64
 
279
- async function listTmuxSessions(assistant: ResolvedAssistant): Promise<void> {
65
+ async function listTmuxSessions(
66
+ assistant: ResolvedManagedAssistant,
67
+ ): Promise<void> {
280
68
  const cols = 120;
281
69
  const rows = 24;
282
70
 
@@ -411,7 +199,7 @@ export async function terminal(): Promise<void> {
411
199
  }
412
200
  }
413
201
 
414
- const assistant = resolveAssistant(assistantName);
202
+ const assistant = resolveManagedAssistant(assistantName);
415
203
 
416
204
  if (subcommand === "list") {
417
205
  await listTmuxSessions(assistant);
@@ -1,7 +1,4 @@
1
- import {
2
- findAssistantByName,
3
- loadLatestAssistant,
4
- } from "../lib/assistant-config";
1
+ import { resolveAssistant } from "../lib/assistant-config";
5
2
  import { runNgrokTunnel } from "../lib/ngrok";
6
3
 
7
4
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -63,9 +60,7 @@ function parseArgs(): TunnelArgs {
63
60
  export async function tunnel(): Promise<void> {
64
61
  const { assistantName, provider } = parseArgs();
65
62
 
66
- const entry = assistantName
67
- ? findAssistantByName(assistantName)
68
- : loadLatestAssistant();
63
+ const entry = resolveAssistant(assistantName ?? undefined);
69
64
 
70
65
  if (!entry) {
71
66
  if (assistantName) {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { spawnSync } from "child_process";
2
3
 
3
4
  import cliPkg from "../../package.json";
4
5
 
@@ -16,7 +17,10 @@ import {
16
17
  startContainers,
17
18
  stopContainers,
18
19
  } from "../lib/docker";
19
- import { resolveImageRefs } from "../lib/platform-releases";
20
+ import {
21
+ fetchLatestStableVersion,
22
+ resolveImageRefs,
23
+ } from "../lib/platform-releases";
20
24
  import {
21
25
  authHeaders,
22
26
  getPlatformUrl,
@@ -47,6 +51,7 @@ import { compareVersions } from "../lib/version-compat.js";
47
51
  interface UpgradeArgs {
48
52
  name: string | null;
49
53
  version: string | null;
54
+ latest: boolean;
50
55
  prepare: boolean;
51
56
  finalize: boolean;
52
57
  }
@@ -55,6 +60,7 @@ function parseArgs(): UpgradeArgs {
55
60
  const args = process.argv.slice(3);
56
61
  let name: string | null = null;
57
62
  let version: string | null = null;
63
+ let latest = false;
58
64
  let prepare = false;
59
65
  let finalize = false;
60
66
 
@@ -73,7 +79,10 @@ function parseArgs(): UpgradeArgs {
73
79
  console.log("");
74
80
  console.log("Options:");
75
81
  console.log(
76
- " --version <version> Target version to upgrade to (default: latest)",
82
+ " --version <version> Target version to upgrade to (default: CLI version)",
83
+ );
84
+ console.log(
85
+ " --latest Upgrade to the latest stable release, updating the CLI first if needed",
77
86
  );
78
87
  console.log(
79
88
  " --prepare Run pre-upgrade steps only (backup, notify) without swapping versions",
@@ -84,7 +93,10 @@ function parseArgs(): UpgradeArgs {
84
93
  console.log("");
85
94
  console.log("Examples:");
86
95
  console.log(
87
- " vellum upgrade # Upgrade the active assistant to the latest version",
96
+ " vellum upgrade # Upgrade the active assistant to the CLI's version",
97
+ );
98
+ console.log(
99
+ " vellum upgrade --latest # Upgrade CLI + assistant to the latest stable release",
88
100
  );
89
101
  console.log(
90
102
  " vellum upgrade my-assistant # Upgrade a specific assistant by name",
@@ -102,6 +114,8 @@ function parseArgs(): UpgradeArgs {
102
114
  }
103
115
  version = next;
104
116
  i++;
117
+ } else if (arg === "--latest") {
118
+ latest = true;
105
119
  } else if (arg === "--prepare") {
106
120
  prepare = true;
107
121
  } else if (arg === "--finalize") {
@@ -121,7 +135,13 @@ function parseArgs(): UpgradeArgs {
121
135
  process.exit(1);
122
136
  }
123
137
 
124
- return { name, version, prepare, finalize };
138
+ if (latest && version) {
139
+ console.error("Error: --latest and --version are mutually exclusive.");
140
+ emitCliError("UNKNOWN", "--latest and --version are mutually exclusive");
141
+ process.exit(1);
142
+ }
143
+
144
+ return { name, version, latest, prepare, finalize };
125
145
  }
126
146
 
127
147
  function resolveCloud(entry: AssistantEntry): string {
@@ -867,8 +887,80 @@ async function upgradeFinalize(
867
887
  );
868
888
  }
869
889
 
890
+ /**
891
+ * When `--latest` is passed, resolve the latest stable version from the
892
+ * platform API. If the running CLI is older than that version, self-update
893
+ * the CLI via `bun install -g` and re-exec so the new CLI's upgrade logic
894
+ * (and its cliPkg.version) drives the rest of the upgrade.
895
+ *
896
+ * Returns the resolved latest version string (e.g. "v0.7.0") for callers
897
+ * that need it. If the CLI was updated and re-exec'd, this function never
898
+ * returns — the process is replaced.
899
+ */
900
+ async function resolveLatestAndMaybeSelfUpdate(
901
+ name: string | null,
902
+ ): Promise<string> {
903
+ console.log("🔍 Fetching latest stable release...");
904
+ const latestVersion = await fetchLatestStableVersion();
905
+ if (!latestVersion) {
906
+ console.error(
907
+ "Error: Could not determine the latest stable release from the platform API.",
908
+ );
909
+ emitCliError(
910
+ "UNKNOWN",
911
+ "Could not determine the latest stable release from the platform API",
912
+ );
913
+ process.exit(1);
914
+ }
915
+
916
+ const latestTag = latestVersion.startsWith("v")
917
+ ? latestVersion
918
+ : `v${latestVersion}`;
919
+ const currentTag = cliPkg.version ? `v${cliPkg.version}` : null;
920
+
921
+ console.log(` Latest stable: ${latestTag}`);
922
+ console.log(` CLI version: ${currentTag ?? "unknown"}\n`);
923
+
924
+ // Check if the CLI needs updating
925
+ const cmp = currentTag ? compareVersions(latestTag, currentTag) : null;
926
+ if (cmp !== null && cmp > 0) {
927
+ console.log(`🔄 Updating CLI to ${latestTag}...`);
928
+ const installResult = spawnSync(
929
+ "bun",
930
+ ["install", "-g", `vellum@${latestVersion}`],
931
+ { stdio: "inherit" },
932
+ );
933
+ if (installResult.error || installResult.status !== 0) {
934
+ const detail =
935
+ installResult.error?.message ?? `exited with code ${installResult.status}`;
936
+ console.error(`\n❌ CLI self-update failed: ${detail}`);
937
+ emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
938
+ process.exit(1);
939
+ }
940
+ console.log(`✅ CLI updated to ${latestTag}\n`);
941
+
942
+ // Re-exec with the updated CLI. Pass --version instead of --latest
943
+ // to avoid re-fetching and to prevent infinite re-exec loops.
944
+ const reexecArgs = ["upgrade"];
945
+ if (name) reexecArgs.push(name);
946
+ reexecArgs.push("--version", latestTag);
947
+
948
+ console.log(`🚀 Re-running upgrade with updated CLI...\n`);
949
+ const reexecResult = spawnSync("vellum", reexecArgs, {
950
+ stdio: "inherit",
951
+ });
952
+ process.exit(reexecResult.status ?? 1);
953
+ }
954
+
955
+ if (cmp !== null && cmp === 0) {
956
+ console.log(`✅ CLI is already on the latest version (${latestTag})\n`);
957
+ }
958
+
959
+ return latestTag;
960
+ }
961
+
870
962
  export async function upgrade(): Promise<void> {
871
- const { name, version, prepare, finalize } = parseArgs();
963
+ const { name, version, latest, prepare, finalize } = parseArgs();
872
964
  const entry = resolveTargetAssistant(name);
873
965
 
874
966
  if (prepare) {
@@ -881,16 +973,25 @@ export async function upgrade(): Promise<void> {
881
973
  return;
882
974
  }
883
975
 
976
+ // When --latest is passed, resolve the target from the platform API and
977
+ // self-update the CLI if it's behind. The resolved version is then used
978
+ // as the explicit target for the rest of the upgrade flow.
979
+ let effectiveVersion = version;
980
+ if (latest) {
981
+ const latestTag = await resolveLatestAndMaybeSelfUpdate(name);
982
+ effectiveVersion = latestTag;
983
+ }
984
+
884
985
  const cloud = resolveCloud(entry);
885
986
 
886
987
  try {
887
988
  if (cloud === "docker") {
888
- await upgradeDocker(entry, version);
989
+ await upgradeDocker(entry, effectiveVersion);
889
990
  return;
890
991
  }
891
992
 
892
993
  if (cloud === "vellum") {
893
- await upgradePlatform(entry, version);
994
+ await upgradePlatform(entry, effectiveVersion);
894
995
  return;
895
996
  }
896
997
  } catch (err) {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
4
  import {
5
+ getDaemonPidPath,
5
6
  resolveTargetAssistant,
6
7
  saveAssistantEntry,
7
8
  } from "../lib/assistant-config.js";
@@ -82,7 +83,7 @@ export async function wake(): Promise<void> {
82
83
  }
83
84
  const resources = entry.resources;
84
85
 
85
- const pidFile = resources.pidFile;
86
+ const pidFile = getDaemonPidPath(resources);
86
87
 
87
88
  // Check if daemon is already running
88
89
  let daemonRunning = false;