@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
|
@@ -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. */
|
|
@@ -170,6 +152,10 @@ export interface RootProcessRecord {
|
|
|
170
152
|
/** The assistant the process fronts, so `cadet ps` can nest the row. */
|
|
171
153
|
assistantId?: string;
|
|
172
154
|
startedAt: string;
|
|
155
|
+
/** Listening port for the nginx edge. */
|
|
156
|
+
port?: number;
|
|
157
|
+
/** Managed process temporarily displaced by a pairing-only tunnel. */
|
|
158
|
+
previous?: RootProcessRecord;
|
|
173
159
|
}
|
|
174
160
|
|
|
175
161
|
export type RootProcessKind = "tunnel" | "app";
|
|
@@ -247,12 +233,21 @@ export function stampRootProcess(
|
|
|
247
233
|
});
|
|
248
234
|
}
|
|
249
235
|
|
|
250
|
-
/**
|
|
251
|
-
export function clearRootProcess(
|
|
236
|
+
/** Release a recorded process, preserving a replacement or restoring a displaced record. */
|
|
237
|
+
export function clearRootProcess(
|
|
238
|
+
kind: RootProcessKind,
|
|
239
|
+
expectedPid?: number,
|
|
240
|
+
): void {
|
|
252
241
|
withCliLockfileLock(() => {
|
|
253
242
|
const data = readLockfile();
|
|
254
|
-
|
|
255
|
-
|
|
243
|
+
const record = data[kind];
|
|
244
|
+
if (
|
|
245
|
+
record === undefined ||
|
|
246
|
+
(expectedPid !== undefined && record.pid !== expectedPid)
|
|
247
|
+
)
|
|
248
|
+
return;
|
|
249
|
+
if (record.previous) data[kind] = record.previous;
|
|
250
|
+
else delete data[kind];
|
|
256
251
|
writeLockfileUnlocked(data);
|
|
257
252
|
});
|
|
258
253
|
}
|
|
@@ -442,7 +437,9 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
442
437
|
}
|
|
443
438
|
|
|
444
439
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
445
|
-
return
|
|
440
|
+
return (
|
|
441
|
+
loadAllAssistants().find((entry) => entry.assistantId === name) ?? null
|
|
442
|
+
);
|
|
446
443
|
}
|
|
447
444
|
|
|
448
445
|
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
|
*/
|
package/src/lib/cloud-client.ts
CHANGED
|
@@ -146,13 +146,10 @@ const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
|
|
|
146
146
|
* orgs in another tab). Clearing the entry forces the next `authHeaders`
|
|
147
147
|
* call to refetch the org ID from the cloud.
|
|
148
148
|
*
|
|
149
|
-
* Exported so
|
|
149
|
+
* Exported so cloud API consumers can implement the
|
|
150
150
|
* same retry pattern without needing direct access to the cache map.
|
|
151
151
|
*/
|
|
152
|
-
export function invalidateOrgIdCache(
|
|
153
|
-
token: string,
|
|
154
|
-
cloudUrl?: string,
|
|
155
|
-
): void {
|
|
152
|
+
export function invalidateOrgIdCache(token: string, cloudUrl?: string): void {
|
|
156
153
|
orgIdCache.delete(`${token}::${cloudUrl ?? ""}`);
|
|
157
154
|
}
|
|
158
155
|
|
|
@@ -658,9 +655,9 @@ export async function hatchAssistant(
|
|
|
658
655
|
}
|
|
659
656
|
|
|
660
657
|
if (response.status === 401 || response.status === 403) {
|
|
661
|
-
const detail = CloudErrorDetailSchema
|
|
662
|
-
.catch({})
|
|
663
|
-
|
|
658
|
+
const detail = CloudErrorDetailSchema.catch({}).parse(
|
|
659
|
+
await response.json().catch(() => ({})),
|
|
660
|
+
);
|
|
664
661
|
throw new Error(
|
|
665
662
|
detail.detail ??
|
|
666
663
|
"Invalid or expired token. Run `cadet login` to re-authenticate.",
|
|
@@ -671,9 +668,9 @@ export async function hatchAssistant(
|
|
|
671
668
|
throw new Error("Insufficient balance to hatch a new assistant.");
|
|
672
669
|
}
|
|
673
670
|
|
|
674
|
-
const errorBody = CloudErrorDetailSchema
|
|
675
|
-
.catch({})
|
|
676
|
-
|
|
671
|
+
const errorBody = CloudErrorDetailSchema.catch({}).parse(
|
|
672
|
+
await response.json().catch(() => ({})),
|
|
673
|
+
);
|
|
677
674
|
throw new Error(
|
|
678
675
|
errorBody.detail ??
|
|
679
676
|
`Platform API error: ${response.status} ${response.statusText}`,
|
|
@@ -987,7 +984,7 @@ export async function platformImportPreflightFromGcs(
|
|
|
987
984
|
{
|
|
988
985
|
method: "POST",
|
|
989
986
|
headers: await authHeaders(token, cloudUrl),
|
|
990
|
-
signal: AbortSignal.timeout(
|
|
987
|
+
signal: AbortSignal.timeout(30 * 60_000),
|
|
991
988
|
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
992
989
|
},
|
|
993
990
|
);
|
|
@@ -1010,7 +1007,7 @@ export async function platformImportBundleFromGcs(
|
|
|
1010
1007
|
method: "POST",
|
|
1011
1008
|
headers: await authHeaders(token, cloudUrl),
|
|
1012
1009
|
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
1013
|
-
signal: AbortSignal.timeout(60_000),
|
|
1010
|
+
signal: AbortSignal.timeout(30 * 60_000),
|
|
1014
1011
|
},
|
|
1015
1012
|
);
|
|
1016
1013
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
3
|
+
|
|
4
|
+
const requestSchema = z.object({
|
|
5
|
+
request_id: z.string().regex(/^[a-f0-9]{64}$/),
|
|
6
|
+
poll_secret: z.string().regex(/^[a-f0-9]{64}$/),
|
|
7
|
+
verification_uri: z.string().url(),
|
|
8
|
+
user_code: z.string().min(1),
|
|
9
|
+
interval: z.number().min(1).max(10),
|
|
10
|
+
});
|
|
11
|
+
const resultSchema = z.object({ session_token: z.string().min(1) });
|
|
12
|
+
|
|
13
|
+
/** Browser approval and polling work when the terminal and browser are on different hosts. */
|
|
14
|
+
export async function pollingCloudLogin(
|
|
15
|
+
cloudUrl: string,
|
|
16
|
+
options: {
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
fetch?: (
|
|
19
|
+
input: string | URL | Request,
|
|
20
|
+
init?: RequestInit,
|
|
21
|
+
) => Promise<Response>;
|
|
22
|
+
log?: (message: string) => void;
|
|
23
|
+
} = {},
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const request = options.fetch ?? loopbackSafeFetch;
|
|
26
|
+
const log = options.log ?? console.log;
|
|
27
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 300_000);
|
|
28
|
+
const origin = new URL(cloudUrl).origin;
|
|
29
|
+
try {
|
|
30
|
+
const started = await request(`${origin}/v1/auth/cli/start`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
signal,
|
|
33
|
+
});
|
|
34
|
+
if (!started.ok)
|
|
35
|
+
throw new Error(`Could not start terminal login (${started.status}).`);
|
|
36
|
+
const login = requestSchema.parse(await started.json());
|
|
37
|
+
log(`Open this URL to log in: ${login.verification_uri}`);
|
|
38
|
+
log(
|
|
39
|
+
`Confirm code ${login.user_code} in your browser. Waiting up to 5 minutes...`,
|
|
40
|
+
);
|
|
41
|
+
while (!signal.aborted) {
|
|
42
|
+
const response = await request(`${origin}/v1/auth/cli/poll`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
signal,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
request_id: login.request_id,
|
|
48
|
+
poll_secret: login.poll_secret,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (response.status === 200)
|
|
52
|
+
return resultSchema.parse(await response.json()).session_token;
|
|
53
|
+
if (response.status === 410)
|
|
54
|
+
throw new Error("Login request expired. Run cadet login again.");
|
|
55
|
+
if (response.status !== 202)
|
|
56
|
+
throw new Error(`Login polling failed (${response.status}).`);
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
const abort = () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
reject(new Error("Login timed out after 5 minutes."));
|
|
61
|
+
};
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
signal.removeEventListener("abort", abort);
|
|
64
|
+
resolve();
|
|
65
|
+
}, login.interval * 1000);
|
|
66
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
if (signal.aborted) abort();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
throw new Error("Login timed out after 5 minutes.");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (signal.aborted) throw new Error("Login timed out after 5 minutes.");
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/lib/cloud-login.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* login when the user picks that provider).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { pollingCloudLogin } from "./cloud-login-poll.js";
|
|
9
10
|
import { randomBytes } from "crypto";
|
|
10
11
|
import { createServer } from "http";
|
|
11
12
|
import type { AddressInfo } from "net";
|
|
@@ -27,7 +28,8 @@ import {
|
|
|
27
28
|
readCloudToken,
|
|
28
29
|
saveCloudToken,
|
|
29
30
|
} from "./cloud-client.js";
|
|
30
|
-
import { fetchAssistantIngressUrl
|
|
31
|
+
import { fetchAssistantIngressUrl } from "./upgrade-lifecycle.js";
|
|
32
|
+
import { fetchCurrentVersion } from "./http-client.js";
|
|
31
33
|
import {
|
|
32
34
|
CALLBACK_PATH,
|
|
33
35
|
buildAuthorizeUrl,
|
|
@@ -37,7 +39,7 @@ import {
|
|
|
37
39
|
generatePkcePair,
|
|
38
40
|
} from "./workos-pkce.js";
|
|
39
41
|
|
|
40
|
-
const LOGIN_TIMEOUT_MS =
|
|
42
|
+
const LOGIN_TIMEOUT_MS = 300_000;
|
|
41
43
|
|
|
42
44
|
function escapeHtml(s: string): string {
|
|
43
45
|
return s
|
|
@@ -258,6 +260,7 @@ export function startLoopbackListener(
|
|
|
258
260
|
|
|
259
261
|
/** App-held WorkOS PKCE login */
|
|
260
262
|
export async function workosPkceLogin(cloudUrl: string): Promise<string> {
|
|
263
|
+
if (!process.stdin.isTTY) return pollingCloudLogin(cloudUrl);
|
|
261
264
|
const clientId = await fetchWorkosClientId(cloudUrl);
|
|
262
265
|
const { verifier, challenge } = generatePkcePair();
|
|
263
266
|
const state = randomBytes(32).toString("hex");
|