feat: ban as type assertions, add runtime schema validation with valibot (#1775)

* fix: resolve all biome lint warnings across the codebase

- Replace all noExplicitAny with proper types (unknown, Record<string, unknown>)
- 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* refactor: move GritQL plugin into cli/lint/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 18:50:53 -08:00 committed by GitHub
parent f0a70b66a1
commit b62dc1af33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 954 additions and 1300 deletions

View file

@ -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<string, unknown> | 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<string, unknown>[] {
if (!Array.isArray(val)) return [];
return val.filter((item): item is Record<string, unknown> =>
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

View file

@ -97,6 +97,7 @@
"bracketSameLine": false
}
},
"plugins": ["./lint/no-type-assertion.grit"],
"assist": {
"enabled": false
}

View file

@ -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=="],
}
}

View file

@ -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"
)
}

View file

@ -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",

View file

@ -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");
});

View file

@ -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);
});

View file

@ -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();

View file

@ -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);
}

View file

@ -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 {

View file

@ -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();

View file

@ -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;

View file

@ -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");

View file

@ -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<Response> => {
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();

View file

@ -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);

View file

@ -142,11 +142,7 @@ describe("Display Name Suggestions in Validation Errors", () => {
let processExitSpy: ReturnType<typeof spyOn>;
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);

View file

@ -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,

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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<string>;
}>,
scriptHandler: (url: string) => Promise<Response>,
) {
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 () => "<!DOCTYPE html>\n<html><body>Error page</body></html>",
};
return new Response("<!DOCTYPE html>\n<html><body>Error page</body></html>");
}
return {
ok: false,
status: 404,
};
return new Response("Not Found", { status: 404 });
});
try {

View file

@ -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);
}
});

View file

@ -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

View file

@ -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);

View file

@ -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);
}

View file

@ -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 {

View file

@ -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"), {

View file

@ -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();
});
});

View file

@ -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", () => {

View file

@ -21,7 +21,7 @@ import type { Manifest } from "../manifest";
// ── Test manifest ───────────────────────────────────────────────────────
function makeManifest(overrides?: Partial<Manifest>): Manifest {
return {
const base: Manifest = {
agents: {
claude: {
name: "Claude Code",
@ -110,15 +110,15 @@ function makeManifest(overrides?: Partial<Manifest>): 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(() => {}),

View file

@ -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,
}),
),
);
}

View file

@ -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);

View file

@ -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<void> {
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<string> {
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<void> {
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<void> {
instanceName,
}),
);
const data = parseJson(resp);
const data = parseJsonWith(resp, InstanceStateSchema);
ip = data?.instance?.publicIpAddress || "";
}
} catch {
@ -1223,8 +1239,8 @@ export async function listServers(): Promise<void> {
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));

View file

@ -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<string> {
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`;

View file

@ -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<void> {
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<string, unknown> via valibot, or null. */
function parseJson(text: string): Record<string, unknown> | null {
return parseJsonWith(text, LooseObject);
}
/** Narrow an already-parsed unknown value to a Record<string, unknown>, or null. */
function toRecord(val: unknown): Record<string, unknown> | null {
const result = v.safeParse(LooseObject, val);
return result.success ? result.output : null;
}
/** Filter an array to only Record<string, unknown> entries. */
function toObjectArray(val: unknown): Record<string, unknown>[] {
if (!Array.isArray(val)) { return []; }
return val.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === "object" && !Array.isArray(item),
);
}
async function daytonaApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise<string> {
@ -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<void> {
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<void> {
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<void> {
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<void> {
export async function listServers(): Promise<void> {
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<void> {
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),
);
}
}

View file

@ -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<void> {
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<string, unknown> | null {
return parseJsonWith(text, LooseObject);
}
function toObjectArray(val: unknown): Record<string, unknown>[] {
if (!Array.isArray(val)) {
return [];
}
return val.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item));
}
// ─── Token Persistence ───────────────────────────────────────────────────────
@ -275,15 +282,18 @@ async function tryRefreshDoToken(): Promise<string | null> {
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<string | null> {
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<void> {
// 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<string, unknown>) => {
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<void> {
export async function listServers(): Promise<void> {
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<void> {
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),
);
}
}

View file

@ -56,7 +56,13 @@ export const agents: Record<string, FlyAgentConfig> = (() => {
})();
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<void> {

View file

@ -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<void> {
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<string, unknown> | null {
return parseJsonWith(text, LooseObject);
}
function toObjectArray(val: unknown): Record<string, unknown>[] {
if (!Array.isArray(val)) { return []; }
return val
.filter((item): item is Record<string, unknown> =>
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<string, unknown>[] = [];
if (Array.isArray(raw)) {
orgs = toObjectArray(raw);
} else {
// Re-parse as Record<string, unknown> 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<void> {
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<void> {
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<void> {
export async function listServers(): Promise<void> {
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<string, unknown>[] = [];
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<void> {
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),
);
}
}

View file

@ -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<void> {
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<string, unknown> | null {
return parseJsonWith(text, LooseObject);
}
/** Narrow an unknown value to a Record if it is a non-array object */
function rec(val: unknown): Record<string, unknown> | 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<string, unknown>[] {
if (!Array.isArray(val)) {
return [];
}
const result: Record<string, unknown>[] = [];
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<boolean> {
// 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<void> {
// 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<void> {
});
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<void> {
// 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<void> {
export async function listServers(): Promise<void> {
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),
);
}
}

View file

@ -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

View file

@ -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<string, any> = {};
for (const key of Object.keys(obj)) {
const clean: Record<string, unknown> = {};
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<Manifest | null> {
@ -159,7 +163,7 @@ async function fetchManifestFromGitHub(): Promise<Manifest | null> {
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) {

View file

@ -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 {}

View file

@ -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<boolean> {
@ -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;
}

31
cli/src/shared/parse.ts Normal file
View file

@ -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<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
text: string,
schema: T,
): v.InferOutput<T> | 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;
}
}

View file

@ -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";
}

View file

@ -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. */

View file

@ -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<string | null> {
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);
}