@vellumai/cli 0.7.0 → 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 (40) hide show
  1. package/README.md +49 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/backup.test.ts +475 -0
  4. package/src/__tests__/config-utils.test.ts +35 -48
  5. package/src/__tests__/teleport.test.ts +86 -28
  6. package/src/commands/backup.ts +117 -71
  7. package/src/commands/client.ts +10 -9
  8. package/src/commands/exec.ts +21 -8
  9. package/src/commands/hatch.ts +2 -6
  10. package/src/commands/login.ts +15 -33
  11. package/src/commands/logs.ts +2 -7
  12. package/src/commands/ps.ts +41 -6
  13. package/src/commands/restore.ts +26 -47
  14. package/src/commands/ssh.ts +2 -5
  15. package/src/commands/teleport.ts +38 -24
  16. package/src/commands/tunnel.ts +2 -7
  17. package/src/commands/upgrade.ts +108 -7
  18. package/src/components/DefaultMainScreen.tsx +25 -3
  19. package/src/index.ts +2 -7
  20. package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
  22. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  23. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  24. package/src/lib/assistant-client.ts +5 -21
  25. package/src/lib/assistant-config.ts +34 -16
  26. package/src/lib/cli-error.ts +1 -0
  27. package/src/lib/client-identity.ts +1 -1
  28. package/src/lib/config-utils.ts +1 -97
  29. package/src/lib/docker.ts +2 -2
  30. package/src/lib/job-polling.ts +1 -1
  31. package/src/lib/local-runtime-client.ts +81 -28
  32. package/src/lib/local.ts +27 -58
  33. package/src/lib/platform-client.ts +1 -220
  34. package/src/lib/platform-releases.ts +23 -0
  35. package/src/lib/runtime-url.ts +30 -0
  36. package/src/lib/sync-cloud-assistants.ts +126 -0
  37. package/src/lib/terminal-client.ts +6 -1
  38. package/src/lib/terminal-session.ts +127 -48
  39. package/src/lib/tui-log.ts +60 -0
  40. package/src/lib/xdg-log.ts +10 -4
@@ -91,14 +91,6 @@ const hatchAssistantMock = spyOn(
91
91
  reusedExisting: false,
92
92
  });
93
93
 
94
- const platformInitiateExportMock = spyOn(
95
- platformClient,
96
- "platformInitiateExport",
97
- ).mockResolvedValue({
98
- jobId: "platform-export-job-1",
99
- status: "pending",
100
- });
101
-
102
94
  const platformPollJobStatusMock = spyOn(
103
95
  platformClient,
104
96
  "platformPollJobStatus",
@@ -294,7 +286,6 @@ afterAll(() => {
294
286
  getPlatformUrlMock.mockRestore();
295
287
  hatchAssistantMock.mockRestore();
296
288
  checkExistingPlatformAssistantMock.mockRestore();
297
- platformInitiateExportMock.mockRestore();
298
289
  platformPollJobStatusMock.mockRestore();
299
290
  platformRequestSignedUrlMock.mockRestore();
300
291
  platformImportBundleFromGcsMock.mockRestore();
@@ -319,7 +310,7 @@ let consoleErrorSpy: ReturnType<typeof spyOn>;
319
310
  let fetchCalls: Array<{ url: string; body: unknown }>;
320
311
 
321
312
  function defaultLocalRuntimePollImpl(
322
- _runtimeUrl: string,
313
+ _entry: unknown,
323
314
  _token: string,
324
315
  jobId: string,
325
316
  ): Promise<{
@@ -378,11 +369,6 @@ beforeEach(() => {
378
369
  },
379
370
  reusedExisting: false,
380
371
  });
381
- platformInitiateExportMock.mockReset();
382
- platformInitiateExportMock.mockResolvedValue({
383
- jobId: "platform-export-job-1",
384
- status: "pending",
385
- });
386
372
  platformPollJobStatusMock.mockReset();
387
373
  platformPollJobStatusMock.mockResolvedValue({
388
374
  jobId: "platform-job-1",
@@ -869,9 +855,14 @@ describe("unified GCS flow — four directions", () => {
869
855
  "https://platform.vellum.ai",
870
856
  );
871
857
 
872
- // Runtime export-to-gcs kicked off with the signed upload URL
858
+ // Runtime export-to-gcs kicked off with the signed upload URL.
859
+ // Helper takes an entry, not a bare URL — the entry's cloud drives
860
+ // URL construction (local → gateway loopback path).
873
861
  expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
874
- "http://localhost:7821",
862
+ expect.objectContaining({
863
+ cloud: "local",
864
+ runtimeUrl: "http://localhost:7821",
865
+ }),
875
866
  "local-token",
876
867
  expect.objectContaining({
877
868
  uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
@@ -926,13 +917,58 @@ describe("unified GCS flow — four directions", () => {
926
917
  bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
927
918
  });
928
919
 
920
+ // The bundle key now flows from the upload signed-URL request rather than
921
+ // the job-status payload — pin it so the download-URL assertion below
922
+ // still uses the same expected key.
923
+ platformRequestSignedUrlMock.mockImplementation(async (params) => ({
924
+ url:
925
+ params.operation === "upload"
926
+ ? "https://storage.googleapis.com/bucket/signed-upload"
927
+ : "https://storage.googleapis.com/bucket/signed-download",
928
+ bundleKey:
929
+ params.bundleKey ?? "platform-exports/org-1/bundle-abc.vbundle",
930
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
931
+ }));
932
+
929
933
  const restoreFetch = installTrackingFetch();
930
934
  try {
931
935
  await teleport();
932
936
 
933
- // Platform side: initiated server export and polled the unified status.
934
- expect(platformInitiateExportMock).toHaveBeenCalled();
935
- expect(platformPollJobStatusMock).toHaveBeenCalled();
937
+ // Platform side: requested an upload URL, kicked off a runtime export to
938
+ // GCS, and polled the unified job status.
939
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
940
+ expect.objectContaining({ operation: "upload" }),
941
+ "platform-token",
942
+ "https://platform.vellum.ai",
943
+ );
944
+ // For platform sources, export-to-gcs is reached via the platform's
945
+ // wildcard runtime proxy. The helper builds the assistant-scoped URL
946
+ // from the entry (`/v1/assistants/<id>/migrations/export-to-gcs`) and
947
+ // sends platform-token auth — no guardian-token bootstrap.
948
+ expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
949
+ expect.objectContaining({
950
+ cloud: "vellum",
951
+ runtimeUrl: "https://platform.vellum.ai",
952
+ assistantId: "my-platform",
953
+ }),
954
+ "platform-token",
955
+ expect.objectContaining({
956
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
957
+ description: "teleport export",
958
+ }),
959
+ );
960
+ // Polling for platform sources also goes through the wildcard via
961
+ // localRuntimePollJobStatus(entry, ...) — the dedicated
962
+ // `/v1/migrations/jobs/{id}/` endpoint queries platform-side
963
+ // ImportJob records and would 404 on runtime-created job IDs.
964
+ expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
965
+ expect.objectContaining({
966
+ cloud: "vellum",
967
+ runtimeUrl: "https://platform.vellum.ai",
968
+ }),
969
+ "platform-token",
970
+ "local-export-job-1",
971
+ );
936
972
 
937
973
  // For the local target we request a download URL keyed by the
938
974
  // platform's bundle_key. The URL must target the SOURCE platform
@@ -949,7 +985,10 @@ describe("unified GCS flow — four directions", () => {
949
985
 
950
986
  // Runtime import-from-gcs was kicked off with that URL.
951
987
  expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
952
- "http://localhost:7821",
988
+ expect.objectContaining({
989
+ cloud: "local",
990
+ runtimeUrl: "http://localhost:7821",
991
+ }),
953
992
  "local-token",
954
993
  expect.objectContaining({
955
994
  bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
@@ -1009,7 +1048,10 @@ describe("unified GCS flow — four directions", () => {
1009
1048
  "https://platform.vellum.ai",
1010
1049
  );
1011
1050
  expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
1012
- "http://localhost:7822",
1051
+ expect.objectContaining({
1052
+ cloud: "docker",
1053
+ runtimeUrl: "http://localhost:7822",
1054
+ }),
1013
1055
  "local-token",
1014
1056
  expect.objectContaining({
1015
1057
  bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
@@ -1062,7 +1104,10 @@ describe("unified GCS flow — four directions", () => {
1062
1104
  "https://platform.vellum.ai",
1063
1105
  );
1064
1106
  expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
1065
- "http://localhost:7822",
1107
+ expect.objectContaining({
1108
+ cloud: "docker",
1109
+ runtimeUrl: "http://localhost:7822",
1110
+ }),
1066
1111
  "local-token",
1067
1112
  expect.objectContaining({
1068
1113
  uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
@@ -1071,7 +1116,10 @@ describe("unified GCS flow — four directions", () => {
1071
1116
 
1072
1117
  // Import leg: download-URL targets the new local runtime
1073
1118
  expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
1074
- "http://localhost:7823",
1119
+ expect.objectContaining({
1120
+ cloud: "local",
1121
+ runtimeUrl: "http://localhost:7823",
1122
+ }),
1075
1123
  "local-token",
1076
1124
  expect.anything(),
1077
1125
  );
@@ -1159,12 +1207,23 @@ describe("signed-URL request targets the bundle-owning platform", () => {
1159
1207
  bundleKey: "dev-bundle-key",
1160
1208
  });
1161
1209
 
1210
+ // Bundle key flows from the upload signed-URL request now; pin it so the
1211
+ // download-URL assertion below uses the same key.
1212
+ platformRequestSignedUrlMock.mockImplementation(async (params) => ({
1213
+ url:
1214
+ params.operation === "upload"
1215
+ ? "https://storage.googleapis.com/bucket/signed-upload"
1216
+ : "https://storage.googleapis.com/bucket/signed-download",
1217
+ bundleKey: params.bundleKey ?? "dev-bundle-key",
1218
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1219
+ }));
1220
+
1162
1221
  const restoreFetch = installTrackingFetch();
1163
1222
  try {
1164
1223
  await teleport();
1165
1224
 
1166
1225
  // The download URL must be requested from the SOURCE platform (where
1167
- // the bundle was written by the server-side export), not the default.
1226
+ // the bundle was written by the runtime export), not the default.
1168
1227
  expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
1169
1228
  { operation: "download", bundleKey: "dev-bundle-key" },
1170
1229
  "platform-token",
@@ -1555,10 +1614,9 @@ describe("dry-run", () => {
1555
1614
  ),
1556
1615
  );
1557
1616
 
1558
- // Must fail BEFORE any export work — no signed URL request, no platform
1559
- // export initiation, nothing that costs time or bandwidth.
1617
+ // Must fail BEFORE any export work — no signed URL request, no runtime
1618
+ // export kickoff, nothing that costs time or bandwidth.
1560
1619
  expect(platformRequestSignedUrlMock).not.toHaveBeenCalled();
1561
- expect(platformInitiateExportMock).not.toHaveBeenCalled();
1562
1620
  expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
1563
1621
  } finally {
1564
1622
  restoreFetch();
@@ -1,14 +1,19 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
 
4
- import { findAssistantByName } from "../lib/assistant-config";
4
+ import type { AssistantEntry } from "../lib/assistant-config.js";
5
+ import { findAssistantByName } from "../lib/assistant-config.js";
5
6
  import { getBackupsDir, formatSize } from "../lib/backup-ops.js";
6
7
  import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
8
+ import { pollJobUntilDone } from "../lib/job-polling.js";
7
9
  import {
10
+ MigrationInProgressError,
11
+ localRuntimeExportToGcs,
12
+ localRuntimePollJobStatus,
13
+ } from "../lib/local-runtime-client.js";
14
+ import {
15
+ platformRequestSignedUrl,
8
16
  readPlatformToken,
9
- platformInitiateExport,
10
- platformPollExportStatus,
11
- platformDownloadExport,
12
17
  } from "../lib/platform-client.js";
13
18
 
14
19
  export async function backup(): Promise<void> {
@@ -73,7 +78,7 @@ export async function backup(): Promise<void> {
73
78
  }
74
79
 
75
80
  if (cloud === "vellum") {
76
- await backupPlatform(name, outputArg, entry.runtimeUrl);
81
+ await backupPlatform(entry, name, outputArg);
77
82
  return;
78
83
  }
79
84
 
@@ -188,39 +193,66 @@ export async function backup(): Promise<void> {
188
193
  }
189
194
 
190
195
  // ---------------------------------------------------------------------------
191
- // Platform (Vellum-hosted) backup via Django async migration export
196
+ // Platform-managed (cloud="vellum") backup over GCS.
197
+ //
198
+ // The runtime exports the bundle straight to a platform-issued signed GCS
199
+ // URL; the CLI then downloads from GCS to local disk. Bytes never flow
200
+ // through Django. Same architectural shape as the platform-source half of
201
+ // `vellum teleport`. Output format and success log lines match mode 1
202
+ // (runtime-direct local backup) so users see one consistent UX.
203
+ //
204
+ // Lifecycle: the GCS bucket has a 1-day TTL on `uploads/<org>/*` objects
205
+ // (see `vellum-assistant-platform/django/app/assistant/migration/views.py`
206
+ // and `migration/services.py`). Backup is single-shot with no import to
207
+ // trigger best-effort cleanup, so the bundle sits in GCS up to 24h before
208
+ // TTL deletion. No explicit cleanup endpoint exists; relying on TTL is
209
+ // intentional.
192
210
  // ---------------------------------------------------------------------------
193
-
194
211
  async function backupPlatform(
212
+ entry: AssistantEntry,
195
213
  name: string,
196
214
  outputArg?: string,
197
- runtimeUrl?: string,
198
215
  ): Promise<void> {
199
- // Step 1 — Authenticate
200
- const token = readPlatformToken();
201
- if (!token) {
202
- console.error("Not logged in. Run 'vellum login' first.");
216
+ const platformToken = readPlatformToken();
217
+ if (!platformToken) {
218
+ console.error(
219
+ "Not logged in. Run 'vellum login' first (required for platform-managed backup).",
220
+ );
203
221
  process.exit(1);
204
222
  }
205
-
206
- // Step 2 Initiate export job
223
+ // Pin upload, download, and runtime requests to the same platform instance
224
+ // the assistant lives on. Using `getPlatformUrl()` instead would target
225
+ // whatever the lockfile / env-var resolves to, which may differ from
226
+ // `entry.runtimeUrl` for staging/dev assistants and end up signing URLs
227
+ // for the wrong GCS bucket. Mirrors the teleport bundlePlatformUrl
228
+ // threading at `cli/src/commands/teleport.ts:1311-1312`.
229
+ const platformUrl = entry.runtimeUrl;
230
+ // Track the working platform token across kickoff/poll/download so a
231
+ // 401-driven refresh during polling stays consistent through the final
232
+ // signed-download request.
233
+ let exportPlatformToken = platformToken;
234
+
235
+ // Step 1 — Request a signed upload URL.
236
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
237
+ { operation: "upload" },
238
+ exportPlatformToken,
239
+ platformUrl,
240
+ );
241
+
242
+ // Step 2 — Kick off runtime export-to-GCS through the platform's
243
+ // wildcard runtime proxy. `localRuntimeExportToGcs` builds the
244
+ // `/v1/assistants/<id>/migrations/export-to-gcs` URL for cloud="vellum"
245
+ // and uses platform-token auth (no guardian-token bootstrap).
207
246
  let jobId: string;
208
247
  try {
209
- const result = await platformInitiateExport(
210
- token,
211
- "CLI backup",
212
- runtimeUrl,
213
- );
214
- jobId = result.jobId;
248
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
249
+ uploadUrl,
250
+ description: "CLI backup",
251
+ }));
215
252
  } catch (err) {
216
- const msg = err instanceof Error ? err.message : String(err);
217
- if (msg.includes("401") || msg.includes("403")) {
218
- console.error("Authentication failed. Run 'vellum login' to refresh.");
219
- process.exit(1);
220
- }
221
- if (msg.includes("429")) {
253
+ if (err instanceof MigrationInProgressError) {
222
254
  console.error(
223
- "Too many export requests. Please wait before trying again.",
255
+ `Error: Another backup or teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish, then re-run.`,
224
256
  );
225
257
  process.exit(1);
226
258
  }
@@ -229,65 +261,79 @@ async function backupPlatform(
229
261
 
230
262
  console.log(`Export started (job ${jobId})...`);
231
263
 
232
- // Step 3 — Poll for completion
233
- const POLL_INTERVAL_MS = 2_000;
234
- const TIMEOUT_MS = 5 * 60 * 1_000; // 5 minutes
235
- const deadline = Date.now() + TIMEOUT_MS;
236
- let downloadUrl: string | undefined;
237
-
238
- while (Date.now() < deadline) {
239
- let status: { status: string; downloadUrl?: string; error?: string };
240
- try {
241
- status = await platformPollExportStatus(jobId, token, runtimeUrl);
242
- } catch (err) {
243
- const msg = err instanceof Error ? err.message : String(err);
244
- // Let non-transient errors (e.g. 404 "job not found") propagate immediately
245
- if (msg.includes("not found")) {
246
- throw err;
264
+ // Step 3 — Poll the job through the wildcard proxy. The dedicated
265
+ // `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
266
+ // records and would 404 on runtime-created job IDs.
267
+ const terminal = await pollJobUntilDone({
268
+ label: "platform export",
269
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
270
+ refreshOn401: async () => {
271
+ const refreshed = readPlatformToken();
272
+ if (!refreshed) {
273
+ throw new Error(
274
+ "Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
275
+ );
247
276
  }
248
- console.warn(`Polling failed, retrying... (${msg})`);
249
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
250
- continue;
251
- }
277
+ exportPlatformToken = refreshed;
278
+ },
279
+ });
252
280
 
253
- if (status.status === "complete") {
254
- downloadUrl = status.downloadUrl;
255
- break;
256
- }
257
-
258
- if (status.status === "failed") {
259
- console.error(`Export failed: ${status.error ?? "unknown error"}`);
260
- process.exit(1);
261
- }
262
-
263
- // Still in progress — wait and retry
264
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
281
+ if (terminal.status === "failed") {
282
+ console.error(`Error: Export failed: ${terminal.error}`);
283
+ process.exit(1);
265
284
  }
266
285
 
267
- if (!downloadUrl) {
268
- console.error("Export timed out after 5 minutes.");
286
+ // Step 4 — Request a signed download URL for the same bundle and fetch
287
+ // it from GCS directly. No auth on signed URLs.
288
+ // Use `exportPlatformToken` (not the original `platformToken`) so a
289
+ // poll-loop 401 refresh doesn't get clobbered here — otherwise a long
290
+ // export that recovered mid-poll via re-auth would still 401 on the
291
+ // download-URL request and abort an otherwise successful run.
292
+ const { url: bundleUrl } = await platformRequestSignedUrl(
293
+ { operation: "download", bundleKey },
294
+ exportPlatformToken,
295
+ platformUrl,
296
+ );
297
+
298
+ let downloadResponse: Response;
299
+ try {
300
+ downloadResponse = await fetch(bundleUrl);
301
+ } catch (err) {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ console.error(`Error: Failed to fetch bundle from GCS: ${msg}`);
304
+ process.exit(1);
305
+ }
306
+ if (!downloadResponse.ok) {
307
+ const body = await downloadResponse.text().catch(() => "");
308
+ console.error(
309
+ `Error: Failed to fetch bundle from GCS (${downloadResponse.status}): ${body}`,
310
+ );
269
311
  process.exit(1);
270
312
  }
271
313
 
272
- // Step 4 Download bundle
314
+ const arrayBuffer = await downloadResponse.arrayBuffer();
315
+ const data = new Uint8Array(arrayBuffer);
316
+
317
+ // Step 5 — Write to disk using the same path resolution mode 1 uses.
273
318
  const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
274
319
  const outputPath =
275
320
  outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
276
-
277
321
  mkdirSync(dirname(outputPath), { recursive: true });
278
-
279
- const response = await platformDownloadExport(downloadUrl);
280
- const arrayBuffer = await response.arrayBuffer();
281
- const data = new Uint8Array(arrayBuffer);
282
-
283
322
  writeFileSync(outputPath, data);
284
323
 
285
- // Step 5 — Print success
324
+ // Step 6 — Print success. Manifest SHA is included only if the runtime
325
+ // surfaced it via the unified job result; the export-to-gcs runtime
326
+ // route does not set the legacy `X-Vbundle-Manifest-Sha256` response
327
+ // header.
286
328
  console.log(`Backup saved to ${outputPath}`);
287
329
  console.log(`Size: ${formatSize(data.byteLength)}`);
288
-
289
- const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
290
- if (manifestSha) {
330
+ const manifestSha =
331
+ terminal.status === "complete" &&
332
+ terminal.result &&
333
+ typeof terminal.result === "object"
334
+ ? (terminal.result as Record<string, unknown>).manifest_sha256
335
+ : undefined;
336
+ if (typeof manifestSha === "string") {
291
337
  console.log(`Manifest SHA-256: ${manifestSha}`);
292
338
  }
293
339
  }
@@ -3,7 +3,7 @@ import { hostname } from "os";
3
3
  import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
- loadLatestAssistant,
6
+ resolveAssistant,
7
7
  } from "../lib/assistant-config";
8
8
  import {
9
9
  DAEMON_INTERNAL_ASSISTANT_ID,
@@ -11,7 +11,8 @@ import {
11
11
  type Species,
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
- import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
14
+ import { getLocalLanIPv4 } from "../lib/local";
15
+ import { tuiLog } from "../lib/tui-log";
15
16
 
16
17
  const ANSI = {
17
18
  reset: "\x1b[0m",
@@ -76,8 +77,8 @@ function parseArgs(): ParsedArgs {
76
77
  }
77
78
  }
78
79
  if (!entry && hasExplicitUrl) {
79
- // URL provided but active assistant missing or unset — use latest for remaining defaults
80
- entry = loadLatestAssistant();
80
+ // URL provided but active assistant missing or unset — resolve for remaining defaults
81
+ entry = resolveAssistant();
81
82
  } else if (!entry) {
82
83
  console.error(
83
84
  "No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
@@ -140,11 +141,6 @@ function maybeSwapToLocalhost(url: string): string {
140
141
  }
141
142
  }
142
143
 
143
- const macHost = getMacLocalHostname();
144
- if (macHost) {
145
- localNames.push(macHost.toLowerCase());
146
- }
147
-
148
144
  const lanIp = getLocalLanIPv4();
149
145
  if (lanIp) {
150
146
  localNames.push(lanIp);
@@ -188,6 +184,9 @@ export async function client(): Promise<void> {
188
184
  const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
189
185
  parseArgs();
190
186
 
187
+ tuiLog.init();
188
+ tuiLog.info("session start", { runtimeUrl, assistantId, species });
189
+
191
190
  const { renderChatApp } = await import("../components/DefaultMainScreen");
192
191
 
193
192
  process.stdout.write("\x1b[2J\x1b[H");
@@ -197,6 +196,8 @@ export async function client(): Promise<void> {
197
196
  assistantId,
198
197
  species,
199
198
  () => {
199
+ tuiLog.info("session end (user disconnect)");
200
+ tuiLog.close();
200
201
  app.unmount();
201
202
  process.stdout.write("\x1b[2J\x1b[H");
202
203
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
@@ -1,10 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import {
4
- findAssistantByName,
5
- loadLatestAssistant,
6
- resolveCloud,
7
- } from "../lib/assistant-config";
3
+ import { resolveAssistant, resolveCloud } from "../lib/assistant-config";
8
4
  import { dockerResourceNames } from "../lib/docker";
9
5
  import type { ServiceName } from "../lib/docker";
10
6
  import { execAppleContainer } from "../lib/exec-apple-container";
@@ -84,6 +80,9 @@ export async function exec(): Promise<void> {
84
80
  console.log(
85
81
  " -it Interactive mode with TTY (like docker exec -it)",
86
82
  );
83
+ console.log(
84
+ " --timeout <secs> Timeout in seconds (default: 30, 0 = no timeout)",
85
+ );
87
86
  console.log(
88
87
  " --verbose Show debug output (SSE events, sentinel parsing)",
89
88
  );
@@ -120,12 +119,20 @@ export async function exec(): Promise<void> {
120
119
  let serviceRaw = "assistant";
121
120
  let interactive = false;
122
121
  let verbose = false;
122
+ let timeoutMs = 30_000;
123
123
 
124
124
  for (let i = 0; i < preArgs.length; i++) {
125
125
  if (preArgs[i] === "--service" && preArgs[i + 1]) {
126
126
  serviceRaw = preArgs[++i];
127
127
  } else if (preArgs[i] === "-it" || preArgs[i] === "-ti") {
128
128
  interactive = true;
129
+ } else if (preArgs[i] === "--timeout" && preArgs[i + 1]) {
130
+ const secs = Number(preArgs[++i]);
131
+ if (!Number.isFinite(secs) || secs < 0) {
132
+ console.error("Error: --timeout must be a non-negative number.");
133
+ process.exit(1);
134
+ }
135
+ timeoutMs = secs === 0 ? 0 : secs * 1000;
129
136
  } else if (preArgs[i] === "--verbose") {
130
137
  verbose = true;
131
138
  } else if (!preArgs[i].startsWith("-")) {
@@ -135,7 +142,7 @@ export async function exec(): Promise<void> {
135
142
 
136
143
  const service = normalizeService(serviceRaw);
137
144
 
138
- const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
145
+ const entry = resolveAssistant(nameArg);
139
146
 
140
147
  if (!entry) {
141
148
  if (nameArg) {
@@ -200,14 +207,20 @@ export async function exec(): Promise<void> {
200
207
  platformUrl: getPlatformUrl(),
201
208
  };
202
209
 
210
+ const serviceParam = service === "assistant" ? undefined : service;
211
+
203
212
  if (interactive) {
204
213
  // Interactive mode: shell-escape argv and delegate to full terminal
205
- await interactiveSession(assistant, shellEscapeArgs(command));
214
+ await interactiveSession(assistant, shellEscapeArgs(command), serviceParam);
206
215
  return;
207
216
  }
208
217
 
209
218
  // Non-interactive: sentinel-based output capture with exit code
210
- await nonInteractiveExec(assistant, command, { verbose });
219
+ await nonInteractiveExec(assistant, command, {
220
+ verbose,
221
+ timeoutMs,
222
+ service: serviceParam,
223
+ });
211
224
  return;
212
225
  }
213
226
 
@@ -15,7 +15,7 @@ import {
15
15
  VALID_SPECIES,
16
16
  } from "../lib/constants";
17
17
  import type { RemoteHost, Species } from "../lib/constants";
18
- import { buildInitialConfig } from "../lib/config-utils";
18
+ import { buildNestedConfig } from "../lib/config-utils";
19
19
  import { hatchDocker } from "../lib/docker";
20
20
  import { hatchGcp } from "../lib/gcp";
21
21
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
@@ -123,11 +123,7 @@ export async function buildStartupScript(
123
123
  // and export the env var so the daemon reads it on first boot.
124
124
  let configWriteBlock = "";
125
125
  if (Object.keys(configValues).length > 0) {
126
- const configJson = JSON.stringify(
127
- buildInitialConfig(configValues),
128
- null,
129
- 2,
130
- );
126
+ const configJson = JSON.stringify(buildNestedConfig(configValues), null, 2);
131
127
  configWriteBlock = `
132
128
  echo "Writing default workspace config..."
133
129
  VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH="/tmp/vellum-initial-config-$$.json"