@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/lib/nginx-ingress.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
* `
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
1141
|
+
remoteWebIngress: webDistDir
|
|
1142
|
+
? { webDistDir, ...spaOptions, version: matchedVersion }
|
|
1143
|
+
: undefined,
|
|
1028
1144
|
});
|
|
1029
1145
|
let exited = false;
|
|
1030
1146
|
child.once("exit", () => {
|
package/src/lib/process.ts
CHANGED
|
@@ -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) {
|