@vellumai/cli 0.10.4-staging.1 → 0.10.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.4-staging.1",
3
+ "version": "0.10.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -83,10 +83,10 @@ describe("vellum flags --assistant routing", () => {
83
83
  fetchCalls.push({ url, method });
84
84
  if (method === "PATCH") {
85
85
  return jsonResponse({
86
- key: "external-plugins",
86
+ key: "voice-mode",
87
87
  enabled: true,
88
88
  defaultEnabled: false,
89
- label: "External Plugins",
89
+ label: "Voice Mode",
90
90
  description: "test",
91
91
  });
92
92
  }
@@ -133,7 +133,7 @@ describe("vellum flags --assistant routing", () => {
133
133
  "vellum",
134
134
  "flags",
135
135
  "set",
136
- "external-plugins",
136
+ "voice-mode",
137
137
  "true",
138
138
  "--assistant",
139
139
  "Bob",
@@ -146,7 +146,7 @@ describe("vellum flags --assistant routing", () => {
146
146
  // bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
147
147
  expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
148
148
  expect(fetchCalls[0].url).toContain(
149
- "/v1/assistants/bob-2/feature-flags/external-plugins",
149
+ "/v1/assistants/bob-2/feature-flags/voice-mode",
150
150
  );
151
151
  });
152
152
 
@@ -166,7 +166,7 @@ describe("vellum flags --assistant routing", () => {
166
166
  "--assistant",
167
167
  "Bob",
168
168
  "set",
169
- "external-plugins",
169
+ "voice-mode",
170
170
  "true",
171
171
  ];
172
172
 
@@ -174,7 +174,7 @@ describe("vellum flags --assistant routing", () => {
174
174
 
175
175
  expect(fetchCalls.length).toBe(1);
176
176
  expect(fetchCalls[0].url).toContain(
177
- "/v1/assistants/bob-2/feature-flags/external-plugins",
177
+ "/v1/assistants/bob-2/feature-flags/voice-mode",
178
178
  );
179
179
  });
180
180
 
@@ -193,7 +193,7 @@ describe("vellum flags --assistant routing", () => {
193
193
  "vellum",
194
194
  "flags",
195
195
  "set",
196
- "external-plugins",
196
+ "voice-mode",
197
197
  "true",
198
198
  ];
199
199
 
@@ -203,7 +203,7 @@ describe("vellum flags --assistant routing", () => {
203
203
  // alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
204
204
  expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
205
205
  expect(fetchCalls[0].url).toContain(
206
- "/v1/assistants/alice-1/feature-flags/external-plugins",
206
+ "/v1/assistants/alice-1/feature-flags/voice-mode",
207
207
  );
208
208
  });
209
209
 
@@ -214,7 +214,7 @@ describe("vellum flags --assistant routing", () => {
214
214
  "vellum",
215
215
  "flags",
216
216
  "set",
217
- "external-plugins",
217
+ "voice-mode",
218
218
  "true",
219
219
  "--assistant",
220
220
  "Ghost",
@@ -234,7 +234,7 @@ describe("vellum flags --assistant routing", () => {
234
234
  "vellum",
235
235
  "flags",
236
236
  "set",
237
- "external-plugins",
237
+ "voice-mode",
238
238
  "true",
239
239
  "--assistant",
240
240
  ];
@@ -13,6 +13,7 @@ import {
13
13
  existsSync,
14
14
  mkdirSync,
15
15
  mkdtempSync,
16
+ renameSync,
16
17
  rmSync,
17
18
  writeFileSync,
18
19
  } from "node:fs";
@@ -220,37 +221,43 @@ describe("recover error cases", () => {
220
221
  describe("recover extraction path — default instance (instanceDir === homedir())", () => {
221
222
  test("extracts to retiredDir and renames staging dir to instanceDir/.vellum", async () => {
222
223
  const name = "default-instance";
223
- // Default instance: instanceDir is the real home directory
224
224
  const entry = makeEntry(name, homedir());
225
225
  const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
226
226
 
227
227
  const expectedTargetDir = join(homedir(), ".vellum");
228
-
229
- process.argv = ["bun", "vellum", "recover", name];
230
- await recover();
231
-
232
- // exec must have been called with -C retiredDir, NOT -C homedir()
233
- expect(execMock).toHaveBeenCalledTimes(1);
234
- const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
235
- expect(cmd).toBe("tar");
236
- expect(args).toContain("-C");
237
- const cIndex = args.indexOf("-C");
238
- expect(args[cIndex + 1]).toBe(retiredDir);
239
- expect(args[cIndex + 1]).not.toBe(homedir());
240
-
241
- // Staging dir was renamed to the correct target
242
- expect(existsSync(extractedPath)).toBe(false);
243
- expect(existsSync(expectedTargetDir)).toBe(true);
244
-
245
- // Archive and metadata were cleaned up
246
- expect(existsSync(archivePath)).toBe(false);
247
-
248
- // Daemon and gateway were started
249
- expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
250
- expect(startGatewayMock).toHaveBeenCalledTimes(1);
251
-
252
- // Clean up so we don't leave a .vellum dir in the real home dir
253
- rmSync(expectedTargetDir, { recursive: true, force: true });
228
+ // If a real ~/.vellum exists (e.g. the machine runs a live assistant),
229
+ // temporarily move it aside so the collision guard doesn't fire.
230
+ const backupDir = join(homedir(), ".vellum-recover-test-bak");
231
+ const hadExisting = existsSync(expectedTargetDir);
232
+ if (hadExisting) renameSync(expectedTargetDir, backupDir);
233
+
234
+ try {
235
+ process.argv = ["bun", "vellum", "recover", name];
236
+ await recover();
237
+
238
+ // exec must have been called with -C retiredDir, NOT -C homedir()
239
+ expect(execMock).toHaveBeenCalledTimes(1);
240
+ const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
241
+ expect(cmd).toBe("tar");
242
+ expect(args).toContain("-C");
243
+ const cIndex = args.indexOf("-C");
244
+ expect(args[cIndex + 1]).toBe(retiredDir);
245
+ expect(args[cIndex + 1]).not.toBe(homedir());
246
+
247
+ // Staging dir was renamed to the correct target
248
+ expect(existsSync(extractedPath)).toBe(false);
249
+ expect(existsSync(expectedTargetDir)).toBe(true);
250
+
251
+ // Archive and metadata were cleaned up
252
+ expect(existsSync(archivePath)).toBe(false);
253
+
254
+ // Daemon and gateway were started
255
+ expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
256
+ expect(startGatewayMock).toHaveBeenCalledTimes(1);
257
+ } finally {
258
+ rmSync(expectedTargetDir, { recursive: true, force: true });
259
+ if (hadExisting) renameSync(backupDir, expectedTargetDir);
260
+ }
254
261
  });
255
262
  });
256
263
 
@@ -259,12 +259,27 @@ const localRuntimePollJobStatusMock = mock<
259
259
  },
260
260
  }));
261
261
 
262
+ const localRuntimePreflightFromGcsMock = mock<
263
+ typeof localRuntimeClient.localRuntimePreflightFromGcs
264
+ >(async () => ({
265
+ can_import: true,
266
+ summary: {
267
+ files_to_create: 2,
268
+ files_to_overwrite: 1,
269
+ files_unchanged: 0,
270
+ total_files: 3,
271
+ },
272
+ files: [],
273
+ conflicts: [],
274
+ }));
275
+
262
276
  mock.module("../lib/local-runtime-client.js", () => ({
263
277
  ...realLocalRuntimeClient,
264
278
  localRuntimeExportToGcs: localRuntimeExportToGcsMock,
265
279
  localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
266
280
  localRuntimeIdentity: localRuntimeIdentityMock,
267
281
  localRuntimePollJobStatus: localRuntimePollJobStatusMock,
282
+ localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
268
283
  }));
269
284
 
270
285
  const hatchLocalMock = mock(async () => {});
@@ -483,6 +498,18 @@ beforeEach(() => {
483
498
  localRuntimeImportFromGcsMock.mockResolvedValue({
484
499
  jobId: "local-import-job-1",
485
500
  });
501
+ localRuntimePreflightFromGcsMock.mockReset();
502
+ localRuntimePreflightFromGcsMock.mockResolvedValue({
503
+ can_import: true,
504
+ summary: {
505
+ files_to_create: 2,
506
+ files_to_overwrite: 1,
507
+ files_unchanged: 0,
508
+ total_files: 3,
509
+ },
510
+ files: [],
511
+ conflicts: [],
512
+ });
486
513
  localRuntimeIdentityMock.mockReset();
487
514
  localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
488
515
  localRuntimePollJobStatusMock.mockReset();
@@ -1932,7 +1959,7 @@ describe("dry-run", () => {
1932
1959
  }
1933
1960
  });
1934
1961
 
1935
- test("dry-run against local target fails fast (no preflight-from-gcs runtime endpoint yet)", async () => {
1962
+ test("dry-run with existing local target calls preflight-from-gcs on runtime", async () => {
1936
1963
  setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
1937
1964
 
1938
1965
  const platformEntry = makeEntry("my-platform", {
@@ -1947,26 +1974,24 @@ describe("dry-run", () => {
1947
1974
  return null;
1948
1975
  });
1949
1976
 
1950
- platformPollJobStatusMock.mockResolvedValue({
1951
- jobId: "platform-export-job-1",
1952
- type: "export",
1953
- status: "complete",
1954
- bundleKey: "bundle-key-from-platform",
1955
- });
1956
-
1957
1977
  const restoreFetch = installTrackingFetch();
1958
1978
  try {
1959
- await expect(teleport()).rejects.toThrow("process.exit:1");
1960
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1961
- expect.stringContaining(
1962
- "--dry-run is not yet supported for local or docker targets",
1963
- ),
1979
+ await teleport();
1980
+
1981
+ // Preflight was run against the local runtime
1982
+ expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledTimes(1);
1983
+ expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledWith(
1984
+ expect.objectContaining({ assistantId: "my-local" }),
1985
+ expect.any(String),
1986
+ { bundleUrl: "https://storage.googleapis.com/bucket/signed-download" },
1964
1987
  );
1965
1988
 
1966
- // Must fail BEFORE any export work no signed URL request, no runtime
1967
- // export kickoff, nothing that costs time or bandwidth.
1968
- expect(platformRequestSignedUrlMock).not.toHaveBeenCalled();
1969
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
1989
+ // No actual import happeneddry-run only
1990
+ expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1991
+
1992
+ // Source was not retired
1993
+ expect(retireLocalMock).not.toHaveBeenCalled();
1994
+ expect(retireDockerMock).not.toHaveBeenCalled();
1970
1995
  } finally {
1971
1996
  restoreFetch();
1972
1997
  }
@@ -98,7 +98,7 @@ function printHelp(): void {
98
98
  " $ vellum flags set voice-mode true # enable a flag",
99
99
  );
100
100
  console.log(
101
- " $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
101
+ " $ vellum flags set voice-mode true --assistant eval-1 # target by name/id",
102
102
  );
103
103
  }
104
104
 
@@ -33,6 +33,7 @@ import {
33
33
  localRuntimeExportToGcs,
34
34
  localRuntimeIdentity,
35
35
  localRuntimeImportFromGcs,
36
+ localRuntimePreflightFromGcs,
36
37
  localRuntimePollJobStatus,
37
38
  MigrationInProgressError,
38
39
  } from "../lib/local-runtime-client.js";
@@ -707,11 +708,46 @@ async function importToAssistant(
707
708
 
708
709
  if (cloud === "local" || cloud === "docker") {
709
710
  if (dryRun) {
710
- // TODO(cli): support dry-run against local targets
711
- console.error(
712
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
711
+ console.log("Running preflight analysis...\n");
712
+
713
+ // Query the target runtime's version before requesting a signed
714
+ // download URL — the platform uses it for the bundle compatibility check.
715
+ let targetRuntimeVersion: string;
716
+ try {
717
+ const identity = await callRuntimeWithAuthRetry(entry, (token) =>
718
+ localRuntimeIdentity(entry, token),
719
+ );
720
+ targetRuntimeVersion = identity.version;
721
+ } catch (err) {
722
+ const msg = err instanceof Error ? err.message : String(err);
723
+ console.error(
724
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
725
+ );
726
+ console.error(`Try: vellum wake ${entry.assistantId}`);
727
+ process.exit(1);
728
+ }
729
+
730
+ let bundleUrl: string;
731
+ try {
732
+ const result = await platformRequestSignedUrl(
733
+ { operation: "download", bundleKey, targetRuntimeVersion },
734
+ platformToken,
735
+ bundlePlatformUrl,
736
+ );
737
+ bundleUrl = result.url;
738
+ } catch (err) {
739
+ if (err instanceof VersionMismatchError) {
740
+ console.error(`Error: ${err.message}`);
741
+ process.exit(1);
742
+ }
743
+ throw err;
744
+ }
745
+
746
+ const preflightData = await callRuntimeWithAuthRetry(entry, (token) =>
747
+ localRuntimePreflightFromGcs(entry, token, { bundleUrl }),
713
748
  );
714
- process.exit(1);
749
+ printPreflightSummary(preflightData as unknown as PreflightResponse);
750
+ return;
715
751
  }
716
752
 
717
753
  // Ask the platform for a signed download URL and hand it to the local
@@ -1231,18 +1267,8 @@ export async function teleport(): Promise<void> {
1231
1267
  process.exit(1);
1232
1268
  }
1233
1269
 
1234
- // Dry-run feasibility checkreject local/docker targets BEFORE any
1235
- // export work. The local runtime has no preflight-from-gcs endpoint yet,
1236
- // so we can't actually run a dry-run against it; burning a GCS upload
1237
- // just to fail afterwards would be wasteful.
1238
- // TODO(cli): support dry-run against local targets (needs a
1239
- // preflight-from-gcs endpoint on the runtime).
1240
- if (toCloud === "local" || toCloud === "docker") {
1241
- console.error(
1242
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
1243
- );
1244
- process.exit(1);
1245
- }
1270
+ // Nothing to rejectpreflight-from-gcs is now implemented for all
1271
+ // target topologies including local and docker.
1246
1272
 
1247
1273
  // Version guard: block platform→non-platform when target is behind
1248
1274
  if (fromCloud === "vellum" && toCloud !== "vellum") {
@@ -195,6 +195,44 @@ export async function localRuntimeImportFromGcs(
195
195
  return { jobId: json.job_id };
196
196
  }
197
197
 
198
+ /**
199
+ * Run a dry-run preflight analysis on a .vbundle archive stored at a signed
200
+ * GCS download URL.
201
+ *
202
+ * For local/docker assistants this POSTs to
203
+ * `{runtimeUrl}/v1/migrations/preflight-from-gcs` with guardian-token bearer
204
+ * auth. The daemon fetches the bundle from GCS, validates it, and returns a
205
+ * report of what would change on import — without writing anything to disk.
206
+ *
207
+ * Returns the raw preflight response body. Callers should cast to their
208
+ * local `PreflightResponse` shape (mirrors the `/import-preflight` shape).
209
+ */
210
+ export async function localRuntimePreflightFromGcs(
211
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
212
+ token: string,
213
+ params: { bundleUrl: string },
214
+ ): Promise<Record<string, unknown>> {
215
+ const response = await fetch(
216
+ resolveRuntimeMigrationUrl(entry, "preflight-from-gcs"),
217
+ {
218
+ method: "POST",
219
+ headers: await migrationRequestHeaders(entry, token),
220
+ body: JSON.stringify({ bundle_url: params.bundleUrl }),
221
+ },
222
+ );
223
+
224
+ if (response.status !== 200) {
225
+ const errText = await response.text().catch(() => "");
226
+ throw new Error(
227
+ `Local runtime preflight-from-gcs failed (${response.status}): ${
228
+ errText || response.statusText
229
+ }`,
230
+ );
231
+ }
232
+
233
+ return (await response.json()) as Record<string, unknown>;
234
+ }
235
+
198
236
  /**
199
237
  * Poll the runtime's unified job-status endpoint.
200
238
  *