From b62dc1af33db05736797b3944e90a18576a24934 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:50:53 -0800 Subject: [PATCH] feat: ban `as` type assertions, add runtime schema validation with valibot (#1775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve all biome lint warnings across the codebase - Replace all noExplicitAny with proper types (unknown, Record) - Fix useBlockStatements in picker.ts (braceless if) - Fix useNumberNamespace in picker.ts (parseInt → Number.parseInt) - Codebase now passes biome lint with 0 errors and 0 warnings Co-Authored-By: Claude Opus 4.6 (1M context) * feat: ban `as` type assertions, add runtime schema validation with valibot Replace all ~170 unsafe `as` type assertions across the entire codebase (production + tests) with runtime-validated alternatives: - Add GritQL biome plugin (`no-type-assertion.grit`) that bans all `as` casts except `as const` - Add valibot for schema-validated JSON parsing (`parseJsonWith`) - Add shared utilities: `parse.ts` (schema parsing), `type-guards.ts` - Replace `as` casts in all 5 cloud modules (aws, daytona, hetzner, digitalocean, fly) with valibot schemas + type guards - Replace `as` casts in shared modules (manifest, update-check, oauth, commands, history, ui) - Replace `as any` in all 26 test files with proper `new Response()` mocks and typed variables - Add 13 tests for parseJsonWith/parseJsonRaw - Add "Embrace Bold Changes" culture rule to CLAUDE.md - Bump version 0.6.19 → 0.7.0 1859 tests pass, 0 lint errors across 95 files, bundle +6KB from valibot. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move GritQL plugin into cli/lint/ directory Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: lab <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 77 ++++++ cli/biome.json | 1 + cli/bun.lock | 3 + cli/lint/no-type-assertion.grit | 10 + cli/package.json | 5 +- cli/src/__tests__/clear-history.test.ts | 12 +- cli/src/__tests__/cloud-info.test.ts | 12 +- cli/src/__tests__/cmd-interactive.test.ts | 167 ++++-------- cli/src/__tests__/cmd-listing-output.test.ts | 8 +- cli/src/__tests__/cmdlast.test.ts | 64 +---- cli/src/__tests__/cmdlist-integration.test.ts | 107 ++------ cli/src/__tests__/cmdrun-happy-path.test.ts | 46 +--- cli/src/__tests__/commands-cloud-info.test.ts | 44 ++-- cli/src/__tests__/commands-display.test.ts | 95 +++---- .../__tests__/commands-error-paths.test.ts | 38 +-- .../commands-name-suggestions.test.ts | 10 +- cli/src/__tests__/commands-output.test.ts | 6 +- .../__tests__/commands-resolve-run.test.ts | 17 +- .../__tests__/commands-swap-resolve.test.ts | 96 ++----- .../commands-update-download.test.ts | 238 ++++-------------- .../__tests__/download-and-failure.test.ts | 120 ++------- cli/src/__tests__/fly.test.ts | 2 +- .../manifest-cache-lifecycle.test.ts | 127 +++------- cli/src/__tests__/manifest-helpers.test.ts | 46 +--- .../__tests__/manifest-type-contracts.test.ts | 76 +++--- cli/src/__tests__/manifest-validation.test.ts | 66 +---- cli/src/__tests__/manifest.test.ts | 38 +-- cli/src/__tests__/parse.test.ts | 81 ++++++ .../__tests__/preflight-credentials.test.ts | 5 +- .../run-path-credential-display.test.ts | 10 +- cli/src/__tests__/test-helpers.ts | 24 +- cli/src/__tests__/update-check.test.ts | 70 +----- cli/src/aws/aws.ts | 52 ++-- cli/src/commands.ts | 15 +- cli/src/daytona/daytona.ts | 56 +++-- cli/src/digitalocean/digitalocean.ts | 67 +++-- cli/src/fly/agents.ts | 8 +- cli/src/fly/fly.ts | 130 ++++++---- cli/src/hetzner/hetzner.ts | 82 ++++-- cli/src/history.ts | 15 +- cli/src/manifest.ts | 22 +- cli/src/picker.ts | 6 +- cli/src/shared/oauth.ts | 10 +- cli/src/shared/parse.ts | 31 +++ cli/src/shared/type-guards.ts | 17 ++ cli/src/shared/ui.ts | 2 +- cli/src/update-check.ts | 20 +- 47 files changed, 954 insertions(+), 1300 deletions(-) create mode 100644 cli/lint/no-type-assertion.grit create mode 100644 cli/src/__tests__/parse.test.ts create mode 100644 cli/src/shared/parse.ts create mode 100644 cli/src/shared/type-guards.ts diff --git a/CLAUDE.md b/CLAUDE.md index bdd435a4..1c317141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,16 @@ # Spawn +## Culture: Embrace Bold Changes + +**This is mandatory.** When a change is right, helps the user, and is tasteful — make it. Do not shy away from large-scope work. + +- **Do NOT refuse work because it's "too big."** If the user asks for a codebase-wide refactor touching 50+ files, do it. Parallelize with agents. Ship it. +- **Do NOT water down scope.** If the plan says "replace all 119 `as` casts across the codebase," you replace ALL 119, not 5 with a TODO for the rest. +- **Do NOT propose half-measures.** Don't suggest "we could start with just one module" when the user wants the whole thing. The whole thing is the point. +- **Parallelize aggressively.** Use the Task tool to spawn multiple agents working on independent files simultaneously. A 50-file change should NOT be sequential. +- **Fix tests too.** If a new lint rule or code change breaks tests, fix every single test. Don't exclude test files from rules to avoid the work. +- **Verify exhaustively.** After a big change: full lint (0 errors), full test suite (0 failures), full build (succeeds). No partial checks. + Spawn is a matrix of **agents x clouds**. Every script provisions a cloud server, installs an agent, injects OpenRouter credentials, and drops the user into an interactive session. ## The Matrix @@ -185,6 +196,72 @@ All TypeScript code in `cli/src/` MUST use ESM (`import`/`export`): - For Node.js built-ins: `import fs from "fs"`, `import path from "path"`, etc. - For dynamic imports: `const mod = await import("./module.ts")` +## Type Safety — No `as` Type Assertions + +**`as` type assertions are banned in all TypeScript code (production AND tests).** This is enforced by a GritQL biome plugin (`cli/no-type-assertion.grit`). + +### Exemptions +- `as const` — allowed (compile-time only, no runtime risk) +- That's it. `as unknown` is also banned. + +### What to use instead + +**For API responses / parsed JSON — use valibot schema validation:** +```typescript +import * as v from "valibot"; +import { parseJsonWith } from "../shared/parse"; + +// Declare schemas at module top level, not inside functions +const UserSchema = v.object({ id: v.number(), name: v.string() }); + +// Returns typed data or null — no `as` needed +const user = parseJsonWith(responseText, UserSchema); +``` + +**For loose JSON objects (many optional fields):** +```typescript +const LooseObject = v.record(v.string(), v.unknown()); +function parseJson(text: string): Record | null { + return parseJsonWith(text, LooseObject); +} +``` + +**For narrowing `unknown` values — use type guards:** +```typescript +typeof val === "string" ? val : "" +typeof val === "number" ? val : 0 +Array.isArray(val) ? val : [] +``` + +**For array-of-objects narrowing:** +```typescript +function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) return []; + return val.filter((item): item is Record => + item !== null && typeof item === "object" && !Array.isArray(item)); +} +``` + +**For test mocks — use proper Response objects instead of `as any`:** +```typescript +// WRONG: global.fetch = mock(() => Promise.resolve({ ok: true, json: async () => data }) as any); +// RIGHT: +global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(data)))); +// For errors: +global.fetch = mock(() => Promise.resolve(new Response("Error", { status: 500 }))); +``` + +**For type literals — use `satisfies` or typed variables:** +```typescript +// WRONG: const config = { ... } as AgentConfig; +// RIGHT: const config: AgentConfig = { ... }; +// OR: const config = { ... } satisfies AgentConfig; +``` + +### Shared utilities +- `cli/src/shared/parse.ts` — `parseJsonWith(text, schema)` and `parseJsonRaw(text)` +- `cli/src/shared/type-guards.ts` — `isString`, `isNumber`, `hasStatus`, `hasMessage` + ## Testing - **NEVER use vitest** — use Bun's built-in test runner (`bun:test`) exclusively diff --git a/cli/biome.json b/cli/biome.json index 0cdd9760..6eb061bf 100644 --- a/cli/biome.json +++ b/cli/biome.json @@ -97,6 +97,7 @@ "bracketSameLine": false } }, + "plugins": ["./lint/no-type-assertion.grit"], "assist": { "enabled": false } diff --git a/cli/bun.lock b/cli/bun.lock index 88476561..6d7c1357 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@clack/prompts": "^1.0.0", "picocolors": "^1.1.1", + "valibot": "^1.2.0", }, "devDependencies": { "@biomejs/biome": "^2.4.3", @@ -48,5 +49,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, ""], "undici-types": ["undici-types@7.16.0", "", {}, ""], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], } } diff --git a/cli/lint/no-type-assertion.grit b/cli/lint/no-type-assertion.grit new file mode 100644 index 00000000..5f3602ca --- /dev/null +++ b/cli/lint/no-type-assertion.grit @@ -0,0 +1,10 @@ +language js(typescript) + +`$value as $type` as $expr where { + !$expr <: `$_ as const`, + register_diagnostic( + span = $expr, + message = "Type assertions (`as`) are banned. Use schema validation (parseJsonWith), type guards, or `satisfies` instead.", + severity = "error" + ) +} diff --git a/cli/package.json b/cli/package.json index 63f3680e..929a42c0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.6.20", + "version": "0.7.0", "type": "module", "bin": { "spawn": "cli.js" @@ -15,7 +15,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "picocolors": "^1.1.1" + "picocolors": "^1.1.1", + "valibot": "^1.2.0" }, "devDependencies": { "@biomejs/biome": "^2.4.3", diff --git a/cli/src/__tests__/clear-history.test.ts b/cli/src/__tests__/clear-history.test.ts index 70dd81aa..c49d7bdb 100644 --- a/cli/src/__tests__/clear-history.test.ts +++ b/cli/src/__tests__/clear-history.test.ts @@ -349,7 +349,7 @@ describe("cmdListClear", () => { it("should call log.info when no history exists", async () => { await cmdListClear(); expect(mockLogInfo).toHaveBeenCalledTimes(1); - const msg = mockLogInfo.mock.calls[0][0] as string; + const msg = String(mockLogInfo.mock.calls[0][0]); expect(msg).toContain("No spawn history to clear"); }); @@ -370,7 +370,7 @@ describe("cmdListClear", () => { await cmdListClear(); expect(mockLogSuccess).toHaveBeenCalledTimes(1); - const msg = mockLogSuccess.mock.calls[0][0] as string; + const msg = String(mockLogSuccess.mock.calls[0][0]); expect(msg).toContain("Cleared 2 spawn records from history"); }); @@ -386,7 +386,7 @@ describe("cmdListClear", () => { await cmdListClear(); expect(mockLogSuccess).toHaveBeenCalledTimes(1); - const msg = mockLogSuccess.mock.calls[0][0] as string; + const msg = String(mockLogSuccess.mock.calls[0][0]); expect(msg).toContain("Cleared 1 spawn record from history"); // Should NOT say "records" (plural) expect(msg).not.toContain("Cleared 1 spawn records"); @@ -412,7 +412,7 @@ describe("cmdListClear", () => { await cmdListClear(); expect(mockLogInfo).toHaveBeenCalledTimes(1); expect(mockLogSuccess).not.toHaveBeenCalled(); - const msg = mockLogInfo.mock.calls[0][0] as string; + const msg = String(mockLogInfo.mock.calls[0][0]); expect(msg).toContain("No spawn history to clear"); }); @@ -422,7 +422,7 @@ describe("cmdListClear", () => { await cmdListClear(); expect(mockLogInfo).toHaveBeenCalledTimes(1); expect(mockLogSuccess).not.toHaveBeenCalled(); - const msg = mockLogInfo.mock.calls[0][0] as string; + const msg = String(mockLogInfo.mock.calls[0][0]); expect(msg).toContain("No spawn history to clear"); }); @@ -439,7 +439,7 @@ describe("cmdListClear", () => { await cmdListClear(); expect(mockLogSuccess).toHaveBeenCalledTimes(1); - const msg = mockLogSuccess.mock.calls[0][0] as string; + const msg = String(mockLogSuccess.mock.calls[0][0]); expect(msg).toContain("Cleared 50 spawn records from history"); }); diff --git a/cli/src/__tests__/cloud-info.test.ts b/cli/src/__tests__/cloud-info.test.ts index ffe24f37..7f7e440c 100644 --- a/cli/src/__tests__/cloud-info.test.ts +++ b/cli/src/__tests__/cloud-info.test.ts @@ -124,19 +124,17 @@ describe("cmdCloudInfo", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); savedORKey = process.env.OPENROUTER_API_KEY; delete process.env.OPENROUTER_API_KEY; originalFetch = global.fetch; - global.fetch = mock(async () => ({ - ok: true, - json: async () => extendedManifest, - text: async () => JSON.stringify(extendedManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(extendedManifest)), + ); await loadManifest(true); }); diff --git a/cli/src/__tests__/cmd-interactive.test.ts b/cli/src/__tests__/cmd-interactive.test.ts index 4bff552c..69c171ee 100644 --- a/cli/src/__tests__/cmd-interactive.test.ts +++ b/cli/src/__tests__/cmd-interactive.test.ts @@ -95,18 +95,16 @@ describe("cmdInteractive", () => { selectReturnValues = []; isCancelValues = new Set(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; // Pre-load manifest - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(mockManifest)), + ); await loadManifest(true); }); @@ -220,11 +218,9 @@ describe("cmdInteractive", () => { }, }; - global.fetch = mock(async () => ({ - ok: true, - json: async () => noCloudManifest, - text: async () => JSON.stringify(noCloudManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noCloudManifest)), + ); await loadManifest(true); selectReturnValues = [ @@ -247,11 +243,9 @@ describe("cmdInteractive", () => { }, }; - global.fetch = mock(async () => ({ - ok: true, - json: async () => noCloudManifest, - text: async () => JSON.stringify(noCloudManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noCloudManifest)), + ); await loadManifest(true); selectReturnValues = [ @@ -280,11 +274,9 @@ describe("cmdInteractive", () => { }, }; - global.fetch = mock(async () => ({ - ok: true, - json: async () => noCloudManifest, - text: async () => JSON.stringify(noCloudManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noCloudManifest)), + ); await loadManifest(true); selectReturnValues = [ @@ -315,17 +307,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -343,17 +328,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -373,17 +351,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -402,17 +373,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -430,17 +394,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -468,17 +425,10 @@ describe("cmdInteractive", () => { fetchedUrls.push(url); } if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -497,19 +447,11 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } // Both primary and fallback fail - return { - ok: false, - status: 404, - text: async () => "Not Found", - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); await loadManifest(true); await expect(cmdInteractive()).rejects.toThrow("process.exit"); @@ -541,17 +483,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => credManifest, - text: async () => JSON.stringify(credManifest), - }; + return new Response(JSON.stringify(credManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -584,17 +519,10 @@ describe("cmdInteractive", () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => credManifest, - text: async () => JSON.stringify(credManifest), - }; + return new Response(JSON.stringify(credManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); @@ -640,17 +568,10 @@ describe("cmdInteractive", () => { fetchedUrls.push(url); } if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => credManifest, - text: async () => JSON.stringify(credManifest), - }; + return new Response(JSON.stringify(credManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); await cmdInteractive(); diff --git a/cli/src/__tests__/cmd-listing-output.test.ts b/cli/src/__tests__/cmd-listing-output.test.ts index b0f01005..6ec2a1b6 100644 --- a/cli/src/__tests__/cmd-listing-output.test.ts +++ b/cli/src/__tests__/cmd-listing-output.test.ts @@ -162,11 +162,9 @@ const { cmdMatrix, cmdAgents, cmdClouds, getTerminalWidth } = await import("../c // ── Helpers ────────────────────────────────────────────────────────────────── function setManifest(manifest: Manifest) { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifest)), + ); return loadManifest(true); } diff --git a/cli/src/__tests__/cmdlast.test.ts b/cli/src/__tests__/cmdlast.test.ts index 3088b842..ba121649 100644 --- a/cli/src/__tests__/cmdlast.test.ts +++ b/cli/src/__tests__/cmdlast.test.ts @@ -105,18 +105,14 @@ describe("cmdLast", () => { // Prime the manifest cache with mock data global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await loadManifest(true); global.fetch = originalFetch; - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); }); afterEach(() => { @@ -207,11 +203,7 @@ describe("cmdLast", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); // We need to mock cmdRun to prevent actual execution @@ -230,11 +222,7 @@ describe("cmdLast", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -253,11 +241,7 @@ describe("cmdLast", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -299,11 +283,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -332,11 +312,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -361,11 +337,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -469,11 +441,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -498,11 +466,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { @@ -536,11 +500,7 @@ describe("cmdLast", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); try { diff --git a/cli/src/__tests__/cmdlist-integration.test.ts b/cli/src/__tests__/cmdlist-integration.test.ts index 765117ea..f2a6115b 100644 --- a/cli/src/__tests__/cmdlist-integration.test.ts +++ b/cli/src/__tests__/cmdlist-integration.test.ts @@ -119,18 +119,14 @@ describe("cmdList integration", () => { // Prime the manifest in-memory cache with mock data so tests don't // depend on network availability or stale values from other test files. global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await loadManifest(true); global.fetch = originalFetch; - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); }); afterEach(() => { @@ -237,11 +233,7 @@ describe("cmdList integration", () => { // Mock fetch to return manifest (for display names) global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -256,11 +248,7 @@ describe("cmdList integration", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -281,11 +269,7 @@ describe("cmdList integration", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -315,11 +299,7 @@ describe("cmdList integration", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -334,11 +314,7 @@ describe("cmdList integration", () => { writeHistory(sampleRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -357,11 +333,7 @@ describe("cmdList integration", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -386,11 +358,7 @@ describe("cmdList integration", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -399,6 +367,7 @@ describe("cmdList integration", () => { // Should show agent and cloud in subtitle expect(output).toContain("Claude Code"); expect(output).toContain("Sprite"); + expect(output).toContain("Fix all linter errors"); }); it("should include prompt in rerun hint for latest record with prompt", async () => { @@ -412,11 +381,7 @@ describe("cmdList integration", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -457,11 +422,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList("claude"); @@ -475,11 +436,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(undefined, "hetzner"); @@ -492,11 +449,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList("claude", "sprite"); @@ -509,11 +462,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList("claude"); @@ -527,11 +476,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -546,11 +491,7 @@ describe("cmdList integration", () => { writeHistory(records); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList("CLAUDE"); @@ -600,11 +541,7 @@ describe("cmdList integration", () => { writeHistory(manyRecords); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); @@ -623,11 +560,7 @@ describe("cmdList integration", () => { ]); global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, + () => Promise.resolve(new Response(JSON.stringify(mockManifest))), ); await cmdList(); diff --git a/cli/src/__tests__/cmdrun-happy-path.test.ts b/cli/src/__tests__/cmdrun-happy-path.test.ts index 770f79e5..67d07278 100644 --- a/cli/src/__tests__/cmdrun-happy-path.test.ts +++ b/cli/src/__tests__/cmdrun-happy-path.test.ts @@ -94,7 +94,7 @@ function mockFetchForDownload(opts: { scriptContent = VALID_SCRIPT, } = opts; - return mock(async (url: string | URL | Request, init?: any) => { + return mock(async (url: string | URL | Request, _init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url instanceof URL ? url.href : url.url; fetchCalls.push({ url: urlStr, @@ -102,53 +102,27 @@ function mockFetchForDownload(opts: { // Manifest fetch if (urlStr.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } // Primary script URL (openrouter.ai) if (urlStr.includes("openrouter.ai")) { if (primaryOk) { - return { - ok: true, - status: primaryStatus, - text: async () => scriptContent, - }; + return new Response(scriptContent, { status: primaryStatus }); } - return { - ok: false, - status: primaryStatus, - statusText: `HTTP ${primaryStatus}`, - text: async () => "error", - }; + return new Response("error", { status: primaryStatus, statusText: `HTTP ${primaryStatus}` }); } // Fallback script URL (raw.githubusercontent.com) if (urlStr.includes("raw.githubusercontent.com")) { if (fallbackOk) { - return { - ok: true, - status: fallbackStatus, - text: async () => scriptContent, - }; + return new Response(scriptContent, { status: fallbackStatus }); } - return { - ok: false, - status: fallbackStatus, - statusText: `HTTP ${fallbackStatus}`, - text: async () => "error", - }; + return new Response("error", { status: fallbackStatus, statusText: `HTTP ${fallbackStatus}` }); } - return { - ok: false, - status: 404, - text: async () => "not found", - }; - }) as any; + return new Response("not found", { status: 404 }); + }); } // ── Test suite ─────────────────────────────────────────────────────────────── @@ -171,9 +145,9 @@ describe("cmdRun happy-path pipeline", () => { fetchCalls = []; spawnCalls = []; - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; diff --git a/cli/src/__tests__/commands-cloud-info.test.ts b/cli/src/__tests__/commands-cloud-info.test.ts index 28372d7c..6ff7bf41 100644 --- a/cli/src/__tests__/commands-cloud-info.test.ts +++ b/cli/src/__tests__/commands-cloud-info.test.ts @@ -96,16 +96,14 @@ describe("cmdCloudInfo", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(mockManifest)), + ); await loadManifest(true); }); @@ -177,11 +175,9 @@ describe("cmdCloudInfo", () => { describe("cloud with notes field", () => { it("should display notes when cloud has notes", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifestWithCloudNotes, - text: async () => JSON.stringify(manifestWithCloudNotes), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifestWithCloudNotes)), + ); await loadManifest(true); await cmdCloudInfo("sprite"); @@ -194,11 +190,9 @@ describe("cmdCloudInfo", () => { describe("cloud with no implemented agents", () => { it("should show no-agents message", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifestWithNotes, - text: async () => JSON.stringify(manifestWithNotes), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifestWithNotes)), + ); await loadManifest(true); await cmdCloudInfo("emptycloud"); @@ -207,11 +201,9 @@ describe("cmdCloudInfo", () => { }); it("should still show cloud name for agent-less cloud", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifestWithNotes, - text: async () => JSON.stringify(manifestWithNotes), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifestWithNotes)), + ); await loadManifest(true); await cmdCloudInfo("emptycloud"); @@ -221,11 +213,9 @@ describe("cmdCloudInfo", () => { }); it("should display notes for agent-less cloud", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifestWithNotes, - text: async () => JSON.stringify(manifestWithNotes), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifestWithNotes)), + ); await loadManifest(true); await cmdCloudInfo("emptycloud"); diff --git a/cli/src/__tests__/commands-display.test.ts b/cli/src/__tests__/commands-display.test.ts index 3e5336fa..af8d32d6 100644 --- a/cli/src/__tests__/commands-display.test.ts +++ b/cli/src/__tests__/commands-display.test.ts @@ -141,16 +141,14 @@ describe("Commands Display Output", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(mockManifest)), + ); await loadManifest(true); }); @@ -202,11 +200,9 @@ describe("Commands Display Output", () => { }); it("should show no-clouds message when agent has no implementations", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => noImplManifest, - text: async () => JSON.stringify(noImplManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noImplManifest)), + ); await loadManifest(true); await cmdAgentInfo("claude"); @@ -260,11 +256,9 @@ describe("Commands Display Output", () => { }); it("should show 0 implemented when nothing is implemented", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => noImplManifest, - text: async () => JSON.stringify(noImplManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noImplManifest)), + ); await loadManifest(true); await cmdMatrix(); @@ -449,12 +443,9 @@ describe("Commands Display Output", () => { describe("cmdUpdate", () => { it("should show already up to date when versions match", async () => { const pkg = await import("../../package.json"); - global.fetch = mock(async () => ({ - ok: true, - json: async () => ({ - version: pkg.default.version, - }), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify({ version: pkg.default.version })), + ); await cmdUpdate(); @@ -467,9 +458,9 @@ describe("Commands Display Output", () => { }); it("should handle fetch failure gracefully", async () => { - global.fetch = mock(async () => { + global.fetch = mock(async (): Promise => { throw new Error("Network timeout"); - }) as any; + }); await cmdUpdate(); @@ -481,9 +472,9 @@ describe("Commands Display Output", () => { }); it("should handle non-ok fetch response", async () => { - global.fetch = mock(async () => ({ - ok: false, - })) as any; + global.fetch = mock(async () => + new Response("error", { status: 500 }), + ); await cmdUpdate(); @@ -496,11 +487,9 @@ describe("Commands Display Output", () => { describe("cmdList - edge cases", () => { it("should handle single implementation correctly", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => singleImplManifest, - text: async () => JSON.stringify(singleImplManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(singleImplManifest)), + ); await loadManifest(true); await cmdMatrix(); @@ -509,11 +498,9 @@ describe("Commands Display Output", () => { }); it("should handle manifest with many clouds", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manyCloudManifest, - text: async () => JSON.stringify(manyCloudManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manyCloudManifest)), + ); await loadManifest(true); await cmdMatrix(); @@ -550,11 +537,9 @@ describe("Commands Display Output", () => { }, }, }; - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifestWithNotes, - text: async () => JSON.stringify(manifestWithNotes), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manifestWithNotes)), + ); await loadManifest(true); await cmdAgentInfo("codex"); @@ -574,11 +559,9 @@ describe("Commands Display Output", () => { describe("cmdAgentInfo - many clouds", () => { it("should list all implemented clouds for agent with many options", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manyCloudManifest, - text: async () => JSON.stringify(manyCloudManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(manyCloudManifest)), + ); await loadManifest(true); await cmdAgentInfo("claude"); @@ -595,11 +578,9 @@ describe("Commands Display Output", () => { describe("cmdAgents - zero implementations", () => { it("should show 0 clouds for all agents", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => noImplManifest, - text: async () => JSON.stringify(noImplManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noImplManifest)), + ); await loadManifest(true); await cmdAgents(); @@ -618,11 +599,9 @@ describe("Commands Display Output", () => { describe("cmdClouds - zero implementations", () => { it("should show 0 agents for all clouds", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => noImplManifest, - text: async () => JSON.stringify(noImplManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(noImplManifest)), + ); await loadManifest(true); await cmdClouds(); diff --git a/cli/src/__tests__/commands-error-paths.test.ts b/cli/src/__tests__/commands-error-paths.test.ts index 8a274691..04104bbe 100644 --- a/cli/src/__tests__/commands-error-paths.test.ts +++ b/cli/src/__tests__/commands-error-paths.test.ts @@ -70,17 +70,15 @@ describe("Commands Error Paths", () => { mockSpinnerStop.mockClear(); // Mock process.exit to throw instead of exiting - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { throw new Error("process.exit"); - }) as any); + }); // Mock fetch to return our controlled manifest data originalFetch = global.fetch; - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => + new Response(JSON.stringify(mockManifest)), + ); // Force-refresh the manifest cache await loadManifest(true); @@ -295,18 +293,11 @@ describe("Commands Error Paths", () => { // Mock fetch to simulate script download failure (not a valid script) global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } // Script download returns non-script content - return { - ok: true, - text: async () => "not a valid script", - }; - }) as any; + return new Response("not a valid script"); + }); // Force refresh manifest with updated fetch await loadManifest(true); @@ -328,17 +319,10 @@ describe("Commands Error Paths", () => { it("should show prompt indicator when prompt is provided", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "not a valid script", - }; - }) as any; + return new Response("not a valid script"); + }); await loadManifest(true); diff --git a/cli/src/__tests__/commands-name-suggestions.test.ts b/cli/src/__tests__/commands-name-suggestions.test.ts index fec1d5de..417cc432 100644 --- a/cli/src/__tests__/commands-name-suggestions.test.ts +++ b/cli/src/__tests__/commands-name-suggestions.test.ts @@ -142,11 +142,7 @@ describe("Display Name Suggestions in Validation Errors", () => { let processExitSpy: ReturnType; function setManifest(manifest: any) { - global.fetch = mock(async () => ({ - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify(manifest))); return loadManifest(true); } @@ -159,9 +155,9 @@ describe("Display Name Suggestions in Validation Errors", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; await setManifest(manifestWithDistinctNames); diff --git a/cli/src/__tests__/commands-output.test.ts b/cli/src/__tests__/commands-output.test.ts index 6e913120..d9275510 100644 --- a/cli/src/__tests__/commands-output.test.ts +++ b/cli/src/__tests__/commands-output.test.ts @@ -53,11 +53,7 @@ describe("Command Output Functions", () => { // Mock fetch to return our controlled manifest data originalFetch = global.fetch; - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify(mockManifest))); // Force-refresh the manifest cache so it picks up our mocked fetch data. // This ensures the in-memory _cached in manifest.ts is set to our test data, diff --git a/cli/src/__tests__/commands-resolve-run.test.ts b/cli/src/__tests__/commands-resolve-run.test.ts index 6dee66b5..c2bfe6ea 100644 --- a/cli/src/__tests__/commands-resolve-run.test.ts +++ b/cli/src/__tests__/commands-resolve-run.test.ts @@ -173,19 +173,12 @@ describe("cmdRun - display name resolution", () => { function setManifestAndScript(manifest: any) { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - }; + return new Response(JSON.stringify(manifest)); } // Script download returns a valid script that will fail at execution // but pass validateScriptContent - return { - ok: true, - text: async () => "#!/bin/bash\necho test", - }; - }) as any; + return new Response("#!/bin/bash\necho test"); + }); return loadManifest(true); } @@ -198,9 +191,9 @@ describe("cmdRun - display name resolution", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; await setManifestAndScript(mockManifest); diff --git a/cli/src/__tests__/commands-swap-resolve.test.ts b/cli/src/__tests__/commands-swap-resolve.test.ts index e7e7accb..546f6554 100644 --- a/cli/src/__tests__/commands-swap-resolve.test.ts +++ b/cli/src/__tests__/commands-swap-resolve.test.ts @@ -65,17 +65,10 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { function setManifestAndScript(manifest: any) { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - }; + return new Response(JSON.stringify(manifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\necho test", - }; - }) as any; + return new Response("#!/bin/bash\necho test"); + }); return loadManifest(true); } @@ -88,9 +81,9 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; await setManifestAndScript(mockManifest); @@ -241,17 +234,10 @@ describe("resolveAndLog via cmdRun", () => { function setManifestAndScript(manifest: any) { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - }; + return new Response(JSON.stringify(manifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\necho test", - }; - }) as any; + return new Response("#!/bin/bash\necho test"); + }); return loadManifest(true); } @@ -264,9 +250,9 @@ describe("resolveAndLog via cmdRun", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; await setManifestAndScript(mockManifest); @@ -381,13 +367,7 @@ describe("manifest validation (isValidManifest)", () => { }); it("should reject manifest missing 'agents' field", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => ({ - clouds: {}, - matrix: {}, - }), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify({ clouds: {}, matrix: {} }))); // Force refresh to avoid cache, should reject invalid manifest try { @@ -404,13 +384,7 @@ describe("manifest validation (isValidManifest)", () => { }); it("should reject manifest missing 'clouds' field", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => ({ - agents: {}, - matrix: {}, - }), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify({ agents: {}, matrix: {} }))); try { await loadManifest(true); @@ -425,13 +399,7 @@ describe("manifest validation (isValidManifest)", () => { }); it("should reject manifest missing 'matrix' field", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => ({ - agents: {}, - clouds: {}, - }), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify({ agents: {}, clouds: {} }))); try { await loadManifest(true); @@ -446,10 +414,7 @@ describe("manifest validation (isValidManifest)", () => { }); it("should reject null manifest data", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => null, - })) as any; + global.fetch = mock(async () => new Response("null")); try { await loadManifest(true); @@ -465,10 +430,7 @@ describe("manifest validation (isValidManifest)", () => { it("should accept valid manifest with all required fields", async () => { const validManifest = createMockManifest(); - global.fetch = mock(async () => ({ - ok: true, - json: async () => validManifest, - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify(validManifest))); const result = await loadManifest(true); expect(result).toHaveProperty("agents"); @@ -482,10 +444,7 @@ describe("manifest validation (isValidManifest)", () => { clouds: {}, matrix: {}, }; - global.fetch = mock(async () => ({ - ok: true, - json: async () => emptyManifest, - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify(emptyManifest))); const result = await loadManifest(true); expect(result).toHaveProperty("agents"); @@ -494,11 +453,7 @@ describe("manifest validation (isValidManifest)", () => { }); it("should handle HTTP error response gracefully", async () => { - global.fetch = mock(async () => ({ - ok: false, - status: 500, - statusText: "Internal Server Error", - })) as any; + global.fetch = mock(async () => new Response("Internal Server Error", { status: 500 })); try { await loadManifest(true); @@ -521,17 +476,10 @@ describe("prompt handling with swapped args", () => { function setManifestAndScript(manifest: any) { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => manifest, - text: async () => JSON.stringify(manifest), - }; + return new Response(JSON.stringify(manifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\necho test", - }; - }) as any; + return new Response("#!/bin/bash\necho test"); + }); return loadManifest(true); } @@ -544,9 +492,9 @@ describe("prompt handling with swapped args", () => { mockSpinnerStart.mockClear(); mockSpinnerStop.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; await setManifestAndScript(mockManifest); diff --git a/cli/src/__tests__/commands-update-download.test.ts b/cli/src/__tests__/commands-update-download.test.ts index 134cab00..3074e137 100644 --- a/cli/src/__tests__/commands-update-download.test.ts +++ b/cli/src/__tests__/commands-update-download.test.ts @@ -68,9 +68,9 @@ describe("cmdUpdate", () => { mockSpinnerStop.mockClear(); mockSpinnerMessage.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; }); @@ -84,18 +84,10 @@ describe("cmdUpdate", () => { it("should report up-to-date when remote version matches current", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("package.json")) { - return { - ok: true, - json: async () => ({ - version: VERSION, - }), - }; + return new Response(JSON.stringify({ version: VERSION })); } - return { - ok: false, - status: 404, - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); await cmdUpdate(); @@ -109,18 +101,10 @@ describe("cmdUpdate", () => { it("should report available update when remote version differs", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("package.json")) { - return { - ok: true, - json: async () => ({ - version: "99.99.99", - }), - }; + return new Response(JSON.stringify({ version: "99.99.99" })); } - return { - ok: false, - status: 404, - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); await cmdUpdate(); @@ -131,11 +115,7 @@ describe("cmdUpdate", () => { }); it("should handle package.json fetch failure gracefully", async () => { - global.fetch = mock(async () => ({ - ok: false, - status: 500, - statusText: "Internal Server Error", - })) as any; + global.fetch = mock(async () => new Response("Internal Server Error", { status: 500 })); await cmdUpdate(); @@ -151,7 +131,7 @@ describe("cmdUpdate", () => { it("should handle network error gracefully", async () => { global.fetch = mock(async () => { throw new TypeError("Failed to fetch"); - }) as any; + }); await cmdUpdate(); @@ -163,18 +143,10 @@ describe("cmdUpdate", () => { it("should handle update failure gracefully", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("package.json")) { - return { - ok: true, - json: async () => ({ - version: "99.99.99", - }), - }; + return new Response(JSON.stringify({ version: "99.99.99" })); } - return { - ok: false, - status: 404, - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); // cmdUpdate now runs execSync which will fail in test env // The function catches errors internally, so it should not throw @@ -186,12 +158,7 @@ describe("cmdUpdate", () => { }); it("should start spinner with checking message", async () => { - global.fetch = mock(async () => ({ - ok: true, - json: async () => ({ - version: VERSION, - }), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify({ version: VERSION }))); await cmdUpdate(); @@ -202,17 +169,10 @@ describe("cmdUpdate", () => { it("should show version in spinner stop during update", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("package.json")) { - return { - ok: true, - json: async () => ({ - version: "2.0.0", - }), - }; + return new Response(JSON.stringify({ version: "2.0.0" })); } - return { - ok: false, - }; - }) as any; + return new Response("Error", { status: 500 }); + }); await cmdUpdate(); @@ -236,18 +196,14 @@ describe("Script download and execution", () => { mockSpinnerStop.mockClear(); mockSpinnerMessage.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; // Set up manifest mock - global.fetch = mock(async () => ({ - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - })) as any; + global.fetch = mock(async () => new Response(JSON.stringify(mockManifest))); await loadManifest(true); }); @@ -260,19 +216,11 @@ describe("Script download and execution", () => { it("should exit when both primary and fallback URLs return 404", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } // Both script URLs return 404 - return { - ok: false, - status: 404, - text: async () => "Not Found", - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); await loadManifest(true); await expect(cmdRun("claude", "sprite")).rejects.toThrow("process.exit"); @@ -287,18 +235,10 @@ describe("Script download and execution", () => { it("should exit when both primary and fallback URLs return server errors", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: false, - status: 500, - text: async () => "Server Error", - }; - }) as any; + return new Response("Server Error", { status: 500 }); + }); await loadManifest(true); await expect(cmdRun("claude", "sprite")).rejects.toThrow("process.exit"); @@ -312,14 +252,10 @@ describe("Script download and execution", () => { const callCount = 0; global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } throw new Error("Network timeout"); - }) as any; + }); await loadManifest(true); @@ -340,33 +276,18 @@ describe("Script download and execution", () => { fetchedUrls.push(url); } if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } if (typeof url === "string" && url.includes("openrouter.ai")) { // Primary fails - return { - ok: false, - status: 503, - text: async () => "Service Unavailable", - }; + return new Response("Service Unavailable", { status: 503 }); } if (typeof url === "string" && url.includes("raw.githubusercontent.com")) { // Fallback returns valid script - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\necho 'hello'", - }; + return new Response("#!/bin/bash\nset -eo pipefail\necho 'hello'"); } - return { - ok: false, - status: 404, - text: async () => "Not found", - }; - }) as any; + return new Response("Not found", { status: 404 }); + }); await loadManifest(true); @@ -393,18 +314,10 @@ describe("Script download and execution", () => { it("should show spinner with download message during script fetch", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: false, - status: 404, - text: async () => "Not found", - }; - }) as any; + return new Response("Not found", { status: 404 }); + }); await loadManifest(true); @@ -421,18 +334,11 @@ describe("Script download and execution", () => { it("should reject script without shebang via validateScriptContent", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } // Return non-script content - return { - ok: true, - text: async () => "echo hello world", - }; - }) as any; + return new Response("echo hello world"); + }); await loadManifest(true); @@ -450,17 +356,10 @@ describe("Script download and execution", () => { it("should reject script with dangerous pattern (rm -rf /)", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nrm -rf / --no-preserve-root", - }; - }) as any; + return new Response("#!/bin/bash\nrm -rf / --no-preserve-root"); + }); await loadManifest(true); @@ -488,18 +387,10 @@ describe("Script download and execution", () => { it("should show script-not-found message when both URLs 404", async () => { global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: false, - status: 404, - text: async () => "Not Found", - }; - }) as any; + return new Response("Not Found", { status: 404 }); + }); await loadManifest(true); @@ -519,32 +410,16 @@ describe("Script download and execution", () => { const callIndex = 0; global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } if (typeof url === "string" && url.includes("openrouter.ai")) { - return { - ok: false, - status: 500, - text: async () => "Error", - }; + return new Response("Error", { status: 500 }); } if (typeof url === "string" && url.includes("raw.githubusercontent.com")) { - return { - ok: false, - status: 502, - text: async () => "Bad Gateway", - }; + return new Response("Bad Gateway", { status: 502 }); } - return { - ok: false, - status: 404, - text: async () => "Not found", - }; - }) as any; + return new Response("Not found", { status: 404 }); + }); await loadManifest(true); @@ -563,17 +438,10 @@ describe("Script download and execution", () => { // when a valid prompt is provided global.fetch = mock(async (url: string) => { if (typeof url === "string" && url.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } - return { - ok: true, - text: async () => "#!/bin/bash\nset -eo pipefail\nexit 0", - }; - }) as any; + return new Response("#!/bin/bash\nset -eo pipefail\nexit 0"); + }); await loadManifest(true); diff --git a/cli/src/__tests__/download-and-failure.test.ts b/cli/src/__tests__/download-and-failure.test.ts index 2ca9152a..cfced9c9 100644 --- a/cli/src/__tests__/download-and-failure.test.ts +++ b/cli/src/__tests__/download-and-failure.test.ts @@ -72,23 +72,15 @@ describe("Download and Failure Pipeline", () => { /** Set up fetch to return manifest from manifest URLs and custom responses for script URLs */ function setupFetch( - scriptHandler: (url: string) => Promise<{ - ok: boolean; - status?: number; - text?: () => Promise; - }>, + scriptHandler: (url: string) => Promise, ) { global.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; if (urlStr.includes("manifest.json")) { - return { - ok: true, - json: async () => mockManifest, - text: async () => JSON.stringify(mockManifest), - }; + return new Response(JSON.stringify(mockManifest)); } return scriptHandler(urlStr); - }) as any; + }); return loadManifest(true); } @@ -102,9 +94,9 @@ describe("Download and Failure Pipeline", () => { mockSpinnerStop.mockClear(); mockSpinnerMessage.mockClear(); - processExitSpy = spyOn(process, "exit").mockImplementation((() => { + processExitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); - }) as any); + }); originalFetch = global.fetch; }); @@ -122,10 +114,7 @@ describe("Download and Failure Pipeline", () => { await setupFetch(async (url) => { // Primary URL succeeds with a valid-looking script if (url.includes("openrouter.ai")) { - return { - ok: true, - text: async () => "#!/bin/bash\nexit 0", - }; + return new Response("#!/bin/bash\nexit 0"); } throw new Error("Should not reach fallback"); }); @@ -150,16 +139,10 @@ describe("Download and Failure Pipeline", () => { let fallbackCalled = false; await setupFetch(async (url) => { if (url.includes("openrouter.ai")) { - return { - ok: true, - text: async () => "#!/bin/bash\nexit 0", - }; + return new Response("#!/bin/bash\nexit 0"); } fallbackCalled = true; - return { - ok: true, - text: async () => "#!/bin/bash\nexit 0", - }; + return new Response("#!/bin/bash\nexit 0"); }); try { @@ -178,22 +161,13 @@ describe("Download and Failure Pipeline", () => { it("should fall back to GitHub raw URL when primary returns 404", async () => { await setupFetch(async (url) => { if (url.includes("openrouter.ai")) { - return { - ok: false, - status: 404, - }; + return new Response("Not Found", { status: 404 }); } // GitHub raw fallback succeeds if (url.includes("raw.githubusercontent.com")) { - return { - ok: true, - text: async () => "#!/bin/bash\nexit 0", - }; + return new Response("#!/bin/bash\nexit 0"); } - return { - ok: false, - status: 500, - }; + return new Response("Server Error", { status: 500 }); }); try { @@ -214,21 +188,12 @@ describe("Download and Failure Pipeline", () => { it("should fall back to GitHub raw URL when primary returns 500", async () => { await setupFetch(async (url) => { if (url.includes("openrouter.ai")) { - return { - ok: false, - status: 500, - }; + return new Response("Server Error", { status: 500 }); } if (url.includes("raw.githubusercontent.com")) { - return { - ok: true, - text: async () => "#!/bin/bash\nexit 0", - }; + return new Response("#!/bin/bash\nexit 0"); } - return { - ok: false, - status: 500, - }; + return new Response("Server Error", { status: 500 }); }); try { @@ -247,11 +212,8 @@ describe("Download and Failure Pipeline", () => { describe("download - both URLs fail", () => { it("should show 'script not found' when both return 404", async () => { - await setupFetch(async (url) => { - return { - ok: false, - status: 404, - }; + await setupFetch(async () => { + return new Response("Not Found", { status: 404 }); }); try { @@ -268,10 +230,7 @@ describe("Download and Failure Pipeline", () => { }); it("should suggest verifying the combination when both return 404", async () => { - await setupFetch(async () => ({ - ok: false, - status: 404, - })); + await setupFetch(async () => new Response("Not Found", { status: 404 })); try { await cmdRun("claude", "sprite"); @@ -284,10 +243,7 @@ describe("Download and Failure Pipeline", () => { }); it("should suggest reporting the issue when both return 404", async () => { - await setupFetch(async () => ({ - ok: false, - status: 404, - })); + await setupFetch(async () => new Response("Not Found", { status: 404 })); try { await cmdRun("claude", "sprite"); @@ -300,10 +256,7 @@ describe("Download and Failure Pipeline", () => { }); it("should show server error message when both return 500", async () => { - await setupFetch(async () => ({ - ok: false, - status: 500, - })); + await setupFetch(async () => new Response("Server Error", { status: 500 })); try { await cmdRun("claude", "sprite"); @@ -316,10 +269,7 @@ describe("Download and Failure Pipeline", () => { }); it("should mention temporary server issues on 500 errors", async () => { - await setupFetch(async () => ({ - ok: false, - status: 500, - })); + await setupFetch(async () => new Response("Server Error", { status: 500 })); try { await cmdRun("claude", "sprite"); @@ -336,15 +286,9 @@ describe("Download and Failure Pipeline", () => { await setupFetch(async (url) => { callCount++; if (url.includes("openrouter.ai")) { - return { - ok: false, - status: 404, - }; + return new Response("Not Found", { status: 404 }); } - return { - ok: false, - status: 500, - }; + return new Response("Server Error", { status: 500 }); }); try { @@ -434,15 +378,9 @@ describe("Download and Failure Pipeline", () => { it("should reject script missing shebang line", async () => { await setupFetch(async (url) => { if (url.includes("openrouter.ai")) { - return { - ok: true, - text: async () => "no shebang here", - }; + return new Response("no shebang here"); } - return { - ok: false, - status: 404, - }; + return new Response("Not Found", { status: 404 }); }); try { @@ -457,15 +395,9 @@ describe("Download and Failure Pipeline", () => { it("should reject HTML response masquerading as script", async () => { await setupFetch(async (url) => { if (url.includes("openrouter.ai")) { - return { - ok: true, - text: async () => "\nError page", - }; + return new Response("\nError page"); } - return { - ok: false, - status: 404, - }; + return new Response("Not Found", { status: 404 }); }); try { diff --git a/cli/src/__tests__/fly.test.ts b/cli/src/__tests__/fly.test.ts index a951042d..a58ab17f 100644 --- a/cli/src/__tests__/fly.test.ts +++ b/cli/src/__tests__/fly.test.ts @@ -232,7 +232,7 @@ describe("fly/lib/agents", () => { it("agents have no vmMemory field (VM sizing is user-chosen)", () => { for (const [key, agent] of Object.entries(agents)) { - expect((agent as any).vmMemory).toBeUndefined(); + expect("vmMemory" in agent).toBe(false); } }); diff --git a/cli/src/__tests__/manifest-cache-lifecycle.test.ts b/cli/src/__tests__/manifest-cache-lifecycle.test.ts index 6bdd0ffd..3e941a97 100644 --- a/cli/src/__tests__/manifest-cache-lifecycle.test.ts +++ b/cli/src/__tests__/manifest-cache-lifecycle.test.ts @@ -213,13 +213,7 @@ describe("Manifest Cache Lifecycle", () => { }); writeFileSync(env.cacheFile, "{ invalid json content !!!"); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -233,13 +227,7 @@ describe("Manifest Cache Lifecycle", () => { }); writeFileSync(env.cacheFile, ""); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -251,13 +239,7 @@ describe("Manifest Cache Lifecycle", () => { }); writeFileSync(env.cacheFile, "[1, 2, 3]"); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -269,13 +251,7 @@ describe("Manifest Cache Lifecycle", () => { }); writeFileSync(env.cacheFile, '"just a string"'); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -293,13 +269,7 @@ describe("Manifest Cache Lifecycle", () => { }), ); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -319,13 +289,8 @@ describe("Manifest Cache Lifecycle", () => { }); it("should fall back to stale cache on HTTP 500", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error" })), ); mkdirSync(join(env.testDir, "spawn"), { @@ -341,13 +306,8 @@ describe("Manifest Cache Lifecycle", () => { }); it("should fall back to stale cache on HTTP 403 (rate limited)", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: false, - status: 403, - statusText: "Forbidden", - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response("Forbidden", { status: 403, statusText: "Forbidden" })), ); mkdirSync(join(env.testDir, "spawn"), { @@ -362,15 +322,7 @@ describe("Manifest Cache Lifecycle", () => { }); it("should fall back to stale cache when fetch response json() throws", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => { - throw new SyntaxError("Unexpected token"); - }, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response("not valid json {{{", { status: 200 }))); mkdirSync(join(env.testDir, "spawn"), { recursive: true, @@ -398,17 +350,17 @@ describe("Manifest Cache Lifecycle", () => { }); it("should fall back when fetch returns invalid manifest structure", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ agents: { claude: {}, }, - }), // missing clouds and matrix - }) as any, - ); + }), + ), + ), + ); // missing clouds and matrix mkdirSync(join(env.testDir, "spawn"), { recursive: true, @@ -454,29 +406,18 @@ describe("Manifest Cache Lifecycle", () => { }); it("should bypass in-memory cache with forceRefresh", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + const fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); + global.fetch = fetchMock; await loadManifest(true); await loadManifest(true); // fetch should have been called at least twice (once per forceRefresh) - expect((global.fetch as any).mock.calls.length).toBeGreaterThanOrEqual(2); + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); it("should return same instance without forceRefresh", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest1 = await loadManifest(true); const manifest2 = await loadManifest(false); @@ -504,15 +445,15 @@ describe("Manifest Cache Lifecycle", () => { const oldTime = Date.now() - 2 * 60 * 60 * 1000; utimesSync(env.cacheFile, new Date(oldTime), new Date(oldTime)); - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ version: 1, data: "not a manifest", }), - }) as any, + ), + ), ); const manifest = await loadManifest(true); @@ -529,16 +470,16 @@ describe("Manifest Cache Lifecycle", () => { writeFileSync(env.cacheFile, JSON.stringify(mockManifest)); // Cache is fresh (just written) - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ agents: {}, clouds: {}, matrix: {}, }), - }) as any, + ), + ), ); // loadManifest(false) should check disk cache first diff --git a/cli/src/__tests__/manifest-helpers.test.ts b/cli/src/__tests__/manifest-helpers.test.ts index 96411ceb..ef80b24f 100644 --- a/cli/src/__tests__/manifest-helpers.test.ts +++ b/cli/src/__tests__/manifest-helpers.test.ts @@ -29,16 +29,16 @@ describe("Manifest Helper Edge Cases", () => { }); it("should reject array as manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => [ + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify([ 1, 2, 3, - ], - }) as any, + ]), + ), + ), ); try { @@ -49,13 +49,7 @@ describe("Manifest Helper Edge Cases", () => { }); it("should reject string as manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => "not a manifest", - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify("not a manifest")))); try { await loadManifest(true); @@ -65,13 +59,7 @@ describe("Manifest Helper Edge Cases", () => { }); it("should reject number as manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => 42, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(42)))); try { await loadManifest(true); @@ -81,13 +69,7 @@ describe("Manifest Helper Edge Cases", () => { }); it("should reject boolean false as manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => false, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(false)))); try { await loadManifest(true); @@ -97,13 +79,7 @@ describe("Manifest Helper Edge Cases", () => { }); it("should reject undefined as manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => undefined, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response("undefined"))); try { await loadManifest(true); diff --git a/cli/src/__tests__/manifest-type-contracts.test.ts b/cli/src/__tests__/manifest-type-contracts.test.ts index 80c0a31a..75e5d6fa 100644 --- a/cli/src/__tests__/manifest-type-contracts.test.ts +++ b/cli/src/__tests__/manifest-type-contracts.test.ts @@ -428,94 +428,92 @@ describe("Interactive prompts structure", () => { describe("Agent metadata field types (when present)", () => { for (const [key, agent] of allAgents) { - const a = agent as any; - - if (a.creator !== undefined) { + if (agent.creator !== undefined) { it(`agent "${key}" creator should be a non-empty string`, () => { - expect(typeof a.creator).toBe("string"); - expect(a.creator.length).toBeGreaterThan(0); + expect(typeof agent.creator).toBe("string"); + expect(agent.creator!.length).toBeGreaterThan(0); }); } - if (a.repo !== undefined) { + if (agent.repo !== undefined) { it(`agent "${key}" repo should match owner/repo format`, () => { - expect(typeof a.repo).toBe("string"); - expect(a.repo).toMatch(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/); + expect(typeof agent.repo).toBe("string"); + expect(agent.repo).toMatch(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/); }); } - if (a.license !== undefined) { + if (agent.license !== undefined) { it(`agent "${key}" license should be a non-empty string`, () => { - expect(typeof a.license).toBe("string"); - expect(a.license.length).toBeGreaterThan(0); + expect(typeof agent.license).toBe("string"); + expect(agent.license!.length).toBeGreaterThan(0); }); } - if (a.created !== undefined) { + if (agent.created !== undefined) { it(`agent "${key}" created should be YYYY-MM format`, () => { - expect(typeof a.created).toBe("string"); - expect(a.created).toMatch(/^\d{4}-\d{2}$/); + expect(typeof agent.created).toBe("string"); + expect(agent.created).toMatch(/^\d{4}-\d{2}$/); }); } - if (a.added !== undefined) { + if (agent.added !== undefined) { it(`agent "${key}" added should be YYYY-MM format`, () => { - expect(typeof a.added).toBe("string"); - expect(a.added).toMatch(/^\d{4}-\d{2}$/); + expect(typeof agent.added).toBe("string"); + expect(agent.added).toMatch(/^\d{4}-\d{2}$/); }); } - if (a.github_stars !== undefined) { + if (agent.github_stars !== undefined) { it(`agent "${key}" github_stars should be a non-negative number`, () => { - expect(typeof a.github_stars).toBe("number"); - expect(a.github_stars).toBeGreaterThanOrEqual(0); - expect(Number.isInteger(a.github_stars)).toBe(true); + expect(typeof agent.github_stars).toBe("number"); + expect(agent.github_stars!).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(agent.github_stars)).toBe(true); }); } - if (a.stars_updated !== undefined) { + if (agent.stars_updated !== undefined) { it(`agent "${key}" stars_updated should be YYYY-MM-DD format`, () => { - expect(typeof a.stars_updated).toBe("string"); - expect(a.stars_updated).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(typeof agent.stars_updated).toBe("string"); + expect(agent.stars_updated).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); } - if (a.language !== undefined) { + if (agent.language !== undefined) { it(`agent "${key}" language should be a non-empty string`, () => { - expect(typeof a.language).toBe("string"); - expect(a.language.length).toBeGreaterThan(0); + expect(typeof agent.language).toBe("string"); + expect(agent.language!.length).toBeGreaterThan(0); }); } - if (a.runtime !== undefined) { + if (agent.runtime !== undefined) { it(`agent "${key}" runtime should be a non-empty string`, () => { - expect(typeof a.runtime).toBe("string"); - expect(a.runtime.length).toBeGreaterThan(0); + expect(typeof agent.runtime).toBe("string"); + expect(agent.runtime!.length).toBeGreaterThan(0); }); } - if (a.category !== undefined) { + if (agent.category !== undefined) { it(`agent "${key}" category should be cli, tui, or ide-extension`, () => { - expect(typeof a.category).toBe("string"); + expect(typeof agent.category).toBe("string"); expect([ "cli", "tui", "ide-extension", - ]).toContain(a.category); + ]).toContain(agent.category); }); } - if (a.tagline !== undefined) { + if (agent.tagline !== undefined) { it(`agent "${key}" tagline should be a non-empty string`, () => { - expect(typeof a.tagline).toBe("string"); - expect(a.tagline.length).toBeGreaterThan(0); + expect(typeof agent.tagline).toBe("string"); + expect(agent.tagline!.length).toBeGreaterThan(0); }); } - if (a.tags !== undefined) { + if (agent.tags !== undefined) { it(`agent "${key}" tags should be an array of non-empty strings`, () => { - expect(Array.isArray(a.tags)).toBe(true); - for (const tag of a.tags) { + expect(Array.isArray(agent.tags)).toBe(true); + for (const tag of agent.tags!) { expect(typeof tag).toBe("string"); expect(tag.length).toBeGreaterThan(0); } diff --git a/cli/src/__tests__/manifest-validation.test.ts b/cli/src/__tests__/manifest-validation.test.ts index ed2ac7ac..0202f782 100644 --- a/cli/src/__tests__/manifest-validation.test.ts +++ b/cli/src/__tests__/manifest-validation.test.ts @@ -29,15 +29,8 @@ describe("Manifest Validation Edge Cases", () => { }); it("should reject manifest missing agents field", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ - clouds: {}, - matrix: {}, - }), - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ clouds: {}, matrix: {} }))), ); try { @@ -49,15 +42,8 @@ describe("Manifest Validation Edge Cases", () => { }); it("should reject manifest missing clouds field", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ - agents: {}, - matrix: {}, - }), - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ agents: {}, matrix: {} }))), ); try { @@ -68,15 +54,8 @@ describe("Manifest Validation Edge Cases", () => { }); it("should reject manifest missing matrix field", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ - agents: {}, - clouds: {}, - }), - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ agents: {}, clouds: {} }))), ); try { @@ -87,13 +66,7 @@ describe("Manifest Validation Edge Cases", () => { }); it("should reject null manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => null, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(null)))); try { await loadManifest(true); @@ -103,13 +76,7 @@ describe("Manifest Validation Edge Cases", () => { }); it("should reject empty object manifest data", async () => { - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({}), - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({})))); try { await loadManifest(true); @@ -124,13 +91,7 @@ describe("Manifest Validation Edge Cases", () => { clouds: {}, matrix: {}, }; - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => validEmpty, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(validEmpty)))); const manifest = await loadManifest(true); expect(manifest).toHaveProperty("agents"); @@ -141,13 +102,8 @@ describe("Manifest Validation Edge Cases", () => { it("should handle non-ok HTTP response from GitHub", async () => { // When GitHub returns a non-ok response, fetchManifestFromGitHub returns null // and loadManifest falls back to cache or throws - global.fetch = mock( - () => - Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) as any, + global.fetch = mock(() => + Promise.resolve(new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error" })), ); try { diff --git a/cli/src/__tests__/manifest.test.ts b/cli/src/__tests__/manifest.test.ts index 84615c0a..47a5c389 100644 --- a/cli/src/__tests__/manifest.test.ts +++ b/cli/src/__tests__/manifest.test.ts @@ -138,13 +138,7 @@ describe("manifest", () => { writeFileSync(env.cacheFile, JSON.stringify(mockManifest)); // Mock fetch (should not be called for fresh cache) - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => mockManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); const manifest = await loadManifest(); @@ -165,13 +159,7 @@ describe("manifest", () => { ...mockManifest, agents: {}, }; - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => updatedManifest, - }) as any, - ); + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(updatedManifest)))); const manifest = await loadManifest(true); @@ -234,15 +222,15 @@ describe("manifest", () => { it("should validate manifest structure", async () => { // Mock fetch with invalid data (missing required fields) - global.fetch = mock( - () => - Promise.resolve({ - ok: true, - json: async () => ({ + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ agents: {}, - }), // missing clouds and matrix - }) as any, - ); + }), + ), + ), + ); // missing clouds and matrix // Write valid cache as fallback mkdirSync(join(env.testDir, "spawn"), { @@ -263,9 +251,9 @@ describe("manifest", () => { it("should handle fetch timeout", async () => { // Mock timeout - global.fetch = mock(async () => { - await new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 100)); - }) as any; + const timeoutFetch: typeof fetch = () => + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 100)); + global.fetch = mock(timeoutFetch); // Write cache as fallback mkdirSync(join(env.testDir, "spawn"), { diff --git a/cli/src/__tests__/parse.test.ts b/cli/src/__tests__/parse.test.ts new file mode 100644 index 00000000..243c7982 --- /dev/null +++ b/cli/src/__tests__/parse.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test"; +import * as v from "valibot"; +import { parseJsonWith, parseJsonRaw } from "../shared/parse"; + +describe("parseJsonWith", () => { + const NumberSchema = v.object({ count: v.number() }); + + it("should return validated data for valid JSON matching the schema", () => { + const result = parseJsonWith('{"count": 42}', NumberSchema); + expect(result).toEqual({ count: 42 }); + }); + + it("should return null for valid JSON that doesn't match the schema", () => { + const result = parseJsonWith('{"count": "not a number"}', NumberSchema); + expect(result).toBeNull(); + }); + + it("should return null for invalid JSON", () => { + const result = parseJsonWith("not json at all", NumberSchema); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = parseJsonWith("", NumberSchema); + expect(result).toBeNull(); + }); + + it("should handle nested schemas", () => { + const NestedSchema = v.object({ + user: v.object({ name: v.string(), age: v.number() }), + }); + const result = parseJsonWith('{"user": {"name": "Alice", "age": 30}}', NestedSchema); + expect(result).toEqual({ user: { name: "Alice", age: 30 } }); + }); + + it("should handle optional fields", () => { + const OptSchema = v.object({ name: v.string(), email: v.optional(v.string()) }); + const result = parseJsonWith('{"name": "Bob"}', OptSchema); + expect(result).toEqual({ name: "Bob" }); + }); + + it("should handle record schemas", () => { + const RecordSchema = v.record(v.string(), v.unknown()); + const result = parseJsonWith('{"key": "value", "num": 1}', RecordSchema); + expect(result).toEqual({ key: "value", num: 1 }); + }); + + it("should reject array when object schema expected", () => { + const result = parseJsonWith("[1, 2, 3]", NumberSchema); + expect(result).toBeNull(); + }); +}); + +describe("parseJsonRaw", () => { + it("should parse valid JSON to unknown", () => { + const result = parseJsonRaw('{"key": "value"}'); + expect(result).toEqual({ key: "value" }); + }); + + it("should parse JSON arrays", () => { + const result = parseJsonRaw("[1, 2, 3]"); + expect(result).toEqual([1, 2, 3]); + }); + + it("should return null for invalid JSON", () => { + const result = parseJsonRaw("not json"); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = parseJsonRaw(""); + expect(result).toBeNull(); + }); + + it("should parse primitive JSON values", () => { + expect(parseJsonRaw("42")).toBe(42); + expect(parseJsonRaw('"hello"')).toBe("hello"); + expect(parseJsonRaw("true")).toBe(true); + expect(parseJsonRaw("null")).toBeNull(); + }); +}); diff --git a/cli/src/__tests__/preflight-credentials.test.ts b/cli/src/__tests__/preflight-credentials.test.ts index e8b169fc..64545057 100644 --- a/cli/src/__tests__/preflight-credentials.test.ts +++ b/cli/src/__tests__/preflight-credentials.test.ts @@ -27,7 +27,7 @@ mock.module("@clack/prompts", () => ({ })); function makeManifest(cloudAuth: string): Manifest { - return { + const m: Manifest = { agents: {}, clouds: { testcloud: { @@ -42,7 +42,8 @@ function makeManifest(cloudAuth: string): Manifest { }, }, matrix: {}, - } as Manifest; + }; + return m; } describe("preflightCredentialCheck", () => { diff --git a/cli/src/__tests__/run-path-credential-display.test.ts b/cli/src/__tests__/run-path-credential-display.test.ts index c5d820d5..273c15ef 100644 --- a/cli/src/__tests__/run-path-credential-display.test.ts +++ b/cli/src/__tests__/run-path-credential-display.test.ts @@ -21,7 +21,7 @@ import type { Manifest } from "../manifest"; // ── Test manifest ─────────────────────────────────────────────────────── function makeManifest(overrides?: Partial): Manifest { - return { + const base: Manifest = { agents: { claude: { name: "Claude Code", @@ -110,15 +110,15 @@ function makeManifest(overrides?: Partial): Manifest { "localcloud/claude": "implemented", "localcloud/codex": "implemented", }, - ...overrides, - } as Manifest; + }; + return overrides ? { ...base, ...overrides } : base; } // ── Mock @clack/prompts ───────────────────────────────────────────────── -const mockExit = spyOn(process, "exit").mockImplementation((() => { +const mockExit = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); -}) as any); +}); const mockLog = { step: mock(() => {}), diff --git a/cli/src/__tests__/test-helpers.ts b/cli/src/__tests__/test-helpers.ts index bce95ce7..1dbae99d 100644 --- a/cli/src/__tests__/test-helpers.ts +++ b/cli/src/__tests__/test-helpers.ts @@ -75,9 +75,10 @@ export function createConsoleMocks() { } export function createProcessExitMock() { - return spyOn(process, "exit").mockImplementation((() => { + const impl: () => never = () => { throw new Error("process.exit"); - }) as any); + }; + return spyOn(process, "exit").mockImplementation(impl); } export function restoreMocks( @@ -96,13 +97,7 @@ export function restoreMocks( // ── Fetch Mocks ──────────────────────────────────────────────────────────────── export function mockSuccessfulFetch(data: any) { - return mock( - () => - Promise.resolve({ - ok: true, - json: async () => data, - }) as any, - ); + return mock(() => Promise.resolve(new Response(JSON.stringify(data)))); } export function mockFailedFetch(error = "Network error") { @@ -110,14 +105,13 @@ export function mockFailedFetch(error = "Network error") { } export function mockFetchWithStatus(status: number, data?: any) { - return mock( - () => - Promise.resolve({ - ok: status >= 200 && status < 300, + return mock(() => + Promise.resolve( + new Response(JSON.stringify(data || {}), { status, statusText: status === 404 ? "Not Found" : "Error", - json: async () => data || {}, - }) as any, + }), + ), ); } diff --git a/cli/src/__tests__/update-check.test.ts b/cli/src/__tests__/update-check.test.ts index 2fe5215c..4799aece 100644 --- a/cli/src/__tests__/update-check.test.ts +++ b/cli/src/__tests__/update-check.test.ts @@ -39,7 +39,9 @@ describe("update-check", () => { clearUpdateBackoff(); consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); // Mock process.exit to prevent tests from exiting - processExitSpy = spyOn(process, "exit").mockImplementation((() => {}) as any); + processExitSpy = spyOn(process, "exit").mockImplementation(() => { + // no-op mock - prevent actual exit + }); }); afterEach(() => { @@ -76,13 +78,7 @@ describe("update-check", () => { it("should check for updates on every run", async () => { const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -102,13 +98,7 @@ describe("update-check", () => { it("should auto-update when newer version is available", async () => { const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -140,13 +130,7 @@ describe("update-check", () => { it("should not update when up to date", async () => { const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "0.2.3", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "0.2.3" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -183,13 +167,7 @@ describe("update-check", () => { it("should handle update failures gracefully", async () => { const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -216,9 +194,7 @@ describe("update-check", () => { it("should handle bad response format", async () => { const mockFetch = mock(() => - Promise.resolve({ - ok: false, - } as Response), + Promise.resolve(new Response("Not Found", { status: 404 })), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -241,13 +217,7 @@ describe("update-check", () => { ]; const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -300,13 +270,7 @@ describe("update-check", () => { ]; const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); @@ -314,10 +278,8 @@ describe("update-check", () => { const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {}); const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation(() => { // Re-exec fails with exit code 42 - const err = new Error("Command failed") as Error & { - status: number; - }; - err.status = 42; + const err = new Error("Command failed"); + Object.assign(err, { status: 42 }); throw err; }); @@ -341,13 +303,7 @@ describe("update-check", () => { ]; const mockFetch = mock(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - version: "99.0.0", - }), - } as Response), + Promise.resolve(new Response(JSON.stringify({ version: "99.0.0" }))), ); const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch); diff --git a/cli/src/aws/aws.ts b/cli/src/aws/aws.ts index f0be2bac..a064ae46 100644 --- a/cli/src/aws/aws.ts +++ b/cli/src/aws/aws.ts @@ -17,6 +17,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; +import * as v from "valibot"; +import { parseJsonWith } from "../shared/parse"; const DASHBOARD_URL = "https://lightsail.aws.amazon.com/"; @@ -137,13 +139,29 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -function parseJson(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; - } -} +// ─── Valibot Schemas for AWS API Responses ────────────────────────────────── + +const InstanceStateSchema = v.object({ + instance: v.object({ + state: v.object({ + name: v.string(), + }), + publicIpAddress: v.optional(v.string()), + }), +}); + +const InstanceListSchema = v.object({ + instances: v.optional( + v.array( + v.object({ + name: v.optional(v.string()), + state: v.optional(v.object({ name: v.optional(v.string()) })), + publicIpAddress: v.optional(v.string()), + bundleId: v.optional(v.string()), + }), + ), + ), +}); // ─── AWS CLI Wrapper ──────────────────────────────────────────────────────── @@ -236,15 +254,13 @@ async function lightsailRest(target: string, body = "{}"): Promise { amzDate, ], ...(awsSessionToken - ? [ - [ + ? (() => { + const tokenHeader: [string, string] = [ "x-amz-security-token", awsSessionToken, - ] as [ - string, - string, - ], - ] + ]; + return [tokenHeader]; + })() : []), [ "x-amz-target", @@ -813,7 +829,7 @@ export async function waitForInstance(maxAttempts = 60): Promise { instanceName, }), ); - const data = parseJson(resp); + const data = parseJsonWith(resp, InstanceStateSchema); state = data?.instance?.state?.name || ""; } } catch { @@ -840,7 +856,7 @@ export async function waitForInstance(maxAttempts = 60): Promise { instanceName, }), ); - const data = parseJson(resp); + const data = parseJsonWith(resp, InstanceStateSchema); ip = data?.instance?.publicIpAddress || ""; } } catch { @@ -1223,8 +1239,8 @@ export async function listServers(): Promise { await proc.exited; } else { const resp = await lightsailRest("Lightsail_20161128.GetInstances", "{}"); - const data = parseJson(resp); - const instances: any[] = data?.instances ?? []; + const data = parseJsonWith(resp, InstanceListSchema); + const instances = data?.instances ?? []; const pad = (s: string, n: number) => (s + " ".repeat(n)).slice(0, n); console.log(pad("Name", 30) + pad("State", 12) + pad("IP", 16) + "Bundle"); console.log("-".repeat(72)); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index e4566529..75ddf9a9 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -1,6 +1,8 @@ import "./unicode-detect.js"; // Must be first: configures TERM before clack reads it import * as p from "@clack/prompts"; import pc from "picocolors"; +import * as v from "valibot"; +import { parseJsonWith } from "./shared/parse"; import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -51,6 +53,10 @@ import { destroyServer as awsDestroyServer, ensureAwsCli, authenticate as awsAut import { destroyServer as daytonaDestroyServer, ensureDaytonaToken } from "./daytona/daytona.js"; import { destroyServer as spriteDestroyServer, ensureSpriteCli, ensureSpriteAuthenticated } from "./sprite/sprite.js"; +// ── Schemas ────────────────────────────────────────────────────────────────── + +const PkgVersionSchema = v.object({ version: v.string() }); + // ── Helpers ──────────────────────────────────────────────────────────────────── const FETCH_TIMEOUT = 10_000; // 10 seconds @@ -3192,10 +3198,11 @@ async function fetchRemoteVersion(): Promise { if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); } - const remotePkg = (await res.json()) as { - version: string; - }; - return remotePkg.version; + const data = parseJsonWith(await res.text(), PkgVersionSchema); + if (!data?.version) { + throw new Error("Invalid package.json: no version field"); + } + return data.version; } const INSTALL_CMD = `curl -fsSL ${RAW_BASE}/cli/install.sh | bash`; diff --git a/cli/src/daytona/daytona.ts b/cli/src/daytona/daytona.ts index 9a18c0ea..8ea08146 100644 --- a/cli/src/daytona/daytona.ts +++ b/cli/src/daytona/daytona.ts @@ -15,6 +15,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; +import { parseJsonWith, parseJsonRaw } from "../shared/parse"; +import * as v from "valibot"; const DAYTONA_API_BASE = "https://app.daytona.io/api"; const DAYTONA_DASHBOARD_URL = "https://app.daytona.io/"; @@ -43,12 +45,26 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -function parseJson(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; - } +const LooseObject = v.record(v.string(), v.unknown()); + +/** Parse a JSON string into a Record via valibot, or null. */ +function parseJson(text: string): Record | null { + return parseJsonWith(text, LooseObject); +} + +/** Narrow an already-parsed unknown value to a Record, or null. */ +function toRecord(val: unknown): Record | null { + const result = v.safeParse(LooseObject, val); + return result.success ? result.output : null; +} + +/** Filter an array to only Record entries. */ +function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { return []; } + return val.filter( + (item): item is Record => + item !== null && typeof item === "object" && !Array.isArray(item), + ); } async function daytonaApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise { @@ -99,7 +115,8 @@ function extractApiError(text: string, fallback = "Unknown error"): string { if (!data) { return fallback; } - return data.message || data.error || data.detail || fallback; + const msg = data.message || data.error || data.detail; + return typeof msg === "string" ? msg : fallback; } // ─── Token Management ──────────────────────────────────────────────────────── @@ -275,8 +292,8 @@ async function setupSshAccess(): Promise { throw new Error("SSH access parse failure"); } - sshToken = data.token || ""; - const sshCommand = data.sshCommand || ""; + sshToken = typeof data.token === "string" ? data.token : ""; + const sshCommand = typeof data.sshCommand === "string" ? data.sshCommand : ""; if (!sshToken) { logError(`Failed to get SSH access: ${extractApiError(sshResp)}`); @@ -323,7 +340,7 @@ export async function createServer(name: string): Promise { const response = await daytonaApi("POST", "/sandbox", body); const data = parseJson(response); - sandboxId = data?.id || ""; + sandboxId = typeof data?.id === "string" ? data.id : ""; if (!sandboxId) { logError(`Failed to create sandbox: ${extractApiError(response)}`); throw new Error("Sandbox creation failed"); @@ -338,13 +355,13 @@ export async function createServer(name: string): Promise { while (waited < maxWait) { const statusResp = await daytonaApi("GET", `/sandbox/${sandboxId}`); const statusData = parseJson(statusResp); - const state = statusData?.state || ""; + const state = typeof statusData?.state === "string" ? statusData.state : ""; if (state === "started" || state === "running") { break; } if (state === "error" || state === "failed") { - const reason = statusData?.errorReason || "unknown"; + const reason = typeof statusData?.errorReason === "string" ? statusData.errorReason : "unknown"; logError(`Sandbox entered error state: ${reason}`); throw new Error("Sandbox error state"); } @@ -638,8 +655,10 @@ export async function destroyServer(id?: string): Promise { export async function listServers(): Promise { const response = await daytonaApi("GET", "/sandbox"); - const data = parseJson(response); - const items: any[] = Array.isArray(data) ? data : (data?.items ?? data?.sandboxes ?? []); + const raw = parseJsonRaw(response); + const parsed = toRecord(raw); + const rawItems = Array.isArray(raw) ? raw : (parsed?.items ?? parsed?.sandboxes ?? []); + const items = toObjectArray(rawItems); if (items.length === 0) { console.log("No sandboxes found"); @@ -650,10 +669,13 @@ export async function listServers(): Promise { console.log(pad("NAME", 25) + pad("ID", 40) + pad("STATE", 12)); console.log("-".repeat(77)); for (const s of items) { + const name = typeof s.name === "string" ? s.name : "N/A"; + const id = typeof s.id === "string" ? s.id : "N/A"; + const state = typeof s.state === "string" ? s.state : "N/A"; console.log( - pad((s.name ?? "N/A").slice(0, 24), 25) + - pad((s.id ?? "N/A").slice(0, 39), 40) + - pad((s.state ?? "N/A").slice(0, 11), 12), + pad(name.slice(0, 24), 25) + + pad(id.slice(0, 39), 40) + + pad(state.slice(0, 11), 12), ); } } diff --git a/cli/src/digitalocean/digitalocean.ts b/cli/src/digitalocean/digitalocean.ts index 0b2d8c61..3e31e260 100644 --- a/cli/src/digitalocean/digitalocean.ts +++ b/cli/src/digitalocean/digitalocean.ts @@ -1,6 +1,7 @@ // digitalocean/digitalocean.ts — Core DigitalOcean provider: API, auth, SSH, provisioning import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import * as v from "valibot"; import { logInfo, logWarn, @@ -16,6 +17,7 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; +import { parseJsonWith } from "../shared/parse"; const DO_API_BASE = "https://api.digitalocean.com/v2"; const DO_DASHBOARD_URL = "https://cloud.digitalocean.com/droplets"; @@ -118,12 +120,17 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -function parseJson(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; +const LooseObject = v.record(v.string(), v.unknown()); + +function parseJson(text: string): Record | null { + return parseJsonWith(text, LooseObject); +} + +function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { + return []; } + return val.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)); } // ─── Token Persistence ─────────────────────────────────────────────────────── @@ -275,15 +282,18 @@ async function tryRefreshDoToken(): Promise { return null; } - const data = (await resp.json()) as any; - if (!data.access_token) { + const data = parseJson(await resp.text()); + if (!data?.access_token) { logWarn("Token refresh returned no access token"); return null; } - await saveTokenToConfig(data.access_token, data.refresh_token || refreshToken, data.expires_in); + const accessToken = typeof data.access_token === "string" ? data.access_token : ""; + const newRefreshToken = typeof data.refresh_token === "string" ? data.refresh_token : undefined; + const expiresIn = typeof data.expires_in === "number" ? data.expires_in : undefined; + await saveTokenToConfig(accessToken, newRefreshToken || refreshToken, expiresIn); logInfo("DigitalOcean token refreshed successfully"); - return data.access_token; + return accessToken; } catch { logWarn("Token refresh request failed"); return null; @@ -454,15 +464,18 @@ async function tryDoOAuth(): Promise { return null; } - const data = (await resp.json()) as any; - if (!data.access_token) { + const data = parseJson(await resp.text()); + if (!data?.access_token) { logError("Token exchange returned no access token"); return null; } - await saveTokenToConfig(data.access_token, data.refresh_token, data.expires_in); + const accessToken = typeof data.access_token === "string" ? data.access_token : ""; + const oauthRefreshToken = typeof data.refresh_token === "string" ? data.refresh_token : undefined; + const expiresIn = typeof data.expires_in === "number" ? data.expires_in : undefined; + await saveTokenToConfig(accessToken, oauthRefreshToken, expiresIn); logInfo("Successfully obtained DigitalOcean access token via OAuth!"); - return data.access_token; + return accessToken; } catch (_err) { logError("Failed to exchange authorization code"); return null; @@ -574,7 +587,7 @@ function generateSshKeyIfMissing(): { mkdirSync(sshDir, { recursive: true, mode: 0o700, - } as any); + }); logStep("Generating SSH key..."); const result = Bun.spawnSync( [ @@ -640,9 +653,9 @@ export async function ensureSshKey(): Promise { // Check if key is registered with DigitalOcean const { text } = await doApi("GET", "/account/keys"); const data = parseJson(text); - const keys: any[] = data?.ssh_keys || []; + const keys = toObjectArray(data?.ssh_keys); - const found = keys.some((k: any) => { + const found = keys.some((k: Record) => { const fp = k.fingerprint || ""; return fp === fingerprint; }); @@ -762,7 +775,7 @@ export async function createServer(name: string, tier?: CloudInitTier): Promise< // Get all SSH key IDs const { text: keysText } = await doApi("GET", "/account/keys"); const keysData = parseJson(keysText); - const sshKeyIds: number[] = (keysData?.ssh_keys || []).map((k: any) => k.id).filter(Boolean); + const sshKeyIds: number[] = toObjectArray(keysData?.ssh_keys).map((k) => typeof k.id === "number" ? k.id : 0).filter((n) => n > 0); const userdata = getCloudInitUserdata(tier); const body = JSON.stringify({ @@ -808,10 +821,10 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis const status = data?.droplet?.status; if (status === "active") { - const networks = data?.droplet?.networks?.v4 || []; - const publicNet = networks.find((n: any) => n.type === "public"); + const v4Networks = toObjectArray(data?.droplet?.networks?.v4); + const publicNet = v4Networks.find((n) => n.type === "public"); if (publicNet?.ip_address) { - doServerIp = publicNet.ip_address; + doServerIp = typeof publicNet.ip_address === "string" ? publicNet.ip_address : ""; logInfo(`Droplet active, IP: ${doServerIp}`); return; } @@ -1194,7 +1207,7 @@ export async function destroyServer(dropletId?: string): Promise { export async function listServers(): Promise { const { text } = await doApi("GET", "/droplets"); const data = parseJson(text); - const droplets: any[] = data?.droplets || []; + const droplets = toObjectArray(data?.droplets); if (droplets.length === 0) { console.log("No droplets found"); @@ -1205,13 +1218,17 @@ export async function listServers(): Promise { console.log(pad("NAME", 25) + pad("ID", 12) + pad("STATUS", 12) + pad("IP", 16) + pad("SIZE", 15)); console.log("-".repeat(80)); for (const d of droplets) { - const ip = (d.networks?.v4 || []).find((n: any) => n.type === "public")?.ip_address || "N/A"; + const networks = d.networks; + const v4 = (networks && typeof networks === "object" && "v4" in networks) ? networks.v4 : undefined; + const v4Arr = toObjectArray(v4); + const publicNet = v4Arr.find((n) => n.type === "public"); + const ip = String(publicNet?.ip_address ?? "N/A"); console.log( - pad((d.name ?? "N/A").slice(0, 24), 25) + + pad(String(d.name ?? "N/A").slice(0, 24), 25) + pad(String(d.id ?? "N/A").slice(0, 11), 12) + - pad((d.status ?? "N/A").slice(0, 11), 12) + + pad(String(d.status ?? "N/A").slice(0, 11), 12) + pad(ip.slice(0, 15), 16) + - pad((d.size_slug ?? "N/A").slice(0, 14), 15), + pad(String(d.size_slug ?? "N/A").slice(0, 14), 15), ); } } diff --git a/cli/src/fly/agents.ts b/cli/src/fly/agents.ts index 0e00f2b2..5076e8e7 100644 --- a/cli/src/fly/agents.ts +++ b/cli/src/fly/agents.ts @@ -56,7 +56,13 @@ export const agents: Record = (() => { })(); export function resolveAgent(name: string): FlyAgentConfig { - return _resolveAgent(agents, name) as FlyAgentConfig; + const agent = agents[name.toLowerCase()]; + if (!agent) { + // Fall back to shared resolver for error handling + _resolveAgent(agents, name); + throw new Error(`Unknown agent: ${name}`); + } + return agent; } export function offerGithubAuth(): Promise { diff --git a/cli/src/fly/fly.ts b/cli/src/fly/fly.ts index 3bfdcb4d..dedf5465 100644 --- a/cli/src/fly/fly.ts +++ b/cli/src/fly/fly.ts @@ -17,6 +17,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; +import * as v from "valibot"; +import { parseJsonWith, parseJsonRaw } from "../shared/parse"; const FLY_API_BASE = "https://api.machines.dev/v1"; const FLY_DASHBOARD_URL = "https://fly.io/dashboard"; @@ -158,12 +160,18 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -function parseJson(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; - } +const LooseObject = v.record(v.string(), v.unknown()); + +function parseJson(text: string): Record | null { + return parseJsonWith(text, LooseObject); +} + +function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { return []; } + return val + .filter((item): item is Record => + item !== null && typeof item === "object" && !Array.isArray(item), + ); } function hasError(text: string): boolean { @@ -586,36 +594,45 @@ interface OrgEntry { } function parseOrgsJson(json: string): OrgEntry[] { - const data = parseJson(json); - if (!data) { - return []; - } + const raw = parseJsonRaw(json); + if (!raw || typeof raw !== "object") { return []; } - // Handle different org response formats - let orgs: any[] = []; - if (Array.isArray(data)) { - orgs = data; - } else if (data.nodes) { - orgs = data.nodes; - } else if (data.organizations) { - orgs = data.organizations; - } else if (data.data?.organizations?.nodes) { - orgs = data.data.organizations.nodes; - } else if (typeof data === "object" && !Array.isArray(data)) { - // {slug: name} format - return Object.entries(data) - .filter(([slug]) => slug) - .map(([slug, name]) => ({ - slug, - label: String(name), - })); + let orgs: Record[] = []; + if (Array.isArray(raw)) { + orgs = toObjectArray(raw); + } else { + // Re-parse as Record via valibot schema + const data = parseJson(json); + if (!data) { return []; } + + if (data.nodes) { + orgs = toObjectArray(data.nodes); + } else if (data.organizations) { + orgs = toObjectArray(data.organizations); + } else if (data.data && typeof data.data === "object") { + const inner = parseJson(JSON.stringify(data.data)); + if (inner?.organizations) { + const orgData = parseJson(JSON.stringify(inner.organizations)); + if (orgData) { + orgs = toObjectArray(orgData.nodes); + } + } + } else { + // {slug: name} format + return Object.entries(data) + .filter(([slug]) => slug) + .map(([slug, name]) => ({ + slug, + label: String(name), + })); + } } return orgs - .filter((o: any) => o.slug || o.name) - .map((o: any) => { - const slug = o.slug || o.name || ""; - const name = o.name || slug; + .filter((o) => o.slug || o.name) + .map((o) => { + const slug = String(o.slug || o.name || ""); + const name = String(o.name || slug); const suffix = o.type ? ` (${o.type})` : ""; return { slug, @@ -723,12 +740,12 @@ async function createApp(name: string): Promise { if (resp.includes('"error"')) { const data = parseJson(resp); const errMsg = data?.error || "Unknown error"; - if (/already exists/i.test(errMsg)) { + if (/already exists/i.test(String(errMsg))) { logInfo(`App '${name}' already exists, reusing it`); return; } logError(`Failed to create Fly.io app: ${errMsg}`); - if (/taken|Name.*valid/i.test(errMsg)) { + if (/taken|Name.*valid/i.test(String(errMsg))) { logWarn("Fly.io app names are globally unique. Set a different name with: FLY_APP_NAME=my-unique-name"); } throw new Error(`App creation failed: ${errMsg}`); @@ -785,7 +802,7 @@ async function createMachine( } const data = parseJson(resp); - const machineId = data?.id; + const machineId = typeof data?.id === "string" ? data.id : undefined; if (!machineId) { logError("Failed to extract machine ID from API response"); throw new Error("No machine ID"); @@ -835,8 +852,9 @@ async function createVolume(name: string, region: string, sizeGb: number): Promi logError("Failed to create volume"); throw new Error("Volume creation failed"); } - logInfo(`Volume created: ${data.id}`); - return data.id; + const volumeId = typeof data.id === "string" ? data.id : String(data.id); + logInfo(`Volume created: ${volumeId}`); + return volumeId; } export async function listVolumes(appName: string): Promise< @@ -847,16 +865,17 @@ export async function listVolumes(appName: string): Promise< }> > { const resp = await flyApi("GET", `/apps/${appName}/volumes`); - const data = parseJson(resp); + const data = parseJsonRaw(resp); if (!Array.isArray(data)) { return []; } - return data - .filter((v: any) => v.id) - .map((v: any) => ({ - id: v.id as string, - name: (v.name || "unnamed") as string, - size_gb: (v.size_gb || 0) as number, + const items = toObjectArray(data); + return items + .filter((item) => item.id) + .map((item) => ({ + id: String(item.id), + name: String(item.name || "unnamed"), + size_gb: typeof item.size_gb === "number" ? item.size_gb : 0, })); } @@ -1209,8 +1228,9 @@ export async function destroyServer(appName?: string): Promise { logStep(`Destroying Fly.io app '${name}'...`); const resp = await flyApi("GET", `/apps/${name}/machines`); - const machines = parseJson(resp); - const ids: string[] = (Array.isArray(machines) ? machines : []).map((m: any) => m.id).filter(Boolean); + const machines = parseJsonRaw(resp); + const machineList = toObjectArray(Array.isArray(machines) ? machines : []); + const ids: string[] = machineList.map((m) => typeof m.id === "string" ? m.id : "").filter(Boolean); for (const mid of ids) { logStep(`Stopping machine ${mid}...`); @@ -1240,8 +1260,14 @@ export async function destroyServer(appName?: string): Promise { export async function listServers(): Promise { const org = flyOrg || process.env.FLY_ORG || "personal"; const resp = await flyApi("GET", `/apps?org_slug=${org}`); - const data = parseJson(resp); - const apps: any[] = Array.isArray(data) ? data : (data?.apps ?? []); + const raw = parseJsonRaw(resp); + let apps: Record[] = []; + if (Array.isArray(raw)) { + apps = toObjectArray(raw); + } else { + const record = parseJson(resp); + apps = record ? toObjectArray(record.apps) : []; + } if (apps.length === 0) { console.log("No apps found"); return; @@ -1251,10 +1277,10 @@ export async function listServers(): Promise { console.log("-".repeat(77)); for (const a of apps) { console.log( - pad((a.name ?? "N/A").slice(0, 24), 25) + - pad((a.id ?? "N/A").slice(0, 19), 20) + - pad((a.status ?? "N/A").slice(0, 11), 12) + - pad((a.network ?? "N/A").slice(0, 19), 20), + pad(String(a.name ?? "N/A").slice(0, 24), 25) + + pad(String(a.id ?? "N/A").slice(0, 19), 20) + + pad(String(a.status ?? "N/A").slice(0, 11), 12) + + pad(String(a.network ?? "N/A").slice(0, 19), 20), ); } } diff --git a/cli/src/hetzner/hetzner.ts b/cli/src/hetzner/hetzner.ts index 57ef479c..0b9d734a 100644 --- a/cli/src/hetzner/hetzner.ts +++ b/cli/src/hetzner/hetzner.ts @@ -16,6 +16,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; +import * as v from "valibot"; +import { parseJsonWith } from "../shared/parse"; const HETZNER_API_BASE = "https://api.hetzner.cloud/v1"; const HETZNER_DASHBOARD_URL = "https://console.hetzner.cloud/"; @@ -80,12 +82,32 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -function parseJson(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; +const LooseObject = v.record(v.string(), v.unknown()); + +function parseJson(text: string): Record | null { + return parseJsonWith(text, LooseObject); +} + +/** Narrow an unknown value to a Record if it is a non-array object */ +function rec(val: unknown): Record | undefined { + if (val && typeof val === "object" && !Array.isArray(val)) { + return Object.fromEntries(Object.entries(val)); } + return undefined; +} + +/** Extract an array of record objects from an unknown value */ +function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { + return []; + } + const result: Record[] = []; + for (const item of val) { + if (item && typeof item === "object" && !Array.isArray(item)) { + result.push(Object.fromEntries(Object.entries(item))); + } + } + return result; } // ─── Token Persistence ─────────────────────────────────────────────────────── @@ -134,7 +156,7 @@ async function testHcloudToken(): Promise { // Hetzner returns { "error": { ... } } on auth failure. // Success responses may contain "error": null inside action objects, // so check for a real error object with a message. - if (data?.error?.message) { + if (rec(data?.error)?.message) { return false; } return true; @@ -214,7 +236,7 @@ function generateSshKeyIfMissing(): { mkdirSync(sshDir, { recursive: true, mode: 0o700, - } as any); + }); logStep("Generating SSH key..."); const result = Bun.spawnSync( [ @@ -277,7 +299,7 @@ export async function ensureSshKey(): Promise { // Check if key is already registered const resp = await hetznerApi("GET", "/ssh_keys"); const data = parseJson(resp); - const sshKeys: any[] = data?.ssh_keys || []; + const sshKeys = toObjectArray(data?.ssh_keys); for (const key of sshKeys) { if (fingerprint && key.fingerprint === fingerprint) { @@ -295,13 +317,15 @@ export async function ensureSshKey(): Promise { }); const regResp = await hetznerApi("POST", "/ssh_keys", body); const regData = parseJson(regResp); - if (regData?.error?.message) { + const regError = rec(regData?.error); + const regErrMsg = typeof regError?.message === "string" ? regError.message : ""; + if (regErrMsg) { // Key may already exist under a different name — non-fatal - if (/already/.test(regData.error.message)) { + if (/already/.test(regErrMsg)) { logInfo("SSH key already registered (different name)"); return; } - logError(`Failed to register SSH key: ${regData.error.message}`); + logError(`Failed to register SSH key: ${regErrMsg}`); throw new Error("SSH key registration failed"); } logInfo("SSH key registered with Hetzner"); @@ -402,7 +426,9 @@ export async function createServer( // Get all SSH key IDs const keysResp = await hetznerApi("GET", "/ssh_keys"); const keysData = parseJson(keysResp); - const sshKeyIds: number[] = (keysData?.ssh_keys || []).map((k: any) => k.id).filter(Boolean); + const sshKeyIds: number[] = toObjectArray(keysData?.ssh_keys) + .map((k) => (typeof k.id === "number" ? k.id : 0)) + .filter(Boolean); const userdata = getCloudInitUserdata(tier); const body = JSON.stringify({ @@ -420,8 +446,9 @@ export async function createServer( // Hetzner success responses contain "error": null in action objects, // so check for presence of .server object, not absence of "error" string. - if (!data?.server) { - const errMsg = data?.error?.message || "Unknown error"; + const server = rec(data?.server); + if (!server) { + const errMsg = rec(data?.error)?.message || "Unknown error"; logError(`Failed to create Hetzner server: ${errMsg}`); logWarn("Common issues:"); logWarn(" - Insufficient account balance or payment method required"); @@ -431,8 +458,10 @@ export async function createServer( throw new Error(`Server creation failed: ${errMsg}`); } - hetznerServerId = String(data.server.id); - hetznerServerIp = data.server.public_net?.ipv4?.ip || ""; + hetznerServerId = String(server.id); + const publicNet = rec(server.public_net); + const ipv4 = rec(publicNet?.ipv4); + hetznerServerIp = typeof ipv4?.ip === "string" ? ipv4.ip : ""; if (!hetznerServerId || hetznerServerId === "null") { logError("Failed to extract server ID from API response"); @@ -761,7 +790,7 @@ export async function destroyServer(serverId?: string): Promise { // Hetzner returns { action: {...} } on success. "error": null in action is normal. if (!data?.action) { - const errMsg = data?.error?.message || "Unknown error"; + const errMsg = rec(data?.error)?.message || "Unknown error"; logError(`Failed to destroy server ${id}: ${errMsg}`); logWarn("The server may still be running and incurring charges."); logWarn(`Delete it manually at: ${HETZNER_DASHBOARD_URL}`); @@ -773,23 +802,28 @@ export async function destroyServer(serverId?: string): Promise { export async function listServers(): Promise { const resp = await hetznerApi("GET", "/servers"); const data = parseJson(resp); - const servers: any[] = data?.servers || []; + const servers = toObjectArray(data?.servers); if (servers.length === 0) { console.log("No servers found"); return; } - const pad = (s: string, n: number) => (s + " ".repeat(n)).slice(0, n); + const pad = (str: string, n: number) => (str + " ".repeat(n)).slice(0, n); + const str = (val: unknown, fallback = "N/A"): string => + typeof val === "string" ? val : (val != null ? String(val) : fallback); console.log(pad("NAME", 25) + pad("ID", 12) + pad("STATUS", 12) + pad("IP", 16) + pad("TYPE", 10)); console.log("-".repeat(75)); for (const s of servers) { + const publicNet = rec(s.public_net); + const ipv4 = rec(publicNet?.ipv4); + const serverType = rec(s.server_type); console.log( - pad((s.name ?? "N/A").slice(0, 24), 25) + - pad(String(s.id ?? "N/A").slice(0, 11), 12) + - pad((s.status ?? "N/A").slice(0, 11), 12) + - pad((s.public_net?.ipv4?.ip ?? "N/A").slice(0, 15), 16) + - pad((s.server_type?.name ?? "N/A").slice(0, 9), 10), + pad(str(s.name).slice(0, 24), 25) + + pad(str(s.id).slice(0, 11), 12) + + pad(str(s.status).slice(0, 11), 12) + + pad(str(ipv4?.ip).slice(0, 15), 16) + + pad(str(serverType?.name).slice(0, 9), 10), ); } } diff --git a/cli/src/history.ts b/cli/src/history.ts index 614dcceb..326a4e1b 100644 --- a/cli/src/history.ts +++ b/cli/src/history.ts @@ -115,7 +115,20 @@ export function mergeLastConnection(): void { } try { - const connData = JSON.parse(readFileSync(connPath, "utf-8")) as VMConnection; + const raw: unknown = JSON.parse(readFileSync(connPath, "utf-8")); + if (!raw || typeof raw !== "object" || !("ip" in raw) || !("user" in raw)) { + unlinkSync(connPath); + return; + } + const entries = Object.fromEntries(Object.entries(raw)); + const connData: VMConnection = { + ip: String(entries.ip ?? ""), + user: String(entries.user ?? ""), + server_id: typeof entries.server_id === "string" ? entries.server_id : undefined, + server_name: typeof entries.server_name === "string" ? entries.server_name : undefined, + cloud: typeof entries.cloud === "string" ? entries.cloud : undefined, + launch_cmd: typeof entries.launch_cmd === "string" ? entries.launch_cmd : undefined, + }; // SECURITY: Validate connection data before merging into history // This prevents malicious bash scripts from injecting invalid data diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts index 6f32292e..4d5b177c 100644 --- a/cli/src/manifest.ts +++ b/cli/src/manifest.ts @@ -99,7 +99,11 @@ function logError(message: string, err?: unknown): void { function readCache(): Manifest | null { try { const raw = JSON.parse(readFileSync(getCacheFile(), "utf-8")); - return stripDangerousKeys(raw) as Manifest; + const cleaned = stripDangerousKeys(raw); + if (isValidManifest(cleaned)) { + return cleaned; + } + return null; } catch (err) { // Cache file missing, corrupted, or unreadable logError(`Failed to read cache from ${getCacheFile()}`, err); @@ -128,25 +132,25 @@ function writeCache(data: Manifest): void { /** Recursively strip __proto__, constructor, and prototype keys from parsed JSON * to prevent prototype pollution attacks (defense in depth). */ -function stripDangerousKeys(obj: any): any { +function stripDangerousKeys(obj: unknown): unknown { if (obj === null || typeof obj !== "object") { return obj; } if (Array.isArray(obj)) { return obj.map(stripDangerousKeys); } - const clean: Record = {}; - for (const key of Object.keys(obj)) { + const clean: Record = {}; + for (const [key, value] of Object.entries(obj)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { continue; } - clean[key] = stripDangerousKeys(obj[key]); + clean[key] = stripDangerousKeys(value); } return clean; } -function isValidManifest(data: any): data is Manifest { - return data && typeof data === "object" && !Array.isArray(data) && data.agents && data.clouds && data.matrix; +function isValidManifest(data: unknown): data is Manifest { + return data !== null && typeof data === "object" && !Array.isArray(data) && "agents" in data && "clouds" in data && "matrix" in data && !!data.agents && !!data.clouds && !!data.matrix; } async function fetchManifestFromGitHub(): Promise { @@ -159,7 +163,7 @@ async function fetchManifestFromGitHub(): Promise { return null; } const raw = await res.json(); - const data = stripDangerousKeys(raw) as Manifest; + const data = stripDangerousKeys(raw); if (!isValidManifest(data)) { logError("Manifest structure validation failed: missing required fields (agents, clouds, or matrix)"); return null; @@ -202,7 +206,7 @@ function tryLoadLocalManifest(): Manifest | null { const raw = JSON.parse(readFileSync(localPath, "utf-8")); const data = stripDangerousKeys(raw); if (isValidManifest(data)) { - return data as Manifest; + return data; } } } catch (_err) { diff --git a/cli/src/picker.ts b/cli/src/picker.ts index 1924c2e0..20a9baab 100644 --- a/cli/src/picker.ts +++ b/cli/src/picker.ts @@ -93,8 +93,10 @@ function getTTYCols(ttyFd: number): number { if (res.status === 0 && res.stdout) { const parts = res.stdout.toString().trim().split(/\s+/); if (parts.length >= 2) { - const c = parseInt(parts[1], 10); - if (c > 0) return c; + const c = Number.parseInt(parts[1], 10); + if (c > 0) { + return c; + } } } } catch {} diff --git a/cli/src/shared/oauth.ts b/cli/src/shared/oauth.ts index 7967a321..860806a5 100644 --- a/cli/src/shared/oauth.ts +++ b/cli/src/shared/oauth.ts @@ -1,7 +1,13 @@ // shared/oauth.ts — OpenRouter OAuth flow + API key management +import * as v from "valibot"; +import { parseJsonWith } from "./parse"; import { logInfo, logWarn, logError, logStep, prompt, openBrowser, validateModelId } from "./ui"; +// ─── Schemas ───────────────────────────────────────────────────────────────── + +const OAuthKeySchema = v.object({ key: v.string() }); + // ─── Key Validation ────────────────────────────────────────────────────────── export async function verifyOpenrouterKey(apiKey: string): Promise { @@ -163,8 +169,8 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: }), signal: AbortSignal.timeout(30_000), }); - const data = (await resp.json()) as any; - if (data.key) { + const data = parseJsonWith(await resp.text(), OAuthKeySchema); + if (data?.key) { logInfo("Successfully obtained OpenRouter API key via OAuth!"); return data.key; } diff --git a/cli/src/shared/parse.ts b/cli/src/shared/parse.ts new file mode 100644 index 00000000..b21e4412 --- /dev/null +++ b/cli/src/shared/parse.ts @@ -0,0 +1,31 @@ +// shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts) + +import * as v from "valibot"; + +/** + * Parse a JSON string and validate it against a valibot schema. + * Returns the validated value, or null if parsing/validation fails. + */ +export function parseJsonWith>>( + text: string, + schema: T, +): v.InferOutput | null { + try { + return v.parse(schema, JSON.parse(text)); + } catch { + return null; + } +} + +/** + * Escape hatch: parse JSON to `unknown` without schema validation. + * Use for dynamic response formats where a fixed schema isn't practical + * (e.g., Fly orgs with 5+ response shapes). + */ +export function parseJsonRaw(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} diff --git a/cli/src/shared/type-guards.ts b/cli/src/shared/type-guards.ts new file mode 100644 index 00000000..a6bdd575 --- /dev/null +++ b/cli/src/shared/type-guards.ts @@ -0,0 +1,17 @@ +// shared/type-guards.ts — Runtime type guards (replaces unsafe `as` casts on non-API values) + +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +export function hasStatus(err: unknown): err is { status: number } { + return err !== null && typeof err === "object" && "status" in err && typeof err.status === "number"; +} + +export function hasMessage(err: unknown): err is { message: string } { + return err !== null && typeof err === "object" && "message" in err && typeof err.message === "string"; +} diff --git a/cli/src/shared/ui.ts b/cli/src/shared/ui.ts index 0270bdce..4862e241 100644 --- a/cli/src/shared/ui.ts +++ b/cli/src/shared/ui.ts @@ -93,7 +93,7 @@ export async function selectFromList(items: string[], promptText: string, defaul if (p.isCancel(result)) { return defaultValue; } - return result as string; + return typeof result === "string" ? result : String(result); } /** Open a URL in the user's browser. */ diff --git a/cli/src/update-check.ts b/cli/src/update-check.ts index 67cbcea4..913be8c7 100644 --- a/cli/src/update-check.ts +++ b/cli/src/update-check.ts @@ -8,6 +8,8 @@ import { import fs from "node:fs"; import path from "node:path"; import pc from "picocolors"; +import * as v from "valibot"; +import { parseJsonWith } from "./shared/parse"; import pkg from "../package.json" with { type: "json" }; import { RAW_BASE } from "./manifest.js"; @@ -21,6 +23,10 @@ export const executor = { // ── Constants ────────────────────────────────────────────────────────────────── +// ── Schemas ────────────────────────────────────────────────────────────────── + +const PkgVersionSchema = v.object({ version: v.string() }); + const FETCH_TIMEOUT = 10000; // 10 seconds const UPDATE_BACKOFF_MS = 60 * 60 * 1000; // 1 hour @@ -47,10 +53,8 @@ async function fetchLatestVersion(): Promise { return null; } - const pkg = (await res.json()) as { - version: string; - }; - return pkg.version; + const data = parseJsonWith(await res.text(), PkgVersionSchema); + return data?.version ?? null; } catch { return null; } @@ -182,12 +186,8 @@ function reExecWithArgs(): void { process.exit(0); } catch (reexecErr) { const code = - reexecErr && typeof reexecErr === "object" && "status" in reexecErr - ? ( - reexecErr as { - status: number; - } - ).status + reexecErr && typeof reexecErr === "object" && "status" in reexecErr && typeof reexecErr.status === "number" + ? reexecErr.status : 1; process.exit(code); }