@mycadet/cli 0.12.10 → 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 +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- 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-login-poll.test.ts +69 -0
- 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 +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +233 -36
- 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 +8 -7
- 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 +17 -7
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +13 -3
- 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 +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -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/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +84 -157
- 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 +20 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- 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 +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- 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 +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +254 -36
- 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/tunnel.ts +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/lib/xdg-log.ts +11 -0
- 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
|
@@ -51,6 +51,10 @@ mock.module("node:fs", () => ({
|
|
|
51
51
|
readFileSync: readFileSyncMock,
|
|
52
52
|
}));
|
|
53
53
|
|
|
54
|
+
const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
|
|
55
|
+
async () => "1.2.3",
|
|
56
|
+
);
|
|
57
|
+
|
|
54
58
|
const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
55
59
|
async () => true,
|
|
56
60
|
);
|
|
@@ -58,6 +62,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
|
|
|
58
62
|
mock.module("../lib/http-client.js", () => ({
|
|
59
63
|
...httpClient,
|
|
60
64
|
waitForDaemonReady: waitForDaemonReadyMock,
|
|
65
|
+
fetchCurrentVersion: fetchCurrentVersionMock,
|
|
61
66
|
}));
|
|
62
67
|
|
|
63
68
|
// Restore the real modules once this file finishes so the mocks do not leak
|
|
@@ -72,10 +77,10 @@ import {
|
|
|
72
77
|
buildIngressNginxConfig,
|
|
73
78
|
hasIpv6Loopback,
|
|
74
79
|
buildRemoteWebIndexHtml,
|
|
75
|
-
cloudWebHubUrl,
|
|
76
80
|
MAX_PRESERVED_MODULE_PRELOADS,
|
|
77
81
|
ensureTunnelEdge,
|
|
78
82
|
EDGE_TEMPLATE_VERSION,
|
|
83
|
+
findWebDistDir,
|
|
79
84
|
startRemoteWebIngress,
|
|
80
85
|
stopContainerTunnelEdge,
|
|
81
86
|
stopIngressNginx,
|
|
@@ -123,6 +128,8 @@ afterEach(() => {
|
|
|
123
128
|
existsSyncMock.mockImplementation(realFs.existsSync);
|
|
124
129
|
readFileSyncMock.mockReset();
|
|
125
130
|
readFileSyncMock.mockImplementation(realFs.readFileSync);
|
|
131
|
+
fetchCurrentVersionMock.mockReset();
|
|
132
|
+
fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
|
|
126
133
|
waitForDaemonReadyMock.mockReset();
|
|
127
134
|
waitForDaemonReadyMock.mockImplementation(async () => true);
|
|
128
135
|
for (const dir of workspaces.splice(0)) {
|
|
@@ -306,7 +313,7 @@ describe("buildIngressNginxConfig", () => {
|
|
|
306
313
|
).toEqual([]);
|
|
307
314
|
});
|
|
308
315
|
|
|
309
|
-
test("revalidates the shell and forbids storing
|
|
316
|
+
test("revalidates the shell and forbids storing inline config", () => {
|
|
310
317
|
const blocks = locationBlocks(remoteConf);
|
|
311
318
|
const cacheControlOf = (matcher: string) =>
|
|
312
319
|
blocks
|
|
@@ -317,11 +324,8 @@ describe("buildIngressNginxConfig", () => {
|
|
|
317
324
|
expect(cacheControlOf("^~ /assistant/assets/")).toBe(
|
|
318
325
|
"public, max-age=31536000, immutable",
|
|
319
326
|
);
|
|
320
|
-
//
|
|
321
|
-
// refuses storage. A second no-store means a revalidating location
|
|
322
|
-
// regressed to re-sending its whole body on every load.
|
|
327
|
+
// Inline config has no validator and must not be stored.
|
|
323
328
|
expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
|
|
324
|
-
expect(remoteConf.match(/no-store/g)).toHaveLength(1);
|
|
325
329
|
});
|
|
326
330
|
|
|
327
331
|
test("serves remote web config for the SPA", () => {
|
|
@@ -456,9 +460,7 @@ describe("buildRemoteWebIndexHtml", () => {
|
|
|
456
460
|
expect(result).toContain(
|
|
457
461
|
'window.__CADET_CONFIG__={"remote":"docker","apiBaseUrl":"/v1"}',
|
|
458
462
|
);
|
|
459
|
-
expect(result.indexOf('"webUrl"')).toBeLessThan(
|
|
460
|
-
result.indexOf('"docker"'),
|
|
461
|
-
);
|
|
463
|
+
expect(result.indexOf('"webUrl"')).toBeLessThan(result.indexOf('"docker"'));
|
|
462
464
|
});
|
|
463
465
|
|
|
464
466
|
test("escapes config JSON before embedding it in a script tag", () => {
|
|
@@ -528,26 +530,6 @@ describe("buildRemoteWebIndexHtml", () => {
|
|
|
528
530
|
});
|
|
529
531
|
});
|
|
530
532
|
|
|
531
|
-
describe("cloudWebHubUrl", () => {
|
|
532
|
-
test("maps build environments to their cloud SPA base", () => {
|
|
533
|
-
expect(cloudWebHubUrl("production")).toBe(
|
|
534
|
-
"https://mycadet.ai/assistant",
|
|
535
|
-
);
|
|
536
|
-
expect(cloudWebHubUrl("staging")).toBe(
|
|
537
|
-
"https://staging.mycadet.ai/assistant",
|
|
538
|
-
);
|
|
539
|
-
expect(cloudWebHubUrl("dev")).toBe(
|
|
540
|
-
"https://dev.mycadet.ai/assistant",
|
|
541
|
-
);
|
|
542
|
-
expect(cloudWebHubUrl("local")).toBe(
|
|
543
|
-
"https://dev.mycadet.ai/assistant",
|
|
544
|
-
);
|
|
545
|
-
expect(cloudWebHubUrl(undefined)).toBe(
|
|
546
|
-
"https://dev.mycadet.ai/assistant",
|
|
547
|
-
);
|
|
548
|
-
});
|
|
549
|
-
});
|
|
550
|
-
|
|
551
533
|
describe("nginx ingress process state", () => {
|
|
552
534
|
function writeIngressState(workspaceDir: string, listenPort: number): void {
|
|
553
535
|
writeFileSync(
|
|
@@ -755,17 +737,19 @@ function mockWebDistMissing(): void {
|
|
|
755
737
|
}
|
|
756
738
|
|
|
757
739
|
/** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
|
|
758
|
-
function mockWebDistPresent(): void {
|
|
740
|
+
function mockWebDistPresent(version: string | null = "1.2.3"): void {
|
|
759
741
|
existsSyncMock.mockImplementation((path) =>
|
|
760
742
|
String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
|
|
761
743
|
);
|
|
762
744
|
readFileSyncMock.mockImplementation(((path, options) =>
|
|
763
745
|
String(path).endsWith(WEB_INDEX_SUFFIX)
|
|
764
746
|
? FAKE_INDEX_HTML
|
|
765
|
-
:
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
747
|
+
: String(path).endsWith("release.json")
|
|
748
|
+
? JSON.stringify(version === null ? {} : { version })
|
|
749
|
+
: realFs.readFileSync(
|
|
750
|
+
path as never,
|
|
751
|
+
options as never,
|
|
752
|
+
)) as typeof readFileSync);
|
|
769
753
|
}
|
|
770
754
|
|
|
771
755
|
/** Record a running edge: ingress state, live pidfile, matching ps output. */
|
|
@@ -846,14 +830,19 @@ const PRODUCTION_HUB_URL = "https://mycadet.ai/assistant";
|
|
|
846
830
|
|
|
847
831
|
/**
|
|
848
832
|
* Mirror of the SPA config fingerprint the edge records in its ingress state
|
|
849
|
-
* (sha256 over the
|
|
833
|
+
* (sha256 over the template, injected config, bundle directory and index). Pins
|
|
850
834
|
* both the injected config shape and the hash format; assumes the
|
|
851
835
|
* production-pinned environment. The version itself is imported rather than
|
|
852
836
|
* mirrored: bumping it is the correct response to any template edit, so a
|
|
853
837
|
* copy here would fail every such edit without naming a defect.
|
|
854
838
|
*/
|
|
855
839
|
function spaConfigHash(
|
|
856
|
-
opts: {
|
|
840
|
+
opts: {
|
|
841
|
+
assistantName?: string;
|
|
842
|
+
assistantId?: string;
|
|
843
|
+
indexHtml?: string;
|
|
844
|
+
webDistDir?: string;
|
|
845
|
+
} = {},
|
|
857
846
|
): string {
|
|
858
847
|
return createHash("sha256")
|
|
859
848
|
.update(
|
|
@@ -866,6 +855,9 @@ function spaConfigHash(
|
|
|
866
855
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
867
856
|
hubUrl: PRODUCTION_HUB_URL,
|
|
868
857
|
},
|
|
858
|
+
webDistDir: opts.webDistDir ?? findWebDistDir(),
|
|
859
|
+
version: "1.2.3",
|
|
860
|
+
indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
|
|
869
861
|
}),
|
|
870
862
|
)
|
|
871
863
|
.digest("hex");
|
|
@@ -890,6 +882,70 @@ function pinProductionEnvironment(): void {
|
|
|
890
882
|
describe("startRemoteWebIngress", () => {
|
|
891
883
|
pinProductionEnvironment();
|
|
892
884
|
|
|
885
|
+
for (const [webVersion, runtimeVersion, ready] of [
|
|
886
|
+
["1.2.3", "1.2.3", true],
|
|
887
|
+
["1.2.3", "v1.2.3", true],
|
|
888
|
+
["1.2.2", "1.2.3", false],
|
|
889
|
+
["1.2.3", undefined, false],
|
|
890
|
+
[null, "1.2.3", false],
|
|
891
|
+
] as const) {
|
|
892
|
+
test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
|
|
893
|
+
const ws = makeWorkspace();
|
|
894
|
+
mockNginxInstalled();
|
|
895
|
+
mockNginxSpawn();
|
|
896
|
+
mockWebDistPresent(webVersion);
|
|
897
|
+
fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
|
|
898
|
+
const result = await startRemoteWebIngress({
|
|
899
|
+
workspaceDir: ws,
|
|
900
|
+
gatewayPort: 7830,
|
|
901
|
+
listenPort: 7845,
|
|
902
|
+
assistantId: "assistant-1",
|
|
903
|
+
});
|
|
904
|
+
expect(result.status).toBe("started");
|
|
905
|
+
expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
|
|
906
|
+
"http://127.0.0.1:7830",
|
|
907
|
+
);
|
|
908
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
909
|
+
const detail = conf
|
|
910
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
911
|
+
?.split("\n }")[0];
|
|
912
|
+
expect(detail).toContain(`return ${ready ? 200 : 409} `);
|
|
913
|
+
if (ready) {
|
|
914
|
+
expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
|
|
915
|
+
} else {
|
|
916
|
+
expect(detail).not.toContain('\\"url\\"');
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
test("restarts an edge when its verified assistant/UI release changes", async () => {
|
|
922
|
+
const ws = makeWorkspace();
|
|
923
|
+
mockNginxInstalled();
|
|
924
|
+
mockNginxSpawn();
|
|
925
|
+
mockWebDistPresent("1.2.3");
|
|
926
|
+
fetchCurrentVersionMock.mockResolvedValue("1.2.4");
|
|
927
|
+
const pid = mockRunningEdge(ws, {
|
|
928
|
+
listenPort: 7845,
|
|
929
|
+
includeWebApp: true,
|
|
930
|
+
gatewayPort: 7830,
|
|
931
|
+
remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
|
|
932
|
+
});
|
|
933
|
+
const edge = mockKillableNginx(pid);
|
|
934
|
+
const result = await startRemoteWebIngress({
|
|
935
|
+
workspaceDir: ws,
|
|
936
|
+
gatewayPort: 7830,
|
|
937
|
+
listenPort: 7845,
|
|
938
|
+
assistantId: "assistant-1",
|
|
939
|
+
});
|
|
940
|
+
expect(result.status).toBe("started");
|
|
941
|
+
expect(edge.killed()).toBe(true);
|
|
942
|
+
const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
|
|
943
|
+
const detail = conf
|
|
944
|
+
.split('location = "/v1/assistants/assistant-1/" {')[1]
|
|
945
|
+
?.split("\n }")[0];
|
|
946
|
+
expect(detail).toContain("return 409 ");
|
|
947
|
+
});
|
|
948
|
+
|
|
893
949
|
test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
|
|
894
950
|
const ws = makeWorkspace();
|
|
895
951
|
mockNginxInstalled();
|
|
@@ -1247,6 +1303,7 @@ describe("startRemoteWebIngress", () => {
|
|
|
1247
1303
|
const ws = makeWorkspace();
|
|
1248
1304
|
mockNginxInstalled();
|
|
1249
1305
|
mockNginxSpawn();
|
|
1306
|
+
mockWebDistPresent();
|
|
1250
1307
|
const pid = mockRunningEdge(ws, {
|
|
1251
1308
|
listenPort: 7845,
|
|
1252
1309
|
gatewayPort: 7830,
|
|
@@ -1300,6 +1357,44 @@ describe("startRemoteWebIngress", () => {
|
|
|
1300
1357
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
1301
1358
|
});
|
|
1302
1359
|
|
|
1360
|
+
for (const drift of ["indexHtml", "webDistDir"] as const) {
|
|
1361
|
+
test(`restarts a detached edge when the web bundle ${drift} changes`, async () => {
|
|
1362
|
+
const ws = makeWorkspace();
|
|
1363
|
+
mockNginxInstalled();
|
|
1364
|
+
mockNginxSpawn();
|
|
1365
|
+
mockWebDistPresent();
|
|
1366
|
+
const pid = mockRunningEdge(ws, {
|
|
1367
|
+
listenPort: 7845,
|
|
1368
|
+
includeWebApp: true,
|
|
1369
|
+
gatewayPort: 7830,
|
|
1370
|
+
remoteWebConfigHash: spaConfigHash({
|
|
1371
|
+
...(drift === "indexHtml"
|
|
1372
|
+
? {
|
|
1373
|
+
indexHtml:
|
|
1374
|
+
'<html><head><script src="/assistant/assets/old.js"></script></head></html>',
|
|
1375
|
+
}
|
|
1376
|
+
: { webDistDir: "/old-release/dist" }),
|
|
1377
|
+
}),
|
|
1378
|
+
});
|
|
1379
|
+
const edge = mockKillableNginx(pid);
|
|
1380
|
+
const result = await startRemoteWebIngress({
|
|
1381
|
+
workspaceDir: ws,
|
|
1382
|
+
gatewayPort: 7830,
|
|
1383
|
+
});
|
|
1384
|
+
expect(result).toMatchObject({ status: "started", listenPort: 7845 });
|
|
1385
|
+
expect(edge.killed()).toBe(true);
|
|
1386
|
+
expect(readConfig(ws).ingress?.nginx?.remoteWebConfigHash).toBe(
|
|
1387
|
+
spaConfigHash(),
|
|
1388
|
+
);
|
|
1389
|
+
expect(
|
|
1390
|
+
realFs.readFileSync(
|
|
1391
|
+
join(ws, "data", "ingress", "assistant-index.html"),
|
|
1392
|
+
"utf-8",
|
|
1393
|
+
),
|
|
1394
|
+
).not.toContain("old.js");
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1303
1398
|
test("restarts the edge when the injected SPA config drifts from the recorded one", async () => {
|
|
1304
1399
|
const ws = makeWorkspace();
|
|
1305
1400
|
mockNginxInstalled();
|
|
@@ -1801,6 +1896,7 @@ describe("ensureTunnelEdge", () => {
|
|
|
1801
1896
|
const ws = makeWorkspace();
|
|
1802
1897
|
mockNginxInstalled();
|
|
1803
1898
|
mockNginxSpawn();
|
|
1899
|
+
mockWebDistPresent();
|
|
1804
1900
|
const pid = mockRunningEdge(ws, {
|
|
1805
1901
|
listenPort: 7845,
|
|
1806
1902
|
includeWebApp: true,
|
|
@@ -2223,3 +2319,104 @@ describe("stopContainerTunnelEdge", () => {
|
|
|
2223
2319
|
expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
|
|
2224
2320
|
});
|
|
2225
2321
|
});
|
|
2322
|
+
|
|
2323
|
+
describe("assistant metadata API", () => {
|
|
2324
|
+
function config(id = "assistant-1") {
|
|
2325
|
+
return buildIngressNginxConfig({
|
|
2326
|
+
gatewayPort: 7830,
|
|
2327
|
+
listenPort: 7840,
|
|
2328
|
+
remoteWebIngress: {
|
|
2329
|
+
webDistDir: "/tmp/web",
|
|
2330
|
+
version: "1.2.3",
|
|
2331
|
+
assistantId: id,
|
|
2332
|
+
assistantName: "My assistant",
|
|
2333
|
+
config: { appAllowedOrigins: ["https://parent.example"] },
|
|
2334
|
+
},
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
function body(conf: string, path: string, status = 200): unknown {
|
|
2338
|
+
const location = conf
|
|
2339
|
+
.split(`location = "${path}" {`)[1]
|
|
2340
|
+
?.split("\n }")[0];
|
|
2341
|
+
const value = location?.match(/return (200|409) ("(?:\\.|[^"\\])*");/);
|
|
2342
|
+
expect(Number(value?.[1])).toBe(status);
|
|
2343
|
+
if (!value) {
|
|
2344
|
+
throw new Error(`Missing metadata for ${path}`);
|
|
2345
|
+
}
|
|
2346
|
+
return JSON.parse(JSON.parse(value[2]));
|
|
2347
|
+
}
|
|
2348
|
+
test("lists only this host's assistant and returns its actual UI version and URL", () => {
|
|
2349
|
+
const conf = config();
|
|
2350
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2351
|
+
results: [{ id: "assistant-1", name: "My assistant" }],
|
|
2352
|
+
next: null,
|
|
2353
|
+
});
|
|
2354
|
+
expect(body(conf, "/v1/assistants/assistant-1/")).toEqual({
|
|
2355
|
+
id: "assistant-1",
|
|
2356
|
+
name: "My assistant",
|
|
2357
|
+
status: "active",
|
|
2358
|
+
version: "1.2.3",
|
|
2359
|
+
url: "assistant/",
|
|
2360
|
+
});
|
|
2361
|
+
expect(conf).not.toContain('location = "/v1/assistants/other/"');
|
|
2362
|
+
});
|
|
2363
|
+
test("metadata supports both slash forms and only read methods", () => {
|
|
2364
|
+
const conf = config();
|
|
2365
|
+
for (const path of [
|
|
2366
|
+
"/v1/assistants",
|
|
2367
|
+
"/v1/assistants/",
|
|
2368
|
+
"/v1/assistants/assistant-1",
|
|
2369
|
+
"/v1/assistants/assistant-1/",
|
|
2370
|
+
]) {
|
|
2371
|
+
expect(body(conf, path)).toBeDefined();
|
|
2372
|
+
}
|
|
2373
|
+
expect(conf).toContain(
|
|
2374
|
+
"if ($request_method !~ ^(GET|HEAD)$) { return 405; }",
|
|
2375
|
+
);
|
|
2376
|
+
expect(conf).toContain(
|
|
2377
|
+
'"https://parent.example" "https://parent.example";',
|
|
2378
|
+
);
|
|
2379
|
+
expect(conf).toContain(
|
|
2380
|
+
"add_header Access-Control-Allow-Origin $cadet_app_origin always;",
|
|
2381
|
+
);
|
|
2382
|
+
});
|
|
2383
|
+
test("unknown identity yields an empty list without inventing an assistant ID", () => {
|
|
2384
|
+
const conf = buildIngressNginxConfig({
|
|
2385
|
+
gatewayPort: 7830,
|
|
2386
|
+
listenPort: 7840,
|
|
2387
|
+
remoteWebIngress: { webDistDir: "/tmp/web" },
|
|
2388
|
+
});
|
|
2389
|
+
expect(body(conf, "/v1/assistants/")).toEqual({ results: [], next: null });
|
|
2390
|
+
});
|
|
2391
|
+
test("a known assistant without a UI bundle returns 409 on both detail paths", () => {
|
|
2392
|
+
const conf = buildIngressNginxConfig({
|
|
2393
|
+
gatewayPort: 7830,
|
|
2394
|
+
listenPort: 7840,
|
|
2395
|
+
remoteWebIngress: { webDistDir: "/tmp/web", assistantId: "assistant-1" },
|
|
2396
|
+
});
|
|
2397
|
+
expect(body(conf, "/v1/assistants/")).toEqual({
|
|
2398
|
+
results: [{ id: "assistant-1" }],
|
|
2399
|
+
next: null,
|
|
2400
|
+
});
|
|
2401
|
+
for (const path of [
|
|
2402
|
+
"/v1/assistants/assistant-1",
|
|
2403
|
+
"/v1/assistants/assistant-1/",
|
|
2404
|
+
]) {
|
|
2405
|
+
expect(body(conf, path, 409)).toEqual({
|
|
2406
|
+
detail: "assistant is not ready",
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
test("invalid browser origins fail before nginx starts", () => {
|
|
2411
|
+
expect(() =>
|
|
2412
|
+
buildIngressNginxConfig({
|
|
2413
|
+
gatewayPort: 7830,
|
|
2414
|
+
listenPort: 7840,
|
|
2415
|
+
remoteWebIngress: {
|
|
2416
|
+
webDistDir: "/tmp/web",
|
|
2417
|
+
config: { appAllowedOrigins: ["https://parent.example/path"] },
|
|
2418
|
+
},
|
|
2419
|
+
}),
|
|
2420
|
+
).toThrow("App origins");
|
|
2421
|
+
});
|
|
2422
|
+
});
|
|
@@ -107,7 +107,7 @@ describe("getKnownPidsFromAssistants", () => {
|
|
|
107
107
|
const entry: AssistantEntry = {
|
|
108
108
|
assistantId: "managed-1",
|
|
109
109
|
runtimeUrl: "https://cloud.mycadet.ai/foo",
|
|
110
|
-
cloud: "
|
|
110
|
+
cloud: "cloud",
|
|
111
111
|
};
|
|
112
112
|
const pids = getKnownPidsFromAssistants([entry]);
|
|
113
113
|
expect(pids.size).toBe(0);
|
|
@@ -102,13 +102,13 @@ describe("paired lifecycle guards", () => {
|
|
|
102
102
|
expect(errors).not.toContain("only works with local and docker");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
test("retire refuses a paired entry and points to
|
|
105
|
+
test("retire refuses a paired entry and points to the host", async () => {
|
|
106
106
|
process.argv = ["bun", "cadet", "retire", "px", "--yes"];
|
|
107
107
|
const { exited, errors } = await runGuarded(retire);
|
|
108
108
|
|
|
109
109
|
expect(exited).toBe(true);
|
|
110
110
|
expect(errors).toContain("paired from another machine");
|
|
111
|
-
expect(errors).toContain("cadet
|
|
111
|
+
expect(errors).toContain("cadet devices");
|
|
112
112
|
// It must NOT fall through to the generic "Unknown cloud type" path.
|
|
113
113
|
expect(errors).not.toContain("Unknown cloud type");
|
|
114
114
|
});
|
|
@@ -230,13 +230,13 @@ describe("cadet ps — platform status line", () => {
|
|
|
230
230
|
assistantId: "assistant-123",
|
|
231
231
|
name: "Alice",
|
|
232
232
|
runtimeUrl: "https://platform.example",
|
|
233
|
-
cloud: "
|
|
233
|
+
cloud: "cloud",
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
assistantId: "assistant-456",
|
|
237
237
|
name: "Bob",
|
|
238
238
|
runtimeUrl: "https://platform.example/b",
|
|
239
|
-
cloud: "
|
|
239
|
+
cloud: "cloud",
|
|
240
240
|
},
|
|
241
241
|
]);
|
|
242
242
|
getActiveAssistantMock.mockReturnValue(null);
|
|
@@ -262,7 +262,8 @@ describe("cadet ps — platform status line", () => {
|
|
|
262
262
|
// children of an assistant row…
|
|
263
263
|
expect(output).toContain("ngrok");
|
|
264
264
|
expect(output).not.toContain("└ ngrok");
|
|
265
|
-
expect(output).toContain("nginx
|
|
265
|
+
expect(output).toContain("nginx");
|
|
266
|
+
expect(output).toContain("port ");
|
|
266
267
|
// …the type column distinguishes the three process kinds…
|
|
267
268
|
expect(output).toContain("🧑🚀 Assistant");
|
|
268
269
|
expect(output).toContain("🚇 Tunnel");
|
|
@@ -277,7 +278,7 @@ describe("cadet ps — platform status line", () => {
|
|
|
277
278
|
assistantId: "assistant-123",
|
|
278
279
|
name: "Alice",
|
|
279
280
|
runtimeUrl: "https://platform.example",
|
|
280
|
-
cloud: "
|
|
281
|
+
cloud: "cloud",
|
|
281
282
|
},
|
|
282
283
|
]);
|
|
283
284
|
getActiveAssistantMock.mockReturnValue("assistant-123");
|
|
@@ -286,7 +287,7 @@ describe("cadet ps — platform status line", () => {
|
|
|
286
287
|
|
|
287
288
|
const output = stdout.join("\n");
|
|
288
289
|
expect(output).toContain("* Alice");
|
|
289
|
-
expect(output).toContain("remote:
|
|
290
|
+
expect(output).toContain("remote: cloud");
|
|
290
291
|
expect(output).toContain("https://platform.example");
|
|
291
292
|
expect(output).not.toContain("* assistant-123");
|
|
292
293
|
});
|
|
@@ -297,13 +298,13 @@ describe("cadet ps — platform status line", () => {
|
|
|
297
298
|
assistantId: "assistant-123",
|
|
298
299
|
name: "Alice",
|
|
299
300
|
runtimeUrl: "https://platform.example/a",
|
|
300
|
-
cloud: "
|
|
301
|
+
cloud: "cloud",
|
|
301
302
|
},
|
|
302
303
|
{
|
|
303
304
|
assistantId: "assistant-456",
|
|
304
305
|
name: "Bob",
|
|
305
306
|
runtimeUrl: "https://platform.example/b",
|
|
306
|
-
cloud: "
|
|
307
|
+
cloud: "cloud",
|
|
307
308
|
},
|
|
308
309
|
]);
|
|
309
310
|
getActiveAssistantMock.mockReturnValue("assistant-123");
|