diff --git a/.github/workflows/sdk-typescript-ci.yml b/.github/workflows/sdk-typescript-ci.yml new file mode 100644 index 0000000..a3b1e5e --- /dev/null +++ b/.github/workflows/sdk-typescript-ci.yml @@ -0,0 +1,49 @@ +name: TypeScript SDK CI + +on: + push: + branches: [main] + paths: + - 'packages/sdk-typescript/**' + pull_request: + branches: [main] + paths: + - 'packages/sdk-typescript/**' + +concurrency: + group: sdk-typescript-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --filter @nimblebrain/mpak-sdk... build + + - name: Lint + run: pnpm --filter @nimblebrain/mpak-sdk lint + + - name: Format check + run: pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" + + - name: Typecheck + run: pnpm --filter @nimblebrain/mpak-sdk typecheck + + - name: Test + run: pnpm --filter @nimblebrain/mpak-sdk test diff --git a/.github/workflows/sdk-typescript-publish.yml b/.github/workflows/sdk-typescript-publish.yml new file mode 100644 index 0000000..cb3d3d8 --- /dev/null +++ b/.github/workflows/sdk-typescript-publish.yml @@ -0,0 +1,57 @@ +name: Publish TypeScript SDK + +on: + push: + tags: + - 'sdk-typescript-v*' + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @nimblebrain/mpak-sdk... build + - run: pnpm --filter @nimblebrain/mpak-sdk lint + - run: pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" + - run: pnpm --filter @nimblebrain/mpak-sdk typecheck + - run: pnpm --filter @nimblebrain/mpak-sdk test + + publish: + needs: verify + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - run: pnpm install --frozen-lockfile + + - name: Verify tag matches package version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/sdk-typescript-v}" + PKG_VERSION=$(node -e "console.log(require('./packages/sdk-typescript/package.json').version)") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) != package.json version ($PKG_VERSION)" + exit 1 + fi + + - name: Build + run: pnpm --filter @nimblebrain/mpak-sdk... build + + - name: Publish to npm + run: npm publish --provenance --access public + working-directory: packages/sdk-typescript diff --git a/PUBLISHING.md b/PUBLISHING.md index 3067f12..6079f9d 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -103,7 +103,7 @@ pnpm build && pnpm publish --no-git-checks cd - # 3. Bump and publish sdk -cd packages/sdk +cd packages/sdk-typescript npm version patch pnpm build && pnpm publish --no-git-checks cd - @@ -115,7 +115,7 @@ pnpm build && pnpm publish --no-git-checks cd - # 5. Commit and tag each -git add packages/schemas/package.json packages/sdk/package.json packages/cli/package.json +git add packages/schemas/package.json packages/sdk-typescript/package.json packages/cli/package.json git commit -m "release: mpak-schemas@0.2.0, mpak-sdk@0.1.1, mpak@0.1.1" git push ``` diff --git a/README.md b/README.md index ec4a9e5..788fb2c 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Zod schemas and inferred TypeScript types for the MCPB manifest format, API resp **Key exports:** `BundleSchema`, `SkillSchema`, `MpakJsonSchema`, `SearchParamsSchema`, validation functions. -### `packages/sdk` +### `packages/sdk-typescript` TypeScript SDK for interacting with a mpak registry. Wraps the HTTP API with typed methods for searching, downloading, and inspecting bundles and skills. diff --git a/SECURITY.md b/SECURITY.md index df75bc3..6538478 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,7 +29,7 @@ The following are in scope: - The mpak registry server (`apps/registry`) - The mpak CLI (`packages/cli`) -- The mpak SDK (`packages/sdk`) +- The mpak SDK (`packages/sdk-typescript`) - The mpak web UI (`apps/web`) - The MTF security scanner (`apps/scanner`) - The OIDC publishing flow diff --git a/packages/sdk/README.md b/packages/sdk-typescript/README.md similarity index 66% rename from packages/sdk/README.md rename to packages/sdk-typescript/README.md index e0a61c3..168ab24 100644 --- a/packages/sdk/README.md +++ b/packages/sdk-typescript/README.md @@ -1,13 +1,12 @@ # @nimblebrain/mpak-sdk -TypeScript SDK for mpak registry - MCPB bundles and Agent Skills. +[![CI](https://github.com/NimbleBrainInc/mpak/actions/workflows/sdk-typescript-ci.yml/badge.svg)](https://github.com/NimbleBrainInc/mpak/actions/workflows/sdk-typescript-ci.yml) +[![npm](https://img.shields.io/npm/v/@nimblebrain/mpak-sdk)](https://www.npmjs.com/package/@nimblebrain/mpak-sdk) +[![Node](https://img.shields.io/node/v/@nimblebrain/mpak-sdk)](https://www.npmjs.com/package/@nimblebrain/mpak-sdk) +[![License](https://img.shields.io/npm/l/@nimblebrain/mpak-sdk)](https://github.com/NimbleBrainInc/mpak/blob/main/packages/sdk-typescript/LICENSE) +[![mpak.dev](https://mpak.dev/badge.svg)](https://mpak.dev) -## Features - -- Type-safe API (types from `@nimblebrain/mpak-schemas`) -- Fail-closed integrity verification -- Skill resolution from mpak, GitHub, and URL sources -- Requires Node.js 18+ (native fetch) +TypeScript SDK for the mpak registry - search, download, and resolve MCPB bundles and Agent Skills. ## Installation @@ -15,9 +14,7 @@ TypeScript SDK for mpak registry - MCPB bundles and Agent Skills. pnpm add @nimblebrain/mpak-sdk ``` -## Usage - -### Search Bundles +## Quick Start ```typescript import { MpakClient } from '@nimblebrain/mpak-sdk'; @@ -26,12 +23,18 @@ const client = new MpakClient(); // Search for bundles const results = await client.searchBundles({ q: 'mcp', limit: 10 }); - for (const bundle of results.bundles) { console.log(`${bundle.name}@${bundle.latest_version}`); } + +// Get download info +const download = await client.getBundleDownload('@nimblebraininc/echo', 'latest'); +console.log(`Download URL: ${download.url}`); +console.log(`SHA256: ${download.bundle.sha256}`); ``` +## Usage + ### Get Bundle Details ```typescript @@ -41,20 +44,6 @@ console.log(bundle.description); console.log(`Versions: ${bundle.versions.map(v => v.version).join(', ')}`); ``` -### Download a Bundle - -```typescript -// Get download info for the latest version -const versions = await client.getBundleVersions('@nimblebraininc/echo'); -const download = await client.getBundleDownload( - '@nimblebraininc/echo', - versions.latest -); - -console.log(`Download URL: ${download.url}`); -console.log(`SHA256: ${download.bundle.sha256}`); -``` - ### Platform-Specific Downloads ```typescript @@ -220,6 +209,50 @@ pnpm typecheck pnpm build ``` +### Verification + +Run all checks before submitting changes: + +```bash +pnpm --filter @nimblebrain/mpak-sdk lint # lint +pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" # format +pnpm --filter @nimblebrain/mpak-sdk typecheck # type check +pnpm --filter @nimblebrain/mpak-sdk test # unit tests +pnpm --filter @nimblebrain/mpak-sdk test:integration # integration tests (hits live registry) +``` + +CI runs lint, format check, typecheck, and unit tests on every PR via [`sdk-typescript-ci.yml`](../../.github/workflows/sdk-typescript-ci.yml). + +## Releasing + +Releases are automated via GitHub Actions. The publish workflow is triggered by git tags. + +**Version is defined in one place:** `package.json`. + +### Steps + +1. **Bump version** in `package.json`: + ```bash + cd packages/sdk-typescript + npm version patch # 0.1.0 -> 0.1.1 + npm version minor # 0.1.0 -> 0.2.0 + npm version major # 0.1.0 -> 1.0.0 + ``` + +2. **Commit and push:** + ```bash + git commit -am "sdk-typescript: bump to X.Y.Z" + git push + ``` + +3. **Tag and push** (this triggers the publish): + ```bash + git tag sdk-typescript-vX.Y.Z + git push origin sdk-typescript-vX.Y.Z + ``` + +CI will run the full verification suite, verify the tag matches `package.json`, build, and publish to npm. See [`sdk-typescript-publish.yml`](../../.github/workflows/sdk-typescript-publish.yml). + ## License Apache-2.0 diff --git a/packages/sdk/package.json b/packages/sdk-typescript/package.json similarity index 94% rename from packages/sdk/package.json rename to packages/sdk-typescript/package.json index 3bd1d98..97056ad 100644 --- a/packages/sdk/package.json +++ b/packages/sdk-typescript/package.json @@ -1,13 +1,13 @@ { "name": "@nimblebrain/mpak-sdk", - "version": "0.1.0", + "version": "0.1.2", "description": "TypeScript SDK for mpak registry - MCPB bundles and Agent Skills", "author": "NimbleBrain Inc ", "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/NimbleBrainInc/mpak.git", - "directory": "packages/sdk" + "directory": "packages/sdk-typescript" }, "homepage": "https://mpak.dev", "bugs": { @@ -50,7 +50,7 @@ "build": "tsup", "clean": "rm -rf dist coverage", "dev": "tsup --watch", - "lint": "eslint src/", + "lint": "eslint src/ tests/", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/sdk/src/client.ts b/packages/sdk-typescript/src/client.ts similarity index 68% rename from packages/sdk/src/client.ts rename to packages/sdk-typescript/src/client.ts index 12d7afc..49af3ab 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -1,4 +1,4 @@ -import { createHash } from "crypto"; +import { createHash } from 'crypto'; import type { MpakClientConfig, BundleSearchParams, @@ -14,18 +14,11 @@ import type { GithubSkillReference, UrlSkillReference, ResolvedSkill, -} from "./types.js"; -import type { - BundleSearchResponse, - SkillSearchResponse, -} from "@nimblebrain/mpak-schemas"; -import { - MpakNotFoundError, - MpakIntegrityError, - MpakNetworkError, -} from "./errors.js"; - -const DEFAULT_REGISTRY_URL = "https://registry.mpak.dev"; +} from './types.js'; +import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas'; +import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from './errors.js'; + +const DEFAULT_REGISTRY_URL = 'https://registry.mpak.dev'; const DEFAULT_TIMEOUT = 30000; /** @@ -52,25 +45,25 @@ export class MpakClient { /** * Search for bundles */ - async searchBundles( - params: BundleSearchParams = {}, - ): Promise { + async searchBundles(params: BundleSearchParams = {}): Promise { const searchParams = new URLSearchParams(); - if (params.q) searchParams.set("q", params.q); - if (params.type) searchParams.set("type", params.type); - if (params.sort) searchParams.set("sort", params.sort); - if (params.limit) searchParams.set("limit", String(params.limit)); - if (params.offset) searchParams.set("offset", String(params.offset)); + if (params.q) searchParams.set('q', params.q); + if (params.type) searchParams.set('type', params.type); + if (params.sort) searchParams.set('sort', params.sort); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); const queryString = searchParams.toString(); - const url = `${this.registryUrl}/v1/bundles/search${queryString ? `?${queryString}` : ""}`; + const url = `${this.registryUrl}/v1/bundles/search${queryString ? `?${queryString}` : ''}`; const response = await this.fetchWithTimeout(url); + if (response.status === 404) { + throw new MpakNotFoundError('bundles/search endpoint'); + } + if (!response.ok) { - throw new MpakNetworkError( - `Failed to search bundles: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to search bundles: HTTP ${response.status}`); } return response.json() as Promise; @@ -90,9 +83,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get bundle: HTTP ${response.status}`); } return response.json() as Promise; @@ -112,9 +103,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle versions: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get bundle versions: HTTP ${response.status}`); } return response.json() as Promise; @@ -123,10 +112,7 @@ export class MpakClient { /** * Get a specific version of a bundle */ - async getBundleVersion( - name: string, - version: string, - ): Promise { + async getBundleVersion(name: string, version: string): Promise { this.validateScopedName(name); const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}`; @@ -137,9 +123,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle version: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get bundle version: HTTP ${response.status}`); } return response.json() as Promise; @@ -157,15 +141,15 @@ export class MpakClient { const params = new URLSearchParams(); if (platform) { - params.set("os", platform.os); - params.set("arch", platform.arch); + params.set('os', platform.os); + params.set('arch', platform.arch); } const queryString = params.toString(); - const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}/download${queryString ? `?${queryString}` : ""}`; + const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}/download${queryString ? `?${queryString}` : ''}`; const response = await this.fetchWithTimeout(url, { - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, }); if (response.status === 404) { @@ -173,9 +157,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle download: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get bundle download: HTTP ${response.status}`); } return response.json() as Promise; @@ -188,27 +170,27 @@ export class MpakClient { /** * Search for skills */ - async searchSkills( - params: SkillSearchParams = {}, - ): Promise { + async searchSkills(params: SkillSearchParams = {}): Promise { const searchParams = new URLSearchParams(); - if (params.q) searchParams.set("q", params.q); - if (params.tags) searchParams.set("tags", params.tags); - if (params.category) searchParams.set("category", params.category); - if (params.surface) searchParams.set("surface", params.surface); - if (params.sort) searchParams.set("sort", params.sort); - if (params.limit) searchParams.set("limit", String(params.limit)); - if (params.offset) searchParams.set("offset", String(params.offset)); + if (params.q) searchParams.set('q', params.q); + if (params.tags) searchParams.set('tags', params.tags); + if (params.category) searchParams.set('category', params.category); + if (params.surface) searchParams.set('surface', params.surface); + if (params.sort) searchParams.set('sort', params.sort); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); const queryString = searchParams.toString(); - const url = `${this.registryUrl}/v1/skills/search${queryString ? `?${queryString}` : ""}`; + const url = `${this.registryUrl}/v1/skills/search${queryString ? `?${queryString}` : ''}`; const response = await this.fetchWithTimeout(url); + if (response.status === 404) { + throw new MpakNotFoundError('skills/search endpoint'); + } + if (!response.ok) { - throw new MpakNetworkError( - `Failed to search skills: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to search skills: HTTP ${response.status}`); } return response.json() as Promise; @@ -228,9 +210,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get skill: HTTP ${response.status}`); } return response.json() as Promise; @@ -245,7 +225,7 @@ export class MpakClient { const url = `${this.registryUrl}/v1/skills/${name}/download`; const response = await this.fetchWithTimeout(url, { - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, }); if (response.status === 404) { @@ -253,9 +233,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill download: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); } return response.json() as Promise; @@ -264,16 +242,13 @@ export class MpakClient { /** * Get download info for a specific skill version */ - async getSkillVersionDownload( - name: string, - version: string, - ): Promise { + async getSkillVersionDownload(name: string, version: string): Promise { this.validateScopedName(name); const url = `${this.registryUrl}/v1/skills/${name}/versions/${version}/download`; const response = await this.fetchWithTimeout(url, { - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, }); if (response.status === 404) { @@ -281,9 +256,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill download: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); } return response.json() as Promise; @@ -301,9 +274,7 @@ export class MpakClient { const response = await this.fetchWithTimeout(downloadUrl); if (!response.ok) { - throw new MpakNetworkError( - `Failed to download skill: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to download skill: HTTP ${response.status}`); } const content = await response.text(); @@ -358,17 +329,15 @@ export class MpakClient { */ async resolveSkillRef(ref: SkillReference): Promise { switch (ref.source) { - case "mpak": + case 'mpak': return this.resolveMpakSkill(ref); - case "github": + case 'github': return this.resolveGithubSkill(ref); - case "url": + case 'url': return this.resolveUrlSkill(ref); default: { const _exhaustive: never = ref; - throw new Error( - `Unknown skill source: ${(_exhaustive as SkillReference).source}`, - ); + throw new Error(`Unknown skill source: ${(_exhaustive as SkillReference).source}`); } } } @@ -378,9 +347,7 @@ export class MpakClient { * * The API returns a ZIP bundle containing SKILL.md and metadata. */ - private async resolveMpakSkill( - ref: SkillReference & { source: "mpak" }, - ): Promise { + private async resolveMpakSkill(ref: SkillReference & { source: 'mpak' }): Promise { const url = `${this.registryUrl}/v1/skills/${ref.name}/versions/${ref.version}/download`; const response = await this.fetchWithTimeout(url); @@ -390,9 +357,7 @@ export class MpakClient { } if (!response.ok) { - throw new MpakNetworkError( - `Failed to fetch skill: HTTP ${response.status}`, - ); + throw new MpakNetworkError(`Failed to fetch skill: HTTP ${response.status}`); } // Response is a ZIP file - extract SKILL.md @@ -401,25 +366,21 @@ export class MpakClient { if (ref.integrity) { this.verifyIntegrityOrThrow(content, ref.integrity); - return { content, version: ref.version, source: "mpak", verified: true }; + return { content, version: ref.version, source: 'mpak', verified: true }; } - return { content, version: ref.version, source: "mpak", verified: false }; + return { content, version: ref.version, source: 'mpak', verified: false }; } /** * Resolve a skill from GitHub releases */ - private async resolveGithubSkill( - ref: GithubSkillReference, - ): Promise { + private async resolveGithubSkill(ref: GithubSkillReference): Promise { const url = `https://github.com/${ref.repo}/releases/download/${ref.version}/${ref.path}`; const response = await this.fetchWithTimeout(url); if (!response.ok) { - throw new MpakNotFoundError( - `github:${ref.repo}/${ref.path}@${ref.version}`, - ); + throw new MpakNotFoundError(`github:${ref.repo}/${ref.path}@${ref.version}`); } const content = await response.text(); @@ -429,7 +390,7 @@ export class MpakClient { return { content, version: ref.version, - source: "github", + source: 'github', verified: true, }; } @@ -437,7 +398,7 @@ export class MpakClient { return { content, version: ref.version, - source: "github", + source: 'github', verified: false, }; } @@ -445,9 +406,7 @@ export class MpakClient { /** * Resolve a skill from a direct URL */ - private async resolveUrlSkill( - ref: UrlSkillReference, - ): Promise { + private async resolveUrlSkill(ref: UrlSkillReference): Promise { const response = await this.fetchWithTimeout(ref.url); if (!response.ok) { @@ -458,39 +417,34 @@ export class MpakClient { if (ref.integrity) { this.verifyIntegrityOrThrow(content, ref.integrity); - return { content, version: ref.version, source: "url", verified: true }; + return { content, version: ref.version, source: 'url', verified: true }; } - return { content, version: ref.version, source: "url", verified: false }; + return { content, version: ref.version, source: 'url', verified: false }; } /** * Extract SKILL.md content from a skill bundle ZIP */ - private async extractSkillFromZip( - zipBuffer: ArrayBuffer, - skillName: string, - ): Promise { - const JSZip = (await import("jszip")).default; + private async extractSkillFromZip(zipBuffer: ArrayBuffer, skillName: string): Promise { + const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(zipBuffer); // Skill name format: @scope/name -> folder is just 'name' - const folderName = skillName.split("/").pop() ?? skillName; + const folderName = skillName.split('/').pop() ?? skillName; const skillPath = `${folderName}/SKILL.md`; const skillFile = zip.file(skillPath); if (!skillFile) { // Try without folder prefix - const altFile = zip.file("SKILL.md"); + const altFile = zip.file('SKILL.md'); if (!altFile) { - throw new MpakNotFoundError( - `SKILL.md not found in bundle for ${skillName}`, - ); + throw new MpakNotFoundError(`SKILL.md not found in bundle for ${skillName}`); } - return altFile.async("string"); + return altFile.async('string'); } - return skillFile.async("string"); + return skillFile.async('string'); } /** @@ -509,10 +463,10 @@ export class MpakClient { * Extract hash from integrity string (removes prefix) */ private extractHash(integrity: string): string { - if (integrity.startsWith("sha256:")) { + if (integrity.startsWith('sha256:')) { return integrity.slice(7); } - if (integrity.startsWith("sha256-")) { + if (integrity.startsWith('sha256-')) { return integrity.slice(7); } return integrity; @@ -531,29 +485,29 @@ export class MpakClient { let os: string; switch (nodePlatform) { - case "darwin": - os = "darwin"; + case 'darwin': + os = 'darwin'; break; - case "win32": - os = "win32"; + case 'win32': + os = 'win32'; break; - case "linux": - os = "linux"; + case 'linux': + os = 'linux'; break; default: - os = "any"; + os = 'any'; } let arch: string; switch (nodeArch) { - case "x64": - arch = "x64"; + case 'x64': + arch = 'x64'; break; - case "arm64": - arch = "arm64"; + case 'arm64': + arch = 'arm64'; break; default: - arch = "any"; + arch = 'any'; } return { os, arch }; @@ -563,27 +517,22 @@ export class MpakClient { * Compute SHA256 hash of content */ private computeSha256(content: string): string { - return createHash("sha256").update(content, "utf8").digest("hex"); + return createHash('sha256').update(content, 'utf8').digest('hex'); } /** * Validate that a name is scoped (@scope/name) */ private validateScopedName(name: string): void { - if (!name.startsWith("@")) { - throw new Error( - "Package name must be scoped (e.g., @scope/package-name)", - ); + if (!name.startsWith('@')) { + throw new Error('Package name must be scoped (e.g., @scope/package-name)'); } } /** * Fetch with timeout support */ - private async fetchWithTimeout( - url: string, - init?: RequestInit, - ): Promise { + private async fetchWithTimeout(url: string, init?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); @@ -593,7 +542,7 @@ export class MpakClient { ...(init?.headers as Record), }; if (this.userAgent) { - headers["User-Agent"] = this.userAgent; + headers['User-Agent'] = this.userAgent; } try { @@ -603,14 +552,10 @@ export class MpakClient { signal: controller.signal, }); } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new MpakNetworkError( - `Request timeout after ${this.timeout}ms`, - ); + if (error instanceof Error && error.name === 'AbortError') { + throw new MpakNetworkError(`Request timeout after ${this.timeout}ms`); } - throw new MpakNetworkError( - error instanceof Error ? error.message : "Network error", - ); + throw new MpakNetworkError(error instanceof Error ? error.message : 'Network error'); } finally { clearTimeout(timeoutId); } diff --git a/packages/sdk/src/errors.ts b/packages/sdk-typescript/src/errors.ts similarity index 73% rename from packages/sdk/src/errors.ts rename to packages/sdk-typescript/src/errors.ts index 3cfe500..10af527 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk-typescript/src/errors.ts @@ -7,7 +7,7 @@ export class MpakError extends Error { constructor(message: string, code: string, statusCode?: number) { super(message); - this.name = "MpakError"; + this.name = 'MpakError'; this.code = code; this.statusCode = statusCode; } @@ -18,8 +18,8 @@ export class MpakError extends Error { */ export class MpakNotFoundError extends MpakError { constructor(resource: string) { - super(`Resource not found: ${resource}`, "NOT_FOUND", 404); - this.name = "MpakNotFoundError"; + super(`Resource not found: ${resource}`, 'NOT_FOUND', 404); + this.name = 'MpakNotFoundError'; } } @@ -32,11 +32,8 @@ export class MpakIntegrityError extends MpakError { actual: string; constructor(expected: string, actual: string) { - super( - `Integrity mismatch: expected ${expected}, got ${actual}`, - "INTEGRITY_MISMATCH", - ); - this.name = "MpakIntegrityError"; + super(`Integrity mismatch: expected ${expected}, got ${actual}`, 'INTEGRITY_MISMATCH'); + this.name = 'MpakIntegrityError'; this.expected = expected; this.actual = actual; } @@ -47,7 +44,7 @@ export class MpakIntegrityError extends MpakError { */ export class MpakNetworkError extends MpakError { constructor(message: string) { - super(message, "NETWORK_ERROR"); - this.name = "MpakNetworkError"; + super(message, 'NETWORK_ERROR'); + this.name = 'MpakNetworkError'; } } diff --git a/packages/sdk/src/index.ts b/packages/sdk-typescript/src/index.ts similarity index 77% rename from packages/sdk/src/index.ts rename to packages/sdk-typescript/src/index.ts index e03d5e2..d6c9a10 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -31,10 +31,10 @@ * ``` */ -export { MpakClient } from "./client.js"; +export { MpakClient } from './client.js'; // Configuration -export type { MpakClientConfig } from "./types.js"; +export type { MpakClientConfig } from './types.js'; // Bundle types export type { @@ -48,10 +48,10 @@ export type { BundleVersion, BundleArtifact, BundleDownloadInfo, -} from "./types.js"; +} from './types.js'; // Re-export BundleSearchResponse from schemas -export type { BundleSearchResponse } from "@nimblebrain/mpak-schemas"; +export type { BundleSearchResponse } from '@nimblebrain/mpak-schemas'; // Skill types export type { @@ -68,18 +68,13 @@ export type { GithubSkillReference, UrlSkillReference, ResolvedSkill, -} from "./types.js"; +} from './types.js'; // Re-export SkillSearchResponse from schemas -export type { SkillSearchResponse } from "@nimblebrain/mpak-schemas"; +export type { SkillSearchResponse } from '@nimblebrain/mpak-schemas'; // Common types -export type { Platform, Pagination, Provenance, Author } from "./types.js"; +export type { Platform, Pagination, Provenance, Author } from './types.js'; // Errors -export { - MpakError, - MpakNotFoundError, - MpakIntegrityError, - MpakNetworkError, -} from "./errors.js"; +export { MpakError, MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from './errors.js'; diff --git a/packages/sdk/src/types.ts b/packages/sdk-typescript/src/types.ts similarity index 90% rename from packages/sdk/src/types.ts rename to packages/sdk-typescript/src/types.ts index 10c41ec..7f23368 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -19,7 +19,7 @@ import type { FullProvenance, Bundle, SkillSummary, -} from "@nimblebrain/mpak-schemas"; +} from '@nimblebrain/mpak-schemas'; // ============================================================================= // Re-exports from @nimblebrain/mpak-schemas @@ -56,10 +56,10 @@ export type { Bundle }; export type { BundleDetail }; /** Version in versions listing */ -export type BundleVersion = VersionsResponse["versions"][number]; +export type BundleVersion = VersionsResponse['versions'][number]; /** Artifact in version detail */ -export type BundleArtifact = VersionDetail["artifacts"][number]; +export type BundleArtifact = VersionDetail['artifacts'][number]; /** Download info alias */ export type BundleDownloadInfo = DownloadInfo; @@ -74,7 +74,7 @@ export type { SkillDetail }; export type { SkillDownloadInfo }; /** Skill version in detail */ -export type SkillVersion = SkillDetail["versions"][number]; +export type SkillVersion = SkillDetail['versions'][number]; // ============================================================================= // Common Types @@ -104,7 +104,7 @@ export interface Author { export interface BundleSearchParams { q?: string; type?: string; - sort?: "downloads" | "recent" | "name"; + sort?: 'downloads' | 'recent' | 'name'; limit?: number; offset?: number; } @@ -115,7 +115,7 @@ export interface SkillSearchParams { tags?: string; category?: string; surface?: string; - sort?: "downloads" | "recent" | "name"; + sort?: 'downloads' | 'recent' | 'name'; limit?: number; offset?: number; } @@ -167,14 +167,14 @@ interface SkillReferenceBase { * Skill reference from mpak registry */ export interface MpakSkillReference extends SkillReferenceBase { - source: "mpak"; + source: 'mpak'; } /** * Skill reference from GitHub repository */ export interface GithubSkillReference extends SkillReferenceBase { - source: "github"; + source: 'github'; /** GitHub repository (owner/repo) */ repo: string; /** Path to skill file in repo */ @@ -185,7 +185,7 @@ export interface GithubSkillReference extends SkillReferenceBase { * Skill reference from direct URL */ export interface UrlSkillReference extends SkillReferenceBase { - source: "url"; + source: 'url'; /** Direct download URL */ url: string; } @@ -193,10 +193,7 @@ export interface UrlSkillReference extends SkillReferenceBase { /** * Discriminated union of skill reference types */ -export type SkillReference = - | MpakSkillReference - | GithubSkillReference - | UrlSkillReference; +export type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference; /** * Result of resolving a skill reference @@ -207,7 +204,7 @@ export interface ResolvedSkill { /** Version that was resolved */ version: string; /** Source the skill was fetched from */ - source: "mpak" | "github" | "url"; + source: 'mpak' | 'github' | 'url'; /** Whether integrity was verified */ verified: boolean; } diff --git a/packages/sdk/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts similarity index 54% rename from packages/sdk/tests/client.integration.test.ts rename to packages/sdk-typescript/tests/client.integration.test.ts index f793bec..3c5745b 100644 --- a/packages/sdk/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -8,18 +8,20 @@ * If bundles are removed, tests may need updating. */ -import { describe, it, expect } from "vitest"; -import { MpakClient } from "../src/client.js"; -import { MpakNotFoundError } from "../src/errors.js"; +import { createHash } from 'crypto'; +import JSZip from 'jszip'; +import { describe, it, expect } from 'vitest'; +import { MpakClient } from '../src/client.js'; +import { MpakNotFoundError } from '../src/errors.js'; // Known bundle that exists in the registry -const KNOWN_BUNDLE = "@nimblebraininc/echo"; +const KNOWN_BUNDLE = '@nimblebraininc/echo'; -describe("MpakClient Integration Tests", () => { +describe('MpakClient Integration Tests', () => { const client = new MpakClient(); - describe("Bundle API", () => { - it("searches bundles", async () => { + describe('Bundle API', () => { + it('searches bundles', async () => { const result = await client.searchBundles({ limit: 5 }); expect(result.bundles).toBeInstanceOf(Array); @@ -29,16 +31,16 @@ describe("MpakClient Integration Tests", () => { expect(result.pagination.limit).toBe(5); }); - it("searches bundles with query", async () => { - const result = await client.searchBundles({ q: "echo" }); + it('searches bundles with query', async () => { + const result = await client.searchBundles({ q: 'echo' }); expect(result.bundles).toBeInstanceOf(Array); // Should find the echo bundle - const echoBundle = result.bundles.find((b) => b.name.includes("echo")); + const echoBundle = result.bundles.find((b) => b.name.includes('echo')); expect(echoBundle).toBeDefined(); }); - it("gets bundle details", async () => { + it('gets bundle details', async () => { const bundle = await client.getBundle(KNOWN_BUNDLE); expect(bundle.name).toBe(KNOWN_BUNDLE); @@ -47,7 +49,7 @@ describe("MpakClient Integration Tests", () => { expect(bundle.versions.length).toBeGreaterThan(0); }); - it("gets bundle versions", async () => { + it('gets bundle versions', async () => { const versions = await client.getBundleVersions(KNOWN_BUNDLE); expect(versions.name).toBe(KNOWN_BUNDLE); @@ -61,15 +63,12 @@ describe("MpakClient Integration Tests", () => { expect(firstVersion?.platforms).toBeInstanceOf(Array); }); - it("gets specific bundle version", async () => { + it('gets specific bundle version', async () => { // First get the versions to find a valid version number const versions = await client.getBundleVersions(KNOWN_BUNDLE); const latestVersion = versions.latest; - const versionInfo = await client.getBundleVersion( - KNOWN_BUNDLE, - latestVersion, - ); + const versionInfo = await client.getBundleVersion(KNOWN_BUNDLE, latestVersion); expect(versionInfo.name).toBe(KNOWN_BUNDLE); expect(versionInfo.version).toBe(latestVersion); @@ -77,32 +76,58 @@ describe("MpakClient Integration Tests", () => { expect(versionInfo.manifest).toBeDefined(); }); - it("gets bundle download info", async () => { + it('gets bundle download info', async () => { // First get the versions to find a valid version number const versions = await client.getBundleVersions(KNOWN_BUNDLE); const latestVersion = versions.latest; - const download = await client.getBundleDownload( - KNOWN_BUNDLE, - latestVersion, - ); + const download = await client.getBundleDownload(KNOWN_BUNDLE, latestVersion); expect(download.url).toBeDefined(); - expect(download.url).toContain("http"); + expect(download.url).toContain('http'); expect(download.bundle).toBeDefined(); expect(download.bundle.sha256).toBeDefined(); expect(download.bundle.size).toBeGreaterThan(0); }); - it("throws MpakNotFoundError for nonexistent bundle", async () => { - await expect( - client.getBundle("@nonexistent/bundle-that-does-not-exist"), - ).rejects.toThrow(MpakNotFoundError); + it('downloads bundle, verifies SHA256, and extracts manifest', async () => { + const platform = MpakClient.detectPlatform(); + const download = await client.getBundleDownload(KNOWN_BUNDLE, 'latest', platform); + + // Download the actual .mcpb file from CDN + const response = await fetch(download.url, { + signal: AbortSignal.timeout(30_000), + }); + expect(response.ok).toBe(true); + + const buffer = await response.arrayBuffer(); + expect(buffer.byteLength).toBeGreaterThan(0); + + // Verify SHA256 integrity + const hash = createHash('sha256').update(Buffer.from(buffer)).digest('hex'); + expect(hash).toBe(download.bundle.sha256); + + // Extract and verify manifest + const zip = await JSZip.loadAsync(buffer); + const manifestFile = zip.file('manifest.json'); + expect(manifestFile).not.toBeNull(); + + const manifestText = await manifestFile!.async('string'); + const manifest = JSON.parse(manifestText); + expect(manifest.name).toBe(KNOWN_BUNDLE); + expect(manifest.version).toBeDefined(); + expect(manifest.server).toBeDefined(); + }); + + it('throws MpakNotFoundError for nonexistent bundle', async () => { + await expect(client.getBundle('@nonexistent/bundle-that-does-not-exist')).rejects.toThrow( + MpakNotFoundError, + ); }); }); - describe("Skill API", () => { - it("searches skills", async () => { + describe('Skill API', () => { + it('searches skills', async () => { const result = await client.searchSkills({ limit: 5 }); expect(result.skills).toBeInstanceOf(Array); @@ -110,9 +135,9 @@ describe("MpakClient Integration Tests", () => { // Note: There may be no skills yet, so we just check the response structure }); - it("searches skills with filters", async () => { + it('searches skills with filters', async () => { const result = await client.searchSkills({ - surface: "claude-code", + surface: 'claude-code', limit: 10, }); @@ -120,42 +145,38 @@ describe("MpakClient Integration Tests", () => { expect(result.pagination).toBeDefined(); }); - it("throws MpakNotFoundError for nonexistent skill", async () => { - await expect( - client.getSkill("@nonexistent/skill-that-does-not-exist"), - ).rejects.toThrow(MpakNotFoundError); + it('throws MpakNotFoundError for nonexistent skill', async () => { + await expect(client.getSkill('@nonexistent/skill-that-does-not-exist')).rejects.toThrow( + MpakNotFoundError, + ); }); }); - describe("Platform detection", () => { - it("detects current platform", () => { + describe('Platform detection', () => { + it('detects current platform', () => { const platform = MpakClient.detectPlatform(); expect(platform.os).toBeDefined(); expect(platform.arch).toBeDefined(); - expect(["darwin", "linux", "win32", "any"]).toContain(platform.os); - expect(["x64", "arm64", "any"]).toContain(platform.arch); + expect(['darwin', 'linux', 'win32', 'any']).toContain(platform.os); + expect(['x64', 'arm64', 'any']).toContain(platform.arch); }); - it("can request bundle for current platform", async () => { + it('can request bundle for current platform', async () => { const versions = await client.getBundleVersions(KNOWN_BUNDLE); const latestVersion = versions.latest; const platform = MpakClient.detectPlatform(); // This should not throw even if the platform-specific artifact doesn't exist // (it falls back to 'any') - const download = await client.getBundleDownload( - KNOWN_BUNDLE, - latestVersion, - platform, - ); + const download = await client.getBundleDownload(KNOWN_BUNDLE, latestVersion, platform); expect(download.url).toBeDefined(); }); }); - describe("Error handling", () => { - it("handles timeout gracefully", async () => { + describe('Error handling', () => { + it('handles timeout gracefully', async () => { const shortTimeoutClient = new MpakClient({ timeout: 1 }); // With a 1ms timeout, this should fail diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts new file mode 100644 index 0000000..ea616a1 --- /dev/null +++ b/packages/sdk-typescript/tests/client.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHash } from 'crypto'; +import { MpakClient } from '../src/client.js'; +import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from '../src/errors.js'; + +// Helper to compute SHA256 hash (same as client implementation) +function sha256(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex'); +} + +// Helper to create a mock Response +function mockResponse( + body: string | object, + init: { status?: number; ok?: boolean } = {}, +): Response { + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + return { + text: () => Promise.resolve(bodyStr), + json: () => Promise.resolve(typeof body === 'string' ? JSON.parse(body) : body), + status: init.status ?? 200, + ok: init.ok ?? (init.status === undefined || init.status < 400), + } as Response; +} + +describe('MpakClient', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('constructor', () => { + it('uses default registry URL when not specified', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); + + await client.searchBundles(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://registry.mpak.dev'), + expect.any(Object), + ); + }); + + it('uses custom registry URL when specified', async () => { + const client = new MpakClient({ + registryUrl: 'https://custom.registry.com', + }); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); + + await client.searchBundles(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://custom.registry.com'), + expect.any(Object), + ); + }); + }); + + describe('searchBundles', () => { + it('returns search results', async () => { + const client = new MpakClient(); + const searchResponse = { + bundles: [ + { + name: '@test/bundle-1', + latest_version: '1.0.0', + downloads: 100, + published_at: '2024-01-01', + verified: true, + }, + ], + total: 1, + pagination: { limit: 20, offset: 0, has_more: false }, + }; + fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); + + const result = await client.searchBundles({ q: 'test' }); + + expect(result.bundles).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('passes query parameters correctly', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); + + await client.searchBundles({ + q: 'mcp', + type: 'python', + sort: 'downloads', + limit: 10, + offset: 5, + }); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('q=mcp'); + expect(calledUrl).toContain('type=python'); + expect(calledUrl).toContain('sort=downloads'); + expect(calledUrl).toContain('limit=10'); + expect(calledUrl).toContain('offset=5'); + }); + + it('calls /v1/bundles/search endpoint', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); + + await client.searchBundles(); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/v1/bundles/search'); + }); + }); + + describe('getBundle', () => { + it('returns bundle details', async () => { + const client = new MpakClient(); + const bundleResponse = { + name: '@test/bundle', + latest_version: '1.0.0', + downloads: 100, + published_at: '2024-01-01', + verified: true, + versions: [], + }; + fetchMock.mockResolvedValueOnce(mockResponse(bundleResponse)); + + const result = await client.getBundle('@test/bundle'); + + expect(result.name).toBe('@test/bundle'); + }); + + it('throws error for unscoped name', async () => { + const client = new MpakClient(); + + await expect(client.getBundle('invalid-name')).rejects.toThrow('Package name must be scoped'); + }); + + it('throws MpakNotFoundError on 404', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); + + await expect(client.getBundle('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); + }); + }); + + describe('getBundleVersions', () => { + it('returns versions list', async () => { + const client = new MpakClient(); + const versionsResponse = { + name: '@test/bundle', + latest: '1.0.0', + versions: [ + { + version: '1.0.0', + artifacts_count: 1, + platforms: [], + published_at: '2024-01-01', + downloads: 50, + publish_method: null, + }, + ], + }; + fetchMock.mockResolvedValueOnce(mockResponse(versionsResponse)); + + const result = await client.getBundleVersions('@test/bundle'); + + expect(result.versions).toHaveLength(1); + expect(result.latest).toBe('1.0.0'); + }); + }); + + describe('getBundleDownload', () => { + it('returns download info with URL', async () => { + const client = new MpakClient(); + const downloadResponse = { + url: 'https://storage.example.com/bundle.mcpb', + bundle: { + name: '@test/bundle', + version: '1.0.0', + platform: { os: 'darwin', arch: 'arm64' }, + sha256: 'abc123', + size: 12345, + }, + expires_at: '2024-01-02T00:00:00Z', + }; + fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); + + const result = await client.getBundleDownload('@test/bundle', '1.0.0'); + + expect(result.url).toBe('https://storage.example.com/bundle.mcpb'); + expect(result.bundle.sha256).toBe('abc123'); + }); + + it('passes platform parameters', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + url: 'https://example.com', + bundle: { + name: '@test/bundle', + version: '1.0.0', + platform: {}, + sha256: '', + size: 0, + }, + }), + ); + + await client.getBundleDownload('@test/bundle', '1.0.0', { + os: 'linux', + arch: 'x64', + }); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('os=linux'); + expect(calledUrl).toContain('arch=x64'); + }); + }); + + describe('searchSkills', () => { + it('returns search results', async () => { + const client = new MpakClient(); + const searchResponse = { + skills: [ + { + name: '@test/skill-1', + description: 'Test skill', + latest_version: '1.0.0', + downloads: 50, + published_at: '2024-01-01', + }, + ], + total: 1, + pagination: { limit: 20, offset: 0, has_more: false }, + }; + fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); + + const result = await client.searchSkills({ q: 'test' }); + + expect(result.skills).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('passes all query parameters', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ skills: [], total: 0, pagination: {} })); + + await client.searchSkills({ + q: 'crm', + tags: 'sales,contacts', + category: 'development', + surface: 'claude-code', + sort: 'recent', + limit: 10, + offset: 5, + }); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('q=crm'); + expect(calledUrl).toContain('tags=sales%2Ccontacts'); + expect(calledUrl).toContain('category=development'); + expect(calledUrl).toContain('surface=claude-code'); + expect(calledUrl).toContain('sort=recent'); + }); + + it('calls /v1/skills/search endpoint', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ skills: [], total: 0, pagination: {} })); + + await client.searchSkills(); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/v1/skills/search'); + }); + }); + + describe('getSkill', () => { + it('returns skill details', async () => { + const client = new MpakClient(); + const skillResponse = { + name: '@test/skill', + description: 'A test skill', + latest_version: '1.0.0', + downloads: 100, + published_at: '2024-01-01', + versions: [], + }; + fetchMock.mockResolvedValueOnce(mockResponse(skillResponse)); + + const result = await client.getSkill('@test/skill'); + + expect(result.name).toBe('@test/skill'); + expect(result.description).toBe('A test skill'); + }); + + it('throws MpakNotFoundError on 404', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); + + await expect(client.getSkill('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); + }); + }); + + describe('getSkillDownload', () => { + it('returns download info', async () => { + const client = new MpakClient(); + const downloadResponse = { + url: 'https://storage.example.com/skill.skill', + skill: { + name: '@test/skill', + version: '1.0.0', + sha256: 'abc123def456', + size: 1024, + }, + expires_at: '2024-01-02T00:00:00Z', + }; + fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); + + const result = await client.getSkillDownload('@test/skill'); + + expect(result.url).toBe('https://storage.example.com/skill.skill'); + expect(result.skill.sha256).toBe('abc123def456'); + }); + }); + + describe('getSkillVersionDownload', () => { + it('returns download info for specific version', async () => { + const client = new MpakClient(); + const downloadResponse = { + url: 'https://storage.example.com/skill-v1.skill', + skill: { + name: '@test/skill', + version: '1.0.0', + sha256: 'version1hash', + size: 1024, + }, + expires_at: '2024-01-02T00:00:00Z', + }; + fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); + + const result = await client.getSkillVersionDownload('@test/skill', '1.0.0'); + + expect(result.skill.version).toBe('1.0.0'); + }); + + it('calls correct versioned endpoint', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + url: 'https://example.com', + skill: { + name: '@test/skill', + version: '2.0.0', + sha256: '', + size: 0, + }, + expires_at: '', + }), + ); + + await client.getSkillVersionDownload('@test/skill', '2.0.0'); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/versions/2.0.0/download'); + }); + }); + + describe('downloadSkillContent', () => { + it('downloads content without verification', async () => { + const client = new MpakClient(); + const content = '# My Skill\n\nSkill content here'; + fetchMock.mockResolvedValueOnce(mockResponse(content)); + + const result = await client.downloadSkillContent('https://example.com/skill.skill'); + + expect(result.content).toBe(content); + expect(result.verified).toBe(false); + }); + + it('verifies integrity when hash provided', async () => { + const client = new MpakClient(); + const content = 'skill content'; + const hash = sha256(content); + fetchMock.mockResolvedValueOnce(mockResponse(content)); + + const result = await client.downloadSkillContent('https://example.com/skill.skill', hash); + + expect(result.content).toBe(content); + expect(result.verified).toBe(true); + }); + + it('throws MpakIntegrityError on hash mismatch (fail-closed)', async () => { + const client = new MpakClient(); + const content = 'actual content'; + fetchMock.mockResolvedValueOnce(mockResponse(content)); + + await expect( + client.downloadSkillContent('https://example.com/skill.skill', 'wrong_hash'), + ).rejects.toThrow(MpakIntegrityError); + }); + + it('does not return content when integrity fails', async () => { + const client = new MpakClient(); + const secretContent = 'sensitive skill content'; + fetchMock.mockResolvedValueOnce(mockResponse(secretContent)); + + let leakedContent: string | undefined; + try { + const result = await client.downloadSkillContent( + 'https://example.com/skill.skill', + 'wrong_hash', + ); + leakedContent = result.content; + } catch { + // Expected + } + + expect(leakedContent).toBeUndefined(); + }); + }); + + describe('detectPlatform', () => { + it('returns current platform', () => { + const platform = MpakClient.detectPlatform(); + + expect(platform).toHaveProperty('os'); + expect(platform).toHaveProperty('arch'); + expect(['darwin', 'linux', 'win32', 'any']).toContain(platform.os); + expect(['x64', 'arm64', 'any']).toContain(platform.arch); + }); + }); + + describe('timeout handling', () => { + it('throws MpakNetworkError on timeout', async () => { + const client = new MpakClient({ timeout: 100 }); + + fetchMock.mockImplementationOnce( + () => + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error('AbortError'); + error.name = 'AbortError'; + reject(error); + }, 50); + }), + ); + + await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); + }); + + it('includes timeout duration in error message', async () => { + const client = new MpakClient({ timeout: 5000 }); + + fetchMock.mockImplementationOnce(() => { + const error = new Error('AbortError'); + error.name = 'AbortError'; + return Promise.reject(error); + }); + + await expect(client.searchBundles()).rejects.toThrow('5000ms'); + }); + + it('wraps generic fetch errors as MpakNetworkError', async () => { + const client = new MpakClient(); + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); + }); + }); +}); diff --git a/packages/sdk-typescript/tests/errors.test.ts b/packages/sdk-typescript/tests/errors.test.ts new file mode 100644 index 0000000..da784fd --- /dev/null +++ b/packages/sdk-typescript/tests/errors.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { + MpakError, + MpakNotFoundError, + MpakIntegrityError, + MpakNetworkError, +} from '../src/errors.js'; + +describe('MpakError', () => { + it('inherits from Error', () => { + const error = new MpakError('test message', 'TEST_CODE'); + expect(error).toBeInstanceOf(Error); + }); + + it('can be caught as Error', () => { + const throwAndCatch = () => { + try { + throw new MpakError('test', 'TEST'); + } catch (e) { + if (e instanceof Error) { + return e.message; + } + return 'not an error'; + } + }; + expect(throwAndCatch()).toBe('test'); + }); + + it('stores status code when provided', () => { + const error = new MpakError('test', 'TEST', 500); + expect(error.statusCode).toBe(500); + }); + + it('leaves status code undefined when not provided', () => { + const error = new MpakError('test', 'TEST'); + expect(error.statusCode).toBeUndefined(); + }); +}); + +describe('MpakNotFoundError', () => { + it('formats resource name in message', () => { + const error = new MpakNotFoundError('@nimbletools/folk-crm@1.0.0'); + expect(error.message).toContain('@nimbletools/folk-crm@1.0.0'); + }); + + it('uses NOT_FOUND code', () => { + const error = new MpakNotFoundError('test'); + expect(error.code).toBe('NOT_FOUND'); + }); + + it('sets HTTP 404 status', () => { + const error = new MpakNotFoundError('test'); + expect(error.statusCode).toBe(404); + }); + + it('can be caught as MpakError', () => { + const throwAndCatch = () => { + try { + throw new MpakNotFoundError('resource'); + } catch (e) { + if (e instanceof MpakError) { + return e.code; + } + return 'not MpakError'; + } + }; + expect(throwAndCatch()).toBe('NOT_FOUND'); + }); +}); + +describe('MpakIntegrityError', () => { + it('stores expected and actual hashes', () => { + const error = new MpakIntegrityError('abc123expected', 'def456actual'); + expect(error.expected).toBe('abc123expected'); + expect(error.actual).toBe('def456actual'); + }); + + it('includes both hashes in message', () => { + const error = new MpakIntegrityError('expected', 'actual'); + expect(error.message).toContain('expected'); + expect(error.message).toContain('actual'); + }); + + it('uses INTEGRITY_MISMATCH code', () => { + const error = new MpakIntegrityError('a', 'b'); + expect(error.code).toBe('INTEGRITY_MISMATCH'); + }); + + it('enables fail-closed error handling pattern', () => { + // This test verifies the error can be used for fail-closed integrity checks + // where content should NOT be returned on mismatch + const performIntegrityCheck = (content: string, expectedHash: string) => { + const actualHash = 'computed_hash'; + if (actualHash !== expectedHash) { + throw new MpakIntegrityError(expectedHash, actualHash); + } + return content; + }; + + expect(() => performIntegrityCheck('secret', 'wrong_hash')).toThrow(MpakIntegrityError); + }); +}); + +describe('MpakNetworkError', () => { + it('uses NETWORK_ERROR code', () => { + const error = new MpakNetworkError('Connection refused'); + expect(error.code).toBe('NETWORK_ERROR'); + }); + + it('preserves original error message', () => { + const error = new MpakNetworkError('ETIMEDOUT'); + expect(error.message).toBe('ETIMEDOUT'); + }); + + it('can be distinguished from other MpakErrors', () => { + const networkError = new MpakNetworkError('timeout'); + const notFoundError = new MpakNotFoundError('resource'); + + expect(networkError).toBeInstanceOf(MpakNetworkError); + expect(networkError).not.toBeInstanceOf(MpakNotFoundError); + expect(notFoundError).not.toBeInstanceOf(MpakNetworkError); + }); +}); + +describe('Error hierarchy', () => { + it('all errors inherit from MpakError', () => { + const errors = [ + new MpakNotFoundError('test'), + new MpakIntegrityError('a', 'b'), + new MpakNetworkError('test'), + ]; + + for (const error of errors) { + expect(error).toBeInstanceOf(MpakError); + expect(error).toBeInstanceOf(Error); + } + }); + + it('allows catching all SDK errors with MpakError', () => { + const errorFactories = [ + () => new MpakNotFoundError('test'), + () => new MpakIntegrityError('a', 'b'), + () => new MpakNetworkError('test'), + ]; + + for (const createError of errorFactories) { + expect(() => { + try { + throw createError(); + } catch (e) { + if (e instanceof MpakError) { + throw e; + } + } + }).toThrow(MpakError); + } + }); +}); diff --git a/packages/sdk/tsconfig.json b/packages/sdk-typescript/tsconfig.json similarity index 100% rename from packages/sdk/tsconfig.json rename to packages/sdk-typescript/tsconfig.json diff --git a/packages/sdk/tsup.config.ts b/packages/sdk-typescript/tsup.config.ts similarity index 100% rename from packages/sdk/tsup.config.ts rename to packages/sdk-typescript/tsup.config.ts diff --git a/packages/sdk/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts similarity index 100% rename from packages/sdk/vitest.config.ts rename to packages/sdk-typescript/vitest.config.ts diff --git a/packages/sdk/vitest.integration.config.ts b/packages/sdk-typescript/vitest.integration.config.ts similarity index 100% rename from packages/sdk/vitest.integration.config.ts rename to packages/sdk-typescript/vitest.integration.config.ts diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts deleted file mode 100644 index a9a672f..0000000 --- a/packages/sdk/tests/client.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createHash } from "crypto"; -import { MpakClient } from "../src/client.js"; -import { - MpakNotFoundError, - MpakIntegrityError, - MpakNetworkError, -} from "../src/errors.js"; - -// Helper to compute SHA256 hash (same as client implementation) -function sha256(content: string): string { - return createHash("sha256").update(content, "utf8").digest("hex"); -} - -// Helper to create a mock Response -function mockResponse( - body: string | object, - init: { status?: number; ok?: boolean } = {}, -): Response { - const bodyStr = typeof body === "string" ? body : JSON.stringify(body); - return { - text: () => Promise.resolve(bodyStr), - json: () => - Promise.resolve(typeof body === "string" ? JSON.parse(body) : body), - status: init.status ?? 200, - ok: init.ok ?? (init.status === undefined || init.status < 400), - } as Response; -} - -describe("MpakClient", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe("constructor", () => { - it("uses default registry URL when not specified", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); - - await client.searchBundles(); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("https://registry.mpak.dev"), - expect.any(Object), - ); - }); - - it("uses custom registry URL when specified", async () => { - const client = new MpakClient({ - registryUrl: "https://custom.registry.com", - }); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); - - await client.searchBundles(); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("https://custom.registry.com"), - expect.any(Object), - ); - }); - }); - - describe("searchBundles", () => { - it("returns search results", async () => { - const client = new MpakClient(); - const searchResponse = { - bundles: [ - { - name: "@test/bundle-1", - latest_version: "1.0.0", - downloads: 100, - published_at: "2024-01-01", - verified: true, - }, - ], - total: 1, - pagination: { limit: 20, offset: 0, has_more: false }, - }; - fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); - - const result = await client.searchBundles({ q: "test" }); - - expect(result.bundles).toHaveLength(1); - expect(result.total).toBe(1); - }); - - it("passes query parameters correctly", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); - - await client.searchBundles({ - q: "mcp", - type: "python", - sort: "downloads", - limit: 10, - offset: 5, - }); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("q=mcp"); - expect(calledUrl).toContain("type=python"); - expect(calledUrl).toContain("sort=downloads"); - expect(calledUrl).toContain("limit=10"); - expect(calledUrl).toContain("offset=5"); - }); - - it("calls /v1/bundles/search endpoint", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); - - await client.searchBundles(); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("/v1/bundles/search"); - }); - }); - - describe("getBundle", () => { - it("returns bundle details", async () => { - const client = new MpakClient(); - const bundleResponse = { - name: "@test/bundle", - latest_version: "1.0.0", - downloads: 100, - published_at: "2024-01-01", - verified: true, - versions: [], - }; - fetchMock.mockResolvedValueOnce(mockResponse(bundleResponse)); - - const result = await client.getBundle("@test/bundle"); - - expect(result.name).toBe("@test/bundle"); - }); - - it("throws error for unscoped name", async () => { - const client = new MpakClient(); - - await expect(client.getBundle("invalid-name")).rejects.toThrow( - "Package name must be scoped", - ); - }); - - it("throws MpakNotFoundError on 404", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce(mockResponse("", { status: 404 })); - - await expect(client.getBundle("@test/nonexistent")).rejects.toThrow( - MpakNotFoundError, - ); - }); - }); - - describe("getBundleVersions", () => { - it("returns versions list", async () => { - const client = new MpakClient(); - const versionsResponse = { - name: "@test/bundle", - latest: "1.0.0", - versions: [ - { - version: "1.0.0", - artifacts_count: 1, - platforms: [], - published_at: "2024-01-01", - downloads: 50, - publish_method: null, - }, - ], - }; - fetchMock.mockResolvedValueOnce(mockResponse(versionsResponse)); - - const result = await client.getBundleVersions("@test/bundle"); - - expect(result.versions).toHaveLength(1); - expect(result.latest).toBe("1.0.0"); - }); - }); - - describe("getBundleDownload", () => { - it("returns download info with URL", async () => { - const client = new MpakClient(); - const downloadResponse = { - url: "https://storage.example.com/bundle.mcpb", - bundle: { - name: "@test/bundle", - version: "1.0.0", - platform: { os: "darwin", arch: "arm64" }, - sha256: "abc123", - size: 12345, - }, - expires_at: "2024-01-02T00:00:00Z", - }; - fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - - const result = await client.getBundleDownload("@test/bundle", "1.0.0"); - - expect(result.url).toBe("https://storage.example.com/bundle.mcpb"); - expect(result.bundle.sha256).toBe("abc123"); - }); - - it("passes platform parameters", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ - url: "https://example.com", - bundle: { - name: "@test/bundle", - version: "1.0.0", - platform: {}, - sha256: "", - size: 0, - }, - }), - ); - - await client.getBundleDownload("@test/bundle", "1.0.0", { - os: "linux", - arch: "x64", - }); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("os=linux"); - expect(calledUrl).toContain("arch=x64"); - }); - }); - - describe("searchSkills", () => { - it("returns search results", async () => { - const client = new MpakClient(); - const searchResponse = { - skills: [ - { - name: "@test/skill-1", - description: "Test skill", - latest_version: "1.0.0", - downloads: 50, - published_at: "2024-01-01", - }, - ], - total: 1, - pagination: { limit: 20, offset: 0, has_more: false }, - }; - fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); - - const result = await client.searchSkills({ q: "test" }); - - expect(result.skills).toHaveLength(1); - expect(result.total).toBe(1); - }); - - it("passes all query parameters", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ skills: [], total: 0, pagination: {} }), - ); - - await client.searchSkills({ - q: "crm", - tags: "sales,contacts", - category: "development", - surface: "claude-code", - sort: "recent", - limit: 10, - offset: 5, - }); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("q=crm"); - expect(calledUrl).toContain("tags=sales%2Ccontacts"); - expect(calledUrl).toContain("category=development"); - expect(calledUrl).toContain("surface=claude-code"); - expect(calledUrl).toContain("sort=recent"); - }); - - it("calls /v1/skills/search endpoint", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ skills: [], total: 0, pagination: {} }), - ); - - await client.searchSkills(); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("/v1/skills/search"); - }); - }); - - describe("getSkill", () => { - it("returns skill details", async () => { - const client = new MpakClient(); - const skillResponse = { - name: "@test/skill", - description: "A test skill", - latest_version: "1.0.0", - downloads: 100, - published_at: "2024-01-01", - versions: [], - }; - fetchMock.mockResolvedValueOnce(mockResponse(skillResponse)); - - const result = await client.getSkill("@test/skill"); - - expect(result.name).toBe("@test/skill"); - expect(result.description).toBe("A test skill"); - }); - - it("throws MpakNotFoundError on 404", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce(mockResponse("", { status: 404 })); - - await expect(client.getSkill("@test/nonexistent")).rejects.toThrow( - MpakNotFoundError, - ); - }); - }); - - describe("getSkillDownload", () => { - it("returns download info", async () => { - const client = new MpakClient(); - const downloadResponse = { - url: "https://storage.example.com/skill.skill", - skill: { - name: "@test/skill", - version: "1.0.0", - sha256: "abc123def456", - size: 1024, - }, - expires_at: "2024-01-02T00:00:00Z", - }; - fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - - const result = await client.getSkillDownload("@test/skill"); - - expect(result.url).toBe("https://storage.example.com/skill.skill"); - expect(result.skill.sha256).toBe("abc123def456"); - }); - }); - - describe("getSkillVersionDownload", () => { - it("returns download info for specific version", async () => { - const client = new MpakClient(); - const downloadResponse = { - url: "https://storage.example.com/skill-v1.skill", - skill: { - name: "@test/skill", - version: "1.0.0", - sha256: "version1hash", - size: 1024, - }, - expires_at: "2024-01-02T00:00:00Z", - }; - fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - - const result = await client.getSkillVersionDownload( - "@test/skill", - "1.0.0", - ); - - expect(result.skill.version).toBe("1.0.0"); - }); - - it("calls correct versioned endpoint", async () => { - const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ - url: "https://example.com", - skill: { - name: "@test/skill", - version: "2.0.0", - sha256: "", - size: 0, - }, - expires_at: "", - }), - ); - - await client.getSkillVersionDownload("@test/skill", "2.0.0"); - - const calledUrl = fetchMock.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain("/versions/2.0.0/download"); - }); - }); - - describe("downloadSkillContent", () => { - it("downloads content without verification", async () => { - const client = new MpakClient(); - const content = "# My Skill\n\nSkill content here"; - fetchMock.mockResolvedValueOnce(mockResponse(content)); - - const result = await client.downloadSkillContent( - "https://example.com/skill.skill", - ); - - expect(result.content).toBe(content); - expect(result.verified).toBe(false); - }); - - it("verifies integrity when hash provided", async () => { - const client = new MpakClient(); - const content = "skill content"; - const hash = sha256(content); - fetchMock.mockResolvedValueOnce(mockResponse(content)); - - const result = await client.downloadSkillContent( - "https://example.com/skill.skill", - hash, - ); - - expect(result.content).toBe(content); - expect(result.verified).toBe(true); - }); - - it("throws MpakIntegrityError on hash mismatch (fail-closed)", async () => { - const client = new MpakClient(); - const content = "actual content"; - fetchMock.mockResolvedValueOnce(mockResponse(content)); - - await expect( - client.downloadSkillContent( - "https://example.com/skill.skill", - "wrong_hash", - ), - ).rejects.toThrow(MpakIntegrityError); - }); - - it("does not return content when integrity fails", async () => { - const client = new MpakClient(); - const secretContent = "sensitive skill content"; - fetchMock.mockResolvedValueOnce(mockResponse(secretContent)); - - let leakedContent: string | undefined; - try { - const result = await client.downloadSkillContent( - "https://example.com/skill.skill", - "wrong_hash", - ); - leakedContent = result.content; - } catch { - // Expected - } - - expect(leakedContent).toBeUndefined(); - }); - }); - - describe("detectPlatform", () => { - it("returns current platform", () => { - const platform = MpakClient.detectPlatform(); - - expect(platform).toHaveProperty("os"); - expect(platform).toHaveProperty("arch"); - expect(["darwin", "linux", "win32", "any"]).toContain(platform.os); - expect(["x64", "arm64", "any"]).toContain(platform.arch); - }); - }); - - describe("timeout handling", () => { - it("throws MpakNetworkError on timeout", async () => { - const client = new MpakClient({ timeout: 100 }); - - fetchMock.mockImplementationOnce( - () => - new Promise((_, reject) => { - setTimeout(() => { - const error = new Error("AbortError"); - error.name = "AbortError"; - reject(error); - }, 50); - }), - ); - - await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); - }); - - it("includes timeout duration in error message", async () => { - const client = new MpakClient({ timeout: 5000 }); - - fetchMock.mockImplementationOnce(() => { - const error = new Error("AbortError"); - error.name = "AbortError"; - return Promise.reject(error); - }); - - await expect(client.searchBundles()).rejects.toThrow("5000ms"); - }); - - it("wraps generic fetch errors as MpakNetworkError", async () => { - const client = new MpakClient(); - fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED")); - - await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); - }); - }); -}); diff --git a/packages/sdk/tests/errors.test.ts b/packages/sdk/tests/errors.test.ts deleted file mode 100644 index 9d25474..0000000 --- a/packages/sdk/tests/errors.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - MpakError, - MpakNotFoundError, - MpakIntegrityError, - MpakNetworkError, -} from "../src/errors.js"; - -describe("MpakError", () => { - it("inherits from Error", () => { - const error = new MpakError("test message", "TEST_CODE"); - expect(error).toBeInstanceOf(Error); - }); - - it("can be caught as Error", () => { - const throwAndCatch = () => { - try { - throw new MpakError("test", "TEST"); - } catch (e) { - if (e instanceof Error) { - return e.message; - } - return "not an error"; - } - }; - expect(throwAndCatch()).toBe("test"); - }); - - it("stores status code when provided", () => { - const error = new MpakError("test", "TEST", 500); - expect(error.statusCode).toBe(500); - }); - - it("leaves status code undefined when not provided", () => { - const error = new MpakError("test", "TEST"); - expect(error.statusCode).toBeUndefined(); - }); -}); - -describe("MpakNotFoundError", () => { - it("formats resource name in message", () => { - const error = new MpakNotFoundError("@nimbletools/folk-crm@1.0.0"); - expect(error.message).toContain("@nimbletools/folk-crm@1.0.0"); - }); - - it("uses NOT_FOUND code", () => { - const error = new MpakNotFoundError("test"); - expect(error.code).toBe("NOT_FOUND"); - }); - - it("sets HTTP 404 status", () => { - const error = new MpakNotFoundError("test"); - expect(error.statusCode).toBe(404); - }); - - it("can be caught as MpakError", () => { - const throwAndCatch = () => { - try { - throw new MpakNotFoundError("resource"); - } catch (e) { - if (e instanceof MpakError) { - return e.code; - } - return "not MpakError"; - } - }; - expect(throwAndCatch()).toBe("NOT_FOUND"); - }); -}); - -describe("MpakIntegrityError", () => { - it("stores expected and actual hashes", () => { - const error = new MpakIntegrityError("abc123expected", "def456actual"); - expect(error.expected).toBe("abc123expected"); - expect(error.actual).toBe("def456actual"); - }); - - it("includes both hashes in message", () => { - const error = new MpakIntegrityError("expected", "actual"); - expect(error.message).toContain("expected"); - expect(error.message).toContain("actual"); - }); - - it("uses INTEGRITY_MISMATCH code", () => { - const error = new MpakIntegrityError("a", "b"); - expect(error.code).toBe("INTEGRITY_MISMATCH"); - }); - - it("enables fail-closed error handling pattern", () => { - // This test verifies the error can be used for fail-closed integrity checks - // where content should NOT be returned on mismatch - const performIntegrityCheck = ( - content: string, - expectedHash: string, - ) => { - const actualHash = "computed_hash"; - if (actualHash !== expectedHash) { - throw new MpakIntegrityError(expectedHash, actualHash); - } - return content; - }; - - expect(() => performIntegrityCheck("secret", "wrong_hash")).toThrow( - MpakIntegrityError, - ); - }); -}); - -describe("MpakNetworkError", () => { - it("uses NETWORK_ERROR code", () => { - const error = new MpakNetworkError("Connection refused"); - expect(error.code).toBe("NETWORK_ERROR"); - }); - - it("preserves original error message", () => { - const error = new MpakNetworkError("ETIMEDOUT"); - expect(error.message).toBe("ETIMEDOUT"); - }); - - it("can be distinguished from other MpakErrors", () => { - const networkError = new MpakNetworkError("timeout"); - const notFoundError = new MpakNotFoundError("resource"); - - expect(networkError).toBeInstanceOf(MpakNetworkError); - expect(networkError).not.toBeInstanceOf(MpakNotFoundError); - expect(notFoundError).not.toBeInstanceOf(MpakNetworkError); - }); -}); - -describe("Error hierarchy", () => { - it("all errors inherit from MpakError", () => { - const errors = [ - new MpakNotFoundError("test"), - new MpakIntegrityError("a", "b"), - new MpakNetworkError("test"), - ]; - - for (const error of errors) { - expect(error).toBeInstanceOf(MpakError); - expect(error).toBeInstanceOf(Error); - } - }); - - it("allows catching all SDK errors with MpakError", () => { - const errorFactories = [ - () => new MpakNotFoundError("test"), - () => new MpakIntegrityError("a", "b"), - () => new MpakNetworkError("test"), - ]; - - for (const createError of errorFactories) { - expect(() => { - try { - throw createError(); - } catch (e) { - if (e instanceof MpakError) { - throw e; - } - } - }).toThrow(MpakError); - } - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 132bf32..40d5eb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,7 +149,7 @@ importers: version: link:../../packages/schemas '@nimblebrain/mpak-sdk': specifier: workspace:* - version: link:../../packages/sdk + version: link:../../packages/sdk-typescript '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -237,7 +237,7 @@ importers: version: link:../schemas '@nimblebrain/mpak-sdk': specifier: workspace:* - version: link:../sdk + version: link:../sdk-typescript archiver: specifier: ^7.0.1 version: 7.0.1 @@ -283,7 +283,7 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.12)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - packages/sdk: + packages/sdk-typescript: dependencies: '@nimblebrain/mpak-schemas': specifier: workspace:*