@mycadet/cli 0.12.11 → 0.13.0
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/README.md +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +3 -2
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +182 -23
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +6 -6
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +3 -3
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +1 -1
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/upgrade.ts +78 -156
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +0 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +134 -18
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
package/src/commands/teleport.ts
CHANGED
|
@@ -1,198 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
findAssistantByName,
|
|
5
|
-
loadAllAssistants,
|
|
6
|
-
getDaemonPidPath,
|
|
7
|
-
removeAssistantEntry,
|
|
8
|
-
saveAssistantEntry,
|
|
9
|
-
setActiveAssistant,
|
|
10
|
-
type AssistantEntry,
|
|
11
|
-
} from "../lib/assistant-config.js";
|
|
12
|
-
import {
|
|
13
|
-
loadGuardianToken,
|
|
14
|
-
leaseGuardianToken,
|
|
15
|
-
computeDeviceId,
|
|
16
|
-
} from "../lib/guardian-token.js";
|
|
17
|
-
import {
|
|
18
|
-
readCloudToken,
|
|
19
|
-
getCloudUrl,
|
|
20
|
-
hatchAssistant,
|
|
21
|
-
checkExistingCloudAssistant,
|
|
22
|
-
platformPollJobStatus,
|
|
23
|
-
platformImportBundleFromGcs,
|
|
24
|
-
platformImportPreflightFromGcs,
|
|
25
|
-
cloudRequestSignedUrl,
|
|
26
|
-
VersionMismatchError,
|
|
27
|
-
ensureSelfHostedLocalRegistration,
|
|
28
|
-
injectCredentialsIntoAssistant,
|
|
29
|
-
fetchCurrentUser,
|
|
30
|
-
fetchOrganizationId,
|
|
31
|
-
} from "../lib/cloud-client.js";
|
|
32
|
-
import {
|
|
33
|
-
localRuntimeExportToGcs,
|
|
34
|
-
localRuntimeIdentity,
|
|
35
|
-
localRuntimeImportFromGcs,
|
|
36
|
-
localRuntimePreflightFromGcs,
|
|
37
|
-
localRuntimePollJobStatus,
|
|
38
|
-
MigrationInProgressError,
|
|
39
|
-
} from "../lib/local-runtime-client.js";
|
|
40
|
-
import { pollJobUntilDone } from "../lib/job-polling.js";
|
|
41
|
-
import {
|
|
42
|
-
createBackup,
|
|
43
|
-
listAssistantBackupTimes,
|
|
44
|
-
pruneOldBackups,
|
|
45
|
-
} from "../lib/backup-ops.js";
|
|
46
|
-
import {
|
|
47
|
-
createPlatformBackup,
|
|
48
|
-
listPlatformBackups,
|
|
49
|
-
} from "../lib/teleport-backup.js";
|
|
50
|
-
import {
|
|
51
|
-
hatchDocker,
|
|
52
|
-
retireDocker,
|
|
53
|
-
sleepContainers,
|
|
54
|
-
dockerResourceNames,
|
|
55
|
-
} from "../lib/docker.js";
|
|
56
|
-
import { hatchLocal } from "../lib/hatch-local.js";
|
|
57
|
-
import { retireLocal } from "../lib/retire-local.js";
|
|
58
|
-
import { validateAssistantName } from "../lib/retire-archive.js";
|
|
59
|
-
import {
|
|
60
|
-
DAEMON_STOP_TIMEOUT_MS,
|
|
61
|
-
stopProcessByPidFile,
|
|
62
|
-
} from "../lib/process.js";
|
|
63
|
-
import {
|
|
64
|
-
fetchAssistantIngressUrl,
|
|
65
|
-
fetchCurrentVersion,
|
|
66
|
-
} from "../lib/upgrade-lifecycle.js";
|
|
67
|
-
import { compareVersions } from "../lib/version-compat.js";
|
|
68
|
-
import { join } from "node:path";
|
|
69
|
-
import {
|
|
70
|
-
hasRecentBackup,
|
|
71
|
-
RECENT_BACKUP_MAX_AGE_MS,
|
|
72
|
-
} from "@mycadet/local-mode/teleport-backup-policy";
|
|
1
|
+
import { teleportHostInstallation } from "../lib/host-teleport.js";
|
|
2
|
+
import { findAssistantByName } from "../lib/assistant-config.js";
|
|
73
3
|
|
|
74
4
|
function printHelp(): void {
|
|
75
5
|
console.log(
|
|
76
|
-
"Usage: cadet teleport --from <assistant> <--local | --docker
|
|
6
|
+
"Usage: cadet teleport --from <assistant> <--local | --docker> [name] [--keep-source] [--dry-run]",
|
|
77
7
|
);
|
|
78
|
-
console.log("");
|
|
79
8
|
console.log(
|
|
80
|
-
"
|
|
9
|
+
"Move the whole installation between local and Docker storage using root tar archives.",
|
|
81
10
|
);
|
|
82
|
-
console.log("");
|
|
83
11
|
console.log(
|
|
84
|
-
"
|
|
12
|
+
"Cloud teleport is unavailable until cloud root archive transfer is supported.",
|
|
85
13
|
);
|
|
86
|
-
console.log(
|
|
87
|
-
"Exactly one environment flag (--local, --docker, --platform) specifies",
|
|
88
|
-
);
|
|
89
|
-
console.log(
|
|
90
|
-
"the target environment. An optional name after the environment flag",
|
|
91
|
-
);
|
|
92
|
-
console.log(
|
|
93
|
-
"targets an existing assistant (overwriting its data) or names a newly",
|
|
94
|
-
);
|
|
95
|
-
console.log(
|
|
96
|
-
"hatched one. If no name is given, a new assistant is hatched with an",
|
|
97
|
-
);
|
|
98
|
-
console.log("auto-generated name.");
|
|
99
|
-
console.log("");
|
|
100
|
-
console.log(
|
|
101
|
-
"The source and target must be different environments. Same-environment",
|
|
102
|
-
);
|
|
103
|
-
console.log("transfers such as local to local are not supported.");
|
|
104
|
-
console.log("");
|
|
105
|
-
console.log(
|
|
106
|
-
"Before exporting, the source assistant is backed up. Platform sources",
|
|
107
|
-
);
|
|
108
|
-
console.log(
|
|
109
|
-
"get a PVC snapshot (waited on until restorable); local and docker",
|
|
110
|
-
);
|
|
111
|
-
console.log(
|
|
112
|
-
"sources get a .vbundle written to ~/.local/share/cadet/backups/ on this",
|
|
113
|
-
);
|
|
114
|
-
console.log(
|
|
115
|
-
`machine. A backup of the source taken within the last ${RECENT_BACKUP_MAX_AGE_MS / 60_000} minutes is`,
|
|
116
|
-
);
|
|
117
|
-
console.log("reused. If the backup fails, the teleport is aborted.");
|
|
118
|
-
console.log("");
|
|
119
|
-
console.log(
|
|
120
|
-
"For local-to-docker and docker-to-local transfers, the source assistant",
|
|
121
|
-
);
|
|
122
|
-
console.log(
|
|
123
|
-
"is automatically retired after a successful import to free up ports and",
|
|
124
|
-
);
|
|
125
|
-
console.log("avoid resource conflicts. Use --keep-source to skip this.");
|
|
126
|
-
console.log("");
|
|
127
|
-
console.log("Environment flags:");
|
|
128
|
-
console.log(" --local [name] Target a local bare-metal assistant");
|
|
129
|
-
console.log(" --docker [name] Target a docker assistant");
|
|
130
|
-
console.log(" --platform [name] Target a Cadet Cloud-hosted assistant");
|
|
131
|
-
console.log("");
|
|
132
|
-
console.log("Options:");
|
|
133
|
-
console.log(
|
|
134
|
-
" --from <name> Source assistant to export data from (required)",
|
|
135
|
-
);
|
|
136
|
-
console.log(
|
|
137
|
-
" --keep-source Do not retire the source after local/docker transfers",
|
|
138
|
-
);
|
|
139
|
-
console.log(
|
|
140
|
-
" --dry-run Preview the transfer without applying changes.",
|
|
141
|
-
);
|
|
142
|
-
console.log(
|
|
143
|
-
" If the target exists, runs preflight analysis.",
|
|
144
|
-
);
|
|
145
|
-
console.log(
|
|
146
|
-
" If the target would be hatched, shows what would happen",
|
|
147
|
-
);
|
|
148
|
-
console.log(" without creating anything.");
|
|
149
|
-
console.log(" --help, -h Show this help");
|
|
150
|
-
console.log("");
|
|
151
|
-
console.log("Examples:");
|
|
152
|
-
console.log(" cadet teleport --from my-local --docker");
|
|
153
|
-
console.log(
|
|
154
|
-
" Hatch a new docker assistant, import data, and retire my-local",
|
|
155
|
-
);
|
|
156
|
-
console.log("");
|
|
157
|
-
console.log(" cadet teleport --from my-local --docker my-docker");
|
|
158
|
-
console.log(
|
|
159
|
-
" Import data from my-local into existing docker assistant my-docker",
|
|
160
|
-
);
|
|
161
|
-
console.log(
|
|
162
|
-
" or hatch a new docker assistant named my-docker if it doesn't exist",
|
|
163
|
-
);
|
|
164
|
-
console.log("");
|
|
165
|
-
console.log(" cadet teleport --from my-local --platform");
|
|
166
|
-
console.log(
|
|
167
|
-
" Hatch a new platform assistant and import data from my-local",
|
|
168
|
-
);
|
|
169
|
-
console.log("");
|
|
170
|
-
console.log(" cadet teleport --from my-cloud --local my-new-local");
|
|
171
|
-
console.log(
|
|
172
|
-
" Import data from platform assistant my-cloud into local assistant",
|
|
173
|
-
);
|
|
174
|
-
console.log("");
|
|
175
|
-
console.log(" cadet teleport --from my-docker --local --keep-source");
|
|
176
|
-
console.log(
|
|
177
|
-
" Transfer to a new local assistant but keep the docker source running",
|
|
178
|
-
);
|
|
179
|
-
console.log("");
|
|
180
|
-
console.log(
|
|
181
|
-
" cadet teleport --from staging --docker staging-copy --dry-run",
|
|
182
|
-
);
|
|
183
|
-
console.log(" Preview what would be imported without applying changes");
|
|
184
14
|
}
|
|
185
15
|
|
|
186
16
|
export function parseArgs(argv: string[]): {
|
|
187
17
|
from: string | undefined;
|
|
188
|
-
targetEnv: "local" | "docker" | "
|
|
18
|
+
targetEnv: "local" | "docker" | "cloud" | undefined;
|
|
189
19
|
targetName: string | undefined;
|
|
190
20
|
keepSource: boolean;
|
|
191
21
|
dryRun: boolean;
|
|
192
22
|
help: boolean;
|
|
193
23
|
} {
|
|
194
24
|
let from: string | undefined;
|
|
195
|
-
let targetEnv: "local" | "docker" | "
|
|
25
|
+
let targetEnv: "local" | "docker" | "cloud" | undefined;
|
|
196
26
|
let targetName: string | undefined;
|
|
197
27
|
let keepSource = false;
|
|
198
28
|
let dryRun = false;
|
|
@@ -207,12 +37,9 @@ export function parseArgs(argv: string[]): {
|
|
|
207
37
|
continue;
|
|
208
38
|
}
|
|
209
39
|
from = argv[++i];
|
|
210
|
-
} else if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
arg === "--platform"
|
|
214
|
-
) {
|
|
215
|
-
const env = arg.slice(2) as "local" | "docker" | "platform";
|
|
40
|
+
} else if (arg === "--local" || arg === "--docker" || arg === "--cloud") {
|
|
41
|
+
const env =
|
|
42
|
+
arg === "--local" ? "local" : arg === "--docker" ? "docker" : "cloud";
|
|
216
43
|
envFlags.push(env);
|
|
217
44
|
targetEnv = env;
|
|
218
45
|
// Peek at next arg for optional target name
|
|
@@ -230,7 +57,7 @@ export function parseArgs(argv: string[]): {
|
|
|
230
57
|
|
|
231
58
|
if (envFlags.length > 1) {
|
|
232
59
|
console.error(
|
|
233
|
-
"Error: Only one environment flag (--local, --docker, --
|
|
60
|
+
"Error: Only one environment flag (--local, --docker, --cloud) may be specified.",
|
|
234
61
|
);
|
|
235
62
|
process.exit(1);
|
|
236
63
|
}
|
|
@@ -238,1415 +65,76 @@ export function parseArgs(argv: string[]): {
|
|
|
238
65
|
return { from, targetEnv, targetName, keepSource, dryRun, help };
|
|
239
66
|
}
|
|
240
67
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
runtimeUrl: string,
|
|
247
|
-
assistantId: string,
|
|
248
|
-
displayName: string,
|
|
249
|
-
options?: { forceRefresh?: boolean; bootstrapSecret?: string },
|
|
250
|
-
): Promise<string> {
|
|
251
|
-
// When forceRefresh is set (e.g. after a runtime 401 on the cached token)
|
|
252
|
-
// we skip the cache and lease a brand-new token from the gateway, so a
|
|
253
|
-
// stale-but-unexpired token can't keep failing on every retry.
|
|
254
|
-
if (!options?.forceRefresh) {
|
|
255
|
-
const tokenData = loadGuardianToken(assistantId);
|
|
256
|
-
|
|
257
|
-
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
258
|
-
return tokenData.accessToken;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
const freshToken = await leaseGuardianToken(
|
|
264
|
-
runtimeUrl,
|
|
265
|
-
assistantId,
|
|
266
|
-
options?.bootstrapSecret,
|
|
267
|
-
);
|
|
268
|
-
return freshToken.accessToken;
|
|
269
|
-
} catch (err) {
|
|
270
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
271
|
-
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
272
|
-
console.error(
|
|
273
|
-
`Error: Could not connect to assistant '${displayName}'. Is it running?`,
|
|
274
|
-
);
|
|
275
|
-
console.error(`Try: cadet wake ${displayName}`);
|
|
276
|
-
process.exit(1);
|
|
277
|
-
}
|
|
278
|
-
throw err;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
|
|
284
|
-
* `localRuntimeImportFromGcs` / `localRuntimeIdentity`. They throw Error
|
|
285
|
-
* with a message of the form `"Local runtime <op> failed (401): ..."` or
|
|
286
|
-
* `"Failed to fetch runtime identity: 401 ..."` when the gateway rejects
|
|
287
|
-
* the cached guardian token.
|
|
288
|
-
*/
|
|
289
|
-
function isRuntime401(err: unknown): boolean {
|
|
290
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
291
|
-
return (
|
|
292
|
-
/Local runtime [^(]*failed \(401\)/.test(msg) ||
|
|
293
|
-
/Failed to fetch runtime identity: 401\b/.test(msg)
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Run a runtime kickoff (`localRuntimeExportToGcs` / `localRuntimeImportFromGcs`)
|
|
299
|
-
* with a one-shot refresh-and-retry on 401. Matches the pre-rewrite
|
|
300
|
-
* `exportViaHttp`/`importViaHttp` behavior: if the cached guardian token is
|
|
301
|
-
* stale-but-unexpired and the runtime returns 401, we lease a fresh token
|
|
302
|
-
* and retry once. Any other error — or a repeated 401 on the refreshed token
|
|
303
|
-
* — propagates to the caller.
|
|
304
|
-
*/
|
|
305
|
-
async function callRuntimeWithAuthRetry<T>(
|
|
306
|
-
entry: AssistantEntry,
|
|
307
|
-
fn: (token: string) => Promise<T>,
|
|
308
|
-
): Promise<T> {
|
|
309
|
-
const firstToken = await getAccessToken(
|
|
310
|
-
entry.runtimeUrl,
|
|
311
|
-
entry.assistantId,
|
|
312
|
-
entry.assistantId,
|
|
313
|
-
{ bootstrapSecret: entry.guardianBootstrapSecret },
|
|
314
|
-
);
|
|
315
|
-
try {
|
|
316
|
-
return await fn(firstToken);
|
|
317
|
-
} catch (err) {
|
|
318
|
-
if (!isRuntime401(err)) {
|
|
319
|
-
throw err;
|
|
320
|
-
}
|
|
321
|
-
const refreshedToken = await getAccessToken(
|
|
322
|
-
entry.runtimeUrl,
|
|
323
|
-
entry.assistantId,
|
|
324
|
-
entry.assistantId,
|
|
325
|
-
{
|
|
326
|
-
forceRefresh: true,
|
|
327
|
-
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
328
|
-
},
|
|
329
|
-
);
|
|
330
|
-
return await fn(refreshedToken);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
// Summary response shapes (reused by the GCS job result payload)
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
|
|
338
|
-
interface PreflightFileEntry {
|
|
339
|
-
path: string;
|
|
340
|
-
action: string;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
interface StructuredError {
|
|
344
|
-
code: string;
|
|
345
|
-
message: string;
|
|
346
|
-
path?: string;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
interface PreflightResponse {
|
|
350
|
-
can_import: boolean;
|
|
351
|
-
validation?: {
|
|
352
|
-
is_valid: false;
|
|
353
|
-
errors: StructuredError[];
|
|
354
|
-
};
|
|
355
|
-
files?: PreflightFileEntry[];
|
|
356
|
-
summary?: {
|
|
357
|
-
files_to_create: number;
|
|
358
|
-
files_to_overwrite: number;
|
|
359
|
-
files_unchanged: number;
|
|
360
|
-
total_files: number;
|
|
361
|
-
};
|
|
362
|
-
conflicts?: StructuredError[];
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
interface ImportResponse {
|
|
366
|
-
success: boolean;
|
|
367
|
-
reason?: string;
|
|
368
|
-
errors?: StructuredError[];
|
|
369
|
-
message?: string;
|
|
370
|
-
warnings?: string[];
|
|
371
|
-
summary?: {
|
|
372
|
-
total_files: number;
|
|
373
|
-
files_created: number;
|
|
374
|
-
files_overwritten: number;
|
|
375
|
-
files_skipped: number;
|
|
376
|
-
backups_created: number;
|
|
377
|
-
};
|
|
378
|
-
credentialsImported?: {
|
|
379
|
-
total: number;
|
|
380
|
-
succeeded: number;
|
|
381
|
-
failed: number;
|
|
382
|
-
failedAccounts: string[];
|
|
383
|
-
skippedCloud?: number;
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// ---------------------------------------------------------------------------
|
|
388
|
-
// Pre-export safety backup of the source
|
|
389
|
-
//
|
|
390
|
-
// Runs before any data leaves the source. Reuses a backup of this assistant
|
|
391
|
-
// younger than RECENT_BACKUP_MAX_AGE_MS; otherwise takes one and blocks until
|
|
392
|
-
// it is usable. Any failure aborts the teleport: the source is retired at the
|
|
393
|
-
// end of a successful teleport, and that is only safe with a restore point
|
|
394
|
-
// behind it.
|
|
395
|
-
//
|
|
396
|
-
// Local and docker sources are exported to the host's CLI backup directory
|
|
397
|
-
// rather than snapshotted by their own gateway: docker retirement removes
|
|
398
|
-
// every source volume (including the gateway's backup pool), and bare-metal
|
|
399
|
-
// gateways share one unlabelled pool across assistants, so neither would
|
|
400
|
-
// give this assistant a restore point that outlives the teleport.
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
/** Export timeout for the host-side pre-teleport backup of a local/docker source. */
|
|
404
|
-
const LOCAL_SOURCE_BACKUP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
405
|
-
|
|
406
|
-
async function backupSourceBeforeTeleport(
|
|
407
|
-
entry: AssistantEntry,
|
|
408
|
-
cloud: string,
|
|
409
|
-
displayName: string,
|
|
410
|
-
): Promise<void> {
|
|
411
|
-
console.log(`Backing up ${displayName} on ${cloud}...`);
|
|
412
|
-
try {
|
|
413
|
-
if (isCloudHome(cloud)) {
|
|
414
|
-
const cloudToken = readCloudToken();
|
|
415
|
-
if (!cloudToken) {
|
|
416
|
-
console.error("Not logged in. Run 'cadet login' first.");
|
|
417
|
-
process.exit(1);
|
|
418
|
-
}
|
|
419
|
-
const createdAts = await listPlatformBackups(entry, cloudToken);
|
|
420
|
-
if (hasRecentBackup(createdAts)) {
|
|
421
|
-
console.log("Recent backup found, reusing it.");
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
await createPlatformBackup(entry, cloudToken);
|
|
425
|
-
console.log("Backup complete.");
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
68
|
+
export const teleport = Object.assign(
|
|
69
|
+
async function teleport(): Promise<void> {
|
|
70
|
+
const args = process.argv.slice(3);
|
|
71
|
+
const { from, targetEnv, targetName, keepSource, dryRun, help } =
|
|
72
|
+
parseArgs(args);
|
|
428
73
|
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
const backupPath = await createBackup(entry.runtimeUrl, entry.assistantId, {
|
|
434
|
-
prefix: `${entry.assistantId}-pre-teleport`,
|
|
435
|
-
description: `Pre-teleport snapshot of ${displayName} (${cloud})`,
|
|
436
|
-
timeoutMs: LOCAL_SOURCE_BACKUP_TIMEOUT_MS,
|
|
437
|
-
});
|
|
438
|
-
if (!backupPath) {
|
|
439
|
-
throw new Error("backup export failed — see the warning above");
|
|
74
|
+
if (help) {
|
|
75
|
+
printHelp();
|
|
76
|
+
process.exit(0);
|
|
440
77
|
}
|
|
441
|
-
pruneOldBackups(entry.assistantId, 3, "pre-teleport");
|
|
442
|
-
console.log(`Backup saved: ${backupPath}`);
|
|
443
|
-
} catch (err) {
|
|
444
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
445
|
-
console.error(`Error: Could not back up '${displayName}': ${msg}`);
|
|
446
|
-
console.error("Teleport aborted; the source assistant was not modified.");
|
|
447
|
-
process.exit(1);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ---------------------------------------------------------------------------
|
|
452
|
-
// Export from source — unified GCS flow
|
|
453
|
-
//
|
|
454
|
-
// Every source (local, docker, platform) produces a `bundleKey` referring to
|
|
455
|
-
// a bundle sitting in GCS. The CLI never holds the bundle bytes.
|
|
456
|
-
// ---------------------------------------------------------------------------
|
|
457
78
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
cloud: string,
|
|
461
|
-
bundleCloudUrl?: string,
|
|
462
|
-
): Promise<{ bundleKey: string }> {
|
|
463
|
-
const cloudToken = readCloudToken();
|
|
464
|
-
if (!cloudToken) {
|
|
465
|
-
console.error(
|
|
466
|
-
"Not logged in. Run 'cadet login' first.",
|
|
467
|
-
);
|
|
468
|
-
process.exit(1);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (cloud === "local" || cloud === "docker") {
|
|
472
|
-
// Ask the source runtime which version it's running before requesting
|
|
473
|
-
// the signed upload URL. The bundle is produced by the daemon (not the
|
|
474
|
-
// CLI), so the daemon's version is what defines the bundle's
|
|
475
|
-
// `min_runtime_version`. Stamping with `cliPkg.version` instead would
|
|
476
|
-
// record an inaccurate compatibility band whenever the CLI/daemon have
|
|
477
|
-
// drifted (a normal case in real usage — `cadet upgrade` swaps the
|
|
478
|
-
// daemon, the CLI is updated separately).
|
|
479
|
-
let sourceRuntimeVersion: string;
|
|
480
|
-
try {
|
|
481
|
-
const identity = await callRuntimeWithAuthRetry(entry, async (token) =>
|
|
482
|
-
localRuntimeIdentity(entry, token),
|
|
483
|
-
);
|
|
484
|
-
sourceRuntimeVersion = identity.version;
|
|
485
|
-
} catch (err) {
|
|
486
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
487
|
-
console.error(
|
|
488
|
-
`Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
|
|
489
|
-
);
|
|
79
|
+
if (!from) {
|
|
80
|
+
printHelp();
|
|
490
81
|
process.exit(1);
|
|
491
82
|
}
|
|
492
83
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// Passing the target's runtime URL here keeps upload and download on
|
|
496
|
-
// the same platform — otherwise a non-default/stale platform URL would
|
|
497
|
-
// cause the import to look at an empty object.
|
|
498
|
-
//
|
|
499
|
-
// The PUT is performed by the source daemon, not this CLI. A bare-metal
|
|
500
|
-
// local daemon shares the host's network view, so the client-signed URL
|
|
501
|
-
// is reachable; a docker daemon runs inside a container and reaches the
|
|
502
|
-
// host the same way managed pods do, so its URL must be signed for the
|
|
503
|
-
// runtime-reachable storage endpoint.
|
|
504
|
-
const { url: uploadUrl, bundleKey } = await cloudRequestSignedUrl(
|
|
505
|
-
{
|
|
506
|
-
operation: "upload",
|
|
507
|
-
minRuntimeVersion: sourceRuntimeVersion,
|
|
508
|
-
maxRuntimeVersion: null,
|
|
509
|
-
...(cloud === "docker" ? { consumer: "runtime" as const } : {}),
|
|
510
|
-
},
|
|
511
|
-
cloudToken,
|
|
512
|
-
bundleCloudUrl,
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// Wrap the kickoff in a one-shot refresh-and-retry helper so a stale-but-
|
|
516
|
-
// unexpired cached guardian token surfaces as 401 → re-lease → retry
|
|
517
|
-
// rather than a terminal failure. `accessToken` below is whichever token
|
|
518
|
-
// succeeded on the kickoff; we reuse it for polling so the runtime sees a
|
|
519
|
-
// consistent credential throughout the migration.
|
|
520
|
-
let jobId: string;
|
|
521
|
-
let accessToken: string;
|
|
522
|
-
try {
|
|
523
|
-
const result = await callRuntimeWithAuthRetry(entry, async (token) => {
|
|
524
|
-
const r = await localRuntimeExportToGcs(entry, token, {
|
|
525
|
-
uploadUrl,
|
|
526
|
-
description: "teleport export",
|
|
527
|
-
});
|
|
528
|
-
return { jobId: r.jobId, token };
|
|
529
|
-
});
|
|
530
|
-
jobId = result.jobId;
|
|
531
|
-
accessToken = result.token;
|
|
532
|
-
} catch (err) {
|
|
533
|
-
if (err instanceof MigrationInProgressError) {
|
|
534
|
-
// Fail fast — the existing job is writing to a different GCS object
|
|
535
|
-
// (its caller's signed URL, not ours), so polling it would leave us
|
|
536
|
-
// pointing at an empty/unrelated bundle. Surface the existing job id
|
|
537
|
-
// so the user can decide whether to wait or investigate.
|
|
538
|
-
console.error(
|
|
539
|
-
`Error: Another teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
|
|
540
|
-
);
|
|
541
|
-
process.exit(1);
|
|
542
|
-
}
|
|
543
|
-
throw err;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
console.log(`Export started (job ${jobId})...`);
|
|
547
|
-
|
|
548
|
-
const terminal = await pollJobUntilDone({
|
|
549
|
-
label: "local-runtime export",
|
|
550
|
-
poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
|
|
551
|
-
// Large exports can take longer than a guardian-token lease. If the
|
|
552
|
-
// runtime returns 401 mid-poll, re-lease a fresh token and rebind the
|
|
553
|
-
// closure variable so the next poll uses it.
|
|
554
|
-
refreshOn401: async () => {
|
|
555
|
-
accessToken = await getAccessToken(
|
|
556
|
-
entry.runtimeUrl,
|
|
557
|
-
entry.assistantId,
|
|
558
|
-
entry.assistantId,
|
|
559
|
-
{
|
|
560
|
-
forceRefresh: true,
|
|
561
|
-
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
},
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
if (terminal.status === "failed") {
|
|
568
|
-
console.error(`Export failed: ${terminal.error}`);
|
|
84
|
+
if (!targetEnv) {
|
|
85
|
+
printHelp();
|
|
569
86
|
process.exit(1);
|
|
570
87
|
}
|
|
571
88
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (isCloudHome(cloud)) {
|
|
576
|
-
// Ask the cloud runtime which version it's running so the signed-URL
|
|
577
|
-
// request records the bundle's actual `min_runtime_version`. The
|
|
578
|
-
// cloud-managed runtime is the exporter; the CLI version is
|
|
579
|
-
// unrelated. Routed via the wildcard proxy with platform-token auth
|
|
580
|
-
// (resolveRuntimeUrl + migrationRequestHeaders inside
|
|
581
|
-
// localRuntimeIdentity).
|
|
582
|
-
let sourceRuntimeVersion: string;
|
|
583
|
-
try {
|
|
584
|
-
const identity = await localRuntimeIdentity(entry, cloudToken);
|
|
585
|
-
sourceRuntimeVersion = identity.version;
|
|
586
|
-
} catch (err) {
|
|
587
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
89
|
+
// Look up source assistant
|
|
90
|
+
const fromEntry = findAssistantByName(from);
|
|
91
|
+
if (!fromEntry) {
|
|
588
92
|
console.error(
|
|
589
|
-
`
|
|
93
|
+
`Assistant '${from}' not found in lockfile. Run \`cadet ps\` to see available assistants.`,
|
|
590
94
|
);
|
|
591
95
|
process.exit(1);
|
|
592
96
|
}
|
|
593
97
|
|
|
594
|
-
|
|
595
|
-
// instance the bundle will eventually be imported from, then ask the
|
|
596
|
-
// managed runtime to export directly to GCS. The runtime endpoint is
|
|
597
|
-
// reached via the cloud's wildcard runtime proxy at
|
|
598
|
-
// `/v1/assistants/<id>/migrations/export-to-gcs` — the
|
|
599
|
-
// `localRuntimeExportToGcs` helper uses `resolveRuntimeMigrationUrl` to
|
|
600
|
-
// pick that shape for `isCloudHome(cloud)` and `migrationRequestHeaders`
|
|
601
|
-
// to send platform-token auth (no guardian-token bootstrap).
|
|
602
|
-
const { url: uploadUrl, bundleKey } = await cloudRequestSignedUrl(
|
|
603
|
-
{
|
|
604
|
-
operation: "upload",
|
|
605
|
-
minRuntimeVersion: sourceRuntimeVersion,
|
|
606
|
-
maxRuntimeVersion: null,
|
|
607
|
-
// The cloud pod PUTs the bundle, not this CLI — the URL must be
|
|
608
|
-
// signed against the runtime-reachable storage endpoint.
|
|
609
|
-
consumer: "runtime",
|
|
610
|
-
},
|
|
611
|
-
cloudToken,
|
|
612
|
-
bundleCloudUrl,
|
|
613
|
-
);
|
|
614
|
-
|
|
615
|
-
let jobId: string;
|
|
616
|
-
let exportPlatformToken = cloudToken;
|
|
617
|
-
try {
|
|
618
|
-
({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
|
|
619
|
-
uploadUrl,
|
|
620
|
-
description: "teleport export",
|
|
621
|
-
}));
|
|
622
|
-
} catch (err) {
|
|
623
|
-
if (err instanceof MigrationInProgressError) {
|
|
624
|
-
console.error(
|
|
625
|
-
`Error: Another teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
|
|
626
|
-
);
|
|
627
|
-
process.exit(1);
|
|
628
|
-
}
|
|
629
|
-
throw err;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
console.log(`Export started (job ${jobId})...`);
|
|
633
|
-
|
|
634
|
-
// Polling also goes through the wildcard proxy — `localRuntimePollJobStatus`
|
|
635
|
-
// builds `/v1/assistants/<id>/migrations/jobs/<jobId>` for `isCloudHome(cloud)`
|
|
636
|
-
// (the dedicated `/v1/migrations/jobs/{id}/` endpoint queries platform-side
|
|
637
|
-
// ImportJob records and 404s on runtime-created job IDs).
|
|
638
|
-
const terminal = await pollJobUntilDone({
|
|
639
|
-
label: "platform export",
|
|
640
|
-
poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
|
|
641
|
-
// The cloud token is normally static per-process, but re-reading the
|
|
642
|
-
// on-disk credential covers the case where the user ran `cadet login`
|
|
643
|
-
// in another terminal during a long migration. A persistent 401 after
|
|
644
|
-
// a re-read surfaces to the caller with a clear next step.
|
|
645
|
-
refreshOn401: async () => {
|
|
646
|
-
const refreshed = readCloudToken();
|
|
647
|
-
if (!refreshed) {
|
|
648
|
-
throw new Error(
|
|
649
|
-
"Platform auth expired during export and no credential was found on disk. Run 'cadet login' and retry.",
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
exportPlatformToken = refreshed;
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
if (terminal.status === "failed") {
|
|
657
|
-
console.error(`Export failed: ${terminal.error}`);
|
|
658
|
-
process.exit(1);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return { bundleKey };
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
console.error(
|
|
665
|
-
"Teleport only supports local, docker, and platform assistants as source.",
|
|
666
|
-
);
|
|
667
|
-
process.exit(1);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// ---------------------------------------------------------------------------
|
|
671
|
-
// Import into target — unified GCS flow
|
|
672
|
-
// ---------------------------------------------------------------------------
|
|
673
|
-
|
|
674
|
-
async function importToAssistant(
|
|
675
|
-
entry: AssistantEntry,
|
|
676
|
-
cloud: string,
|
|
677
|
-
bundleKey: string,
|
|
678
|
-
dryRun: boolean,
|
|
679
|
-
bundleCloudUrl?: string,
|
|
680
|
-
): Promise<void> {
|
|
681
|
-
const cloudToken = readCloudToken();
|
|
682
|
-
if (!cloudToken) {
|
|
683
|
-
console.error(
|
|
684
|
-
"Not logged in. Run 'cadet login' first.",
|
|
685
|
-
);
|
|
686
|
-
process.exit(1);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
if (isCloudHome(cloud)) {
|
|
690
|
-
// Platform target — the bundle is already in GCS; kick off preflight or
|
|
691
|
-
// async import via the unified job-status endpoint.
|
|
692
|
-
if (dryRun) {
|
|
693
|
-
console.log("Running preflight analysis...\n");
|
|
694
|
-
|
|
695
|
-
const preflight = await platformImportPreflightFromGcs(
|
|
696
|
-
bundleKey,
|
|
697
|
-
cloudToken,
|
|
698
|
-
entry.runtimeUrl,
|
|
699
|
-
);
|
|
700
|
-
|
|
701
|
-
if (preflight.statusCode === 401 || preflight.statusCode === 403) {
|
|
702
|
-
console.error("Authentication failed. Run 'cadet login' to refresh.");
|
|
703
|
-
process.exit(1);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (preflight.statusCode === 404) {
|
|
707
|
-
console.error("Assistant not found or not running.");
|
|
708
|
-
process.exit(1);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (
|
|
712
|
-
preflight.statusCode === 502 ||
|
|
713
|
-
preflight.statusCode === 503 ||
|
|
714
|
-
preflight.statusCode === 504
|
|
715
|
-
) {
|
|
716
|
-
console.error(
|
|
717
|
-
`Assistant is unreachable. Try 'cadet wake ${entry.assistantId}'.`,
|
|
718
|
-
);
|
|
719
|
-
process.exit(1);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (preflight.statusCode !== 200) {
|
|
723
|
-
console.error(
|
|
724
|
-
`Error: Preflight check failed (${preflight.statusCode}): ${JSON.stringify(preflight.body)}`,
|
|
725
|
-
);
|
|
726
|
-
process.exit(1);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const result = preflight.body as unknown as PreflightResponse;
|
|
730
|
-
printPreflightSummary(result);
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
console.log("Importing data...");
|
|
735
|
-
|
|
736
|
-
const importResult = await platformImportBundleFromGcs(
|
|
737
|
-
bundleKey,
|
|
738
|
-
cloudToken,
|
|
739
|
-
entry.runtimeUrl,
|
|
740
|
-
);
|
|
741
|
-
|
|
742
|
-
if (importResult.statusCode === 401 || importResult.statusCode === 403) {
|
|
743
|
-
console.error("Authentication failed. Run 'cadet login' to refresh.");
|
|
744
|
-
process.exit(1);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
if (importResult.statusCode === 404) {
|
|
748
|
-
console.error("Assistant not found or not running.");
|
|
749
|
-
process.exit(1);
|
|
750
|
-
}
|
|
98
|
+
const fromCloud = fromEntry.cloud;
|
|
751
99
|
|
|
752
|
-
if (
|
|
753
|
-
importResult.statusCode === 502 ||
|
|
754
|
-
importResult.statusCode === 503 ||
|
|
755
|
-
importResult.statusCode === 504
|
|
756
|
-
) {
|
|
100
|
+
if (fromCloud === "apple-container") {
|
|
757
101
|
console.error(
|
|
758
|
-
`
|
|
102
|
+
`Error: '${from}' uses the Apple Containers runtime. Teleport is not yet supported for this topology.`,
|
|
759
103
|
);
|
|
760
104
|
process.exit(1);
|
|
761
105
|
}
|
|
762
106
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
let finalBody: Record<string, unknown> = importResult.body;
|
|
769
|
-
|
|
770
|
-
if (importResult.statusCode === 202) {
|
|
771
|
-
const jobId = (importResult.body as { job_id?: string }).job_id;
|
|
772
|
-
if (!jobId) {
|
|
773
|
-
console.error("Error: Import accepted but no job ID returned.");
|
|
774
|
-
process.exit(1);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
let importPlatformToken = cloudToken;
|
|
778
|
-
const terminal = await pollJobUntilDone({
|
|
779
|
-
label: "platform import",
|
|
780
|
-
poll: () =>
|
|
781
|
-
platformPollJobStatus(jobId, importPlatformToken, entry.runtimeUrl),
|
|
782
|
-
refreshOn401: async () => {
|
|
783
|
-
const refreshed = readCloudToken();
|
|
784
|
-
if (!refreshed) {
|
|
785
|
-
throw new Error(
|
|
786
|
-
"Platform auth expired during import and no credential was found on disk. Run 'cadet login' and retry.",
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
importPlatformToken = refreshed;
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
if (terminal.status === "failed") {
|
|
794
|
-
console.error(`Import failed: ${terminal.error}`);
|
|
795
|
-
process.exit(1);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
finalBody = (terminal.result as Record<string, unknown>) ?? {};
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const result = finalBody as unknown as ImportResponse;
|
|
802
|
-
printImportSummary(result);
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
if (cloud === "local" || cloud === "docker") {
|
|
807
|
-
if (dryRun) {
|
|
808
|
-
console.log("Running preflight analysis...\n");
|
|
809
|
-
|
|
810
|
-
// Query the target runtime's version before requesting a signed
|
|
811
|
-
// download URL — the cloud uses it for the bundle compatibility check.
|
|
812
|
-
let targetRuntimeVersion: string;
|
|
813
|
-
try {
|
|
814
|
-
const identity = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
815
|
-
localRuntimeIdentity(entry, token),
|
|
816
|
-
);
|
|
817
|
-
targetRuntimeVersion = identity.version;
|
|
818
|
-
} catch (err) {
|
|
819
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
820
|
-
console.error(
|
|
821
|
-
`Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
|
|
822
|
-
);
|
|
823
|
-
console.error(`Try: cadet wake ${entry.assistantId}`);
|
|
824
|
-
process.exit(1);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
let bundleUrl: string;
|
|
828
|
-
try {
|
|
829
|
-
const result = await cloudRequestSignedUrl(
|
|
830
|
-
{ operation: "download", bundleKey, targetRuntimeVersion },
|
|
831
|
-
cloudToken,
|
|
832
|
-
bundleCloudUrl,
|
|
833
|
-
);
|
|
834
|
-
bundleUrl = result.url;
|
|
835
|
-
} catch (err) {
|
|
836
|
-
if (err instanceof VersionMismatchError) {
|
|
837
|
-
console.error(`Error: ${err.message}`);
|
|
838
|
-
process.exit(1);
|
|
839
|
-
}
|
|
840
|
-
throw err;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const preflightData = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
844
|
-
localRuntimePreflightFromGcs(entry, token, { bundleUrl }),
|
|
845
|
-
);
|
|
846
|
-
printPreflightSummary(preflightData as unknown as PreflightResponse);
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Ask the cloud for a signed download URL and hand it to the local
|
|
851
|
-
// runtime. The runtime streams the bundle straight out of GCS — the CLI
|
|
852
|
-
// never touches the bytes. The URL must target the same platform the
|
|
853
|
-
// bundle was uploaded to; otherwise the object won't exist on this
|
|
854
|
-
// platform's GCS bucket.
|
|
855
|
-
//
|
|
856
|
-
// The cloud's vbundle version gate compares the **target runtime's**
|
|
857
|
-
// version against the bundle's compatibility range. The CLI and the
|
|
858
|
-
// target assistant's daemon can diverge (assistants upgrade
|
|
859
|
-
// independently), so we MUST query the target runtime's `/v1/identity`
|
|
860
|
-
// for its version rather than sending `cliPkg.version`. Sending the CLI
|
|
861
|
-
// version here would falsely 422 a valid import (or pass a bundle the
|
|
862
|
-
// target can't actually load) whenever the two drift apart.
|
|
863
|
-
let targetRuntimeVersion: string;
|
|
864
|
-
try {
|
|
865
|
-
const identity = await callRuntimeWithAuthRetry(entry, (token) =>
|
|
866
|
-
localRuntimeIdentity(entry, token),
|
|
867
|
-
);
|
|
868
|
-
targetRuntimeVersion = identity.version;
|
|
869
|
-
} catch (err) {
|
|
870
|
-
// Surface and abort — silently falling back to `cliPkg.version` would
|
|
871
|
-
// re-introduce the bug this code is fixing. If the runtime is
|
|
872
|
-
// unreachable, the import would fail downstream anyway.
|
|
873
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
// Early same-environment guard — compare source cloud against the CLI flag
|
|
108
|
+
// BEFORE exporting or hatching, to avoid creating orphaned assistants.
|
|
109
|
+
if (fromCloud === targetEnv) {
|
|
874
110
|
console.error(
|
|
875
|
-
`
|
|
111
|
+
`Cannot teleport between two ${targetEnv} assistants. Teleport transfers data across different environments.`,
|
|
876
112
|
);
|
|
877
|
-
console.error(`Try: cadet wake ${entry.assistantId}`);
|
|
878
|
-
process.exit(1);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
let bundleUrl: string;
|
|
882
|
-
try {
|
|
883
|
-
const result = await cloudRequestSignedUrl(
|
|
884
|
-
{
|
|
885
|
-
operation: "download",
|
|
886
|
-
bundleKey,
|
|
887
|
-
targetRuntimeVersion,
|
|
888
|
-
},
|
|
889
|
-
cloudToken,
|
|
890
|
-
bundleCloudUrl,
|
|
891
|
-
);
|
|
892
|
-
bundleUrl = result.url;
|
|
893
|
-
} catch (err) {
|
|
894
|
-
if (err instanceof VersionMismatchError) {
|
|
895
|
-
// 422 version_mismatch is terminal — the bundle's runtime range and
|
|
896
|
-
// the target runtime's version don't overlap. Surface the
|
|
897
|
-
// platform-formatted message and exit; do NOT retry.
|
|
898
|
-
console.error(`Error: ${err.message}`);
|
|
899
|
-
process.exit(1);
|
|
900
|
-
}
|
|
901
|
-
throw err;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
console.log("Importing data...");
|
|
905
|
-
|
|
906
|
-
let jobId: string;
|
|
907
|
-
let accessToken: string;
|
|
908
|
-
try {
|
|
909
|
-
const result = await callRuntimeWithAuthRetry(entry, async (token) => {
|
|
910
|
-
const r = await localRuntimeImportFromGcs(entry, token, {
|
|
911
|
-
bundleUrl,
|
|
912
|
-
});
|
|
913
|
-
return { jobId: r.jobId, token };
|
|
914
|
-
});
|
|
915
|
-
jobId = result.jobId;
|
|
916
|
-
accessToken = result.token;
|
|
917
|
-
} catch (err) {
|
|
918
|
-
if (err instanceof MigrationInProgressError) {
|
|
919
|
-
// Fail fast — the existing job is importing someone else's bundle
|
|
920
|
-
// (the original caller's), not ours. Polling it would report success
|
|
921
|
-
// on an import that wasn't the one we just kicked off.
|
|
922
|
-
console.error(
|
|
923
|
-
`Error: Another teleport import is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
|
|
924
|
-
);
|
|
925
|
-
process.exit(1);
|
|
926
|
-
}
|
|
927
|
-
throw err;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const terminal = await pollJobUntilDone({
|
|
931
|
-
label: "local-runtime import",
|
|
932
|
-
poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
|
|
933
|
-
refreshOn401: async () => {
|
|
934
|
-
accessToken = await getAccessToken(
|
|
935
|
-
entry.runtimeUrl,
|
|
936
|
-
entry.assistantId,
|
|
937
|
-
entry.assistantId,
|
|
938
|
-
{
|
|
939
|
-
forceRefresh: true,
|
|
940
|
-
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
941
|
-
},
|
|
942
|
-
);
|
|
943
|
-
},
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
if (terminal.status === "failed") {
|
|
947
|
-
console.error(`Import failed: ${terminal.error}`);
|
|
948
113
|
process.exit(1);
|
|
949
114
|
}
|
|
950
115
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
console.error(
|
|
958
|
-
"Teleport only supports local, docker, and platform assistants as target.",
|
|
959
|
-
);
|
|
960
|
-
process.exit(1);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// ---------------------------------------------------------------------------
|
|
964
|
-
// Resolve or hatch target assistant
|
|
965
|
-
// ---------------------------------------------------------------------------
|
|
966
|
-
|
|
967
|
-
export async function resolveOrHatchTarget(
|
|
968
|
-
targetEnv: "local" | "docker" | "platform",
|
|
969
|
-
targetName?: string,
|
|
970
|
-
): Promise<AssistantEntry> {
|
|
971
|
-
// If a name is provided, try to find an existing assistant
|
|
972
|
-
if (targetName) {
|
|
973
|
-
const existing = findAssistantByName(targetName);
|
|
974
|
-
if (existing) {
|
|
975
|
-
// Validate the existing assistant's cloud matches the requested env
|
|
976
|
-
const existingCloud = existing.cloud;
|
|
977
|
-
const normalizedExisting =
|
|
978
|
-
existingCloud === "cadet" ? "platform" : existingCloud;
|
|
979
|
-
if (normalizedExisting !== targetEnv) {
|
|
980
|
-
console.error(
|
|
981
|
-
`Error: Assistant '${targetName}' is a ${normalizedExisting} assistant, not ${targetEnv}. ` +
|
|
982
|
-
`Use --${normalizedExisting} to target it.`,
|
|
983
|
-
);
|
|
984
|
-
process.exit(1);
|
|
985
|
-
}
|
|
986
|
-
console.log(`Target: ${targetName} — ${targetEnv}`);
|
|
987
|
-
return existing;
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// Name not found — will hatch.
|
|
991
|
-
if (targetEnv === "platform") {
|
|
992
|
-
// Platform API doesn't accept custom names — warn and ignore
|
|
993
|
-
console.log(
|
|
994
|
-
`Note: Platform assistants receive a server-assigned ID. The name '${targetName}' will not be used.`,
|
|
995
|
-
);
|
|
996
|
-
} else {
|
|
997
|
-
// Validate the name before passing to hatch
|
|
116
|
+
if (
|
|
117
|
+
(fromCloud === "local" || fromCloud === "docker") &&
|
|
118
|
+
(targetEnv === "local" || targetEnv === "docker")
|
|
119
|
+
) {
|
|
998
120
|
try {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
"Error: Target name contains invalid characters (path separators or traversal segments are not allowed).",
|
|
1003
|
-
);
|
|
1004
|
-
process.exit(1);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
// Hatch a new assistant in the target environment
|
|
1010
|
-
if (targetEnv === "local") {
|
|
1011
|
-
const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
|
|
1012
|
-
await hatchLocal({
|
|
1013
|
-
name: targetName ?? null,
|
|
1014
|
-
setupProviderCredentials: false,
|
|
1015
|
-
});
|
|
1016
|
-
const entry = targetName
|
|
1017
|
-
? findAssistantByName(targetName)
|
|
1018
|
-
: (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
|
|
1019
|
-
null);
|
|
1020
|
-
if (!entry) {
|
|
1021
|
-
console.error("Error: Could not find the newly hatched local assistant.");
|
|
1022
|
-
process.exit(1);
|
|
1023
|
-
}
|
|
1024
|
-
console.log(`Hatched new local assistant: ${entry.assistantId}`);
|
|
1025
|
-
return entry;
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
if (targetEnv === "docker") {
|
|
1029
|
-
const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
|
|
1030
|
-
await hatchDocker({
|
|
1031
|
-
name: targetName ?? null,
|
|
1032
|
-
setupProviderCredentials: false,
|
|
1033
|
-
});
|
|
1034
|
-
const entry = targetName
|
|
1035
|
-
? findAssistantByName(targetName)
|
|
1036
|
-
: (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
|
|
1037
|
-
null);
|
|
1038
|
-
if (!entry) {
|
|
1039
|
-
console.error(
|
|
1040
|
-
"Error: Could not find the newly hatched docker assistant.",
|
|
1041
|
-
);
|
|
1042
|
-
process.exit(1);
|
|
1043
|
-
}
|
|
1044
|
-
console.log(`Hatched new docker assistant: ${entry.assistantId}`);
|
|
1045
|
-
return entry;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
if (targetEnv === "platform") {
|
|
1049
|
-
const token = readCloudToken();
|
|
1050
|
-
if (!token) {
|
|
1051
|
-
console.error("Not logged in. Run 'cadet login' first.");
|
|
1052
|
-
process.exit(1);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
const { assistant: result, reusedExisting } = await hatchAssistant(token);
|
|
1056
|
-
|
|
1057
|
-
// Defensive safety net — should not happen because of the pre-check in
|
|
1058
|
-
// teleport(), but guards against a TOCTOU race between the pre-check and
|
|
1059
|
-
// hatch (e.g. another client hatches in the GCS-upload window).
|
|
1060
|
-
if (reusedExisting) {
|
|
1061
|
-
const entry: AssistantEntry = {
|
|
1062
|
-
assistantId: result.id,
|
|
1063
|
-
runtimeUrl: getCloudUrl(),
|
|
1064
|
-
cloud: "cloud",
|
|
1065
|
-
hatchedAt: new Date().toISOString(),
|
|
1066
|
-
};
|
|
1067
|
-
saveAssistantEntry(entry);
|
|
1068
|
-
console.error(
|
|
1069
|
-
`Error: You already have a cloud assistant '${result.id}'.`,
|
|
1070
|
-
);
|
|
1071
|
-
console.error(
|
|
1072
|
-
`Retire it first with 'cadet retire ${result.id}', then retry the teleport.`,
|
|
1073
|
-
);
|
|
1074
|
-
process.exit(1);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
const entry: AssistantEntry = {
|
|
1078
|
-
assistantId: result.id,
|
|
1079
|
-
runtimeUrl: getCloudUrl(),
|
|
1080
|
-
cloud: "cloud",
|
|
1081
|
-
hatchedAt: new Date().toISOString(),
|
|
1082
|
-
};
|
|
1083
|
-
saveAssistantEntry(entry);
|
|
1084
|
-
setActiveAssistant(result.id);
|
|
1085
|
-
console.log(`Hatched new Cadet Cloud assistant: ${result.id}`);
|
|
1086
|
-
return entry;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
console.error(`Error: Unknown target environment '${targetEnv}'.`);
|
|
1090
|
-
process.exit(1);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
// ---------------------------------------------------------------------------
|
|
1094
|
-
// Summary printing — matches restore.ts format
|
|
1095
|
-
// ---------------------------------------------------------------------------
|
|
1096
|
-
|
|
1097
|
-
function printPreflightSummary(result: PreflightResponse): void {
|
|
1098
|
-
if (!result.can_import) {
|
|
1099
|
-
if (result.validation?.errors?.length) {
|
|
1100
|
-
console.error("Import blocked by validation errors:");
|
|
1101
|
-
for (const err of result.validation.errors) {
|
|
1102
|
-
console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
if (result.conflicts?.length) {
|
|
1106
|
-
console.error("Import blocked by conflicts:");
|
|
1107
|
-
for (const conflict of result.conflicts) {
|
|
1108
|
-
console.error(
|
|
1109
|
-
` - ${conflict.message}${conflict.path ? ` (${conflict.path})` : ""}`,
|
|
1110
|
-
);
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
process.exit(1);
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
const summary = result.summary ?? {
|
|
1117
|
-
files_to_create: 0,
|
|
1118
|
-
files_to_overwrite: 0,
|
|
1119
|
-
files_unchanged: 0,
|
|
1120
|
-
total_files: 0,
|
|
1121
|
-
};
|
|
1122
|
-
console.log("Preflight analysis:");
|
|
1123
|
-
console.log(` Files to create: ${summary.files_to_create}`);
|
|
1124
|
-
console.log(` Files to overwrite: ${summary.files_to_overwrite}`);
|
|
1125
|
-
console.log(` Files unchanged: ${summary.files_unchanged}`);
|
|
1126
|
-
console.log(` Total: ${summary.total_files}`);
|
|
1127
|
-
console.log("");
|
|
1128
|
-
|
|
1129
|
-
const conflicts = result.conflicts ?? [];
|
|
1130
|
-
console.log(
|
|
1131
|
-
`Conflicts: ${conflicts.length > 0 ? conflicts.map((c) => c.message).join(", ") : "none"}`,
|
|
1132
|
-
);
|
|
1133
|
-
|
|
1134
|
-
if (result.files && result.files.length > 0) {
|
|
1135
|
-
console.log("");
|
|
1136
|
-
console.log("Files:");
|
|
1137
|
-
for (const file of result.files) {
|
|
1138
|
-
console.log(` [${file.action}] ${file.path}`);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function printImportSummary(result: ImportResponse): void {
|
|
1144
|
-
if (!result.success) {
|
|
1145
|
-
console.error(
|
|
1146
|
-
`Error: Import failed — ${result.message ?? result.reason ?? "unknown reason"}`,
|
|
1147
|
-
);
|
|
1148
|
-
for (const err of result.errors ?? []) {
|
|
1149
|
-
console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
|
|
1150
|
-
}
|
|
1151
|
-
process.exit(1);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
const summary = result.summary ?? {
|
|
1155
|
-
total_files: 0,
|
|
1156
|
-
files_created: 0,
|
|
1157
|
-
files_overwritten: 0,
|
|
1158
|
-
files_skipped: 0,
|
|
1159
|
-
backups_created: 0,
|
|
1160
|
-
};
|
|
1161
|
-
console.log(` Files created: ${summary.files_created}`);
|
|
1162
|
-
console.log(` Files overwritten: ${summary.files_overwritten}`);
|
|
1163
|
-
console.log(` Files skipped: ${summary.files_skipped}`);
|
|
1164
|
-
console.log(` Backups created: ${summary.backups_created}`);
|
|
1165
|
-
|
|
1166
|
-
const creds = result.credentialsImported;
|
|
1167
|
-
if (creds) {
|
|
1168
|
-
console.log(` Credentials imported: ${creds.succeeded}/${creds.total}`);
|
|
1169
|
-
if (creds.skippedCloud) {
|
|
1170
|
-
console.log(` Cadet Cloud credentials skipped: ${creds.skippedCloud}`);
|
|
1171
|
-
}
|
|
1172
|
-
if (creds.failed > 0) {
|
|
1173
|
-
console.log(` Credentials failed: ${creds.failed}`);
|
|
1174
|
-
for (const account of creds.failedAccounts) {
|
|
1175
|
-
console.log(` - ${account}`);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
const warnings = result.warnings ?? [];
|
|
1181
|
-
if (warnings.length > 0) {
|
|
1182
|
-
console.log("");
|
|
1183
|
-
console.log("Warnings:");
|
|
1184
|
-
for (const warning of warnings) {
|
|
1185
|
-
console.log(` ${warning}`);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* After teleporting to a local/docker target, register the assistant with
|
|
1192
|
-
* the cloud and inject fresh cloud credentials — mirroring the
|
|
1193
|
-
* login flow. Non-fatal: failures are logged as warnings.
|
|
1194
|
-
*/
|
|
1195
|
-
async function tryInjectCloudCredentials(
|
|
1196
|
-
entry: AssistantEntry,
|
|
1197
|
-
): Promise<void> {
|
|
1198
|
-
const token = readCloudToken();
|
|
1199
|
-
if (!token) {
|
|
1200
|
-
console.log(" Skipped cloud credential injection — not logged in.");
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
try {
|
|
1205
|
-
const user = await fetchCurrentUser(token);
|
|
1206
|
-
const orgId = await fetchOrganizationId(token);
|
|
1207
|
-
const clientInstallationId = computeDeviceId();
|
|
1208
|
-
const [assistantVersion, ingressUrl] = await Promise.all([
|
|
1209
|
-
fetchCurrentVersion(entry.runtimeUrl),
|
|
1210
|
-
fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
|
|
1211
|
-
]);
|
|
1212
|
-
const cloudUrl = getCloudUrl();
|
|
1213
|
-
const registration = await ensureSelfHostedLocalRegistration(
|
|
1214
|
-
token,
|
|
1215
|
-
orgId,
|
|
1216
|
-
clientInstallationId,
|
|
1217
|
-
entry.assistantId,
|
|
1218
|
-
"cli",
|
|
1219
|
-
assistantVersion,
|
|
1220
|
-
cloudUrl,
|
|
1221
|
-
ingressUrl,
|
|
1222
|
-
);
|
|
1223
|
-
saveAssistantEntry({
|
|
1224
|
-
...entry,
|
|
1225
|
-
cloudAssistantId: registration.assistant.id,
|
|
1226
|
-
cloudBaseUrl: cloudUrl,
|
|
1227
|
-
cloudOrganizationId: orgId,
|
|
1228
|
-
});
|
|
1229
|
-
|
|
1230
|
-
// Which key to hand the assistant, and why. Shared with login so the two
|
|
1231
|
-
// injection paths cannot drift.
|
|
1232
|
-
const resolvedKey = await resolveAssistantApiKeyForInjection({
|
|
1233
|
-
registrationApiKey: registration.assistant_api_key,
|
|
1234
|
-
runtimeUrl: entry.runtimeUrl,
|
|
1235
|
-
bearerToken: entry.bearerToken,
|
|
1236
|
-
token,
|
|
1237
|
-
organizationId: orgId,
|
|
1238
|
-
clientInstallationId,
|
|
1239
|
-
runtimeAssistantId: entry.assistantId,
|
|
1240
|
-
clientPlatform: "cli",
|
|
1241
|
-
});
|
|
1242
|
-
const assistantApiKey = resolvedKey.apiKey;
|
|
1243
|
-
|
|
1244
|
-
const allInjected = await injectCredentialsIntoAssistant({
|
|
1245
|
-
gatewayUrl: entry.runtimeUrl,
|
|
1246
|
-
bearerToken: entry.bearerToken,
|
|
1247
|
-
assistantApiKey,
|
|
1248
|
-
cloudAssistantId: registration.assistant.id,
|
|
1249
|
-
organizationId: orgId,
|
|
1250
|
-
userId: user.id,
|
|
1251
|
-
webhookSecret: registration.webhook_secret,
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
if (allInjected) {
|
|
1255
|
-
console.log(" Cadet Cloud credentials injected.");
|
|
1256
|
-
} else {
|
|
1257
|
-
console.warn(" Some cloud credentials could not be injected.");
|
|
1258
|
-
}
|
|
1259
|
-
} catch (err) {
|
|
1260
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1261
|
-
console.warn(` Cadet Cloud credential injection skipped: ${msg}`);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// ---------------------------------------------------------------------------
|
|
1266
|
-
// Main entry point
|
|
1267
|
-
// ---------------------------------------------------------------------------
|
|
1268
|
-
|
|
1269
|
-
export const teleport = Object.assign(
|
|
1270
|
-
async function teleport(): Promise<void> {
|
|
1271
|
-
const args = process.argv.slice(3);
|
|
1272
|
-
const { from, targetEnv, targetName, keepSource, dryRun, help } =
|
|
1273
|
-
parseArgs(args);
|
|
1274
|
-
|
|
1275
|
-
if (help) {
|
|
1276
|
-
printHelp();
|
|
1277
|
-
process.exit(0);
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
if (!from) {
|
|
1281
|
-
printHelp();
|
|
1282
|
-
process.exit(1);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
if (!targetEnv) {
|
|
1286
|
-
printHelp();
|
|
1287
|
-
process.exit(1);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
// Look up source assistant
|
|
1291
|
-
const fromEntry = findAssistantByName(from);
|
|
1292
|
-
if (!fromEntry) {
|
|
1293
|
-
console.error(
|
|
1294
|
-
`Assistant '${from}' not found in lockfile. Run \`cadet ps\` to see available assistants.`,
|
|
1295
|
-
);
|
|
1296
|
-
process.exit(1);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
const fromCloud = fromEntry.cloud;
|
|
1300
|
-
|
|
1301
|
-
if (fromCloud === "apple-container") {
|
|
1302
|
-
console.error(
|
|
1303
|
-
`Error: '${from}' uses the Apple Containers runtime. Teleport is not yet supported for this topology.`,
|
|
1304
|
-
);
|
|
1305
|
-
process.exit(1);
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// Early same-environment guard — compare source cloud against the CLI flag
|
|
1309
|
-
// BEFORE exporting or hatching, to avoid creating orphaned assistants.
|
|
1310
|
-
const normalizedSourceEnv = fromCloud === "cadet" ? "platform" : fromCloud;
|
|
1311
|
-
if (normalizedSourceEnv === targetEnv) {
|
|
1312
|
-
console.error(
|
|
1313
|
-
`Cannot teleport between two ${targetEnv} assistants. Teleport transfers data across different environments.`,
|
|
1314
|
-
);
|
|
1315
|
-
process.exit(1);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// Dry-run without an existing target: skip export, hatch, and import —
|
|
1319
|
-
// just report what would happen.
|
|
1320
|
-
if (dryRun) {
|
|
1321
|
-
const existingTarget = targetName ? findAssistantByName(targetName) : null;
|
|
1322
|
-
|
|
1323
|
-
if (existingTarget) {
|
|
1324
|
-
// Target exists — validate cloud matches the flag, then run preflight
|
|
1325
|
-
const toCloud = existingTarget.cloud;
|
|
1326
|
-
const normalizedTargetEnv = toCloud === "cadet" ? "platform" : toCloud;
|
|
1327
|
-
if (normalizedTargetEnv !== targetEnv) {
|
|
1328
|
-
console.error(
|
|
1329
|
-
`Error: Assistant '${targetName}' is a ${normalizedTargetEnv} assistant, not ${targetEnv}. ` +
|
|
1330
|
-
`Use --${normalizedTargetEnv} to target it.`,
|
|
1331
|
-
);
|
|
1332
|
-
process.exit(1);
|
|
1333
|
-
}
|
|
1334
|
-
if (normalizedSourceEnv === normalizedTargetEnv) {
|
|
1335
|
-
console.error(
|
|
1336
|
-
`Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
|
|
1337
|
-
);
|
|
1338
|
-
process.exit(1);
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
// Nothing to reject — preflight-from-gcs is now implemented for all
|
|
1342
|
-
// target topologies including local and docker.
|
|
1343
|
-
|
|
1344
|
-
// Version guard: block platform→non-cloud when target is behind
|
|
1345
|
-
if (fromCloud === "cadet" && toCloud !== "cadet") {
|
|
1346
|
-
const [sourceVersion, targetVersion] = await Promise.all([
|
|
1347
|
-
fetchCurrentVersion(fromEntry.runtimeUrl),
|
|
1348
|
-
fetchCurrentVersion(existingTarget.runtimeUrl),
|
|
1349
|
-
]);
|
|
1350
|
-
const cmp =
|
|
1351
|
-
sourceVersion && targetVersion
|
|
1352
|
-
? compareVersions(targetVersion, sourceVersion)
|
|
1353
|
-
: null;
|
|
1354
|
-
if (cmp !== null && cmp < 0) {
|
|
1355
|
-
console.error(
|
|
1356
|
-
`Error: Target assistant '${existingTarget.assistantId}' is running ${targetVersion}, ` +
|
|
1357
|
-
`but the cloud source is on ${sourceVersion}.`,
|
|
1358
|
-
);
|
|
1359
|
-
console.error(
|
|
1360
|
-
`Upgrade your ${toCloud} assistant first: cadet upgrade ${existingTarget.assistantId}`,
|
|
1361
|
-
);
|
|
1362
|
-
process.exit(1);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
// Pin both upload and download to the same platform instance. For
|
|
1367
|
-
// platform targets the bundle is owned by the target platform; for
|
|
1368
|
-
// platform sources it's owned by the source platform. Only one of
|
|
1369
|
-
// these branches applies at a time (same-env was rejected earlier).
|
|
1370
|
-
const bundleCloudUrl =
|
|
1371
|
-
toCloud === "cadet"
|
|
1372
|
-
? existingTarget.runtimeUrl
|
|
1373
|
-
: fromCloud === "cadet"
|
|
1374
|
-
? fromEntry.runtimeUrl
|
|
1375
|
-
: undefined;
|
|
1376
|
-
|
|
1377
|
-
console.log(`Exporting from ${from} on ${fromCloud}...`);
|
|
1378
|
-
const { bundleKey } = await exportFromAssistant(
|
|
1379
|
-
fromEntry,
|
|
1380
|
-
fromCloud,
|
|
1381
|
-
bundleCloudUrl,
|
|
1382
|
-
);
|
|
1383
|
-
console.log(`Importing to ${existingTarget.assistantId} on ${toCloud}...`);
|
|
1384
|
-
await importToAssistant(
|
|
1385
|
-
existingTarget,
|
|
1386
|
-
toCloud,
|
|
1387
|
-
bundleKey,
|
|
1388
|
-
true,
|
|
1389
|
-
bundleCloudUrl,
|
|
1390
|
-
);
|
|
1391
|
-
} else {
|
|
1392
|
-
// No existing target — just describe what would happen
|
|
1393
|
-
console.log("Dry run summary:");
|
|
1394
|
-
console.log(` Would export data from: ${from} on ${fromCloud}`);
|
|
1395
|
-
console.log(` Would upload bundle via signed URL`);
|
|
1396
|
-
console.log(
|
|
1397
|
-
` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
|
|
1398
|
-
);
|
|
1399
|
-
console.log(` Would import data into the new assistant`);
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
console.log(`Dry run complete — no changes were made.`);
|
|
1403
|
-
return;
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
// Platform target: reordered flow — upload to GCS before hatching so that
|
|
1407
|
-
// if export/upload fails, no empty assistant is left dangling on the cloud.
|
|
1408
|
-
if (targetEnv === "platform") {
|
|
1409
|
-
const token = readCloudToken();
|
|
1410
|
-
if (!token) {
|
|
1411
|
-
console.error("Not logged in. Run 'cadet login' first.");
|
|
1412
|
-
process.exit(1);
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// If targeting an existing assistant, validate cloud match early — before
|
|
1416
|
-
// exporting — so we don't waste work on an invalid command.
|
|
1417
|
-
const existingTarget = targetName ? findAssistantByName(targetName) : null;
|
|
1418
|
-
if (existingTarget) {
|
|
1419
|
-
const existingCloud = existingTarget.cloud;
|
|
1420
|
-
if (existingCloud !== "cadet") {
|
|
1421
|
-
console.error(
|
|
1422
|
-
`Error: Assistant '${targetName}' is a ${existingCloud} assistant, not platform. ` +
|
|
1423
|
-
`Use --${existingCloud} to target it.`,
|
|
1424
|
-
);
|
|
1425
|
-
process.exit(1);
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
// Use the existing target's runtimeUrl for all platform calls so the
|
|
1430
|
-
// export, upload, and import all hit the same instance.
|
|
1431
|
-
const targetCloudUrl = existingTarget?.runtimeUrl;
|
|
1432
|
-
|
|
1433
|
-
// Pre-check: block if the user already has a cloud assistant. This
|
|
1434
|
-
// runs BEFORE the expensive export so we don't waste the upload.
|
|
1435
|
-
if (!existingTarget) {
|
|
1436
|
-
const existing = await checkExistingCloudAssistant(
|
|
1437
|
-
token,
|
|
1438
|
-
targetCloudUrl,
|
|
1439
|
-
);
|
|
1440
|
-
if (existing) {
|
|
1441
|
-
saveAssistantEntry({
|
|
1442
|
-
assistantId: existing.id,
|
|
1443
|
-
runtimeUrl: getCloudUrl(),
|
|
1444
|
-
cloud: "cloud",
|
|
1445
|
-
hatchedAt: new Date().toISOString(),
|
|
121
|
+
await teleportHostInstallation(fromEntry, targetEnv, targetName, {
|
|
122
|
+
keepSource,
|
|
123
|
+
dryRun,
|
|
1446
124
|
});
|
|
125
|
+
} catch (error) {
|
|
1447
126
|
console.error(
|
|
1448
|
-
`Error:
|
|
1449
|
-
);
|
|
1450
|
-
console.error(
|
|
1451
|
-
`Retire it first with 'cadet retire ${existing.id}', then retry the teleport.`,
|
|
127
|
+
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
1452
128
|
);
|
|
1453
129
|
process.exit(1);
|
|
1454
130
|
}
|
|
131
|
+
return;
|
|
1455
132
|
}
|
|
1456
133
|
|
|
1457
|
-
// Export — for local/docker sources this uploads straight into GCS via
|
|
1458
|
-
// the cloud's signed URL; for platform sources this runs a server-side
|
|
1459
|
-
// export and we read the resulting bundle_key.
|
|
1460
|
-
// The signed upload URL must be requested from the same platform instance
|
|
1461
|
-
// where the import will run. For existing targets that's the lockfile's
|
|
1462
|
-
// runtimeUrl; for fresh hatches it's getCloudUrl() (which is what
|
|
1463
|
-
// resolveOrHatchTarget writes to the new entry).
|
|
1464
|
-
await backupSourceBeforeTeleport(fromEntry, fromCloud, from);
|
|
1465
|
-
|
|
1466
|
-
console.log(`Exporting from ${from} on ${fromCloud}...`);
|
|
1467
|
-
const bundleCloudUrl = targetCloudUrl ?? getCloudUrl();
|
|
1468
|
-
const { bundleKey } = await exportFromAssistant(
|
|
1469
|
-
fromEntry,
|
|
1470
|
-
fromCloud,
|
|
1471
|
-
bundleCloudUrl,
|
|
1472
|
-
);
|
|
1473
|
-
|
|
1474
|
-
// Hatch (export succeeded — safe to create the target)
|
|
1475
|
-
const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
|
|
1476
|
-
const toCloud = toEntry.cloud;
|
|
1477
|
-
|
|
1478
|
-
// Import from GCS
|
|
1479
|
-
console.log(`Importing to ${toEntry.assistantId} on ${toCloud}...`);
|
|
1480
|
-
await importToAssistant(
|
|
1481
|
-
toEntry,
|
|
1482
|
-
toCloud,
|
|
1483
|
-
bundleKey,
|
|
1484
|
-
false,
|
|
1485
|
-
bundleCloudUrl,
|
|
1486
|
-
);
|
|
1487
|
-
|
|
1488
|
-
console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
// Non-cloud targets (local/docker)
|
|
1493
|
-
// For local<->docker transfers, stop (sleep) the source to free up ports
|
|
1494
|
-
// before hatching the target. We do NOT retire yet — if hatch or import
|
|
1495
|
-
// fails, the user can recover by running `cadet wake <source>`.
|
|
1496
|
-
const sourceIsLocalOrDocker = fromCloud === "local" || fromCloud === "docker";
|
|
1497
|
-
const targetIsLocalOrDocker = targetEnv === "local" || targetEnv === "docker";
|
|
1498
|
-
|
|
1499
|
-
// Version guard (pre-hatch): for existing targets, check BEFORE hatching
|
|
1500
|
-
// to avoid creating orphaned assistants when the version check would fail.
|
|
1501
|
-
let versionGuardPassed = false;
|
|
1502
|
-
if (fromCloud === "cadet" && targetIsLocalOrDocker && targetName) {
|
|
1503
|
-
const existingTarget = findAssistantByName(targetName);
|
|
1504
|
-
if (existingTarget) {
|
|
1505
|
-
const [sourceVersion, existingVersion] = await Promise.all([
|
|
1506
|
-
fetchCurrentVersion(fromEntry.runtimeUrl),
|
|
1507
|
-
fetchCurrentVersion(existingTarget.runtimeUrl),
|
|
1508
|
-
]);
|
|
1509
|
-
const cmp =
|
|
1510
|
-
sourceVersion && existingVersion
|
|
1511
|
-
? compareVersions(existingVersion, sourceVersion)
|
|
1512
|
-
: null;
|
|
1513
|
-
if (cmp !== null && cmp < 0) {
|
|
1514
|
-
console.error(
|
|
1515
|
-
`Error: Target assistant '${existingTarget.assistantId}' is running ${existingVersion}, ` +
|
|
1516
|
-
`but the cloud source is on ${sourceVersion}.`,
|
|
1517
|
-
);
|
|
1518
|
-
console.error(
|
|
1519
|
-
`Upgrade your ${targetEnv} assistant first: cadet upgrade ${existingTarget.assistantId}`,
|
|
1520
|
-
);
|
|
1521
|
-
process.exit(1);
|
|
1522
|
-
}
|
|
1523
|
-
// Pre-hatch check passed (or was best-effort skipped) — skip post-hatch
|
|
1524
|
-
versionGuardPassed = true;
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// Pin the bundle's platform instance so upload and download land on the
|
|
1529
|
-
// same GCS bucket. For platform sources the bundle is owned by the source
|
|
1530
|
-
// platform. For local/docker→local/docker the bundle lives on whatever
|
|
1531
|
-
// platform getCloudUrl() currently resolves to — we resolve it once
|
|
1532
|
-
// here so a lockfile change mid-teleport can't split export and import.
|
|
1533
|
-
const bundleCloudUrl =
|
|
1534
|
-
fromCloud === "cadet" ? fromEntry.runtimeUrl : getCloudUrl();
|
|
1535
|
-
|
|
1536
|
-
await backupSourceBeforeTeleport(fromEntry, fromCloud, from);
|
|
1537
|
-
|
|
1538
|
-
// Export from source (bundle lives in GCS after this returns).
|
|
1539
|
-
console.log(`Exporting from ${from} on ${fromCloud}...`);
|
|
1540
|
-
const { bundleKey } = await exportFromAssistant(
|
|
1541
|
-
fromEntry,
|
|
1542
|
-
fromCloud,
|
|
1543
|
-
bundleCloudUrl,
|
|
1544
|
-
);
|
|
1545
|
-
|
|
1546
|
-
if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
|
|
1547
|
-
console.log(`Stopping source assistant '${from}' to free ports...`);
|
|
1548
|
-
if (fromCloud === "docker") {
|
|
1549
|
-
const res = dockerResourceNames(fromEntry.assistantId);
|
|
1550
|
-
await sleepContainers(res);
|
|
1551
|
-
} else if (fromEntry.resources) {
|
|
1552
|
-
const cadetDir = fromEntry.resources.instanceDir;
|
|
1553
|
-
const gatewayPidFile = join(cadetDir, "gateway.pid");
|
|
1554
|
-
await stopProcessByPidFile(
|
|
1555
|
-
getDaemonPidPath(fromEntry.resources),
|
|
1556
|
-
"assistant",
|
|
1557
|
-
undefined,
|
|
1558
|
-
DAEMON_STOP_TIMEOUT_MS,
|
|
1559
|
-
);
|
|
1560
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1561
|
-
}
|
|
1562
|
-
console.log(`Source assistant '${from}' stopped.`);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// Resolve or hatch target (after source is stopped to avoid port conflicts)
|
|
1566
|
-
const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
|
|
1567
|
-
const toCloud = toEntry.cloud;
|
|
1568
|
-
|
|
1569
|
-
// Post-hatch same-environment safety net — uses resolved clouds in case
|
|
1570
|
-
// the resolved target cloud differs from the CLI flag (e.g., --docker
|
|
1571
|
-
// targeting a name that is actually a local entry).
|
|
1572
|
-
const normalizedTargetEnv = toCloud === "cadet" ? "platform" : toCloud;
|
|
1573
|
-
if (normalizedSourceEnv === normalizedTargetEnv) {
|
|
1574
134
|
console.error(
|
|
1575
|
-
|
|
135
|
+
"Cloud teleport is unavailable until cloud root archive transfer is supported. No installation was changed.",
|
|
1576
136
|
);
|
|
1577
137
|
process.exit(1);
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// Version guard (post-hatch): for newly hatched targets we must check after
|
|
1581
|
-
// hatch because the assistant doesn't exist yet before. If it fails, clean
|
|
1582
|
-
// up the freshly hatched assistant to avoid orphans.
|
|
1583
|
-
// Skip if the pre-hatch guard already ran for an existing target.
|
|
1584
|
-
if (!versionGuardPassed && fromCloud === "cadet" && toCloud !== "cadet") {
|
|
1585
|
-
const [sourceVersion, targetVersion] = await Promise.all([
|
|
1586
|
-
fetchCurrentVersion(fromEntry.runtimeUrl),
|
|
1587
|
-
fetchCurrentVersion(toEntry.runtimeUrl),
|
|
1588
|
-
]);
|
|
1589
|
-
const cmp =
|
|
1590
|
-
sourceVersion && targetVersion
|
|
1591
|
-
? compareVersions(targetVersion, sourceVersion)
|
|
1592
|
-
: null;
|
|
1593
|
-
if (cmp !== null && cmp < 0) {
|
|
1594
|
-
// Clean up the freshly hatched assistant to avoid orphans
|
|
1595
|
-
console.error(
|
|
1596
|
-
`Cleaning up newly hatched assistant '${toEntry.assistantId}'...`,
|
|
1597
|
-
);
|
|
1598
|
-
if (toCloud === "docker") {
|
|
1599
|
-
await retireDocker(toEntry.assistantId);
|
|
1600
|
-
} else {
|
|
1601
|
-
await retireLocal(toEntry.assistantId, toEntry);
|
|
1602
|
-
}
|
|
1603
|
-
removeAssistantEntry(toEntry.assistantId);
|
|
1604
|
-
console.error(
|
|
1605
|
-
`Error: Target assistant '${toEntry.assistantId}' was running ${targetVersion}, ` +
|
|
1606
|
-
`but the cloud source is on ${sourceVersion}.`,
|
|
1607
|
-
);
|
|
1608
|
-
console.error(
|
|
1609
|
-
`Upgrade your ${toCloud} environment first, then retry the teleport.`,
|
|
1610
|
-
);
|
|
1611
|
-
process.exit(1);
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
// Import to target (also GCS-driven)
|
|
1616
|
-
console.log(`Importing to ${toEntry.assistantId} on ${toCloud}...`);
|
|
1617
|
-
await importToAssistant(
|
|
1618
|
-
toEntry,
|
|
1619
|
-
toCloud,
|
|
1620
|
-
bundleKey,
|
|
1621
|
-
false,
|
|
1622
|
-
bundleCloudUrl,
|
|
1623
|
-
);
|
|
1624
|
-
|
|
1625
|
-
// After successful import, inject fresh cloud credentials if the
|
|
1626
|
-
// user is logged in — replaces the source's stale cadet:* credentials
|
|
1627
|
-
// that were filtered during import.
|
|
1628
|
-
if (fromCloud === "cadet") {
|
|
1629
|
-
await tryInjectCloudCredentials(toEntry);
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// Retire source after successful import
|
|
1633
|
-
if (sourceIsLocalOrDocker && targetIsLocalOrDocker) {
|
|
1634
|
-
if (!keepSource) {
|
|
1635
|
-
console.log(`Retiring source assistant '${from}'...`);
|
|
1636
|
-
if (fromCloud === "docker") {
|
|
1637
|
-
await retireDocker(fromEntry.assistantId);
|
|
1638
|
-
} else {
|
|
1639
|
-
await retireLocal(fromEntry.assistantId, fromEntry);
|
|
1640
|
-
}
|
|
1641
|
-
removeAssistantEntry(fromEntry.assistantId);
|
|
1642
|
-
console.log(`Source assistant '${from}' retired.`);
|
|
1643
|
-
} else {
|
|
1644
|
-
console.log(`Source assistant '${from}' kept via --keep-source.`);
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
|
|
1649
|
-
},
|
|
1650
|
-
{ description: "Transfer assistant data between environments" },
|
|
138
|
+
},
|
|
139
|
+
{ description: "Move an installation between local and Docker storage" },
|
|
1651
140
|
);
|
|
1652
|
-
|