@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e
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/channels.ts +0 -1
- 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/channels.ts +0 -1
- 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/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +8 -3
- 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 +587 -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__/shell-mode.test.ts +83 -0
- 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 +22 -4
- 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__/wake.test.ts +4 -1
- 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 +91 -11
- 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 +43 -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/ingress-config.ts +31 -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 +572 -95
- 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 +87 -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 +79 -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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { fetchCurrentVersion } from "../http-client.js";
|
|
3
|
+
|
|
4
|
+
const request = spyOn(globalThis, "fetch");
|
|
5
|
+
afterEach(() => request.mockReset());
|
|
6
|
+
afterAll(() => request.mockRestore());
|
|
7
|
+
|
|
8
|
+
test("reads the gateway's version through the public health endpoint", async () => {
|
|
9
|
+
request.mockResolvedValueOnce(
|
|
10
|
+
Response.json({ version: "1.2.3", status: "ok" }),
|
|
11
|
+
);
|
|
12
|
+
expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBe("1.2.3");
|
|
13
|
+
expect(request.mock.calls[0][0]).toBe("http://127.0.0.1:7830/healthz");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("an unverified gateway response never supplies a release version", async () => {
|
|
17
|
+
for (const value of [{}, null, { version: "" }, { version: 123 }]) {
|
|
18
|
+
request.mockResolvedValueOnce(Response.json(value));
|
|
19
|
+
expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
|
|
20
|
+
}
|
|
21
|
+
request.mockResolvedValueOnce(new Response("not JSON"));
|
|
22
|
+
expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
|
|
23
|
+
request.mockResolvedValueOnce(
|
|
24
|
+
Response.json({ version: "1.2.3" }, { status: 503 }),
|
|
25
|
+
);
|
|
26
|
+
expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
|
|
27
|
+
request.mockRejectedValueOnce(new Error("offline"));
|
|
28
|
+
expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
|
|
29
|
+
});
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type { AssistantEntry } from "../assistant-config.js";
|
|
4
|
-
import {
|
|
5
|
-
resolveRuntimeMigrationUrl,
|
|
6
|
-
resolveRuntimeUrl,
|
|
7
|
-
} from "../runtime-url.js";
|
|
4
|
+
import { resolveRuntimeUrl } from "../runtime-url.js";
|
|
8
5
|
|
|
9
6
|
function makeEntry(
|
|
10
7
|
overrides: Partial<AssistantEntry> & {
|
|
@@ -20,75 +17,6 @@ function makeEntry(
|
|
|
20
17
|
};
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
describe("resolveRuntimeMigrationUrl", () => {
|
|
24
|
-
test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
|
|
25
|
-
const entry = makeEntry({
|
|
26
|
-
cloud: "local",
|
|
27
|
-
runtimeUrl: "http://localhost:7821",
|
|
28
|
-
assistantId: "ast-local-1",
|
|
29
|
-
});
|
|
30
|
-
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
31
|
-
"http://localhost:7821/v1/migrations/export-to-gcs",
|
|
32
|
-
);
|
|
33
|
-
expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
|
|
34
|
-
"http://localhost:7821/v1/migrations/import-from-gcs",
|
|
35
|
-
);
|
|
36
|
-
expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
|
|
37
|
-
"http://localhost:7821/v1/migrations/jobs/job-abc",
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
|
|
42
|
-
const entry = makeEntry({
|
|
43
|
-
cloud: "docker",
|
|
44
|
-
runtimeUrl: "http://localhost:7831",
|
|
45
|
-
assistantId: "ast-docker-1",
|
|
46
|
-
});
|
|
47
|
-
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
48
|
-
"http://localhost:7831/v1/migrations/export-to-gcs",
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("cadet (cloud-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
|
|
53
|
-
const entry = makeEntry({
|
|
54
|
-
cloud: "cadet",
|
|
55
|
-
runtimeUrl: "https://cloud.mycadet.ai",
|
|
56
|
-
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
57
|
-
});
|
|
58
|
-
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
59
|
-
"https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
|
|
60
|
-
);
|
|
61
|
-
expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
|
|
62
|
-
"https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
|
|
63
|
-
);
|
|
64
|
-
expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
|
|
65
|
-
"https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("dev platform URL still routes through the wildcard prefix", () => {
|
|
70
|
-
const entry = makeEntry({
|
|
71
|
-
cloud: "cadet",
|
|
72
|
-
runtimeUrl: "https://dev-cloud.mycadet.ai",
|
|
73
|
-
assistantId: "ast-dev-1",
|
|
74
|
-
});
|
|
75
|
-
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
76
|
-
"https://dev-cloud.mycadet.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("a non-cadet, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
|
|
81
|
-
const entry = makeEntry({
|
|
82
|
-
cloud: "gcp",
|
|
83
|
-
runtimeUrl: "http://10.0.0.5:7821",
|
|
84
|
-
assistantId: "ast-gcp-1",
|
|
85
|
-
});
|
|
86
|
-
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
87
|
-
"http://10.0.0.5:7821/v1/migrations/export-to-gcs",
|
|
88
|
-
);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
20
|
describe("resolveRuntimeUrl", () => {
|
|
93
21
|
test("local cloud uses gateway-loopback /v1/<subpath>", () => {
|
|
94
22
|
const entry = makeEntry({
|
|
@@ -112,9 +40,9 @@ describe("resolveRuntimeUrl", () => {
|
|
|
112
40
|
);
|
|
113
41
|
});
|
|
114
42
|
|
|
115
|
-
test("
|
|
43
|
+
test("cloud home uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
|
|
116
44
|
const entry = makeEntry({
|
|
117
|
-
cloud: "
|
|
45
|
+
cloud: "cloud",
|
|
118
46
|
runtimeUrl: "https://cloud.mycadet.ai",
|
|
119
47
|
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
120
48
|
});
|
|
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { findWebDistDir } from "../web-dist.js";
|
|
6
|
+
import { findWebDistDir, readWebReleaseVersion } from "../web-dist.js";
|
|
7
7
|
|
|
8
8
|
const tempDirs: string[] = [];
|
|
9
9
|
|
|
@@ -84,3 +84,29 @@ test("preserves installed package and source checkout resolution", () => {
|
|
|
84
84
|
}),
|
|
85
85
|
).toBe(sourceDist);
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("reads the built UI release, including a normalized version prefix", () => {
|
|
89
|
+
const root = makeTempDir();
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(root, "release.json"),
|
|
92
|
+
JSON.stringify({ version: "v1.2.3-dev.5" }),
|
|
93
|
+
);
|
|
94
|
+
expect(readWebReleaseVersion(root)).toBe("1.2.3-dev.5");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("missing or malformed release metadata cannot stand in for a release", () => {
|
|
98
|
+
const root = makeTempDir();
|
|
99
|
+
expect(readWebReleaseVersion(root)).toBeUndefined();
|
|
100
|
+
for (const value of [
|
|
101
|
+
"not JSON",
|
|
102
|
+
"null",
|
|
103
|
+
"{}",
|
|
104
|
+
'{"version":null}',
|
|
105
|
+
'{"version":"latest"}',
|
|
106
|
+
'{"version":""}',
|
|
107
|
+
'{"version":"bundle-hash"}',
|
|
108
|
+
]) {
|
|
109
|
+
writeFileSync(join(root, "release.json"), value);
|
|
110
|
+
expect(readWebReleaseVersion(root)).toBeUndefined();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -334,7 +334,7 @@ export class AssistantClient {
|
|
|
334
334
|
|
|
335
335
|
// A stale cached Cadet-Organization-Id is the usual cause of a 401 on the
|
|
336
336
|
// platform path (e.g. the user switched orgs elsewhere). Drop it and retry
|
|
337
|
-
// once.
|
|
337
|
+
// once.
|
|
338
338
|
if (response.status === 401 && this.authMode === "platform" && this.token) {
|
|
339
339
|
invalidateOrgIdCache(this.token, this.runtimeUrl);
|
|
340
340
|
this.platformHeaders = undefined;
|
|
@@ -103,16 +103,11 @@ export type AssistantEntry = Omit<
|
|
|
103
103
|
/** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
|
|
104
104
|
* Avoids mDNS resolution issues when the machine checks its own gateway. */
|
|
105
105
|
localUrl?: string;
|
|
106
|
-
/** Public https ingress URL recorded by `cadet tunnel` providers for this
|
|
107
|
-
* instance. The advertised-URL default for remote-web pairing. */
|
|
108
|
-
ingressUrl?: string;
|
|
109
106
|
bearerToken?: string;
|
|
110
107
|
/** True when this entry was registered via `cadet connect import` (a remote
|
|
111
108
|
* pairing). Set alongside `cloud: "paired"`; also backs the re-import /
|
|
112
109
|
* overwrite guard in connect import. */
|
|
113
110
|
paired?: boolean;
|
|
114
|
-
instanceId?: string;
|
|
115
|
-
namespace?: string;
|
|
116
111
|
project?: string;
|
|
117
112
|
region?: string;
|
|
118
113
|
sshUser?: string;
|
|
@@ -123,19 +118,6 @@ export type AssistantEntry = Omit<
|
|
|
123
118
|
guardianBootstrapSecret?: string;
|
|
124
119
|
/** Docker image metadata for rollback. Only present for docker topology entries. */
|
|
125
120
|
containerInfo?: ContainerInfo;
|
|
126
|
-
/** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
|
|
127
|
-
previousContainerInfo?: ContainerInfo;
|
|
128
|
-
/** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
|
|
129
|
-
* only the backup from the specific upgrade being rolled back — never a stale backup from a
|
|
130
|
-
* previous upgrade cycle. */
|
|
131
|
-
preUpgradeBackupPath?: string;
|
|
132
|
-
/** Running version of the service group at the time of the last upgrade, as reported by
|
|
133
|
-
* the health endpoint. Used by saved-state rollback for logging / broadcast events. */
|
|
134
|
-
previousVersion?: string;
|
|
135
|
-
/** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
|
|
136
|
-
previousDbMigrationVersion?: number;
|
|
137
|
-
/** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
|
|
138
|
-
previousWorkspaceMigrationId?: string;
|
|
139
121
|
/** Client-minted uuid (web's temp-client-id pattern) the CLI addresses this
|
|
140
122
|
* assistant's chat conversation by. Minted once on first `cadet client`
|
|
141
123
|
* launch and persisted here so every session shares one conversation. */
|
|
@@ -154,6 +136,8 @@ interface LockfileData {
|
|
|
154
136
|
cloudBaseUrl?: string;
|
|
155
137
|
tunnel?: RootProcessRecord;
|
|
156
138
|
app?: RootProcessRecord;
|
|
139
|
+
/** The nginx edge's shell selection; see readEdgeShellSelection. */
|
|
140
|
+
edgeShell?: unknown;
|
|
157
141
|
[key: string]: unknown;
|
|
158
142
|
}
|
|
159
143
|
|
|
@@ -170,6 +154,10 @@ export interface RootProcessRecord {
|
|
|
170
154
|
/** The assistant the process fronts, so `cadet ps` can nest the row. */
|
|
171
155
|
assistantId?: string;
|
|
172
156
|
startedAt: string;
|
|
157
|
+
/** Listening port for the nginx edge. */
|
|
158
|
+
port?: number;
|
|
159
|
+
/** Managed process temporarily displaced by a pairing-only tunnel. */
|
|
160
|
+
previous?: RootProcessRecord;
|
|
173
161
|
}
|
|
174
162
|
|
|
175
163
|
export type RootProcessKind = "tunnel" | "app";
|
|
@@ -247,12 +235,21 @@ export function stampRootProcess(
|
|
|
247
235
|
});
|
|
248
236
|
}
|
|
249
237
|
|
|
250
|
-
/**
|
|
251
|
-
export function clearRootProcess(
|
|
238
|
+
/** Release a recorded process, preserving a replacement or restoring a displaced record. */
|
|
239
|
+
export function clearRootProcess(
|
|
240
|
+
kind: RootProcessKind,
|
|
241
|
+
expectedPid?: number,
|
|
242
|
+
): void {
|
|
252
243
|
withCliLockfileLock(() => {
|
|
253
244
|
const data = readLockfile();
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
const record = data[kind];
|
|
246
|
+
if (
|
|
247
|
+
record === undefined ||
|
|
248
|
+
(expectedPid !== undefined && record.pid !== expectedPid)
|
|
249
|
+
)
|
|
250
|
+
return;
|
|
251
|
+
if (record.previous) data[kind] = record.previous;
|
|
252
|
+
else delete data[kind];
|
|
256
253
|
writeLockfileUnlocked(data);
|
|
257
254
|
});
|
|
258
255
|
}
|
|
@@ -269,6 +266,27 @@ export function readRootProcesses(): {
|
|
|
269
266
|
return { tunnel: data.tunnel, app: data.app };
|
|
270
267
|
}
|
|
271
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The nginx edge's shell selection, persisted at the lockfile root beside the
|
|
271
|
+
* `app` record that tracks the edge's process. The edge is a CLI-managed
|
|
272
|
+
* server rather than a per-cadet setting, and that record is lifecycle-scoped
|
|
273
|
+
* (cleared when the edge stops), so the selection lives on its own root key
|
|
274
|
+
* to survive restarts. Read raw: the lockfile is user-editable disk state,
|
|
275
|
+
* so the shape check lives with the readers.
|
|
276
|
+
*/
|
|
277
|
+
export function readEdgeShellSelection(): unknown {
|
|
278
|
+
return readLockfile().edgeShell;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Persist the nginx edge's shell selection under the lockfile lock. */
|
|
282
|
+
export function saveEdgeShellSelection(shell: "web" | "app"): void {
|
|
283
|
+
withCliLockfileLock(() => {
|
|
284
|
+
const data = readLockfile();
|
|
285
|
+
data.edgeShell = shell;
|
|
286
|
+
writeLockfileUnlocked(data);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
272
290
|
/**
|
|
273
291
|
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
274
292
|
* Returns undefined if the URL is malformed or has no explicit port.
|
|
@@ -442,7 +460,9 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
442
460
|
}
|
|
443
461
|
|
|
444
462
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
445
|
-
return
|
|
463
|
+
return (
|
|
464
|
+
loadAllAssistants().find((entry) => entry.assistantId === name) ?? null
|
|
465
|
+
);
|
|
446
466
|
}
|
|
447
467
|
|
|
448
468
|
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
mkdirSync,
|
|
4
|
-
readdirSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
statSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "fs";
|
|
10
|
-
import { dirname, join } from "path";
|
|
1
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
11
3
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
stageBundleForRestore,
|
|
15
|
-
type RestoreStagingTarget,
|
|
16
|
-
} from "./bundle-staging.js";
|
|
17
|
-
import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
|
|
18
|
-
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
4
|
+
import { findAssistantByName } from "./assistant-config.js";
|
|
5
|
+
import { createRootBackup, restoreRootBackup } from "./root-backup.js";
|
|
19
6
|
import { getStateDir } from "./environments/paths.js";
|
|
20
7
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
8
|
|
|
@@ -31,261 +18,60 @@ export function formatSize(bytes: number): string {
|
|
|
31
18
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
19
|
}
|
|
33
20
|
|
|
34
|
-
/**
|
|
35
|
-
* Obtain a valid guardian access token.
|
|
36
|
-
*
|
|
37
|
-
* Resolution order:
|
|
38
|
-
* 1. Cached token that is not yet expired — use as-is.
|
|
39
|
-
* 2. Cached token with a valid refresh token — call /v1/guardian/refresh.
|
|
40
|
-
* 3. No usable token — return null so callers can skip the backup gracefully
|
|
41
|
-
* rather than hitting /v1/guardian/init (which 403s on bootstrapped instances).
|
|
42
|
-
*/
|
|
43
|
-
async function getGuardianAccessToken(
|
|
44
|
-
runtimeUrl: string,
|
|
45
|
-
assistantId: string,
|
|
46
|
-
): Promise<string | null> {
|
|
47
|
-
const tokenData = loadGuardianToken(assistantId);
|
|
48
|
-
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
49
|
-
return tokenData.accessToken;
|
|
50
|
-
}
|
|
51
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
52
|
-
return refreshed?.accessToken ?? null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Create a .vbundle backup of a running assistant.
|
|
57
|
-
* Returns the path to the saved backup, or null if backup failed.
|
|
58
|
-
* Never throws — failures are logged as warnings.
|
|
59
|
-
*/
|
|
21
|
+
/** Internal safety backup callers resolve their registered host topology, never runtime auth. */
|
|
60
22
|
export async function createBackup(
|
|
61
|
-
|
|
23
|
+
_runtimeUrl: string,
|
|
62
24
|
assistantId: string,
|
|
63
25
|
options?: { prefix?: string; description?: string; timeoutMs?: number },
|
|
64
26
|
): Promise<string | null> {
|
|
65
|
-
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
66
27
|
try {
|
|
67
|
-
|
|
68
|
-
if (!
|
|
69
|
-
|
|
70
|
-
"Warning: backup skipped — no valid guardian token available",
|
|
71
|
-
);
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
let response = await loopbackSafeFetch(
|
|
76
|
-
`${runtimeUrl}/v1/migrations/export`,
|
|
77
|
-
{
|
|
78
|
-
method: "POST",
|
|
79
|
-
headers: {
|
|
80
|
-
Authorization: `Bearer ${accessToken}`,
|
|
81
|
-
"Content-Type": "application/json",
|
|
82
|
-
},
|
|
83
|
-
body: JSON.stringify({
|
|
84
|
-
description: options?.description ?? "CLI backup",
|
|
85
|
-
}),
|
|
86
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
87
|
-
},
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
// Retry once with a refreshed token on 401 — the cached token may be
|
|
91
|
-
// stale after a container restart that regenerated the gateway signing key.
|
|
92
|
-
if (response.status === 401) {
|
|
93
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
94
|
-
if (!refreshed) {
|
|
95
|
-
console.warn(
|
|
96
|
-
`Warning: backup export failed (401) and token refresh failed`,
|
|
97
|
-
);
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
accessToken = refreshed.accessToken;
|
|
101
|
-
response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: {
|
|
104
|
-
Authorization: `Bearer ${accessToken}`,
|
|
105
|
-
"Content-Type": "application/json",
|
|
106
|
-
},
|
|
107
|
-
body: JSON.stringify({
|
|
108
|
-
description: options?.description ?? "CLI backup",
|
|
109
|
-
}),
|
|
110
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!response.ok) {
|
|
115
|
-
const body = await response.text();
|
|
116
|
-
console.warn(
|
|
117
|
-
`Warning: backup export failed (${response.status}): ${body}`,
|
|
118
|
-
);
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
123
|
-
const data = new Uint8Array(arrayBuffer);
|
|
124
|
-
|
|
125
|
-
const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
28
|
+
const entry = findAssistantByName(assistantId);
|
|
29
|
+
if (!entry)
|
|
30
|
+
throw new Error(`No registered assistant found for '${assistantId}'.`);
|
|
126
31
|
const prefix = options?.prefix ?? assistantId;
|
|
127
|
-
|
|
32
|
+
if (prefix.includes("/") || prefix.includes("\\"))
|
|
33
|
+
throw new Error("Backup prefix must be a filename.");
|
|
34
|
+
const path = join(
|
|
128
35
|
getBackupsDir(),
|
|
129
|
-
`${prefix}-${
|
|
36
|
+
`${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`,
|
|
130
37
|
);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return outputPath;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
-
console.warn(`Warning: backup failed: ${msg}`);
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function postRestoreWithRetries(
|
|
144
|
-
postImport: () => Promise<Response>,
|
|
145
|
-
runtimeUrl: string,
|
|
146
|
-
assistantId: string,
|
|
147
|
-
onTokenRefresh: (token: string) => void,
|
|
148
|
-
): Promise<Response | null> {
|
|
149
|
-
let response = await postImport();
|
|
150
|
-
|
|
151
|
-
// Retry once with a refreshed token on 401 — the cached token may be
|
|
152
|
-
// stale after a container restart that regenerated the gateway signing key.
|
|
153
|
-
if (response.status === 401) {
|
|
154
|
-
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
155
|
-
if (!refreshed) {
|
|
156
|
-
console.warn(`Warning: restore failed (401) and token refresh failed`);
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
onTokenRefresh(refreshed.accessToken);
|
|
160
|
-
response = await postImport();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// A freshly-(re)started gateway 503s {status:"starting"} for a couple of
|
|
164
|
-
// seconds until its own assistant poll observes the migration state and
|
|
165
|
-
// opens the traffic gate — retry through that window rather than failing
|
|
166
|
-
// the recovery. The daemon's running-migrations 503 carries a `reason`
|
|
167
|
-
// field and is excluded: the import must not race an in-flight migration.
|
|
168
|
-
const startingGateDeadline = Date.now() + 15_000;
|
|
169
|
-
while (!response.ok) {
|
|
170
|
-
const body = await response.text();
|
|
171
|
-
let gatewayStarting = false;
|
|
172
|
-
try {
|
|
173
|
-
const parsed = JSON.parse(body) as {
|
|
174
|
-
status?: string;
|
|
175
|
-
reason?: string;
|
|
176
|
-
};
|
|
177
|
-
gatewayStarting =
|
|
178
|
-
parsed.status === "starting" && parsed.reason === undefined;
|
|
179
|
-
} catch {
|
|
180
|
-
// Non-JSON error body — not the starting gate.
|
|
181
|
-
}
|
|
182
|
-
if (gatewayStarting && Date.now() < startingGateDeadline) {
|
|
183
|
-
await new Promise((r) => setTimeout(r, 1_000));
|
|
184
|
-
response = await postImport();
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
console.warn(`Warning: restore failed (${response.status}): ${body}`);
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return response;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function finishRestoreResponse(response: Response): Promise<boolean> {
|
|
195
|
-
const result = (await response.json()) as {
|
|
196
|
-
success: boolean;
|
|
197
|
-
message?: string;
|
|
198
|
-
reason?: string;
|
|
199
|
-
};
|
|
200
|
-
if (!result.success) {
|
|
38
|
+
await createRootBackup(entry, path);
|
|
39
|
+
return path;
|
|
40
|
+
} catch (error) {
|
|
201
41
|
console.warn(
|
|
202
|
-
`
|
|
42
|
+
`Backup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
203
43
|
);
|
|
204
|
-
return
|
|
44
|
+
return null;
|
|
205
45
|
}
|
|
206
|
-
return true;
|
|
207
46
|
}
|
|
208
47
|
|
|
209
|
-
/**
|
|
210
|
-
* Restore a .vbundle backup into a running assistant.
|
|
211
|
-
* Returns true if restore succeeded, false otherwise.
|
|
212
|
-
* Never throws — failures are logged as warnings.
|
|
213
|
-
*/
|
|
48
|
+
/** Restore every persistent root; failed-state recovery can leave services stopped for its caller to restart. */
|
|
214
49
|
export async function restoreBackup(
|
|
215
|
-
|
|
50
|
+
_runtimeUrl: string,
|
|
216
51
|
assistantId: string,
|
|
217
52
|
backupPath: string,
|
|
218
|
-
|
|
53
|
+
options: { resume?: boolean } = {},
|
|
219
54
|
): Promise<boolean> {
|
|
220
55
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"Warning: restore skipped — no valid guardian token available",
|
|
230
|
-
);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
let token = initialToken;
|
|
234
|
-
|
|
235
|
-
if (staging) {
|
|
236
|
-
const staged = await stageBundleForRestore(staging, backupPath);
|
|
237
|
-
try {
|
|
238
|
-
const postImport = () =>
|
|
239
|
-
importStagedBundle(runtimeUrl, token, staged.relativePath);
|
|
240
|
-
const response = await postRestoreWithRetries(
|
|
241
|
-
postImport,
|
|
242
|
-
runtimeUrl,
|
|
243
|
-
assistantId,
|
|
244
|
-
(nextToken) => {
|
|
245
|
-
token = nextToken;
|
|
246
|
-
},
|
|
247
|
-
);
|
|
248
|
-
if (!response) {
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
return await finishRestoreResponse(response);
|
|
252
|
-
} finally {
|
|
253
|
-
await staged.cleanup();
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const bundleData = new Uint8Array(readFileSync(backupPath));
|
|
258
|
-
const postImport = () =>
|
|
259
|
-
loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
260
|
-
method: "POST",
|
|
261
|
-
headers: {
|
|
262
|
-
Authorization: `Bearer ${token}`,
|
|
263
|
-
"Content-Type": "application/octet-stream",
|
|
264
|
-
},
|
|
265
|
-
body: bundleData,
|
|
266
|
-
signal: AbortSignal.timeout(120_000),
|
|
267
|
-
});
|
|
268
|
-
const response = await postRestoreWithRetries(
|
|
269
|
-
postImport,
|
|
270
|
-
runtimeUrl,
|
|
271
|
-
assistantId,
|
|
272
|
-
(nextToken) => {
|
|
273
|
-
token = nextToken;
|
|
274
|
-
},
|
|
56
|
+
const entry = findAssistantByName(assistantId);
|
|
57
|
+
if (!entry)
|
|
58
|
+
throw new Error(`No registered assistant found for '${assistantId}'.`);
|
|
59
|
+
await restoreRootBackup(entry, backupPath, options);
|
|
60
|
+
return true;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`Restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
64
|
);
|
|
276
|
-
if (!response) {
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
return await finishRestoreResponse(response);
|
|
280
|
-
} catch (err) {
|
|
281
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
282
|
-
console.warn(`Warning: restore failed: ${msg}`);
|
|
283
65
|
return false;
|
|
284
66
|
}
|
|
285
67
|
}
|
|
286
68
|
|
|
287
|
-
/** Filename kinds this CLI writes: `<assistantId>-<kind>-<timestamp>.
|
|
288
|
-
export const CLI_BACKUP_KINDS = [
|
|
69
|
+
/** Filename kinds this CLI writes: `<assistantId>-<kind>-<timestamp>.tar.gz`. */
|
|
70
|
+
export const CLI_BACKUP_KINDS = [
|
|
71
|
+
"pre-upgrade",
|
|
72
|
+
"pre-rollback",
|
|
73
|
+
"pre-teleport",
|
|
74
|
+
] as const;
|
|
289
75
|
export type CliBackupKind = (typeof CLI_BACKUP_KINDS)[number];
|
|
290
76
|
|
|
291
77
|
/** `new Date().toISOString().replace(/[:.]/g, "-")`, as used in every CLI backup filename. */
|
|
@@ -307,12 +93,12 @@ export function assistantBackupFilenamePattern(
|
|
|
307
93
|
kinds: readonly CliBackupKind[] = CLI_BACKUP_KINDS,
|
|
308
94
|
): RegExp {
|
|
309
95
|
return new RegExp(
|
|
310
|
-
`^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.
|
|
96
|
+
`^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.tar\\.gz$`,
|
|
311
97
|
);
|
|
312
98
|
}
|
|
313
99
|
|
|
314
100
|
/**
|
|
315
|
-
* Modification times of the `.
|
|
101
|
+
* Modification times of the `.tar.gz` backups this CLI has written for
|
|
316
102
|
* `assistantId` (every kind in `CLI_BACKUP_KINDS`), as ISO timestamps.
|
|
317
103
|
* Missing directory yields `[]`.
|
|
318
104
|
*/
|