@vellumai/cli 0.6.2 → 0.6.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.
Files changed (50) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bunfig.toml +6 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-config.test.ts +124 -0
  6. package/src/__tests__/env-drift.test.ts +87 -0
  7. package/src/__tests__/guardian-token.test.ts +172 -0
  8. package/src/__tests__/multi-local.test.ts +61 -14
  9. package/src/__tests__/orphan-detection.test.ts +214 -0
  10. package/src/__tests__/platform-client.test.ts +204 -0
  11. package/src/__tests__/preload.ts +27 -0
  12. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  13. package/src/__tests__/teleport.test.ts +1073 -57
  14. package/src/commands/backup.ts +8 -0
  15. package/src/commands/hatch.ts +5 -28
  16. package/src/commands/login.ts +178 -9
  17. package/src/commands/logs.ts +652 -0
  18. package/src/commands/pair.ts +9 -1
  19. package/src/commands/ps.ts +37 -7
  20. package/src/commands/recover.ts +8 -4
  21. package/src/commands/restore.ts +124 -12
  22. package/src/commands/retire.ts +17 -3
  23. package/src/commands/rollback.ts +32 -33
  24. package/src/commands/sleep.ts +7 -0
  25. package/src/commands/ssh-apple-container.ts +162 -0
  26. package/src/commands/ssh.ts +7 -0
  27. package/src/commands/teleport.ts +307 -3
  28. package/src/commands/upgrade.ts +43 -52
  29. package/src/commands/wake.ts +21 -10
  30. package/src/components/DefaultMainScreen.tsx +7 -1
  31. package/src/index.ts +3 -0
  32. package/src/lib/__tests__/docker.test.ts +78 -0
  33. package/src/lib/assistant-config.ts +54 -87
  34. package/src/lib/aws.ts +12 -1
  35. package/src/lib/constants.ts +0 -10
  36. package/src/lib/docker.ts +73 -4
  37. package/src/lib/environments/__tests__/paths.test.ts +234 -0
  38. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  39. package/src/lib/environments/paths.ts +110 -0
  40. package/src/lib/environments/resolve.ts +96 -0
  41. package/src/lib/environments/seeds.ts +46 -0
  42. package/src/lib/environments/types.ts +60 -0
  43. package/src/lib/gcp.ts +12 -1
  44. package/src/lib/guardian-token.ts +8 -10
  45. package/src/lib/hatch-local.ts +30 -35
  46. package/src/lib/local.ts +46 -5
  47. package/src/lib/orphan-detection.ts +28 -12
  48. package/src/lib/platform-client.ts +261 -25
  49. package/src/lib/retire-apple-container.ts +102 -0
  50. package/src/lib/upgrade-lifecycle.ts +101 -28
@@ -0,0 +1,162 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "../lib/assistant-config";
5
+
6
+ /**
7
+ * Connect to an Apple Container assistant via its management socket.
8
+ * Sends a JSON handshake then relays stdin/stdout in raw mode.
9
+ */
10
+ export async function sshAppleContainer(entry: AssistantEntry): Promise<void> {
11
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
12
+ if (!mgmtSocket) {
13
+ console.error(
14
+ `No management socket found for '${entry.assistantId}'.\n` +
15
+ "The assistant may not have finished starting. Try again in a moment.",
16
+ );
17
+ process.exit(1);
18
+ }
19
+
20
+ if (!existsSync(mgmtSocket)) {
21
+ console.error(
22
+ `Management socket not found at ${mgmtSocket}.\n` +
23
+ "The assistant may have been stopped. Run 'vellum hatch' to start it.",
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ console.log(
29
+ `🔗 Connecting to ${entry.assistantId} via apple container exec...\n`,
30
+ );
31
+
32
+ const cols = process.stdout.columns || 120;
33
+ const rows = process.stdout.rows || 40;
34
+
35
+ const handshake =
36
+ JSON.stringify({
37
+ command: ["/bin/bash"],
38
+ service: "vellum-assistant",
39
+ cols,
40
+ rows,
41
+ }) + "\n";
42
+
43
+ return new Promise<void>((resolve, reject) => {
44
+ const socket = createConnection({ path: mgmtSocket }, () => {
45
+ // Send handshake as soon as connected.
46
+ socket.write(handshake);
47
+ });
48
+
49
+ // 10s handshake timeout — matches SSH ConnectTimeout.
50
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
51
+ let handshakeComplete = false;
52
+ const handshakeChunks: Buffer[] = [];
53
+ let handshakeLen = 0;
54
+
55
+ socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
56
+ socket.on("timeout", () => {
57
+ if (!handshakeComplete) {
58
+ console.error(
59
+ "Timed out waiting for handshake response from management socket.",
60
+ );
61
+ socket.destroy();
62
+ process.exit(1);
63
+ }
64
+ // After handshake, no timeout — interactive session runs indefinitely.
65
+ });
66
+
67
+ socket.on("data", (data: Buffer) => {
68
+ if (!handshakeComplete) {
69
+ // Accumulate raw buffers until we find a newline (end of JSON response).
70
+ handshakeChunks.push(data);
71
+ handshakeLen += data.length;
72
+ const accumulated = Buffer.concat(handshakeChunks, handshakeLen);
73
+ const nlIndex = accumulated.indexOf(0x0a);
74
+ if (nlIndex === -1) return; // Wait for more data.
75
+
76
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
77
+ const remainder = accumulated.slice(nlIndex + 1);
78
+ handshakeComplete = true;
79
+ socket.setTimeout(0); // Disable timeout for interactive session.
80
+
81
+ let response: { status: string; message?: string };
82
+ try {
83
+ response = JSON.parse(responseLine) as {
84
+ status: string;
85
+ message?: string;
86
+ };
87
+ } catch {
88
+ console.error("Invalid handshake response from management socket.");
89
+ socket.destroy();
90
+ process.exit(1);
91
+ return;
92
+ }
93
+
94
+ if (response.status !== "ok") {
95
+ console.error(`Exec failed: ${response.message || "unknown error"}`);
96
+ socket.destroy();
97
+ process.exit(1);
98
+ return;
99
+ }
100
+
101
+ // Handshake succeeded — enter raw mode and relay stdio.
102
+ if (process.stdin.isTTY) {
103
+ process.stdin.setRawMode(true);
104
+ }
105
+ process.stdin.resume();
106
+ process.stdin.pipe(socket);
107
+
108
+ // Write any raw bytes that arrived after the handshake newline.
109
+ if (remainder.length > 0) {
110
+ process.stdout.write(remainder);
111
+ }
112
+
113
+ // From now on, relay socket data to stdout.
114
+ return;
115
+ }
116
+
117
+ // Raw mode: relay container output to stdout.
118
+ process.stdout.write(data);
119
+ });
120
+
121
+ socket.on("end", () => {
122
+ cleanup();
123
+ if (handshakeComplete) {
124
+ resolve();
125
+ } else {
126
+ reject(
127
+ new Error(
128
+ "Management socket closed before handshake completed. " +
129
+ "The assistant may be restarting.",
130
+ ),
131
+ );
132
+ }
133
+ });
134
+
135
+ socket.on("error", (err) => {
136
+ cleanup();
137
+ reject(new Error(`Management socket error: ${err.message}`));
138
+ });
139
+
140
+ socket.on("close", () => {
141
+ cleanup();
142
+ if (handshakeComplete) {
143
+ resolve();
144
+ } else {
145
+ reject(
146
+ new Error(
147
+ "Management socket closed before handshake completed. " +
148
+ "The assistant may be restarting.",
149
+ ),
150
+ );
151
+ }
152
+ });
153
+
154
+ function cleanup(): void {
155
+ if (process.stdin.isTTY) {
156
+ process.stdin.setRawMode(false);
157
+ }
158
+ process.stdin.unpipe(socket);
159
+ process.stdin.pause();
160
+ }
161
+ });
162
+ }
@@ -6,6 +6,7 @@ import {
6
6
  } from "../lib/assistant-config";
7
7
  import type { AssistantEntry } from "../lib/assistant-config";
8
8
  import { dockerResourceNames } from "../lib/docker";
9
+ import { sshAppleContainer } from "./ssh-apple-container";
9
10
 
10
11
  const SSH_OPTS = [
11
12
  "-o",
@@ -81,6 +82,12 @@ export async function ssh(): Promise<void> {
81
82
  process.exit(1);
82
83
  }
83
84
 
85
+ // Apple container: connect to the management socket for an interactive shell.
86
+ if (cloud === "apple-container") {
87
+ await sshAppleContainer(entry);
88
+ return;
89
+ }
90
+
84
91
  let child;
85
92
 
86
93
  if (cloud === "docker") {
@@ -9,11 +9,13 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
9
9
  import {
10
10
  loadGuardianToken,
11
11
  leaseGuardianToken,
12
+ computeDeviceId,
12
13
  } from "../lib/guardian-token.js";
13
14
  import {
14
15
  readPlatformToken,
15
16
  getPlatformUrl,
16
17
  hatchAssistant,
18
+ checkExistingPlatformAssistant,
17
19
  platformInitiateExport,
18
20
  platformPollExportStatus,
19
21
  platformDownloadExport,
@@ -23,6 +25,11 @@ import {
23
25
  platformUploadToSignedUrl,
24
26
  platformImportPreflightFromGcs,
25
27
  platformImportBundleFromGcs,
28
+ platformPollImportStatus,
29
+ ensureSelfHostedLocalRegistration,
30
+ injectCredentialsIntoAssistant,
31
+ fetchCurrentUser,
32
+ fetchOrganizationId,
26
33
  } from "../lib/platform-client.js";
27
34
  import {
28
35
  hatchDocker,
@@ -34,6 +41,8 @@ import { hatchLocal } from "../lib/hatch-local.js";
34
41
  import { retireLocal } from "../lib/retire-local.js";
35
42
  import { validateAssistantName } from "../lib/retire-archive.js";
36
43
  import { stopProcessByPidFile } from "../lib/process.js";
44
+ import { fetchCurrentVersion } from "../lib/upgrade-lifecycle.js";
45
+ import { compareVersions } from "../lib/version-compat.js";
37
46
  import { join } from "node:path";
38
47
 
39
48
  function printHelp(): void {
@@ -512,6 +521,16 @@ async function exportFromAssistant(
512
521
  if (msg.includes("not found")) {
513
522
  throw err;
514
523
  }
524
+ // Re-throw permanent 4xx errors (auth, forbidden, etc.)
525
+ // but retry transient 5xx errors
526
+ const statusMatch = msg.match(/status check failed: (\d+)/);
527
+ if (statusMatch) {
528
+ const statusCode = parseInt(statusMatch[1], 10);
529
+ if (statusCode >= 400 && statusCode < 500) {
530
+ throw err;
531
+ }
532
+ }
533
+ // Transient error — retry
515
534
  console.warn(`Polling failed, retrying... (${msg})`);
516
535
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
517
536
  continue;
@@ -595,6 +614,13 @@ interface ImportResponse {
595
614
  files_skipped: number;
596
615
  backups_created: number;
597
616
  };
617
+ credentialsImported?: {
618
+ total: number;
619
+ succeeded: number;
620
+ failed: number;
621
+ failedAccounts: string[];
622
+ skippedPlatform?: number;
623
+ };
598
624
  }
599
625
 
600
626
  async function importToAssistant(
@@ -706,7 +732,7 @@ async function importToAssistant(
706
732
  : await platformImportBundle(bundleData, token, entry.runtimeUrl);
707
733
  } catch (err) {
708
734
  if (err instanceof Error && err.name === "TimeoutError") {
709
- console.error("Error: Import request timed out after 5 minutes.");
735
+ console.error("Error: Import request timed out.");
710
736
  process.exit(1);
711
737
  }
712
738
  throw err;
@@ -714,6 +740,74 @@ async function importToAssistant(
714
740
 
715
741
  handleImportStatusErrors(importResult.statusCode, entry.assistantId);
716
742
 
743
+ if (importResult.statusCode === 202) {
744
+ const jobId = (importResult.body as { job_id?: string }).job_id;
745
+ if (!jobId) {
746
+ console.error("Error: Import accepted but no job ID returned.");
747
+ process.exit(1);
748
+ }
749
+
750
+ const POLL_INTERVAL_MS = 5_000;
751
+ const TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes (platform staleness is 930s)
752
+ const startTime = Date.now();
753
+ const deadline = startTime + TIMEOUT_MS;
754
+
755
+ while (Date.now() < deadline) {
756
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
757
+
758
+ let status: {
759
+ status: string;
760
+ result?: Record<string, unknown>;
761
+ error?: string;
762
+ };
763
+ try {
764
+ status = await platformPollImportStatus(
765
+ jobId,
766
+ token,
767
+ entry.runtimeUrl,
768
+ );
769
+ } catch (err) {
770
+ const msg = err instanceof Error ? err.message : String(err);
771
+ if (msg.includes("not found")) {
772
+ throw err;
773
+ }
774
+ // Re-throw permanent 4xx errors (auth, forbidden, etc.)
775
+ // but retry transient 5xx errors
776
+ const statusMatch = msg.match(/status check failed: (\d+)/);
777
+ if (statusMatch) {
778
+ const statusCode = parseInt(statusMatch[1], 10);
779
+ if (statusCode >= 400 && statusCode < 500) {
780
+ throw err;
781
+ }
782
+ }
783
+ // Transient error — retry
784
+ console.warn(`Polling failed, retrying... (${msg})`);
785
+ continue;
786
+ }
787
+
788
+ if (status.status === "complete") {
789
+ importResult = { statusCode: 200, body: status.result ?? {} };
790
+ break;
791
+ }
792
+
793
+ if (status.status === "failed") {
794
+ console.error(`Import failed: ${status.error ?? "unknown error"}`);
795
+ process.exit(1);
796
+ }
797
+
798
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
799
+ process.stdout.write(`\rImporting... ${elapsed}s elapsed`);
800
+ }
801
+
802
+ // Clear the progress line
803
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
804
+
805
+ if (importResult.statusCode === 202) {
806
+ console.error("Import timed out after 10 minutes.");
807
+ process.exit(1);
808
+ }
809
+ }
810
+
717
811
  const result = importResult.body as unknown as ImportResponse;
718
812
  printImportSummary(result);
719
813
  return;
@@ -779,7 +873,7 @@ export async function resolveOrHatchTarget(
779
873
  // Hatch a new assistant in the target environment
780
874
  if (targetEnv === "local") {
781
875
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
782
- await hatchLocal("vellum", targetName ?? null, false, false, false, {});
876
+ await hatchLocal("vellum", targetName ?? null, false, false, {});
783
877
  const entry = targetName
784
878
  ? findAssistantByName(targetName)
785
879
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -816,7 +910,29 @@ export async function resolveOrHatchTarget(
816
910
  process.exit(1);
817
911
  }
818
912
 
819
- const result = await hatchAssistant(token);
913
+ const { assistant: result, reusedExisting } = await hatchAssistant(token);
914
+
915
+ // Defensive safety net — should not happen because of the pre-check in
916
+ // teleport(), but guards against a TOCTOU race between the pre-check and
917
+ // hatch (e.g. another client hatches in the GCS-upload window).
918
+ if (reusedExisting) {
919
+ const entry: AssistantEntry = {
920
+ assistantId: result.id,
921
+ runtimeUrl: getPlatformUrl(),
922
+ cloud: "vellum",
923
+ species: "vellum",
924
+ hatchedAt: new Date().toISOString(),
925
+ };
926
+ saveAssistantEntry(entry);
927
+ console.error(
928
+ `Error: You already have a platform assistant '${result.id}'.`,
929
+ );
930
+ console.error(
931
+ `Retire it first with 'vellum retire ${result.id}', then retry the teleport.`,
932
+ );
933
+ process.exit(1);
934
+ }
935
+
820
936
  const entry: AssistantEntry = {
821
937
  assistantId: result.id,
822
938
  runtimeUrl: getPlatformUrl(),
@@ -969,6 +1085,20 @@ function printImportSummary(result: ImportResponse): void {
969
1085
  console.log(` Files skipped: ${summary.files_skipped}`);
970
1086
  console.log(` Backups created: ${summary.backups_created}`);
971
1087
 
1088
+ const creds = result.credentialsImported;
1089
+ if (creds) {
1090
+ console.log(` Credentials imported: ${creds.succeeded}/${creds.total}`);
1091
+ if (creds.skippedPlatform) {
1092
+ console.log(` Platform credentials skipped: ${creds.skippedPlatform}`);
1093
+ }
1094
+ if (creds.failed > 0) {
1095
+ console.log(` Credentials failed: ${creds.failed}`);
1096
+ for (const account of creds.failedAccounts) {
1097
+ console.log(` - ${account}`);
1098
+ }
1099
+ }
1100
+ }
1101
+
972
1102
  const warnings = result.warnings ?? [];
973
1103
  if (warnings.length > 0) {
974
1104
  console.log("");
@@ -979,6 +1109,54 @@ function printImportSummary(result: ImportResponse): void {
979
1109
  }
980
1110
  }
981
1111
 
1112
+ /**
1113
+ * After teleporting to a local/docker target, register the assistant with
1114
+ * the platform and inject fresh platform credentials — mirroring the
1115
+ * login flow. Non-fatal: failures are logged as warnings.
1116
+ */
1117
+ async function tryInjectPlatformCredentials(
1118
+ entry: AssistantEntry,
1119
+ ): Promise<void> {
1120
+ const token = readPlatformToken();
1121
+ if (!token) {
1122
+ console.log(" Skipped platform credential injection (not logged in).");
1123
+ return;
1124
+ }
1125
+
1126
+ try {
1127
+ const user = await fetchCurrentUser(token);
1128
+ const orgId = await fetchOrganizationId(token);
1129
+ const clientInstallationId = computeDeviceId();
1130
+ const registration = await ensureSelfHostedLocalRegistration(
1131
+ token,
1132
+ orgId,
1133
+ clientInstallationId,
1134
+ entry.assistantId,
1135
+ "cli",
1136
+ );
1137
+
1138
+ const allInjected = await injectCredentialsIntoAssistant({
1139
+ gatewayUrl: entry.runtimeUrl,
1140
+ bearerToken: entry.bearerToken,
1141
+ assistantApiKey: registration.assistant_api_key,
1142
+ platformAssistantId: registration.assistant.id,
1143
+ platformBaseUrl: getPlatformUrl(),
1144
+ organizationId: orgId,
1145
+ userId: user.id,
1146
+ webhookSecret: registration.webhook_secret,
1147
+ });
1148
+
1149
+ if (allInjected) {
1150
+ console.log(" Platform credentials injected.");
1151
+ } else {
1152
+ console.warn(" Some platform credentials could not be injected.");
1153
+ }
1154
+ } catch (err) {
1155
+ const msg = err instanceof Error ? err.message : String(err);
1156
+ console.warn(` Platform credential injection skipped: ${msg}`);
1157
+ }
1158
+ }
1159
+
982
1160
  // ---------------------------------------------------------------------------
983
1161
  // Main entry point
984
1162
  // ---------------------------------------------------------------------------
@@ -1025,6 +1203,13 @@ export async function teleport(): Promise<void> {
1025
1203
 
1026
1204
  const fromCloud = resolveCloud(fromEntry);
1027
1205
 
1206
+ if (fromCloud === "apple-container") {
1207
+ console.error(
1208
+ `Error: '${from}' uses the Apple Containers runtime. Teleport is not yet supported for this topology.`,
1209
+ );
1210
+ process.exit(1);
1211
+ }
1212
+
1028
1213
  // Early same-environment guard — compare source cloud against the CLI flag
1029
1214
  // BEFORE exporting or hatching, to avoid creating orphaned assistants.
1030
1215
  const normalizedSourceEnv = fromCloud === "vellum" ? "platform" : fromCloud;
@@ -1058,6 +1243,28 @@ export async function teleport(): Promise<void> {
1058
1243
  process.exit(1);
1059
1244
  }
1060
1245
 
1246
+ // Version guard: block platform→non-platform when target is behind
1247
+ if (fromCloud === "vellum" && toCloud !== "vellum") {
1248
+ const [sourceVersion, targetVersion] = await Promise.all([
1249
+ fetchCurrentVersion(fromEntry.runtimeUrl),
1250
+ fetchCurrentVersion(existingTarget.runtimeUrl),
1251
+ ]);
1252
+ const cmp =
1253
+ sourceVersion && targetVersion
1254
+ ? compareVersions(targetVersion, sourceVersion)
1255
+ : null;
1256
+ if (cmp !== null && cmp < 0) {
1257
+ console.error(
1258
+ `Error: Target assistant '${existingTarget.assistantId}' is running ${targetVersion}, ` +
1259
+ `but the platform source is on ${sourceVersion}.`,
1260
+ );
1261
+ console.error(
1262
+ `Upgrade your ${toCloud} assistant first: vellum upgrade ${existingTarget.assistantId}`,
1263
+ );
1264
+ process.exit(1);
1265
+ }
1266
+ }
1267
+
1061
1268
  console.log(`Exporting from ${from} (${fromCloud})...`);
1062
1269
  const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1063
1270
  console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
@@ -1117,6 +1324,31 @@ export async function teleport(): Promise<void> {
1117
1324
  // and import hit the same instance.
1118
1325
  const targetPlatformUrl = existingTarget?.runtimeUrl;
1119
1326
 
1327
+ // Step B2 — Pre-check: block if the user already has a platform assistant.
1328
+ // This runs BEFORE the expensive GCS upload so we don't waste bandwidth.
1329
+ if (!existingTarget) {
1330
+ const existing = await checkExistingPlatformAssistant(
1331
+ token,
1332
+ targetPlatformUrl,
1333
+ );
1334
+ if (existing) {
1335
+ saveAssistantEntry({
1336
+ assistantId: existing.id,
1337
+ runtimeUrl: getPlatformUrl(),
1338
+ cloud: "vellum",
1339
+ species: "vellum",
1340
+ hatchedAt: new Date().toISOString(),
1341
+ });
1342
+ console.error(
1343
+ `Error: You already have a platform assistant '${existing.id}'.`,
1344
+ );
1345
+ console.error(
1346
+ `Retire it first with 'vellum retire ${existing.id}', then retry the teleport.`,
1347
+ );
1348
+ process.exit(1);
1349
+ }
1350
+ }
1351
+
1120
1352
  // Step C — Upload to GCS
1121
1353
  // bundleKey: string = uploaded successfully, null = tried but unavailable,
1122
1354
  // undefined would mean "never tried" (not used here).
@@ -1159,6 +1391,36 @@ export async function teleport(): Promise<void> {
1159
1391
  // fails, the user can recover by running `vellum wake <source>`.
1160
1392
  const sourceIsLocalOrDocker = fromCloud === "local" || fromCloud === "docker";
1161
1393
  const targetIsLocalOrDocker = targetEnv === "local" || targetEnv === "docker";
1394
+
1395
+ // Version guard (pre-hatch): for existing targets, check BEFORE hatching
1396
+ // to avoid creating orphaned assistants when the version check would fail.
1397
+ let versionGuardPassed = false;
1398
+ if (fromCloud === "vellum" && targetIsLocalOrDocker && targetName) {
1399
+ const existingTarget = findAssistantByName(targetName);
1400
+ if (existingTarget) {
1401
+ const [sourceVersion, existingVersion] = await Promise.all([
1402
+ fetchCurrentVersion(fromEntry.runtimeUrl),
1403
+ fetchCurrentVersion(existingTarget.runtimeUrl),
1404
+ ]);
1405
+ const cmp =
1406
+ sourceVersion && existingVersion
1407
+ ? compareVersions(existingVersion, sourceVersion)
1408
+ : null;
1409
+ if (cmp !== null && cmp < 0) {
1410
+ console.error(
1411
+ `Error: Target assistant '${existingTarget.assistantId}' is running ${existingVersion}, ` +
1412
+ `but the platform source is on ${sourceVersion}.`,
1413
+ );
1414
+ console.error(
1415
+ `Upgrade your ${targetEnv} assistant first: vellum upgrade ${existingTarget.assistantId}`,
1416
+ );
1417
+ process.exit(1);
1418
+ }
1419
+ // Pre-hatch check passed (or was best-effort skipped) — skip post-hatch
1420
+ versionGuardPassed = true;
1421
+ }
1422
+ }
1423
+
1162
1424
  if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
1163
1425
  console.log(`Stopping source assistant '${from}' to free ports...`);
1164
1426
  if (fromCloud === "docker") {
@@ -1189,10 +1451,52 @@ export async function teleport(): Promise<void> {
1189
1451
  process.exit(1);
1190
1452
  }
1191
1453
 
1454
+ // Version guard (post-hatch): for newly hatched targets we must check after
1455
+ // hatch because the assistant doesn't exist yet before. If it fails, clean
1456
+ // up the freshly hatched assistant to avoid orphans.
1457
+ // Skip if the pre-hatch guard already ran for an existing target.
1458
+ if (!versionGuardPassed && fromCloud === "vellum" && toCloud !== "vellum") {
1459
+ const [sourceVersion, targetVersion] = await Promise.all([
1460
+ fetchCurrentVersion(fromEntry.runtimeUrl),
1461
+ fetchCurrentVersion(toEntry.runtimeUrl),
1462
+ ]);
1463
+ const cmp =
1464
+ sourceVersion && targetVersion
1465
+ ? compareVersions(targetVersion, sourceVersion)
1466
+ : null;
1467
+ if (cmp !== null && cmp < 0) {
1468
+ // Clean up the freshly hatched assistant to avoid orphans
1469
+ console.error(
1470
+ `Cleaning up newly hatched assistant '${toEntry.assistantId}'...`,
1471
+ );
1472
+ if (toCloud === "docker") {
1473
+ await retireDocker(toEntry.assistantId);
1474
+ } else {
1475
+ await retireLocal(toEntry.assistantId, toEntry);
1476
+ }
1477
+ removeAssistantEntry(toEntry.assistantId);
1478
+ console.error(
1479
+ `Error: Target assistant '${toEntry.assistantId}' was running ${targetVersion}, ` +
1480
+ `but the platform source is on ${sourceVersion}.`,
1481
+ );
1482
+ console.error(
1483
+ `Upgrade your ${toCloud} environment first, then retry the teleport.`,
1484
+ );
1485
+ process.exit(1);
1486
+ }
1487
+ }
1488
+
1192
1489
  // Import to target
1193
1490
  console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1194
1491
  await importToAssistant(toEntry, toCloud, bundleData, false);
1195
1492
 
1493
+ // After successful import, inject fresh platform credentials if the
1494
+ // user is logged in — replaces the source's stale vellum:* credentials
1495
+ // that were filtered during import.
1496
+ if (fromCloud === "vellum") {
1497
+ await tryInjectPlatformCredentials(toEntry);
1498
+ }
1499
+
1196
1500
  // Retire source after successful import
1197
1501
  if (sourceIsLocalOrDocker && targetIsLocalOrDocker) {
1198
1502
  if (!keepSource) {