@vellumai/cli 0.5.16 → 0.6.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.
- package/bun.lock +46 -52
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +430 -4
- package/src/__tests__/version-compat.test.ts +206 -0
- package/src/commands/backup.ts +1 -15
- package/src/commands/events.ts +146 -0
- package/src/commands/message.ts +105 -0
- package/src/commands/restore.ts +1 -21
- package/src/commands/retire.ts +2 -7
- package/src/commands/rollback.ts +14 -37
- package/src/commands/teleport.ts +125 -65
- package/src/commands/upgrade.ts +50 -43
- package/src/index.ts +6 -0
- package/src/lib/arg-utils.ts +13 -0
- package/src/lib/assistant-client.ts +228 -0
- package/src/lib/aws.ts +2 -1
- package/src/lib/constants.ts +0 -11
- package/src/lib/docker.ts +168 -62
- package/src/lib/gcp.ts +2 -5
- package/src/lib/hatch-local.ts +5 -2
- package/src/lib/health-check.ts +3 -8
- package/src/lib/ngrok.ts +11 -1
- package/src/lib/platform-client.ts +191 -36
- package/src/lib/upgrade-lifecycle.ts +13 -15
- package/src/lib/version-compat.ts +67 -5
- package/src/shared/provider-env-vars.ts +19 -0
|
@@ -64,7 +64,6 @@ mock.module("../lib/guardian-token.js", () => ({
|
|
|
64
64
|
}));
|
|
65
65
|
|
|
66
66
|
const readPlatformTokenMock = mock((): string | null => "platform-token");
|
|
67
|
-
const fetchOrganizationIdMock = mock(async () => "org-123");
|
|
68
67
|
const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
|
|
69
68
|
const hatchAssistantMock = mock(async () => ({
|
|
70
69
|
id: "platform-new-id",
|
|
@@ -108,10 +107,40 @@ const platformImportBundleMock = mock(async () => ({
|
|
|
108
107
|
},
|
|
109
108
|
} as Record<string, unknown>,
|
|
110
109
|
}));
|
|
110
|
+
const platformRequestUploadUrlMock = mock(async () => ({
|
|
111
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
112
|
+
bundleKey: "bundle-key-123",
|
|
113
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
114
|
+
}));
|
|
115
|
+
const platformUploadToSignedUrlMock = mock(async () => {});
|
|
116
|
+
const platformImportPreflightFromGcsMock = mock(async () => ({
|
|
117
|
+
statusCode: 200,
|
|
118
|
+
body: {
|
|
119
|
+
can_import: true,
|
|
120
|
+
summary: {
|
|
121
|
+
files_to_create: 2,
|
|
122
|
+
files_to_overwrite: 1,
|
|
123
|
+
files_unchanged: 0,
|
|
124
|
+
total_files: 3,
|
|
125
|
+
},
|
|
126
|
+
} as Record<string, unknown>,
|
|
127
|
+
}));
|
|
128
|
+
const platformImportBundleFromGcsMock = mock(async () => ({
|
|
129
|
+
statusCode: 200,
|
|
130
|
+
body: {
|
|
131
|
+
success: true,
|
|
132
|
+
summary: {
|
|
133
|
+
total_files: 3,
|
|
134
|
+
files_created: 2,
|
|
135
|
+
files_overwritten: 1,
|
|
136
|
+
files_skipped: 0,
|
|
137
|
+
backups_created: 1,
|
|
138
|
+
},
|
|
139
|
+
} as Record<string, unknown>,
|
|
140
|
+
}));
|
|
111
141
|
|
|
112
142
|
mock.module("../lib/platform-client.js", () => ({
|
|
113
143
|
readPlatformToken: readPlatformTokenMock,
|
|
114
|
-
fetchOrganizationId: fetchOrganizationIdMock,
|
|
115
144
|
getPlatformUrl: getPlatformUrlMock,
|
|
116
145
|
hatchAssistant: hatchAssistantMock,
|
|
117
146
|
platformInitiateExport: platformInitiateExportMock,
|
|
@@ -119,6 +148,10 @@ mock.module("../lib/platform-client.js", () => ({
|
|
|
119
148
|
platformDownloadExport: platformDownloadExportMock,
|
|
120
149
|
platformImportPreflight: platformImportPreflightMock,
|
|
121
150
|
platformImportBundle: platformImportBundleMock,
|
|
151
|
+
platformRequestUploadUrl: platformRequestUploadUrlMock,
|
|
152
|
+
platformUploadToSignedUrl: platformUploadToSignedUrlMock,
|
|
153
|
+
platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
|
|
154
|
+
platformImportBundleFromGcs: platformImportBundleFromGcsMock,
|
|
122
155
|
}));
|
|
123
156
|
|
|
124
157
|
const hatchLocalMock = mock(async () => {});
|
|
@@ -205,8 +238,6 @@ beforeEach(() => {
|
|
|
205
238
|
|
|
206
239
|
readPlatformTokenMock.mockReset();
|
|
207
240
|
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
208
|
-
fetchOrganizationIdMock.mockReset();
|
|
209
|
-
fetchOrganizationIdMock.mockResolvedValue("org-123");
|
|
210
241
|
getPlatformUrlMock.mockReset();
|
|
211
242
|
getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
|
|
212
243
|
hatchAssistantMock.mockReset();
|
|
@@ -256,6 +287,41 @@ beforeEach(() => {
|
|
|
256
287
|
},
|
|
257
288
|
},
|
|
258
289
|
});
|
|
290
|
+
platformRequestUploadUrlMock.mockReset();
|
|
291
|
+
platformRequestUploadUrlMock.mockResolvedValue({
|
|
292
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
293
|
+
bundleKey: "bundle-key-123",
|
|
294
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
295
|
+
});
|
|
296
|
+
platformUploadToSignedUrlMock.mockReset();
|
|
297
|
+
platformUploadToSignedUrlMock.mockResolvedValue(undefined);
|
|
298
|
+
platformImportPreflightFromGcsMock.mockReset();
|
|
299
|
+
platformImportPreflightFromGcsMock.mockResolvedValue({
|
|
300
|
+
statusCode: 200,
|
|
301
|
+
body: {
|
|
302
|
+
can_import: true,
|
|
303
|
+
summary: {
|
|
304
|
+
files_to_create: 2,
|
|
305
|
+
files_to_overwrite: 1,
|
|
306
|
+
files_unchanged: 0,
|
|
307
|
+
total_files: 3,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
platformImportBundleFromGcsMock.mockReset();
|
|
312
|
+
platformImportBundleFromGcsMock.mockResolvedValue({
|
|
313
|
+
statusCode: 200,
|
|
314
|
+
body: {
|
|
315
|
+
success: true,
|
|
316
|
+
summary: {
|
|
317
|
+
total_files: 3,
|
|
318
|
+
files_created: 2,
|
|
319
|
+
files_overwritten: 1,
|
|
320
|
+
files_skipped: 0,
|
|
321
|
+
backups_created: 1,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
});
|
|
259
325
|
|
|
260
326
|
hatchLocalMock.mockReset();
|
|
261
327
|
hatchLocalMock.mockResolvedValue(undefined);
|
|
@@ -998,3 +1064,363 @@ describe("teleport full flow", () => {
|
|
|
998
1064
|
);
|
|
999
1065
|
});
|
|
1000
1066
|
});
|
|
1067
|
+
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1069
|
+
// Signed-URL upload tests
|
|
1070
|
+
// ---------------------------------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
describe("signed-URL upload flow", () => {
|
|
1073
|
+
test("happy path: signed URL upload succeeds → GCS-based import used", async () => {
|
|
1074
|
+
setArgv("--from", "my-local", "--platform");
|
|
1075
|
+
|
|
1076
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1077
|
+
|
|
1078
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1079
|
+
if (name === "my-local") return localEntry;
|
|
1080
|
+
return null;
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const originalFetch = globalThis.fetch;
|
|
1084
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
await teleport();
|
|
1088
|
+
|
|
1089
|
+
// Signed-URL flow should be used
|
|
1090
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1091
|
+
expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
|
|
1092
|
+
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1093
|
+
"bundle-key-123",
|
|
1094
|
+
"platform-token",
|
|
1095
|
+
"https://platform.vellum.ai",
|
|
1096
|
+
);
|
|
1097
|
+
// Inline import should NOT be called
|
|
1098
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1099
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1100
|
+
expect.stringContaining("Teleport complete"),
|
|
1101
|
+
);
|
|
1102
|
+
} finally {
|
|
1103
|
+
globalThis.fetch = originalFetch;
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test("happy path dry-run: signed URL upload succeeds → GCS-based preflight used", async () => {
|
|
1108
|
+
setArgv(
|
|
1109
|
+
"--from",
|
|
1110
|
+
"my-local",
|
|
1111
|
+
"--platform",
|
|
1112
|
+
"existing-platform",
|
|
1113
|
+
"--dry-run",
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1117
|
+
const platformEntry = makeEntry("existing-platform", {
|
|
1118
|
+
cloud: "vellum",
|
|
1119
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1123
|
+
if (name === "my-local") return localEntry;
|
|
1124
|
+
if (name === "existing-platform") return platformEntry;
|
|
1125
|
+
return null;
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const originalFetch = globalThis.fetch;
|
|
1129
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
await teleport();
|
|
1133
|
+
|
|
1134
|
+
// Signed-URL flow should be used for preflight
|
|
1135
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1136
|
+
expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
|
|
1137
|
+
expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
|
|
1138
|
+
"bundle-key-123",
|
|
1139
|
+
"platform-token",
|
|
1140
|
+
"https://platform.vellum.ai",
|
|
1141
|
+
);
|
|
1142
|
+
// Inline preflight should NOT be called
|
|
1143
|
+
expect(platformImportPreflightMock).not.toHaveBeenCalled();
|
|
1144
|
+
} finally {
|
|
1145
|
+
globalThis.fetch = originalFetch;
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("fallback: platformRequestUploadUrl throws 503 → falls back to inline import", async () => {
|
|
1150
|
+
setArgv("--from", "my-local", "--platform");
|
|
1151
|
+
|
|
1152
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1153
|
+
|
|
1154
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1155
|
+
if (name === "my-local") return localEntry;
|
|
1156
|
+
return null;
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
// Simulate 503 — "not available" in the error message
|
|
1160
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1161
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
const originalFetch = globalThis.fetch;
|
|
1165
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1166
|
+
|
|
1167
|
+
try {
|
|
1168
|
+
await teleport();
|
|
1169
|
+
|
|
1170
|
+
// Should fall back to inline import
|
|
1171
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1172
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1173
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1174
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1175
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1176
|
+
expect.stringContaining("Teleport complete"),
|
|
1177
|
+
);
|
|
1178
|
+
} finally {
|
|
1179
|
+
globalThis.fetch = originalFetch;
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("fallback: platformRequestUploadUrl throws 404 → falls back to inline import", async () => {
|
|
1184
|
+
setArgv("--from", "my-local", "--platform");
|
|
1185
|
+
|
|
1186
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1187
|
+
|
|
1188
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1189
|
+
if (name === "my-local") return localEntry;
|
|
1190
|
+
return null;
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// Simulate 404 — endpoint doesn't exist on older platform versions
|
|
1194
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1195
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
const originalFetch = globalThis.fetch;
|
|
1199
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1200
|
+
|
|
1201
|
+
try {
|
|
1202
|
+
await teleport();
|
|
1203
|
+
|
|
1204
|
+
// Should fall back to inline import
|
|
1205
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1206
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1207
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1208
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1209
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1210
|
+
expect.stringContaining("Teleport complete"),
|
|
1211
|
+
);
|
|
1212
|
+
} finally {
|
|
1213
|
+
globalThis.fetch = originalFetch;
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("upload error: platformUploadToSignedUrl throws → error propagates", async () => {
|
|
1218
|
+
setArgv("--from", "my-local", "--platform");
|
|
1219
|
+
|
|
1220
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1221
|
+
|
|
1222
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1223
|
+
if (name === "my-local") return localEntry;
|
|
1224
|
+
return null;
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Upload succeeds at getting URL but fails during PUT
|
|
1228
|
+
platformUploadToSignedUrlMock.mockRejectedValue(
|
|
1229
|
+
new Error("Upload to signed URL failed: 500 Internal Server Error"),
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
const originalFetch = globalThis.fetch;
|
|
1233
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
await expect(teleport()).rejects.toThrow(
|
|
1237
|
+
"Upload to signed URL failed: 500 Internal Server Error",
|
|
1238
|
+
);
|
|
1239
|
+
// Should NOT fall back to inline import
|
|
1240
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1241
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1242
|
+
} finally {
|
|
1243
|
+
globalThis.fetch = originalFetch;
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test("413 from GCS import: error message includes 'too large'", async () => {
|
|
1248
|
+
setArgv("--from", "my-local", "--platform");
|
|
1249
|
+
|
|
1250
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1251
|
+
|
|
1252
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1253
|
+
if (name === "my-local") return localEntry;
|
|
1254
|
+
return null;
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
// GCS import returns 413
|
|
1258
|
+
platformImportBundleFromGcsMock.mockRejectedValue(
|
|
1259
|
+
new Error("Bundle too large to import"),
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1262
|
+
const originalFetch = globalThis.fetch;
|
|
1263
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
await expect(teleport()).rejects.toThrow("too large");
|
|
1267
|
+
} finally {
|
|
1268
|
+
globalThis.fetch = originalFetch;
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
// Platform teleport org ID and reordered flow tests
|
|
1275
|
+
// ---------------------------------------------------------------------------
|
|
1276
|
+
|
|
1277
|
+
describe("platform teleport org ID and reordered flow", () => {
|
|
1278
|
+
test("hatchAssistant is called without orgId (authHeaders fetches it internally)", async () => {
|
|
1279
|
+
setArgv("--from", "my-local", "--platform");
|
|
1280
|
+
|
|
1281
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1282
|
+
|
|
1283
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1284
|
+
if (name === "my-local") return localEntry;
|
|
1285
|
+
return null;
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
const originalFetch = globalThis.fetch;
|
|
1289
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
await teleport();
|
|
1293
|
+
|
|
1294
|
+
// hatchAssistant should be called with just the token (orgId is resolved internally by authHeaders)
|
|
1295
|
+
expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
|
|
1296
|
+
} finally {
|
|
1297
|
+
globalThis.fetch = originalFetch;
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("upload to GCS happens before hatchAssistant for platform targets", async () => {
|
|
1302
|
+
setArgv("--from", "my-local", "--platform");
|
|
1303
|
+
|
|
1304
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1305
|
+
|
|
1306
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1307
|
+
if (name === "my-local") return localEntry;
|
|
1308
|
+
return null;
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
const callOrder: string[] = [];
|
|
1312
|
+
|
|
1313
|
+
platformRequestUploadUrlMock.mockImplementation(async () => {
|
|
1314
|
+
callOrder.push("platformRequestUploadUrl");
|
|
1315
|
+
return {
|
|
1316
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
1317
|
+
bundleKey: "bundle-key-123",
|
|
1318
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1319
|
+
};
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
platformUploadToSignedUrlMock.mockImplementation(async () => {
|
|
1323
|
+
callOrder.push("platformUploadToSignedUrl");
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
hatchAssistantMock.mockImplementation(async () => {
|
|
1327
|
+
callOrder.push("hatchAssistant");
|
|
1328
|
+
return { id: "platform-new-id", name: "platform-new", status: "active" };
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
const originalFetch = globalThis.fetch;
|
|
1332
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1333
|
+
|
|
1334
|
+
try {
|
|
1335
|
+
await teleport();
|
|
1336
|
+
|
|
1337
|
+
// Verify ordering: upload steps come before hatch
|
|
1338
|
+
const uploadUrlIdx = callOrder.indexOf("platformRequestUploadUrl");
|
|
1339
|
+
const uploadIdx = callOrder.indexOf("platformUploadToSignedUrl");
|
|
1340
|
+
const hatchIdx = callOrder.indexOf("hatchAssistant");
|
|
1341
|
+
|
|
1342
|
+
expect(uploadUrlIdx).toBeGreaterThanOrEqual(0);
|
|
1343
|
+
expect(uploadIdx).toBeGreaterThanOrEqual(0);
|
|
1344
|
+
expect(hatchIdx).toBeGreaterThanOrEqual(0);
|
|
1345
|
+
expect(uploadUrlIdx).toBeLessThan(hatchIdx);
|
|
1346
|
+
expect(uploadIdx).toBeLessThan(hatchIdx);
|
|
1347
|
+
} finally {
|
|
1348
|
+
globalThis.fetch = originalFetch;
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("signed-URL fallback: when platformRequestUploadUrl throws 'not available', falls back to inline upload via importToAssistant", async () => {
|
|
1353
|
+
setArgv("--from", "my-local", "--platform");
|
|
1354
|
+
|
|
1355
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1356
|
+
|
|
1357
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1358
|
+
if (name === "my-local") return localEntry;
|
|
1359
|
+
return null;
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// Simulate 503 — signed uploads not available
|
|
1363
|
+
platformRequestUploadUrlMock.mockRejectedValue(
|
|
1364
|
+
new Error("Signed uploads are not available on this platform instance"),
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
const originalFetch = globalThis.fetch;
|
|
1368
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1369
|
+
|
|
1370
|
+
try {
|
|
1371
|
+
await teleport();
|
|
1372
|
+
|
|
1373
|
+
// Upload URL was attempted but failed
|
|
1374
|
+
expect(platformRequestUploadUrlMock).toHaveBeenCalled();
|
|
1375
|
+
// No signed URL upload should have happened
|
|
1376
|
+
expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
|
|
1377
|
+
// Should NOT use GCS-based import
|
|
1378
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
1379
|
+
// Should fall back to inline import
|
|
1380
|
+
expect(platformImportBundleMock).toHaveBeenCalled();
|
|
1381
|
+
// Hatch should still succeed
|
|
1382
|
+
expect(hatchAssistantMock).toHaveBeenCalled();
|
|
1383
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
1384
|
+
expect.stringContaining("Teleport complete"),
|
|
1385
|
+
);
|
|
1386
|
+
} finally {
|
|
1387
|
+
globalThis.fetch = originalFetch;
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
test("bundleKey from pre-upload is forwarded to platformImportBundleFromGcs", async () => {
|
|
1392
|
+
setArgv("--from", "my-local", "--platform");
|
|
1393
|
+
|
|
1394
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
1395
|
+
|
|
1396
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
1397
|
+
if (name === "my-local") return localEntry;
|
|
1398
|
+
return null;
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
// Return a specific bundle key from the pre-upload step
|
|
1402
|
+
platformRequestUploadUrlMock.mockResolvedValue({
|
|
1403
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
|
|
1404
|
+
bundleKey: "pre-uploaded-key-789",
|
|
1405
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
const originalFetch = globalThis.fetch;
|
|
1409
|
+
globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
|
|
1410
|
+
|
|
1411
|
+
try {
|
|
1412
|
+
await teleport();
|
|
1413
|
+
|
|
1414
|
+
// The bundle key from the pre-upload step should be forwarded to GCS import
|
|
1415
|
+
expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
|
|
1416
|
+
"pre-uploaded-key-789",
|
|
1417
|
+
"platform-token",
|
|
1418
|
+
expect.any(String),
|
|
1419
|
+
);
|
|
1420
|
+
// Inline import should NOT be used since signed upload succeeded
|
|
1421
|
+
expect(platformImportBundleMock).not.toHaveBeenCalled();
|
|
1422
|
+
} finally {
|
|
1423
|
+
globalThis.fetch = originalFetch;
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
parseVersion,
|
|
5
|
+
compareVersions,
|
|
6
|
+
isVersionCompatible,
|
|
7
|
+
} from "../lib/version-compat.js";
|
|
8
|
+
|
|
9
|
+
describe("parseVersion", () => {
|
|
10
|
+
test("parses basic semver", () => {
|
|
11
|
+
expect(parseVersion("1.2.3")).toEqual({
|
|
12
|
+
major: 1,
|
|
13
|
+
minor: 2,
|
|
14
|
+
patch: 3,
|
|
15
|
+
pre: null,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("strips v prefix", () => {
|
|
20
|
+
expect(parseVersion("v1.2.3")).toEqual({
|
|
21
|
+
major: 1,
|
|
22
|
+
minor: 2,
|
|
23
|
+
patch: 3,
|
|
24
|
+
pre: null,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("strips V prefix", () => {
|
|
29
|
+
expect(parseVersion("V1.2.3")).toEqual({
|
|
30
|
+
major: 1,
|
|
31
|
+
minor: 2,
|
|
32
|
+
patch: 3,
|
|
33
|
+
pre: null,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("defaults missing patch to 0", () => {
|
|
38
|
+
expect(parseVersion("1.2")).toEqual({
|
|
39
|
+
major: 1,
|
|
40
|
+
minor: 2,
|
|
41
|
+
patch: 0,
|
|
42
|
+
pre: null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("captures pre-release suffix", () => {
|
|
47
|
+
expect(parseVersion("0.6.0-staging.5")).toEqual({
|
|
48
|
+
major: 0,
|
|
49
|
+
minor: 6,
|
|
50
|
+
patch: 0,
|
|
51
|
+
pre: "staging.5",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("captures pre-release with v prefix", () => {
|
|
56
|
+
expect(parseVersion("v0.6.0-staging.1")).toEqual({
|
|
57
|
+
major: 0,
|
|
58
|
+
minor: 6,
|
|
59
|
+
patch: 0,
|
|
60
|
+
pre: "staging.1",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("captures hyphenated pre-release suffix", () => {
|
|
65
|
+
expect(parseVersion("1.0.0-pre-release-1")).toEqual({
|
|
66
|
+
major: 1,
|
|
67
|
+
minor: 0,
|
|
68
|
+
patch: 0,
|
|
69
|
+
pre: "pre-release-1",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns null for single segment", () => {
|
|
74
|
+
expect(parseVersion("1")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns null for non-numeric segments", () => {
|
|
78
|
+
expect(parseVersion("abc.def")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns null for empty string", () => {
|
|
82
|
+
expect(parseVersion("")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("compareVersions", () => {
|
|
87
|
+
// ── Basic numeric comparison ──────────────────────────────────────
|
|
88
|
+
test("equal versions return 0", () => {
|
|
89
|
+
expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("higher major returns positive", () => {
|
|
93
|
+
expect(compareVersions("2.0.0", "1.0.0")).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("lower major returns negative", () => {
|
|
97
|
+
expect(compareVersions("1.0.0", "2.0.0")).toBeLessThan(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("higher minor returns positive", () => {
|
|
101
|
+
expect(compareVersions("1.3.0", "1.2.0")).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("higher patch returns positive", () => {
|
|
105
|
+
expect(compareVersions("1.2.4", "1.2.3")).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── v prefix handling ─────────────────────────────────────────────
|
|
109
|
+
test("strips v prefix for comparison", () => {
|
|
110
|
+
expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── Pre-release vs release ────────────────────────────────────────
|
|
114
|
+
test("pre-release sorts lower than release", () => {
|
|
115
|
+
expect(compareVersions("0.6.0-staging.1", "0.6.0")).toBeLessThan(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("release sorts higher than pre-release", () => {
|
|
119
|
+
expect(compareVersions("0.6.0", "0.6.0-staging.1")).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Pre-release numeric comparison ────────────────────────────────
|
|
123
|
+
test("staging.1 < staging.2", () => {
|
|
124
|
+
expect(compareVersions("0.6.0-staging.1", "0.6.0-staging.2")).toBeLessThan(
|
|
125
|
+
0,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("staging.10 > staging.2 (numeric, not lexical)", () => {
|
|
130
|
+
expect(
|
|
131
|
+
compareVersions("0.6.0-staging.10", "0.6.0-staging.2"),
|
|
132
|
+
).toBeGreaterThan(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Pre-release lexical comparison ────────────────────────────────
|
|
136
|
+
test("alpha < beta (lexical)", () => {
|
|
137
|
+
expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Mixed numeric vs non-numeric per §11.4.4 ─────────────────────
|
|
141
|
+
test("numeric identifier sorts lower than non-numeric", () => {
|
|
142
|
+
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── Fewer pre-release identifiers sorts earlier ───────────────────
|
|
146
|
+
test("fewer pre-release identifiers sorts earlier", () => {
|
|
147
|
+
expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Returns null for unparseable input ────────────────────────────
|
|
151
|
+
test("returns null if first version is unparseable", () => {
|
|
152
|
+
expect(compareVersions("bad", "1.0.0")).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns null if second version is unparseable", () => {
|
|
156
|
+
expect(compareVersions("1.0.0", "bad")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── Different major.minor.patch trumps pre-release ────────────────
|
|
160
|
+
test("higher patch wins regardless of pre-release", () => {
|
|
161
|
+
expect(compareVersions("0.6.1", "0.6.0-staging.99")).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── Sort integration ──────────────────────────────────────────────
|
|
165
|
+
test("Array.sort produces correct semver order", () => {
|
|
166
|
+
const versions = [
|
|
167
|
+
"0.6.0",
|
|
168
|
+
"0.6.0-staging.2",
|
|
169
|
+
"0.5.9",
|
|
170
|
+
"0.6.0-staging.10",
|
|
171
|
+
"v0.6.0-staging.1",
|
|
172
|
+
"0.6.1",
|
|
173
|
+
];
|
|
174
|
+
const sorted = [...versions].sort((a, b) => compareVersions(a, b) ?? 0);
|
|
175
|
+
expect(sorted).toEqual([
|
|
176
|
+
"0.5.9",
|
|
177
|
+
"v0.6.0-staging.1",
|
|
178
|
+
"0.6.0-staging.2",
|
|
179
|
+
"0.6.0-staging.10",
|
|
180
|
+
"0.6.0",
|
|
181
|
+
"0.6.1",
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("isVersionCompatible", () => {
|
|
187
|
+
test("same major.minor are compatible", () => {
|
|
188
|
+
expect(isVersionCompatible("1.2.3", "1.2.5")).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("different minor are incompatible", () => {
|
|
192
|
+
expect(isVersionCompatible("1.2.3", "1.3.0")).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("different major are incompatible", () => {
|
|
196
|
+
expect(isVersionCompatible("1.2.3", "2.2.3")).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("pre-release on same major.minor is compatible", () => {
|
|
200
|
+
expect(isVersionCompatible("0.6.0-staging.5", "0.6.0")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns false for unparseable input", () => {
|
|
204
|
+
expect(isVersionCompatible("bad", "1.0.0")).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|