@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.
Files changed (86) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-releases.test.ts +82 -0
  14. package/src/__tests__/commands-mock.ts +101 -0
  15. package/src/__tests__/debug.test.ts +17 -91
  16. package/src/__tests__/devices.test.ts +2 -2
  17. package/src/__tests__/gateway-token.test.ts +267 -0
  18. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  19. package/src/__tests__/host-teleport.test.ts +405 -0
  20. package/src/__tests__/nginx-ingress.test.ts +182 -23
  21. package/src/__tests__/orphan-detection.test.ts +1 -1
  22. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  23. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  24. package/src/__tests__/root-backup-docker.test.ts +294 -0
  25. package/src/__tests__/root-backup.test.ts +527 -0
  26. package/src/__tests__/root-restore-command.test.ts +147 -0
  27. package/src/__tests__/root-signing-key.test.ts +50 -0
  28. package/src/__tests__/setup.test.ts +3 -3
  29. package/src/__tests__/sleep.test.ts +11 -0
  30. package/src/__tests__/teleport.test.ts +68 -2996
  31. package/src/__tests__/tunnel.test.ts +1 -1
  32. package/src/__tests__/upgrade-local.test.ts +21 -11
  33. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  34. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  35. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  36. package/src/commands/backup.ts +44 -399
  37. package/src/commands/client.ts +3 -3
  38. package/src/commands/completions.ts +0 -2
  39. package/src/commands/debug.ts +75 -131
  40. package/src/commands/devices.ts +51 -17
  41. package/src/commands/gateway/token.ts +77 -23
  42. package/src/commands/pair.ts +2 -2
  43. package/src/commands/restore.ts +77 -444
  44. package/src/commands/retire.ts +2 -3
  45. package/src/commands/sleep.ts +15 -2
  46. package/src/commands/teleport.ts +47 -1559
  47. package/src/commands/upgrade.ts +78 -156
  48. package/src/commands/wake.ts +1 -1
  49. package/src/index.ts +0 -4
  50. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  51. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  52. package/src/lib/__tests__/web-dist.test.ts +27 -1
  53. package/src/lib/assistant-client.ts +1 -1
  54. package/src/lib/assistant-config.ts +0 -18
  55. package/src/lib/backup-ops.ts +37 -251
  56. package/src/lib/cloud-client.ts +10 -13
  57. package/src/lib/cloud-login.ts +2 -1
  58. package/src/lib/cloud-releases.ts +54 -10
  59. package/src/lib/cloud-values.ts +1 -1
  60. package/src/lib/confirm-action.ts +2 -2
  61. package/src/lib/docker.ts +59 -22
  62. package/src/lib/github-repository.ts +2 -0
  63. package/src/lib/guardian-token.ts +1 -23
  64. package/src/lib/hatch-local.ts +17 -1
  65. package/src/lib/host-teleport.ts +506 -0
  66. package/src/lib/http-client.ts +25 -0
  67. package/src/lib/live-voice/tunnel.test.ts +1 -1
  68. package/src/lib/live-voice/tunnel.ts +1 -1
  69. package/src/lib/local.ts +4 -2
  70. package/src/lib/nginx-ingress.ts +134 -18
  71. package/src/lib/process.ts +13 -0
  72. package/src/lib/root-backup.ts +917 -0
  73. package/src/lib/root-signing-key.ts +31 -0
  74. package/src/lib/runtime-url.ts +0 -29
  75. package/src/lib/teleport-backup.ts +3 -16
  76. package/src/lib/upgrade-lifecycle.ts +63 -141
  77. package/src/lib/web-dist.ts +23 -1
  78. package/src/__tests__/bundle-staging.test.ts +0 -268
  79. package/src/__tests__/unpair.test.ts +0 -160
  80. package/src/commands/rollback.ts +0 -479
  81. package/src/commands/unpair.ts +0 -122
  82. package/src/lib/__tests__/job-polling.test.ts +0 -278
  83. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  84. package/src/lib/bundle-staging.ts +0 -220
  85. package/src/lib/job-polling.ts +0 -195
  86. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,198 +1,28 @@
1
- import { isCloudHome } from "../lib/cloud-values.js";
2
- import { resolveAssistantApiKeyForInjection } from "../lib/assistant-api-key-resolution";
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 | --platform> [name] [options]",
6
+ "Usage: cadet teleport --from <assistant> <--local | --docker> [name] [--keep-source] [--dry-run]",
77
7
  );
78
- console.log("");
79
8
  console.log(
80
- "Transfer assistant data between local, docker, and platform environments.",
9
+ "Move the whole installation between local and Docker storage using root tar archives.",
81
10
  );
82
- console.log("");
83
11
  console.log(
84
- "The --from flag specifies the source assistant to export data from.",
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" | "platform" | undefined;
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" | "platform" | undefined;
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
- arg === "--local" ||
212
- arg === "--docker" ||
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, --platform) may be specified.",
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
- // Auth helper — same pattern as restore.ts
243
- // ---------------------------------------------------------------------------
244
-
245
- async function getAccessToken(
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 (hasRecentBackup(listAssistantBackupTimes(entry.assistantId))) {
430
- console.log("Recent backup found, reusing it.");
431
- return;
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
- async function exportFromAssistant(
459
- entry: AssistantEntry,
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
- // Request a signed upload URL from the cloud instance that will
494
- // eventually own the bundle (i.e. the one the importer will read from).
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
- return { bundleKey };
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
- `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
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
- // Platform source — request a signed upload URL on the same platform
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
- `Assistant is unreachable. Try 'cadet wake ${entry.assistantId}'.`,
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
- if (importResult.statusCode !== 202 && importResult.statusCode !== 200) {
764
- console.error(`Error: Import failed (${importResult.statusCode})`);
765
- process.exit(1);
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
- `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
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
- const result = ((terminal.result as Record<string, unknown>) ??
952
- {}) as unknown as ImportResponse;
953
- printImportSummary(result);
954
- return;
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
- validateAssistantName(targetName);
1000
- } catch {
1001
- console.error(
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: You already have a cloud assistant '${existing.id}'.`,
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
- `Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
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
-