@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
@@ -17,6 +17,8 @@ import { createServer } from "node:net";
17
17
  import { networkInterfaces } from "node:os";
18
18
  import { join } from "node:path";
19
19
 
20
+ import { z } from "zod";
21
+
20
22
  import { cloudAssistantHubUrl } from "@mycadet/environments";
21
23
 
22
24
  import {
@@ -28,7 +30,7 @@ import {
28
30
  } from "./assistant-config.js";
29
31
  import type { RemoteHost } from "./constants.js";
30
32
  import { getCurrentEnvironment } from "./environments/resolve.js";
31
- import { waitForDaemonReady } from "./http-client.js";
33
+ import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
32
34
  import {
33
35
  getDefaultWorkspaceDir,
34
36
  isLocalContainerEntry,
@@ -36,7 +38,8 @@ import {
36
38
  parseGatewayPortFromEntryUrls,
37
39
  saveRawConfig,
38
40
  } from "./ingress-config.js";
39
- import { findWebDistDir } from "./web-dist.js";
41
+ import { stripVersionPrefix } from "./version-compat.js";
42
+ import { findWebDistDir, readWebReleaseVersion } from "./web-dist.js";
40
43
 
41
44
  export { findWebDistDir } from "./web-dist.js";
42
45
 
@@ -141,6 +144,8 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
141
144
  export interface RemoteWebIngressOptions {
142
145
  webDistDir: string;
143
146
  indexHtmlPath?: string;
147
+ /** Assistant release verified against the served UI artifact. */
148
+ version?: string;
144
149
  config?: Record<string, unknown>;
145
150
  /** Serving assistant's display name, stamped into the served config so
146
151
  * remote clients can label this origin. Absent in older served configs. */
@@ -150,19 +155,10 @@ export interface RemoteWebIngressOptions {
150
155
  * configs. */
151
156
  assistantId?: string;
152
157
  /** Cloud web SPA base the remote client can hand this origin to (see
153
- * `cloudWebHubUrl`). Absent in older served configs. */
158
+ * `cloudAssistantHubUrl`). Absent in older served configs. */
154
159
  hubUrl?: string;
155
160
  }
156
161
 
157
- /**
158
- * Cloud web SPA base URL for a build environment. The mapping lives in
159
- * `@mycadet/environments` (`cloudAssistantHubUrl`) and is shared with the
160
- * Capacitor shell's `server.url`, so the two consumers cannot drift.
161
- */
162
- export function cloudWebHubUrl(env: string | undefined): string {
163
- return cloudAssistantHubUrl(env);
164
- }
165
-
166
162
  // The edge serves remote browsers, so the bundle's remote-gateway machinery
167
163
  // must always run: it is reached through this self-hosted edge however the
168
164
  // cadet itself is hosted. "cloud" is never stamped (the cloud serves its
@@ -208,7 +204,7 @@ function remoteWebIngressConfig(
208
204
  * fingerprint matches, so this must change whenever the generated index or
209
205
  * nginx template does.
210
206
  */
211
- export const EDGE_TEMPLATE_VERSION = 10;
207
+ export const EDGE_TEMPLATE_VERSION = 12;
212
208
 
213
209
  /**
214
210
  * Stable fingerprint of the injected SPA config, its source bundle, and
@@ -219,6 +215,7 @@ export const EDGE_TEMPLATE_VERSION = 10;
219
215
  function remoteWebConfigFingerprint(
220
216
  config: Record<string, unknown>,
221
217
  webDistDir: string,
218
+ version?: string,
222
219
  ): string {
223
220
  return createHash("sha256")
224
221
  .update(
@@ -226,6 +223,7 @@ function remoteWebConfigFingerprint(
226
223
  template: EDGE_TEMPLATE_VERSION,
227
224
  config,
228
225
  webDistDir,
226
+ version,
229
227
  indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
230
228
  }),
231
229
  )
@@ -345,9 +343,100 @@ export async function findAvailableIngressPort(
345
343
  return null;
346
344
  }
347
345
 
348
- /**
349
- * Build the nginx config that forwards tunnel web traffic to the gateway.
350
- */
346
+ const AssistantHostConfigSchema = z.object({
347
+ assistantId: z.string().min(1).optional(),
348
+ assistantName: z.string().optional(),
349
+ version: z.string().min(1).optional(),
350
+ hubUrl: z.string().url().optional(),
351
+ appAllowedOrigins: z.array(z.string().url()).default([]),
352
+ });
353
+
354
+ function assistantHostConfig(config: Record<string, unknown>) {
355
+ const result = AssistantHostConfigSchema.safeParse(config);
356
+ if (!result.success) {
357
+ throw new Error("Invalid assistant host configuration.");
358
+ }
359
+ return result.data;
360
+ }
361
+
362
+ function appOrigins(config: Record<string, unknown>): string[] {
363
+ const metadata = assistantHostConfig(config);
364
+ const origins = [
365
+ ...metadata.appAllowedOrigins,
366
+ ...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
367
+ ];
368
+ return [...new Set(origins)].map((origin) => {
369
+ const url = new URL(origin);
370
+ if (
371
+ (url.protocol !== "https:" &&
372
+ !(
373
+ url.protocol === "http:" &&
374
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
375
+ )) ||
376
+ url.origin !== origin.replace(/\/$/, "")
377
+ ) {
378
+ throw new Error(
379
+ "App origins must be HTTPS origins or loopback HTTP origins.",
380
+ );
381
+ }
382
+ return url.origin;
383
+ });
384
+ }
385
+
386
+ function assistantMetadataLocations(config: Record<string, unknown>): string {
387
+ const metadata = assistantHostConfig(config);
388
+ const id = metadata.assistantId;
389
+ const choice = id
390
+ ? {
391
+ id,
392
+ ...(metadata.assistantName ? { name: metadata.assistantName } : {}),
393
+ }
394
+ : undefined;
395
+ const responses = [
396
+ {
397
+ path: "/v1/assistants",
398
+ status: 200,
399
+ body: { results: choice ? [choice] : [], next: null },
400
+ },
401
+ ...(choice && id
402
+ ? [
403
+ {
404
+ path: `/v1/assistants/${encodeURIComponent(id)}`,
405
+ status: metadata.version ? 200 : 409,
406
+ body: metadata.version
407
+ ? {
408
+ ...choice,
409
+ status: "active",
410
+ version: metadata.version,
411
+ url: "assistant/",
412
+ }
413
+ : { detail: "assistant is not ready" },
414
+ },
415
+ ]
416
+ : []),
417
+ ];
418
+ return responses
419
+ .flatMap(({ path, status, body }) =>
420
+ [path, `${path}/`].map(
421
+ (route) => `
422
+ location = ${nginxQuoted(route, "assistant API path")} {
423
+ default_type application/json;
424
+ add_header Cache-Control "no-store" always;
425
+ add_header Access-Control-Allow-Origin $cadet_app_origin always;
426
+ add_header Access-Control-Allow-Credentials "true" always;
427
+ add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
428
+ add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
429
+ add_header Vary "Origin" always;
430
+ if ($request_method = OPTIONS) { return 204; }
431
+ if ($request_method !~ ^(GET|HEAD)$) { return 405; }
432
+ return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
433
+ }`,
434
+ ),
435
+ )
436
+ .join("\n");
437
+ }
438
+
439
+ /** Build the nginx config for the tunnel edge and its public assistant metadata. */
351
440
  export function buildIngressNginxConfig(opts: {
352
441
  gatewayPort: number;
353
442
  listenPort: number;
@@ -363,12 +452,16 @@ export function buildIngressNginxConfig(opts: {
363
452
  ? ` listen [::1]:${opts.listenPort};\n`
364
453
  : "";
365
454
  const remoteWebIngress = opts.remoteWebIngress;
455
+ const allowedOrigins = remoteWebIngress
456
+ ? appOrigins(remoteWebIngressConfig(remoteWebIngress))
457
+ : [];
366
458
  const serverLocations = remoteWebIngress
367
459
  ? buildRemoteWebIngressLocations({
368
460
  gatewayPort: opts.gatewayPort,
369
461
  webDistDir: remoteWebIngress.webDistDir,
370
462
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
371
463
  config: remoteWebIngressConfig(remoteWebIngress),
464
+ version: remoteWebIngress.version,
372
465
  })
373
466
  : `${DENYLIST_LOCATIONS}
374
467
 
@@ -417,6 +510,11 @@ http {
417
510
  text/plain txt;
418
511
  }
419
512
 
513
+ map $http_origin $cadet_app_origin {
514
+ default "";
515
+ ${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
516
+ }
517
+
420
518
  map $http_upgrade $connection_upgrade {
421
519
  default upgrade;
422
520
  "" close;
@@ -457,6 +555,7 @@ function buildRemoteWebIngressLocations(opts: {
457
555
  webDistDir: string;
458
556
  indexHtmlPath?: string;
459
557
  config: Record<string, unknown>;
558
+ version?: string;
460
559
  }): string {
461
560
  const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
462
561
  const webDistDir = nginxDirPath(opts.webDistDir);
@@ -467,6 +566,8 @@ function buildRemoteWebIngressLocations(opts: {
467
566
 
468
567
  return `${DENYLIST_LOCATIONS}
469
568
 
569
+ ${assistantMetadataLocations({ ...opts.config, version: opts.version })}
570
+
470
571
  location = /healthz {
471
572
  ${proxyBlock}
472
573
  }
@@ -756,6 +857,7 @@ export function startIngressNginx(opts: {
756
857
  remoteWebConfigHash: remoteWebConfigFingerprint(
757
858
  remoteWebIngress.config,
758
859
  remoteWebIngress.webDistDir,
860
+ remoteWebIngress.version,
759
861
  ),
760
862
  }
761
863
  : {}),
@@ -947,7 +1049,7 @@ export async function startRemoteWebIngress(opts: {
947
1049
  // reuse decision and the actual spawn share one value and cannot drift.
948
1050
  const spaOptions = includeWebApp
949
1051
  ? {
950
- hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
1052
+ hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
951
1053
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
952
1054
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
953
1055
  }
@@ -956,11 +1058,23 @@ export async function startRemoteWebIngress(opts: {
956
1058
  if (includeWebApp && !webDistDir) {
957
1059
  return { status: "web-dist-missing" };
958
1060
  }
1061
+ const runtimeVersion = webDistDir
1062
+ ? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
1063
+ : undefined;
1064
+ const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
1065
+ const assistantVersion = runtimeVersion
1066
+ ? stripVersionPrefix(runtimeVersion)
1067
+ : undefined;
1068
+ const matchedVersion =
1069
+ assistantVersion && webVersion === assistantVersion
1070
+ ? assistantVersion
1071
+ : undefined;
959
1072
  const requestedConfigHash =
960
1073
  spaOptions && webDistDir
961
1074
  ? remoteWebConfigFingerprint(
962
1075
  remoteWebIngressConfig(spaOptions),
963
1076
  webDistDir,
1077
+ matchedVersion,
964
1078
  )
965
1079
  : undefined;
966
1080
  // Both sides are undefined in webhooks-only mode; an SPA record without a
@@ -1024,7 +1138,9 @@ export async function startRemoteWebIngress(opts: {
1024
1138
  workspaceDir: opts.workspaceDir,
1025
1139
  gatewayPort: opts.gatewayPort,
1026
1140
  listenPort,
1027
- remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
1141
+ remoteWebIngress: webDistDir
1142
+ ? { webDistDir, ...spaOptions, version: matchedVersion }
1143
+ : undefined,
1028
1144
  });
1029
1145
  let exited = false;
1030
1146
  child.once("exit", () => {
@@ -1,3 +1,4 @@
1
+ import { DAEMON_STOP_TIMEOUT_MS } from "@mycadet/local-mode";
1
2
  import { execFileSync } from "child_process";
2
3
  import { existsSync, readFileSync, unlinkSync } from "fs";
3
4
  import { platform } from "os";
@@ -324,6 +325,7 @@ export async function stopProcess(
324
325
  });
325
326
  },
326
327
  readIdentity: (pid: number) => string | null = readPosixCommandLine,
328
+ gracefulOnly = false,
327
329
  ): Promise<boolean> {
328
330
  try {
329
331
  process.kill(pid, 0);
@@ -339,6 +341,9 @@ export async function stopProcess(
339
341
  console.log(`Stopping ${label} (pid ${pid})...`);
340
342
  let waitForGracefulExit = true;
341
343
  if (hostPlatform === "win32") {
344
+ if (gracefulOnly) {
345
+ throw new Error("Graceful-only shutdown is not supported for Windows local services.");
346
+ }
342
347
  try {
343
348
  runTaskkill(["/PID", String(pid), "/T"], timeoutMs);
344
349
  } catch {
@@ -363,6 +368,7 @@ export async function stopProcess(
363
368
  } catch {
364
369
  return true;
365
370
  }
371
+ if (gracefulOnly) return false;
366
372
  if (hostPlatform === "win32") {
367
373
  console.log(`${label} did not exit, terminating its process tree...`);
368
374
  try {
@@ -415,6 +421,13 @@ export async function stopProcess(
415
421
  }
416
422
  }
417
423
 
424
+ /** Stop a service without escalation. Failure preserves PID tracking and aborts capture/replacement. */
425
+ export async function stopProcessGracefully(pid: number, label: string, timeoutMs = DAEMON_STOP_TIMEOUT_MS): Promise<boolean> {
426
+ const stopped = await stopProcess(pid, label, timeoutMs, undefined, undefined, undefined, true);
427
+ if (!stopped) throw new Error(`${label} did not stop gracefully.`);
428
+ return stopped;
429
+ }
430
+
418
431
  /** Remove one or more files, ignoring missing-file errors. */
419
432
  function removeFiles(...files: (string | string[] | undefined)[]): void {
420
433
  for (const entry of files) {