@mycadet/cli 0.12.6-dev.202610082233.54244ee → 0.12.7-dev.202610102207.24bf931
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/eslint.config.mjs +7 -0
- package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
- package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
- package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
- package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
- package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
- package/package.json +5 -4
- package/src/__tests__/assistant-config.test.ts +30 -28
- package/src/__tests__/backup.test.ts +40 -45
- package/src/__tests__/connect-import.test.ts +55 -39
- package/src/__tests__/debug.test.ts +32 -32
- package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
- package/src/__tests__/flags.test.ts +2 -2
- package/src/__tests__/guardian-token.test.ts +149 -56
- package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
- package/src/__tests__/helpers/guardian-token.ts +22 -0
- package/src/__tests__/helpers/http.ts +10 -0
- package/src/__tests__/ingress-config.test.ts +2 -2
- package/src/__tests__/login-loopback.test.ts +1 -1
- package/src/__tests__/nginx-ingress.test.ts +80 -88
- package/src/__tests__/pair.test.ts +128 -121
- package/src/__tests__/platform-client.test.ts +2 -2
- package/src/__tests__/{recover.test.ts → restore.test.ts} +118 -24
- package/src/__tests__/retire.test.ts +5 -3
- package/src/__tests__/signing-key-continuity.test.ts +261 -0
- package/src/__tests__/sleep.test.ts +202 -19
- package/src/__tests__/tailscale-tunnel.test.ts +1 -1
- package/src/__tests__/teleport.test.ts +55 -71
- package/src/__tests__/tunnel.test.ts +135 -154
- package/src/__tests__/uninstall.test.ts +180 -0
- package/src/__tests__/upgrade-local.test.ts +16 -3
- package/src/__tests__/use.test.ts +2 -2
- package/src/__tests__/wake-error.test.ts +76 -0
- package/src/__tests__/wake.test.ts +551 -68
- package/src/commands/backup.ts +6 -2
- package/src/commands/clean.ts +6 -2
- package/src/commands/client.ts +91 -41
- package/src/commands/confirm.ts +6 -2
- package/src/commands/connect.ts +6 -2
- package/src/commands/debug.ts +6 -2
- package/src/commands/devices.ts +6 -2
- package/src/commands/events.ts +6 -2
- package/src/commands/exec.ts +50 -12
- package/src/commands/flags.ts +6 -2
- package/src/commands/gateway.ts +6 -2
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +26 -356
- package/src/commands/logs.ts +6 -2
- package/src/commands/message.ts +6 -2
- package/src/commands/pair.ts +6 -2
- package/src/commands/ps.ts +7 -2
- package/src/commands/restart.ts +30 -0
- package/src/commands/restore.ts +130 -8
- package/src/commands/retire.ts +6 -2
- package/src/commands/rollback.ts +15 -17
- package/src/commands/setup.ts +76 -9
- package/src/commands/sleep.ts +131 -31
- package/src/commands/teleport.ts +6 -2
- package/src/commands/terminal.ts +157 -13
- package/src/commands/tunnel.ts +6 -2
- package/src/commands/uninstall.ts +276 -0
- package/src/commands/unpair.ts +6 -2
- package/src/commands/upgrade.ts +15 -27
- package/src/commands/use.ts +6 -2
- package/src/commands/voice.ts +6 -2
- package/src/commands/wake.ts +222 -70
- package/src/components/DefaultMainScreen.tsx +250 -36
- package/src/index.ts +26 -64
- package/src/lib/assistant-config.ts +28 -22
- package/src/lib/backup-ops.ts +3 -4
- package/src/lib/cli-error.ts +5 -0
- package/src/lib/client-identity.ts +8 -10
- package/src/lib/cloud-login.ts +422 -0
- package/src/lib/color.ts +16 -0
- package/src/lib/default-main-screen-formatting.ts +32 -8
- package/src/lib/docker.ts +107 -6
- package/src/lib/environments/__tests__/paths.test.ts +9 -11
- package/src/lib/environments/paths.ts +2 -20
- package/src/lib/guardian-token.ts +104 -18
- package/src/lib/hatch-local.ts +60 -13
- package/src/lib/health-check.ts +24 -15
- package/src/lib/lifecycle-reporter.ts +9 -1
- package/src/lib/local-hatch-lock.ts +19 -27
- package/src/lib/local-runtime-client.ts +25 -14
- package/src/lib/local.ts +17 -43
- package/src/lib/nginx-ingress.ts +9 -3
- package/src/lib/ngrok.ts +48 -16
- package/src/lib/platform-client.ts +141 -72
- package/src/lib/retire-archive.ts +4 -4
- package/src/lib/retire-local.ts +2 -25
- package/src/lib/teleport-backup.ts +23 -9
- package/src/lib/terminal-client.ts +21 -9
- package/src/lib/terminal-session.ts +4 -4
- package/src/lib/tunnel-edge.ts +11 -13
- package/src/lib/upgrade-lifecycle.ts +16 -18
- package/src/lib/workos-pkce.ts +52 -21
- package/src/commands/recover.ts +0 -126
- package/src/commands/roadmap.ts +0 -455
- package/src/commands/ssh.ts +0 -140
- package/src/commands/workflows.ts +0 -301
package/eslint.config.mjs
CHANGED
|
@@ -10,6 +10,13 @@ const eslintConfig = defineConfig([
|
|
|
10
10
|
"error",
|
|
11
11
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
12
12
|
],
|
|
13
|
+
// Ban `as` casts (the platform repo's rule) at warning level: the
|
|
14
|
+
// lint script pins --max-warnings to the current count and each PR
|
|
15
|
+
// chips away at it — prefer zod schemas or `in`-narrowing.
|
|
16
|
+
"@typescript-eslint/consistent-type-assertions": [
|
|
17
|
+
"warn",
|
|
18
|
+
{ assertionStyle: "never" },
|
|
19
|
+
],
|
|
13
20
|
},
|
|
14
21
|
},
|
|
15
22
|
]);
|
|
@@ -117,8 +117,7 @@ describe("path resolvers honor the persisted default", () => {
|
|
|
117
117
|
CADET_ENVIRONMENT: "production",
|
|
118
118
|
};
|
|
119
119
|
expect(resolveLockfilePaths(env)).toEqual([
|
|
120
|
-
path.join(
|
|
121
|
-
path.join(os.homedir(), ".cadet.lockfile.json"),
|
|
120
|
+
path.join(configHome, "cadet", "lockfile.json"),
|
|
122
121
|
]);
|
|
123
122
|
});
|
|
124
123
|
});
|
|
@@ -162,8 +161,6 @@ describe("Windows path resolution", () => {
|
|
|
162
161
|
const productionEnv = { CADET_ENVIRONMENT: "production" };
|
|
163
162
|
expect(resolveLockfilePaths(productionEnv, options)).toEqual([
|
|
164
163
|
"C:\\Users\\Example\\AppData\\Roaming\\cadet\\lockfile.json",
|
|
165
|
-
"C:\\Users\\Example\\.cadet.lock.json",
|
|
166
|
-
"C:\\Users\\Example\\.cadet.lockfile.json",
|
|
167
164
|
]);
|
|
168
165
|
expect(defaultEnvironmentFilePaths(env, options)).toEqual([
|
|
169
166
|
"C:\\Users\\Example\\AppData\\Roaming\\cadet\\environment",
|
|
@@ -52,31 +52,26 @@ export function resolveLockfilePaths(
|
|
|
52
52
|
if ((options.platform ?? process.platform) === "win32") {
|
|
53
53
|
const canonicalDir = lockfileDir ?? resolveConfigDir(env, options);
|
|
54
54
|
const canonical = joinLocalPath(options, canonicalDir, "lockfile.json");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
? [".cadet.lock.json", ".cadet.lockfile.json"].map((name) =>
|
|
59
|
-
joinLocalPath(options, legacyDir, name),
|
|
60
|
-
)
|
|
61
|
-
: [
|
|
62
|
-
joinLocalPath(
|
|
63
|
-
options,
|
|
64
|
-
lockfileDir ?? resolveConfigDirPaths(env, options).at(-1)!,
|
|
65
|
-
"lockfile.json",
|
|
66
|
-
),
|
|
67
|
-
];
|
|
55
|
+
if (cadetEnv === PRODUCTION_ENVIRONMENT_NAME) {
|
|
56
|
+
return [canonical];
|
|
57
|
+
}
|
|
68
58
|
return [
|
|
69
59
|
canonical,
|
|
70
|
-
|
|
60
|
+
joinLocalPath(
|
|
61
|
+
options,
|
|
62
|
+
lockfileDir ?? resolveConfigDirPaths(env, options).at(-1)!,
|
|
63
|
+
"lockfile.json",
|
|
64
|
+
),
|
|
71
65
|
];
|
|
72
66
|
}
|
|
73
67
|
|
|
74
68
|
if (cadetEnv === PRODUCTION_ENVIRONMENT_NAME) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
// Canonical: the XDG config dir, where every other environment keeps
|
|
70
|
+
// its lockfile. Installs that predate the move are handled by the
|
|
71
|
+
// vellum → cadet migration, which moves the home-level file into
|
|
72
|
+
// place — no legacy candidates here.
|
|
73
|
+
const dir = lockfileDir ?? resolveConfigDir(env, options);
|
|
74
|
+
return [joinLocalPath(options, dir, "lockfile.json")];
|
|
80
75
|
}
|
|
81
76
|
|
|
82
77
|
const dir = lockfileDir ?? resolveConfigDir(env, options);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
const KeySchema = z.string().regex(/^[0-9a-f]{64}$/i);
|
|
7
|
+
const ResourcesSchema = z.object({
|
|
8
|
+
instanceDir: z.string().min(1),
|
|
9
|
+
signingKey: KeySchema.optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export class SigningKeyContinuityError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly code: "missing" | "invalid" | "unreadable" | "conflict",
|
|
15
|
+
) {
|
|
16
|
+
super(
|
|
17
|
+
`Cannot resume authentication: signing key ${code}. Preserve the existing authentication state and restore its original key; no replacement key was generated.`,
|
|
18
|
+
);
|
|
19
|
+
this.name = "SigningKeyContinuityError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve existing identity material. This operation never creates or rotates a key. */
|
|
24
|
+
export function resolveExistingLocalSigningKey(
|
|
25
|
+
resources: unknown,
|
|
26
|
+
stateRoot?: string,
|
|
27
|
+
): string {
|
|
28
|
+
const parsed = ResourcesSchema.safeParse(resources);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
throw new SigningKeyContinuityError("invalid");
|
|
31
|
+
}
|
|
32
|
+
const { instanceDir, signingKey } = parsed.data;
|
|
33
|
+
// The host registry is authoritative after provisioning or migration.
|
|
34
|
+
// Legacy files can be stale after an explicit identity repair.
|
|
35
|
+
if (signingKey) {
|
|
36
|
+
return signingKey.toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
const candidates: Buffer[] = [];
|
|
39
|
+
const root = stateRoot ?? instanceDir;
|
|
40
|
+
// Read-only migration sources, scoped to this instance rather than the user's home.
|
|
41
|
+
const legacyPaths = [
|
|
42
|
+
join(root, "gateway-security", "actor-token-signing-key"),
|
|
43
|
+
join(root, "workspace", "deprecated", "actor-token-signing-key"),
|
|
44
|
+
];
|
|
45
|
+
for (const keyPath of legacyPaths) {
|
|
46
|
+
let raw: Buffer;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(keyPath);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (
|
|
51
|
+
error &&
|
|
52
|
+
typeof error === "object" &&
|
|
53
|
+
"code" in error &&
|
|
54
|
+
error.code === "ENOENT"
|
|
55
|
+
) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw new SigningKeyContinuityError("unreadable");
|
|
59
|
+
}
|
|
60
|
+
if (raw.length !== 32) {
|
|
61
|
+
throw new SigningKeyContinuityError("invalid");
|
|
62
|
+
}
|
|
63
|
+
candidates.push(raw);
|
|
64
|
+
}
|
|
65
|
+
const key = candidates[0];
|
|
66
|
+
if (!key) {
|
|
67
|
+
throw new SigningKeyContinuityError("missing");
|
|
68
|
+
}
|
|
69
|
+
if (candidates.some((candidate) => !timingSafeEqual(candidate, key))) {
|
|
70
|
+
throw new SigningKeyContinuityError("conflict");
|
|
71
|
+
}
|
|
72
|
+
return key.toString("hex");
|
|
73
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
LockfileAssistantSchema,
|
|
5
6
|
parseLockfile,
|
|
6
7
|
resolveCloud,
|
|
7
8
|
type Lockfile,
|
|
@@ -207,7 +208,8 @@ export function stampLockfileAssistantOnboardedIfPresent(
|
|
|
207
208
|
lockfilePaths,
|
|
208
209
|
assistantId,
|
|
209
210
|
{ onboardedAt },
|
|
210
|
-
(entry) =>
|
|
211
|
+
(entry) =>
|
|
212
|
+
typeof entry.onboardedAt === "string" && entry.onboardedAt !== "",
|
|
211
213
|
);
|
|
212
214
|
}
|
|
213
215
|
|
|
@@ -289,6 +291,10 @@ export function upsertLockfileAssistant(
|
|
|
289
291
|
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
290
292
|
}
|
|
291
293
|
|
|
294
|
+
const RendererAssistantUpdateSchema = LockfileAssistantSchema.omit({
|
|
295
|
+
resources: true,
|
|
296
|
+
});
|
|
297
|
+
|
|
292
298
|
const PAIRED_LOCKFILE_WRITE_ERROR =
|
|
293
299
|
"Paired assistant identity can only be changed through the connect flow";
|
|
294
300
|
|
|
@@ -296,6 +302,7 @@ const PAIRED_LOCKFILE_WRITE_ERROR =
|
|
|
296
302
|
* Apply a renderer-originated lockfile upsert without allowing it to create,
|
|
297
303
|
* retarget, or reclassify a paired assistant. Existing paired entries may
|
|
298
304
|
* still update non-security fields and become the active assistant.
|
|
305
|
+
* Resources and secrets are host-owned and never accepted from a renderer.
|
|
299
306
|
*/
|
|
300
307
|
export function upsertRendererLockfileAssistant(
|
|
301
308
|
lockfilePaths: string[],
|
|
@@ -309,7 +316,11 @@ export function upsertRendererLockfileAssistant(
|
|
|
309
316
|
// Lock spans the paired-guard read and the upsert (which reenters) so the
|
|
310
317
|
// guard cannot be judged against a snapshot another writer replaces.
|
|
311
318
|
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
312
|
-
const
|
|
319
|
+
const read = readRawLockfileStrict(lockfilePaths);
|
|
320
|
+
if (!read.ok) {
|
|
321
|
+
return { ok: false, status: 409, error: read.error };
|
|
322
|
+
}
|
|
323
|
+
const lockfile = read.lockfile;
|
|
313
324
|
const assistants = Array.isArray(lockfile.assistants)
|
|
314
325
|
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
315
326
|
: [];
|
|
@@ -335,7 +346,11 @@ export function upsertRendererLockfileAssistant(
|
|
|
335
346
|
return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
|
|
336
347
|
}
|
|
337
348
|
|
|
338
|
-
|
|
349
|
+
const update = RendererAssistantUpdateSchema.safeParse(assistant);
|
|
350
|
+
if (!update.success) {
|
|
351
|
+
return { ok: false, status: 400, error: "Invalid assistant metadata" };
|
|
352
|
+
}
|
|
353
|
+
return upsertLockfileAssistant(lockfilePaths, update.data, activeAssistant);
|
|
339
354
|
});
|
|
340
355
|
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
341
356
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mycadet/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.7-dev.202610102207.24bf931",
|
|
4
4
|
"description": "CLI tools for mycadet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"./src/commands/*": "./src/commands/*.ts"
|
|
16
16
|
},
|
|
17
17
|
"bin": {
|
|
18
|
-
"cadet": "./src/index.ts"
|
|
18
|
+
"cadet-dev": "./src/index.ts"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"format": "prettier --write .",
|
|
22
22
|
"format:check": "prettier --check .",
|
|
23
|
-
"lint": "eslint",
|
|
23
|
+
"lint": "eslint --max-warnings 326",
|
|
24
24
|
"lint:unused": "knip --include files,dependencies,unlisted",
|
|
25
25
|
"compile:check": "bun build --compile src/index.ts --outfile /dev/null",
|
|
26
26
|
"test": "bun run scripts/run-tests.ts",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"nanoid": "5.1.7",
|
|
41
41
|
"qrcode-terminal": "0.12.0",
|
|
42
42
|
"react": "19.2.6",
|
|
43
|
-
"react-devtools-core": "7.0.1"
|
|
43
|
+
"react-devtools-core": "7.0.1",
|
|
44
|
+
"zod": "4.3.6"
|
|
44
45
|
},
|
|
45
46
|
"bundledDependencies": [
|
|
46
47
|
"@mycadet/assistant-api",
|
|
@@ -46,7 +46,7 @@ afterAll(() => {
|
|
|
46
46
|
|
|
47
47
|
function writeLockfile(data: unknown): void {
|
|
48
48
|
writeFileSync(
|
|
49
|
-
join(testDir, ".
|
|
49
|
+
join(testDir, "lockfile.json"),
|
|
50
50
|
JSON.stringify(data, null, 2),
|
|
51
51
|
);
|
|
52
52
|
}
|
|
@@ -64,14 +64,9 @@ const makeEntry = (
|
|
|
64
64
|
|
|
65
65
|
describe("assistant-config", () => {
|
|
66
66
|
beforeEach(() => {
|
|
67
|
-
// Reset lockfile between tests
|
|
67
|
+
// Reset the canonical lockfile between tests
|
|
68
68
|
try {
|
|
69
|
-
rmSync(join(testDir, ".
|
|
70
|
-
} catch {
|
|
71
|
-
// file may not exist
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
rmSync(join(testDir, ".cadet.lockfile.json"));
|
|
69
|
+
rmSync(join(testDir, "lockfile.json"));
|
|
75
70
|
} catch {
|
|
76
71
|
// file may not exist
|
|
77
72
|
}
|
|
@@ -82,7 +77,17 @@ describe("assistant-config", () => {
|
|
|
82
77
|
});
|
|
83
78
|
|
|
84
79
|
test("loadAllAssistants returns empty array for malformed lockfile", () => {
|
|
85
|
-
writeFileSync(join(testDir, ".
|
|
80
|
+
writeFileSync(join(testDir, "lockfile.json"), "not json");
|
|
81
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("legacy home-level lockfile names are no longer read (the migration moves them into place)", () => {
|
|
85
|
+
// A pre-move home-level file alone must NOT be picked up — the
|
|
86
|
+
// migration tooling is what moves it to the canonical config-dir path.
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(testDir, ".cadet.lock.json"),
|
|
89
|
+
JSON.stringify({ assistants: [makeEntry("legacy-1")] }, null, 2),
|
|
90
|
+
);
|
|
86
91
|
expect(loadAllAssistants()).toEqual([]);
|
|
87
92
|
});
|
|
88
93
|
|
|
@@ -103,11 +108,11 @@ describe("assistant-config", () => {
|
|
|
103
108
|
saveAssistantEntry(makeEntry("locked-1"));
|
|
104
109
|
expect(loadAllAssistants()).toHaveLength(1);
|
|
105
110
|
// The shared `${lockfilePath}.lock` key hosts contend on is released.
|
|
106
|
-
expect(existsSync(join(testDir, ".
|
|
111
|
+
expect(existsSync(join(testDir, "lockfile.json.lock"))).toBe(false);
|
|
107
112
|
});
|
|
108
113
|
|
|
109
114
|
test("saveAssistantEntry breaks a stale lock and still lands the write", () => {
|
|
110
|
-
const lockDir = join(testDir, ".
|
|
115
|
+
const lockDir = join(testDir, "lockfile.json.lock");
|
|
111
116
|
mkdirSync(lockDir);
|
|
112
117
|
const past = new Date(Date.now() - 60_000);
|
|
113
118
|
utimesSync(lockDir, past, past);
|
|
@@ -118,7 +123,7 @@ describe("assistant-config", () => {
|
|
|
118
123
|
});
|
|
119
124
|
|
|
120
125
|
test("saveAssistantEntry fails fast without writing when another holder is fresh", () => {
|
|
121
|
-
const lockDir = join(testDir, ".
|
|
126
|
+
const lockDir = join(testDir, "lockfile.json.lock");
|
|
122
127
|
mkdirSync(lockDir);
|
|
123
128
|
try {
|
|
124
129
|
const start = Date.now();
|
|
@@ -615,9 +620,11 @@ describe("legacy migration via loadAllAssistants", () => {
|
|
|
615
620
|
);
|
|
616
621
|
expect(all[0].resources!.gatewayPort).toBe(7830);
|
|
617
622
|
|
|
618
|
-
// AND the lockfile on disk should reflect the migration
|
|
623
|
+
// AND the lockfile on disk should reflect the migration — persisted to
|
|
624
|
+
// the canonical config-dir path (the legacy seed file is left in place
|
|
625
|
+
// as a read-only fallback).
|
|
619
626
|
const rawDisk = JSON.parse(
|
|
620
|
-
readFileSync(join(testDir, ".
|
|
627
|
+
readFileSync(join(testDir, "lockfile.json"), "utf-8"),
|
|
621
628
|
);
|
|
622
629
|
const diskEntry = rawDisk.assistants[0];
|
|
623
630
|
expect(diskEntry.baseDataDir).toBeUndefined();
|
|
@@ -715,21 +722,17 @@ describe("env-scoped lockfile and migration", () => {
|
|
|
715
722
|
}
|
|
716
723
|
});
|
|
717
724
|
|
|
718
|
-
test("production
|
|
725
|
+
test("production writes land at the config-dir lockfile.json, never the legacy home-level name", () => {
|
|
719
726
|
// With CADET_ENVIRONMENT unset, the env package resolves production,
|
|
720
|
-
// whose canonical lockfile
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
// the production convention (not the non-prod "lockfile.json").
|
|
727
|
+
// whose canonical lockfile is the config-dir `lockfile.json` — the same
|
|
728
|
+
// convention every environment uses. The home-level `.cadet.lock.json`
|
|
729
|
+
// name is a read-only fallback and is never (re)created by writes.
|
|
724
730
|
const prevEnv = process.env.CADET_ENVIRONMENT;
|
|
725
731
|
delete process.env.CADET_ENVIRONMENT;
|
|
726
732
|
try {
|
|
727
733
|
// Clear any existing lockfile from earlier tests
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
} catch {
|
|
731
|
-
// ignore
|
|
732
|
-
}
|
|
734
|
+
rmSync(join(testDir, "lockfile.json"), { force: true });
|
|
735
|
+
rmSync(join(testDir, ".cadet.lock.json"), { force: true });
|
|
733
736
|
|
|
734
737
|
saveAssistantEntry({
|
|
735
738
|
assistantId: "prod-bot",
|
|
@@ -737,12 +740,10 @@ describe("env-scoped lockfile and migration", () => {
|
|
|
737
740
|
cloud: "local",
|
|
738
741
|
});
|
|
739
742
|
|
|
740
|
-
|
|
741
|
-
// filename — rather than testDir/lockfile.json.
|
|
742
|
-
const prodPath = join(testDir, ".cadet.lock.json");
|
|
743
|
-
const raw = JSON.parse(readFileSync(prodPath, "utf-8"));
|
|
743
|
+
const raw = JSON.parse(readFileSync(join(testDir, "lockfile.json"), "utf-8"));
|
|
744
744
|
expect(raw.assistants).toHaveLength(1);
|
|
745
745
|
expect(raw.assistants[0].assistantId).toBe("prod-bot");
|
|
746
|
+
expect(existsSync(join(testDir, ".cadet.lock.json"))).toBe(false);
|
|
746
747
|
} finally {
|
|
747
748
|
if (prevEnv !== undefined) {
|
|
748
749
|
process.env.CADET_ENVIRONMENT = prevEnv;
|
|
@@ -765,6 +766,7 @@ describe("cross-environment hint on resolution failure", () => {
|
|
|
765
766
|
// copy is deterministic. The current-env lockfile is emptied so resolution
|
|
766
767
|
// genuinely finds nothing.
|
|
767
768
|
process.env.CADET_ENVIRONMENT = "production";
|
|
769
|
+
rmSync(join(testDir, "lockfile.json"), { force: true });
|
|
768
770
|
rmSync(join(testDir, ".cadet.lock.json"), { force: true });
|
|
769
771
|
rmSync(join(testDir, ".cadet.lockfile.json"), { force: true });
|
|
770
772
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
describe,
|
|
6
6
|
expect,
|
|
7
7
|
mock,
|
|
8
|
+
type Mock,
|
|
8
9
|
spyOn,
|
|
9
10
|
test,
|
|
10
11
|
} from "bun:test";
|
|
@@ -79,6 +80,7 @@ const localRuntimePollJobStatusMock = spyOn(
|
|
|
79
80
|
result: { manifest_sha256: "abc123def456" },
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
/** A complete-but-fake guardian token; the SUT only reads the fields it sets. */
|
|
82
84
|
// Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
|
|
83
85
|
// it here, but the spies need to exist so the module under test can import
|
|
84
86
|
// them without surprises. Saved to variables so afterAll can restore them —
|
|
@@ -87,31 +89,25 @@ const localRuntimePollJobStatusMock = spyOn(
|
|
|
87
89
|
const loadGuardianTokenSpy = spyOn(
|
|
88
90
|
guardianToken,
|
|
89
91
|
"loadGuardianToken",
|
|
90
|
-
).mockReturnValue({
|
|
91
|
-
accessToken: "local-token",
|
|
92
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
93
|
-
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
92
|
+
).mockReturnValue(makeGuardianTokenData({ accessToken: "local-token" }));
|
|
94
93
|
const leaseGuardianTokenSpy = spyOn(
|
|
95
94
|
guardianToken,
|
|
96
95
|
"leaseGuardianToken",
|
|
97
|
-
).mockResolvedValue({
|
|
98
|
-
accessToken: "leased-token",
|
|
99
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
100
|
-
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
96
|
+
).mockResolvedValue(makeGuardianTokenData({ accessToken: "leased-token" }));
|
|
101
97
|
|
|
102
98
|
const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
|
|
103
99
|
"/tmp/backups-default",
|
|
104
100
|
);
|
|
105
101
|
|
|
106
102
|
const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
|
|
107
|
-
(
|
|
103
|
+
() => undefined,
|
|
108
104
|
);
|
|
109
105
|
const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
|
|
110
106
|
() => undefined,
|
|
111
107
|
);
|
|
112
108
|
|
|
113
109
|
let originalFetch: typeof globalThis.fetch;
|
|
114
|
-
let exitMock:
|
|
110
|
+
let exitMock: Mock<(code?: number | string | undefined) => never>;
|
|
115
111
|
|
|
116
112
|
const CADET_ENTRY = {
|
|
117
113
|
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
@@ -126,10 +122,10 @@ function setArgv(...rest: string[]) {
|
|
|
126
122
|
|
|
127
123
|
beforeEach(() => {
|
|
128
124
|
originalFetch = globalThis.fetch;
|
|
129
|
-
exitMock = mock((code?: number) => {
|
|
125
|
+
exitMock = mock((code?: number | string | undefined): never => {
|
|
130
126
|
throw new Error(`process.exit:${code}`);
|
|
131
127
|
});
|
|
132
|
-
process.exit = exitMock
|
|
128
|
+
process.exit = exitMock;
|
|
133
129
|
|
|
134
130
|
findAssistantByNameMock.mockReset();
|
|
135
131
|
findAssistantByNameMock.mockReturnValue(null);
|
|
@@ -162,17 +158,15 @@ beforeEach(() => {
|
|
|
162
158
|
getBackupsDirMock.mockReset();
|
|
163
159
|
getBackupsDirMock.mockReturnValue("/tmp/backups-default");
|
|
164
160
|
loadGuardianTokenSpy.mockReset();
|
|
165
|
-
loadGuardianTokenSpy.mockReturnValue(
|
|
166
|
-
accessToken: "local-token",
|
|
167
|
-
|
|
168
|
-
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
161
|
+
loadGuardianTokenSpy.mockReturnValue(
|
|
162
|
+
makeGuardianTokenData({ accessToken: "local-token" }),
|
|
163
|
+
);
|
|
169
164
|
leaseGuardianTokenSpy.mockReset();
|
|
170
|
-
leaseGuardianTokenSpy.mockResolvedValue(
|
|
171
|
-
accessToken: "leased-token",
|
|
172
|
-
|
|
173
|
-
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
165
|
+
leaseGuardianTokenSpy.mockResolvedValue(
|
|
166
|
+
makeGuardianTokenData({ accessToken: "leased-token" }),
|
|
167
|
+
);
|
|
174
168
|
mkdirSyncMock.mockReset();
|
|
175
|
-
mkdirSyncMock.mockImplementation((
|
|
169
|
+
mkdirSyncMock.mockImplementation(() => undefined);
|
|
176
170
|
writeFileSyncMock.mockReset();
|
|
177
171
|
writeFileSyncMock.mockImplementation(() => undefined);
|
|
178
172
|
});
|
|
@@ -200,20 +194,26 @@ afterAll(() => {
|
|
|
200
194
|
});
|
|
201
195
|
|
|
202
196
|
import { backup } from "../commands/backup.js";
|
|
197
|
+
import { withPreconnect } from "./helpers/http.js";
|
|
198
|
+
import { makeGuardianTokenData } from "./helpers/guardian-token.js";
|
|
203
199
|
|
|
204
200
|
// ---------------------------------------------------------------------------
|
|
205
201
|
// Helper: simulated GCS download response
|
|
206
202
|
// ---------------------------------------------------------------------------
|
|
207
|
-
function mockGcsDownload(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
function mockGcsDownload(
|
|
204
|
+
body: Uint8Array<ArrayBuffer>,
|
|
205
|
+
ok = true,
|
|
206
|
+
status = 200,
|
|
207
|
+
) {
|
|
208
|
+
const gcsFetch = mock(async () => {
|
|
209
|
+
const responseBody: BodyInit = ok ? new Blob([body]) : "boom";
|
|
212
210
|
return new Response(responseBody, {
|
|
213
211
|
status,
|
|
214
212
|
statusText: ok ? "OK" : "Error",
|
|
215
213
|
});
|
|
216
|
-
})
|
|
214
|
+
});
|
|
215
|
+
globalThis.fetch = withPreconnect(gcsFetch);
|
|
216
|
+
return gcsFetch;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
describe("cadet backup <local>: guardian bootstrap secret", () => {
|
|
@@ -228,11 +228,9 @@ describe("cadet backup <local>: guardian bootstrap secret", () => {
|
|
|
228
228
|
loadGuardianTokenSpy.mockReturnValue(null);
|
|
229
229
|
setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
|
|
230
230
|
|
|
231
|
-
globalThis.fetch =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
});
|
|
235
|
-
}) as unknown as typeof globalThis.fetch;
|
|
231
|
+
globalThis.fetch = withPreconnect(
|
|
232
|
+
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
|
233
|
+
);
|
|
236
234
|
|
|
237
235
|
await backup();
|
|
238
236
|
|
|
@@ -294,9 +292,9 @@ describe("cadet backup: --export-timeout flag", () => {
|
|
|
294
292
|
"/tmp/local-backup.vbundle",
|
|
295
293
|
);
|
|
296
294
|
|
|
297
|
-
globalThis.fetch =
|
|
298
|
-
|
|
299
|
-
|
|
295
|
+
globalThis.fetch = withPreconnect(
|
|
296
|
+
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
|
297
|
+
);
|
|
300
298
|
|
|
301
299
|
await backup();
|
|
302
300
|
|
|
@@ -313,7 +311,7 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
|
|
|
313
311
|
setArgv("my-platform");
|
|
314
312
|
|
|
315
313
|
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
316
|
-
mockGcsDownload(bytes);
|
|
314
|
+
const gcsFetch = mockGcsDownload(bytes);
|
|
317
315
|
|
|
318
316
|
await backup();
|
|
319
317
|
|
|
@@ -366,13 +364,12 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
|
|
|
366
364
|
"https://cloud.mycadet.ai",
|
|
367
365
|
);
|
|
368
366
|
const downloadCall = platformRequestSignedUrlMock.mock.calls.find(
|
|
369
|
-
(c) =>
|
|
367
|
+
(c) => c[0].operation === "download",
|
|
370
368
|
);
|
|
371
369
|
expect(downloadCall).toBeDefined();
|
|
372
370
|
expect(downloadCall![0]).not.toHaveProperty("targetRuntimeVersion");
|
|
373
371
|
|
|
374
372
|
// GCS fetch went directly to the signed download URL with no auth.
|
|
375
|
-
const gcsFetch = globalThis.fetch as unknown as ReturnType<typeof mock>;
|
|
376
373
|
expect(gcsFetch).toHaveBeenCalledWith(
|
|
377
374
|
"https://storage.googleapis.com/bucket/signed-download",
|
|
378
375
|
);
|
|
@@ -382,7 +379,7 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
|
|
|
382
379
|
const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
|
|
383
380
|
expect(written).toEqual(bytes);
|
|
384
381
|
expect(typeof outputPath).toBe("string");
|
|
385
|
-
expect(outputPath
|
|
382
|
+
expect(outputPath).toMatch(
|
|
386
383
|
/\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
|
|
387
384
|
);
|
|
388
385
|
expect(mkdirSyncMock).toHaveBeenCalled();
|
|
@@ -411,10 +408,8 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
|
|
|
411
408
|
await backup();
|
|
412
409
|
|
|
413
410
|
const [outputPath] = writeFileSyncMock.mock.calls[0]!;
|
|
414
|
-
expect(outputPath
|
|
415
|
-
|
|
416
|
-
);
|
|
417
|
-
expect(outputPath as string).toMatch(/\.vbundle$/);
|
|
411
|
+
expect(outputPath).toMatch(/^\/tmp\/backups-default\/my-platform-/);
|
|
412
|
+
expect(outputPath).toMatch(/\.vbundle$/);
|
|
418
413
|
});
|
|
419
414
|
|
|
420
415
|
test("signed-URL requests target entry.runtimeUrl, not getPlatformUrl() — regression for staging/dev assistants", async () => {
|
|
@@ -502,10 +497,10 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
|
|
|
502
497
|
// `platformToken` parameter instead of `exportPlatformToken`, a
|
|
503
498
|
// future poll-loop refresh would silently break this invariant.
|
|
504
499
|
const uploadCallToken = platformRequestSignedUrlMock.mock.calls.find(
|
|
505
|
-
(c) =>
|
|
500
|
+
(c) => c[0].operation === "upload",
|
|
506
501
|
)![1];
|
|
507
502
|
const downloadCallToken = platformRequestSignedUrlMock.mock.calls.find(
|
|
508
|
-
(c) =>
|
|
503
|
+
(c) => c[0].operation === "download",
|
|
509
504
|
)![1];
|
|
510
505
|
expect(downloadCallToken).toBe(uploadCallToken);
|
|
511
506
|
const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];
|