From 4eee908479b4bd62f8ce8e54e55cabf596b38d56 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:13:23 -1000 Subject: [PATCH 1/4] Rename packages/sdk to packages/sdk-typescript, add e2e download test Rename the directory to match sdk-python naming convention. The npm package name (@nimblebrain/mpak-sdk) is unchanged. Add an integration test that downloads a real bundle from the CDN, verifies SHA256 integrity, extracts the zip, and validates the manifest. --- PUBLISHING.md | 4 +- README.md | 2 +- SECURITY.md | 2 +- packages/{sdk => sdk-typescript}/README.md | 0 packages/{sdk => sdk-typescript}/package.json | 2 +- .../{sdk => sdk-typescript}/src/client.ts | 0 .../{sdk => sdk-typescript}/src/errors.ts | 0 packages/{sdk => sdk-typescript}/src/index.ts | 0 packages/{sdk => sdk-typescript}/src/types.ts | 0 .../tests/client.integration.test.ts | 38 +++++++++++++++++++ .../tests/client.test.ts | 0 .../tests/errors.test.ts | 0 .../{sdk => sdk-typescript}/tsconfig.json | 0 .../{sdk => sdk-typescript}/tsup.config.ts | 0 .../{sdk => sdk-typescript}/vitest.config.ts | 0 .../vitest.integration.config.ts | 0 pnpm-lock.yaml | 6 +-- 17 files changed, 46 insertions(+), 8 deletions(-) rename packages/{sdk => sdk-typescript}/README.md (100%) rename packages/{sdk => sdk-typescript}/package.json (97%) rename packages/{sdk => sdk-typescript}/src/client.ts (100%) rename packages/{sdk => sdk-typescript}/src/errors.ts (100%) rename packages/{sdk => sdk-typescript}/src/index.ts (100%) rename packages/{sdk => sdk-typescript}/src/types.ts (100%) rename packages/{sdk => sdk-typescript}/tests/client.integration.test.ts (80%) rename packages/{sdk => sdk-typescript}/tests/client.test.ts (100%) rename packages/{sdk => sdk-typescript}/tests/errors.test.ts (100%) rename packages/{sdk => sdk-typescript}/tsconfig.json (100%) rename packages/{sdk => sdk-typescript}/tsup.config.ts (100%) rename packages/{sdk => sdk-typescript}/vitest.config.ts (100%) rename packages/{sdk => sdk-typescript}/vitest.integration.config.ts (100%) 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 100% rename from packages/sdk/README.md rename to packages/sdk-typescript/README.md diff --git a/packages/sdk/package.json b/packages/sdk-typescript/package.json similarity index 97% rename from packages/sdk/package.json rename to packages/sdk-typescript/package.json index 3bd1d98..9f781ed 100644 --- a/packages/sdk/package.json +++ b/packages/sdk-typescript/package.json @@ -7,7 +7,7 @@ "repository": { "type": "git", "url": "https://github.com/NimbleBrainInc/mpak.git", - "directory": "packages/sdk" + "directory": "packages/sdk-typescript" }, "homepage": "https://mpak.dev", "bugs": { diff --git a/packages/sdk/src/client.ts b/packages/sdk-typescript/src/client.ts similarity index 100% rename from packages/sdk/src/client.ts rename to packages/sdk-typescript/src/client.ts diff --git a/packages/sdk/src/errors.ts b/packages/sdk-typescript/src/errors.ts similarity index 100% rename from packages/sdk/src/errors.ts rename to packages/sdk-typescript/src/errors.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk-typescript/src/index.ts similarity index 100% rename from packages/sdk/src/index.ts rename to packages/sdk-typescript/src/index.ts diff --git a/packages/sdk/src/types.ts b/packages/sdk-typescript/src/types.ts similarity index 100% rename from packages/sdk/src/types.ts rename to packages/sdk-typescript/src/types.ts diff --git a/packages/sdk/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts similarity index 80% rename from packages/sdk/tests/client.integration.test.ts rename to packages/sdk-typescript/tests/client.integration.test.ts index f793bec..6d6d75a 100644 --- a/packages/sdk/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -8,6 +8,11 @@ * If bundles are removed, tests may need updating. */ +import { createHash } from "crypto"; +import { mkdtemp, rm, readFile, readdir } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import JSZip from "jszip"; import { describe, it, expect } from "vitest"; import { MpakClient } from "../src/client.js"; import { MpakNotFoundError } from "../src/errors.js"; @@ -94,6 +99,39 @@ describe("MpakClient Integration Tests", () => { expect(download.bundle.size).toBeGreaterThan(0); }); + 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); + 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"), diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts similarity index 100% rename from packages/sdk/tests/client.test.ts rename to packages/sdk-typescript/tests/client.test.ts diff --git a/packages/sdk/tests/errors.test.ts b/packages/sdk-typescript/tests/errors.test.ts similarity index 100% rename from packages/sdk/tests/errors.test.ts rename to packages/sdk-typescript/tests/errors.test.ts 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/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:* From f83904fc0ada526152ab901c8cc523e1e6fb470e Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:21:31 -1000 Subject: [PATCH 2/4] Address PR review, add CI/publish workflows, fix #8 - Remove unused imports from integration test (MINOR-1) - Add AbortSignal.timeout to CDN fetch in e2e test (MINOR-2) - Extend lint scope to include tests/ - Add 404 handling to searchBundles/searchSkills (fixes #8) - Add sdk-typescript-ci.yml (lint, format, typecheck, test) - Add sdk-typescript-publish.yml (tag-based OIDC publish to npm) - Update README with badges and release docs --- .github/workflows/sdk-typescript-ci.yml | 49 +++++++++++ .github/workflows/sdk-typescript-publish.yml | 57 +++++++++++++ packages/sdk-typescript/README.md | 83 +++++++++++++------ packages/sdk-typescript/package.json | 2 +- packages/sdk-typescript/src/client.ts | 8 ++ .../tests/client.integration.test.ts | 7 +- 6 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/sdk-typescript-ci.yml create mode 100644 .github/workflows/sdk-typescript-publish.yml 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/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index e0a61c3..168ab24 100644 --- a/packages/sdk-typescript/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-typescript/package.json b/packages/sdk-typescript/package.json index 9f781ed..a3b3b26 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -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-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 12d7afc..a4e6d4f 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -67,6 +67,10 @@ export class MpakClient { 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}`, @@ -205,6 +209,10 @@ export class MpakClient { 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}`, diff --git a/packages/sdk-typescript/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts index 6d6d75a..cc5a2b3 100644 --- a/packages/sdk-typescript/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -9,9 +9,6 @@ */ import { createHash } from "crypto"; -import { mkdtemp, rm, readFile, readdir } from "fs/promises"; -import { tmpdir } from "os"; -import { join } from "path"; import JSZip from "jszip"; import { describe, it, expect } from "vitest"; import { MpakClient } from "../src/client.js"; @@ -108,7 +105,9 @@ describe("MpakClient Integration Tests", () => { ); // Download the actual .mcpb file from CDN - const response = await fetch(download.url); + const response = await fetch(download.url, { + signal: AbortSignal.timeout(30_000), + }); expect(response.ok).toBe(true); const buffer = await response.arrayBuffer(); From 3908a1777812c6b8658183eba838fc85f0cd897a Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:22:42 -1000 Subject: [PATCH 3/4] sdk-typescript: bump to 0.1.2 --- packages/sdk-typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index a3b3b26..97056ad 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "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", From cde701c3091b149ab57ff893f3be0472088f9665 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:23:47 -1000 Subject: [PATCH 4/4] sdk-typescript: apply prettier formatting --- packages/sdk-typescript/src/client.ts | 235 ++++------- packages/sdk-typescript/src/errors.ts | 17 +- packages/sdk-typescript/src/index.ts | 21 +- packages/sdk-typescript/src/types.ts | 25 +- .../tests/client.integration.test.ts | 104 +++-- packages/sdk-typescript/tests/client.test.ts | 370 ++++++++---------- packages/sdk-typescript/tests/errors.test.ts | 127 +++--- 7 files changed, 385 insertions(+), 514 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index a4e6d4f..49af3ab 100644 --- a/packages/sdk-typescript/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,29 +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"); + 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; @@ -94,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; @@ -116,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; @@ -127,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}`; @@ -141,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; @@ -161,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) { @@ -177,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; @@ -192,31 +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"); + 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; @@ -236,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; @@ -253,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) { @@ -261,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; @@ -272,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) { @@ -289,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; @@ -309,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(); @@ -366,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}`); } } } @@ -386,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); @@ -398,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 @@ -409,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(); @@ -437,7 +390,7 @@ export class MpakClient { return { content, version: ref.version, - source: "github", + source: 'github', verified: true, }; } @@ -445,7 +398,7 @@ export class MpakClient { return { content, version: ref.version, - source: "github", + source: 'github', verified: false, }; } @@ -453,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) { @@ -466,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'); } /** @@ -517,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; @@ -539,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 }; @@ -571,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(); @@ -601,7 +542,7 @@ export class MpakClient { ...(init?.headers as Record), }; if (this.userAgent) { - headers["User-Agent"] = this.userAgent; + headers['User-Agent'] = this.userAgent; } try { @@ -611,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-typescript/src/errors.ts b/packages/sdk-typescript/src/errors.ts index 3cfe500..10af527 100644 --- a/packages/sdk-typescript/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-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index e03d5e2..d6c9a10 100644 --- a/packages/sdk-typescript/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-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index 10c41ec..7f23368 100644 --- a/packages/sdk-typescript/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-typescript/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts index cc5a2b3..3c5745b 100644 --- a/packages/sdk-typescript/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -8,20 +8,20 @@ * If bundles are removed, tests may need updating. */ -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"; +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); @@ -31,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); @@ -49,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); @@ -63,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); @@ -79,30 +76,23 @@ 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("downloads bundle, verifies SHA256, and extracts manifest", async () => { + it('downloads bundle, verifies SHA256, and extracts manifest', async () => { const platform = MpakClient.detectPlatform(); - const download = await client.getBundleDownload( - KNOWN_BUNDLE, - "latest", - platform, - ); + const download = await client.getBundleDownload(KNOWN_BUNDLE, 'latest', platform); // Download the actual .mcpb file from CDN const response = await fetch(download.url, { @@ -114,32 +104,30 @@ describe("MpakClient Integration Tests", () => { expect(buffer.byteLength).toBeGreaterThan(0); // Verify SHA256 integrity - const hash = createHash("sha256") - .update(Buffer.from(buffer)) - .digest("hex"); + 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"); + const manifestFile = zip.file('manifest.json'); expect(manifestFile).not.toBeNull(); - const manifestText = await manifestFile!.async("string"); + 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); + 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); @@ -147,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, }); @@ -157,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 index a9a672f..ea616a1 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -1,15 +1,11 @@ -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"; +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"); + return createHash('sha256').update(content, 'utf8').digest('hex'); } // Helper to create a mock Response @@ -17,70 +13,65 @@ function mockResponse( body: string | object, init: { status?: number; ok?: boolean } = {}, ): Response { - const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); return { text: () => Promise.resolve(bodyStr), - json: () => - Promise.resolve(typeof body === "string" ? JSON.parse(body) : body), + 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", () => { +describe('MpakClient', () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); - describe("constructor", () => { - it("uses default registry URL when not specified", async () => { + describe('constructor', () => { + it('uses default registry URL when not specified', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); await client.searchBundles(); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("https://registry.mpak.dev"), + expect.stringContaining('https://registry.mpak.dev'), expect.any(Object), ); }); - it("uses custom registry URL when specified", async () => { + it('uses custom registry URL when specified', async () => { const client = new MpakClient({ - registryUrl: "https://custom.registry.com", + registryUrl: 'https://custom.registry.com', }); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); await client.searchBundles(); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("https://custom.registry.com"), + expect.stringContaining('https://custom.registry.com'), expect.any(Object), ); }); }); - describe("searchBundles", () => { - it("returns search results", async () => { + describe('searchBundles', () => { + it('returns search results', async () => { const client = new MpakClient(); const searchResponse = { bundles: [ { - name: "@test/bundle-1", - latest_version: "1.0.0", + name: '@test/bundle-1', + latest_version: '1.0.0', downloads: 100, - published_at: "2024-01-01", + published_at: '2024-01-01', verified: true, }, ], @@ -89,95 +80,87 @@ describe("MpakClient", () => { }; fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); - const result = await client.searchBundles({ q: "test" }); + const result = await client.searchBundles({ q: 'test' }); expect(result.bundles).toHaveLength(1); expect(result.total).toBe(1); }); - it("passes query parameters correctly", async () => { + it('passes query parameters correctly', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); + fetchMock.mockResolvedValueOnce(mockResponse({ bundles: [], total: 0, pagination: {} })); await client.searchBundles({ - q: "mcp", - type: "python", - sort: "downloads", + 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"); + 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 () => { + it('calls /v1/bundles/search endpoint', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ bundles: [], total: 0, pagination: {} }), - ); + 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"); + expect(calledUrl).toContain('/v1/bundles/search'); }); }); - describe("getBundle", () => { - it("returns bundle details", async () => { + describe('getBundle', () => { + it('returns bundle details', async () => { const client = new MpakClient(); const bundleResponse = { - name: "@test/bundle", - latest_version: "1.0.0", + name: '@test/bundle', + latest_version: '1.0.0', downloads: 100, - published_at: "2024-01-01", + published_at: '2024-01-01', verified: true, versions: [], }; fetchMock.mockResolvedValueOnce(mockResponse(bundleResponse)); - const result = await client.getBundle("@test/bundle"); + const result = await client.getBundle('@test/bundle'); - expect(result.name).toBe("@test/bundle"); + expect(result.name).toBe('@test/bundle'); }); - it("throws error for unscoped name", async () => { + it('throws error for unscoped name', async () => { const client = new MpakClient(); - await expect(client.getBundle("invalid-name")).rejects.toThrow( - "Package name must be scoped", - ); + await expect(client.getBundle('invalid-name')).rejects.toThrow('Package name must be scoped'); }); - it("throws MpakNotFoundError on 404", async () => { + it('throws MpakNotFoundError on 404', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce(mockResponse("", { status: 404 })); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); - await expect(client.getBundle("@test/nonexistent")).rejects.toThrow( - MpakNotFoundError, - ); + await expect(client.getBundle('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); }); }); - describe("getBundleVersions", () => { - it("returns versions list", async () => { + describe('getBundleVersions', () => { + it('returns versions list', async () => { const client = new MpakClient(); const versionsResponse = { - name: "@test/bundle", - latest: "1.0.0", + name: '@test/bundle', + latest: '1.0.0', versions: [ { - version: "1.0.0", + version: '1.0.0', artifacts_count: 1, platforms: [], - published_at: "2024-01-01", + published_at: '2024-01-01', downloads: 50, publish_method: null, }, @@ -185,72 +168,72 @@ describe("MpakClient", () => { }; fetchMock.mockResolvedValueOnce(mockResponse(versionsResponse)); - const result = await client.getBundleVersions("@test/bundle"); + const result = await client.getBundleVersions('@test/bundle'); expect(result.versions).toHaveLength(1); - expect(result.latest).toBe("1.0.0"); + expect(result.latest).toBe('1.0.0'); }); }); - describe("getBundleDownload", () => { - it("returns download info with URL", async () => { + describe('getBundleDownload', () => { + it('returns download info with URL', async () => { const client = new MpakClient(); const downloadResponse = { - url: "https://storage.example.com/bundle.mcpb", + url: 'https://storage.example.com/bundle.mcpb', bundle: { - name: "@test/bundle", - version: "1.0.0", - platform: { os: "darwin", arch: "arm64" }, - sha256: "abc123", + name: '@test/bundle', + version: '1.0.0', + platform: { os: 'darwin', arch: 'arm64' }, + sha256: 'abc123', size: 12345, }, - expires_at: "2024-01-02T00:00:00Z", + expires_at: '2024-01-02T00:00:00Z', }; fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - const result = await client.getBundleDownload("@test/bundle", "1.0.0"); + 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"); + expect(result.url).toBe('https://storage.example.com/bundle.mcpb'); + expect(result.bundle.sha256).toBe('abc123'); }); - it("passes platform parameters", async () => { + it('passes platform parameters', async () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce( mockResponse({ - url: "https://example.com", + url: 'https://example.com', bundle: { - name: "@test/bundle", - version: "1.0.0", + name: '@test/bundle', + version: '1.0.0', platform: {}, - sha256: "", + sha256: '', size: 0, }, }), ); - await client.getBundleDownload("@test/bundle", "1.0.0", { - os: "linux", - arch: "x64", + 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"); + expect(calledUrl).toContain('os=linux'); + expect(calledUrl).toContain('arch=x64'); }); }); - describe("searchSkills", () => { - it("returns search results", async () => { + 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", + name: '@test/skill-1', + description: 'Test skill', + latest_version: '1.0.0', downloads: 50, - published_at: "2024-01-01", + published_at: '2024-01-01', }, ], total: 1, @@ -258,197 +241,180 @@ describe("MpakClient", () => { }; fetchMock.mockResolvedValueOnce(mockResponse(searchResponse)); - const result = await client.searchSkills({ q: "test" }); + const result = await client.searchSkills({ q: 'test' }); expect(result.skills).toHaveLength(1); expect(result.total).toBe(1); }); - it("passes all query parameters", async () => { + it('passes all query parameters', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ skills: [], total: 0, pagination: {} }), - ); + fetchMock.mockResolvedValueOnce(mockResponse({ skills: [], total: 0, pagination: {} })); await client.searchSkills({ - q: "crm", - tags: "sales,contacts", - category: "development", - surface: "claude-code", - sort: "recent", + 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"); + 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 () => { + it('calls /v1/skills/search endpoint', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ skills: [], total: 0, pagination: {} }), - ); + 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"); + expect(calledUrl).toContain('/v1/skills/search'); }); }); - describe("getSkill", () => { - it("returns skill details", async () => { + 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", + name: '@test/skill', + description: 'A test skill', + latest_version: '1.0.0', downloads: 100, - published_at: "2024-01-01", + published_at: '2024-01-01', versions: [], }; fetchMock.mockResolvedValueOnce(mockResponse(skillResponse)); - const result = await client.getSkill("@test/skill"); + const result = await client.getSkill('@test/skill'); - expect(result.name).toBe("@test/skill"); - expect(result.description).toBe("A test skill"); + expect(result.name).toBe('@test/skill'); + expect(result.description).toBe('A test skill'); }); - it("throws MpakNotFoundError on 404", async () => { + it('throws MpakNotFoundError on 404', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce(mockResponse("", { status: 404 })); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); - await expect(client.getSkill("@test/nonexistent")).rejects.toThrow( - MpakNotFoundError, - ); + await expect(client.getSkill('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); }); }); - describe("getSkillDownload", () => { - it("returns download info", async () => { + describe('getSkillDownload', () => { + it('returns download info', async () => { const client = new MpakClient(); const downloadResponse = { - url: "https://storage.example.com/skill.skill", + url: 'https://storage.example.com/skill.skill', skill: { - name: "@test/skill", - version: "1.0.0", - sha256: "abc123def456", + name: '@test/skill', + version: '1.0.0', + sha256: 'abc123def456', size: 1024, }, - expires_at: "2024-01-02T00:00:00Z", + expires_at: '2024-01-02T00:00:00Z', }; fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - const result = await client.getSkillDownload("@test/skill"); + const result = await client.getSkillDownload('@test/skill'); - expect(result.url).toBe("https://storage.example.com/skill.skill"); - expect(result.skill.sha256).toBe("abc123def456"); + 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 () => { + describe('getSkillVersionDownload', () => { + it('returns download info for specific version', async () => { const client = new MpakClient(); const downloadResponse = { - url: "https://storage.example.com/skill-v1.skill", + url: 'https://storage.example.com/skill-v1.skill', skill: { - name: "@test/skill", - version: "1.0.0", - sha256: "version1hash", + name: '@test/skill', + version: '1.0.0', + sha256: 'version1hash', size: 1024, }, - expires_at: "2024-01-02T00:00:00Z", + expires_at: '2024-01-02T00:00:00Z', }; fetchMock.mockResolvedValueOnce(mockResponse(downloadResponse)); - const result = await client.getSkillVersionDownload( - "@test/skill", - "1.0.0", - ); + const result = await client.getSkillVersionDownload('@test/skill', '1.0.0'); - expect(result.skill.version).toBe("1.0.0"); + expect(result.skill.version).toBe('1.0.0'); }); - it("calls correct versioned endpoint", async () => { + it('calls correct versioned endpoint', async () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce( mockResponse({ - url: "https://example.com", + url: 'https://example.com', skill: { - name: "@test/skill", - version: "2.0.0", - sha256: "", + name: '@test/skill', + version: '2.0.0', + sha256: '', size: 0, }, - expires_at: "", + expires_at: '', }), ); - await client.getSkillVersionDownload("@test/skill", "2.0.0"); + 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"); + expect(calledUrl).toContain('/versions/2.0.0/download'); }); }); - describe("downloadSkillContent", () => { - it("downloads content without verification", async () => { + describe('downloadSkillContent', () => { + it('downloads content without verification', async () => { const client = new MpakClient(); - const content = "# My Skill\n\nSkill content here"; + const content = '# My Skill\n\nSkill content here'; fetchMock.mockResolvedValueOnce(mockResponse(content)); - const result = await client.downloadSkillContent( - "https://example.com/skill.skill", - ); + 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 () => { + it('verifies integrity when hash provided', async () => { const client = new MpakClient(); - const content = "skill content"; + const content = 'skill content'; const hash = sha256(content); fetchMock.mockResolvedValueOnce(mockResponse(content)); - const result = await client.downloadSkillContent( - "https://example.com/skill.skill", - hash, - ); + 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 () => { + it('throws MpakIntegrityError on hash mismatch (fail-closed)', async () => { const client = new MpakClient(); - const content = "actual content"; + const content = 'actual content'; fetchMock.mockResolvedValueOnce(mockResponse(content)); await expect( - client.downloadSkillContent( - "https://example.com/skill.skill", - "wrong_hash", - ), + client.downloadSkillContent('https://example.com/skill.skill', 'wrong_hash'), ).rejects.toThrow(MpakIntegrityError); }); - it("does not return content when integrity fails", async () => { + it('does not return content when integrity fails', async () => { const client = new MpakClient(); - const secretContent = "sensitive skill content"; + 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", + 'https://example.com/skill.skill', + 'wrong_hash', ); leakedContent = result.content; } catch { @@ -459,27 +425,27 @@ describe("MpakClient", () => { }); }); - describe("detectPlatform", () => { - it("returns current platform", () => { + 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); + 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 () => { + 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"; + const error = new Error('AbortError'); + error.name = 'AbortError'; reject(error); }, 50); }), @@ -488,21 +454,21 @@ describe("MpakClient", () => { await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); }); - it("includes timeout duration in error message", async () => { + it('includes timeout duration in error message', async () => { const client = new MpakClient({ timeout: 5000 }); fetchMock.mockImplementationOnce(() => { - const error = new Error("AbortError"); - error.name = "AbortError"; + const error = new Error('AbortError'); + error.name = 'AbortError'; return Promise.reject(error); }); - await expect(client.searchBundles()).rejects.toThrow("5000ms"); + await expect(client.searchBundles()).rejects.toThrow('5000ms'); }); - it("wraps generic fetch errors as MpakNetworkError", async () => { + it('wraps generic fetch errors as MpakNetworkError', async () => { const client = new MpakClient(); - fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED")); + 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 index 9d25474..da784fd 100644 --- a/packages/sdk-typescript/tests/errors.test.ts +++ b/packages/sdk-typescript/tests/errors.test.ts @@ -1,125 +1,120 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; import { MpakError, MpakNotFoundError, MpakIntegrityError, MpakNetworkError, -} from "../src/errors.js"; +} from '../src/errors.js'; -describe("MpakError", () => { - it("inherits from Error", () => { - const error = new MpakError("test message", "TEST_CODE"); +describe('MpakError', () => { + it('inherits from Error', () => { + const error = new MpakError('test message', 'TEST_CODE'); expect(error).toBeInstanceOf(Error); }); - it("can be caught as Error", () => { + it('can be caught as Error', () => { const throwAndCatch = () => { try { - throw new MpakError("test", "TEST"); + throw new MpakError('test', 'TEST'); } catch (e) { if (e instanceof Error) { return e.message; } - return "not an error"; + return 'not an error'; } }; - expect(throwAndCatch()).toBe("test"); + expect(throwAndCatch()).toBe('test'); }); - it("stores status code when provided", () => { - const error = new MpakError("test", "TEST", 500); + 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"); + 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"); +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('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"); + it('sets HTTP 404 status', () => { + const error = new MpakNotFoundError('test'); expect(error.statusCode).toBe(404); }); - it("can be caught as MpakError", () => { + it('can be caught as MpakError', () => { const throwAndCatch = () => { try { - throw new MpakNotFoundError("resource"); + throw new MpakNotFoundError('resource'); } catch (e) { if (e instanceof MpakError) { return e.code; } - return "not MpakError"; + return 'not MpakError'; } }; - expect(throwAndCatch()).toBe("NOT_FOUND"); + 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"); +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('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('uses INTEGRITY_MISMATCH code', () => { + const error = new MpakIntegrityError('a', 'b'); + expect(error.code).toBe('INTEGRITY_MISMATCH'); }); - it("enables fail-closed error handling pattern", () => { + 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"; + 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, - ); + 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"); +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('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"); + 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); @@ -127,12 +122,12 @@ describe("MpakNetworkError", () => { }); }); -describe("Error hierarchy", () => { - it("all errors inherit from MpakError", () => { +describe('Error hierarchy', () => { + it('all errors inherit from MpakError', () => { const errors = [ - new MpakNotFoundError("test"), - new MpakIntegrityError("a", "b"), - new MpakNetworkError("test"), + new MpakNotFoundError('test'), + new MpakIntegrityError('a', 'b'), + new MpakNetworkError('test'), ]; for (const error of errors) { @@ -141,11 +136,11 @@ describe("Error hierarchy", () => { } }); - it("allows catching all SDK errors with MpakError", () => { + it('allows catching all SDK errors with MpakError', () => { const errorFactories = [ - () => new MpakNotFoundError("test"), - () => new MpakIntegrityError("a", "b"), - () => new MpakNetworkError("test"), + () => new MpakNotFoundError('test'), + () => new MpakIntegrityError('a', 'b'), + () => new MpakNetworkError('test'), ]; for (const createError of errorFactories) {