@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.
- package/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bunfig.toml +6 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -57
- package/src/commands/backup.ts +8 -0
- package/src/commands/hatch.ts +5 -28
- package/src/commands/login.ts +178 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +124 -12
- package/src/commands/retire.ts +17 -3
- package/src/commands/rollback.ts +32 -33
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +307 -3
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +21 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +54 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +73 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +30 -35
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +261 -25
- package/src/lib/retire-apple-container.ts +102 -0
- 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
|
+
}
|
package/src/commands/ssh.ts
CHANGED
|
@@ -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") {
|
package/src/commands/teleport.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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) {
|