From b06a04961a93006bc00b05cbb2ae2d16428fcc8f Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 11:50:58 -0500 Subject: [PATCH 01/10] feat(sdk-typescript): add downloadContent and downloadBundle methods Add two new public methods to MpakClient: - downloadContent(url, sha256): low-level fetch + SHA-256 verification, returns a Buffer. Works for any downloadable artifact. - downloadBundle(name, version?, platform?): high-level method that resolves bundle download info via the registry API, then downloads and verifies the binary. Defaults to latest version and auto-detected platform. Returns { bundleRaw, bundleMetadata }. Also update computeSha256 to accept Buffer in addition to string. Includes unit tests for both methods covering happy path, integrity errors, network errors, and 404 propagation. Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 53 ++++++++- packages/sdk-typescript/tests/client.test.ts | 114 ++++++++++++++++++- 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 49af3ab..1ed1438 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -472,6 +472,55 @@ export class MpakClient { return integrity; } + // =========================================================================== + // Download Methods + // =========================================================================== + + /** + * Download content from a URL and verify its SHA-256 integrity. + * + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadContent(url: string, sha256: string): Promise { + const response = await this.fetchWithTimeout(url); + + if (!response.ok) { + throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + + const actualHash = this.computeSha256(buffer); + if (actualHash !== sha256) { + throw new MpakIntegrityError(sha256, actualHash); + } + + return buffer; + } + + /** + * Download a bundle by name, with optional version and platform. + * Defaults to latest version and auto-detected platform. + * + * @throws {MpakNotFoundError} If bundle not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadBundle( + name: string, + version?: string, + platform?: Platform, + ): Promise<{ bundleRaw: Buffer; bundleMetadata: BundleDownloadResponse['bundle'] }> { + const resolvedPlatform = platform ?? MpakClient.detectPlatform(); + const resolvedVersion = version ?? 'latest'; + + const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform); + const bundleRaw = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256); + + return { bundleRaw, bundleMetadata: downloadInfo.bundle }; + } + // =========================================================================== // Utility Methods // =========================================================================== @@ -516,8 +565,8 @@ export class MpakClient { /** * Compute SHA256 hash of content */ - private computeSha256(content: string): string { - return createHash('sha256').update(content, 'utf8').digest('hex'); + private computeSha256(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); } /** diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index ea616a1..640fac4 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -4,8 +4,8 @@ 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'); +function sha256(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); } // Helper to create a mock Response @@ -17,6 +17,19 @@ function mockResponse( return { text: () => Promise.resolve(bodyStr), json: () => Promise.resolve(typeof body === 'string' ? JSON.parse(body) : body), + arrayBuffer: () => Promise.resolve(Buffer.from(bodyStr).buffer), + status: init.status ?? 200, + ok: init.ok ?? (init.status === undefined || init.status < 400), + } as Response; +} + +// Helper to create a mock binary Response +function mockBinaryResponse( + buffer: Buffer, + init: { status?: number; ok?: boolean } = {}, +): Response { + return { + arrayBuffer: () => Promise.resolve(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)), status: init.status ?? 200, ok: init.ok ?? (init.status === undefined || init.status < 400), } as Response; @@ -425,6 +438,103 @@ describe('MpakClient', () => { }); }); + describe('downloadContent', () => { + it('downloads and verifies SHA-256', async () => { + const client = new MpakClient(); + const content = Buffer.from('bundle binary data'); + const hash = sha256(content); + fetchMock.mockResolvedValueOnce(mockBinaryResponse(content)); + + const result = await client.downloadContent('https://example.com/file.mcpb', hash); + + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString()).toBe('bundle binary data'); + }); + + it('throws MpakIntegrityError on SHA-256 mismatch', async () => { + const client = new MpakClient(); + const content = Buffer.from('some data'); + fetchMock.mockResolvedValueOnce(mockBinaryResponse(content)); + + await expect( + client.downloadContent('https://example.com/file.mcpb', 'wrong_hash'), + ).rejects.toThrow(MpakIntegrityError); + }); + + it('throws MpakNetworkError on fetch failure', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockBinaryResponse(Buffer.from(''), { status: 500, ok: false })); + + await expect( + client.downloadContent('https://example.com/file.mcpb', 'anyhash'), + ).rejects.toThrow(MpakNetworkError); + }); + }); + + describe('downloadBundle', () => { + const bundleContent = Buffer.from('fake mcpb bundle'); + const bundleHash = sha256(bundleContent); + const downloadInfoResponse = { + url: 'https://storage.example.com/bundle.mcpb', + bundle: { + name: '@test/bundle', + version: '1.0.0', + platform: { os: 'darwin', arch: 'arm64' }, + sha256: bundleHash, + size: bundleContent.length, + }, + expires_at: '2024-01-02T00:00:00Z', + }; + + it('resolves download info and returns verified buffer + metadata', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(bundleContent)); + + const result = await client.downloadBundle('@test/bundle', '1.0.0', { os: 'darwin', arch: 'arm64' }); + + expect(Buffer.isBuffer(result.bundleRaw)).toBe(true); + expect(result.bundleRaw.toString()).toBe('fake mcpb bundle'); + expect(result.bundleMetadata.name).toBe('@test/bundle'); + expect(result.bundleMetadata.version).toBe('1.0.0'); + expect(result.bundleMetadata.sha256).toBe(bundleHash); + }); + + it('defaults version to latest and auto-detects platform', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(bundleContent)); + + await client.downloadBundle('@test/bundle'); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/versions/latest/download'); + expect(calledUrl).toContain('os='); + expect(calledUrl).toContain('arch='); + }); + + it('propagates MpakNotFoundError from getBundleDownload', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); + + await expect(client.downloadBundle('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); + }); + + it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { + const client = new MpakClient(); + const tampered = Buffer.from('tampered content'); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(tampered)); + + await expect( + client.downloadBundle('@test/bundle', '1.0.0'), + ).rejects.toThrow(MpakIntegrityError); + }); + }); + describe('detectPlatform', () => { it('returns current platform', () => { const platform = MpakClient.detectPlatform(); From 811429d138b39b64eb422236601fb87c07e31e61 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 12:34:50 -0500 Subject: [PATCH 02/10] feat(sdk-typescript): add downloadSkillBundle method Add downloadSkillBundle(name, version?) to MpakClient, mirroring the downloadBundle method for consistency. Internally uses getSkillVersionDownload with 'latest' as default version, matching how downloadBundle uses getBundleDownload with 'latest'. Both download methods now follow the same pattern: - Resolve download info from registry API - Fetch binary via downloadContent (shared fetch + SHA-256 verify) - Return { raw, metadata } with verified buffer and artifact metadata Includes unit tests covering happy path, version defaulting to latest, 404 propagation, and SHA-256 integrity error propagation. All monorepo tests pass (55 SDK, 105 CLI, 65 registry, 64 schemas, 61 web). Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 20 +++++++ packages/sdk-typescript/tests/client.test.ts | 61 ++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 1ed1438..c231279 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -521,6 +521,26 @@ export class MpakClient { return { bundleRaw, bundleMetadata: downloadInfo.bundle }; } + /** + * Download a skill bundle by name, with optional version. + * Defaults to latest version. + * + * @throws {MpakNotFoundError} If skill not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadSkillBundle( + name: string, + version?: string, + ): Promise<{ skillRaw: Buffer; skillMetadata: SkillDownloadResponse['skill'] }> { + const resolvedVersion = version ?? 'latest'; + + const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion); + const skillRaw = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256); + + return { skillRaw, skillMetadata: downloadInfo.skill }; + } + // =========================================================================== // Utility Methods // =========================================================================== diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index 640fac4..67e1d9c 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -535,6 +535,67 @@ describe('MpakClient', () => { }); }); + describe('downloadSkillBundle', () => { + const skillContent = Buffer.from('fake skill bundle'); + const skillHash = sha256(skillContent); + const skillDownloadInfoResponse = { + url: 'https://storage.example.com/skill.skill', + skill: { + name: '@test/skill', + version: '1.0.0', + sha256: skillHash, + size: skillContent.length, + }, + expires_at: '2024-01-02T00:00:00Z', + }; + + it('resolves download info and returns verified buffer + metadata', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(skillDownloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(skillContent)); + + const result = await client.downloadSkillBundle('@test/skill', '1.0.0'); + + expect(Buffer.isBuffer(result.skillRaw)).toBe(true); + expect(result.skillRaw.toString()).toBe('fake skill bundle'); + expect(result.skillMetadata.name).toBe('@test/skill'); + expect(result.skillMetadata.version).toBe('1.0.0'); + expect(result.skillMetadata.sha256).toBe(skillHash); + }); + + it('defaults version to latest', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(skillDownloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(skillContent)); + + await client.downloadSkillBundle('@test/skill'); + + const calledUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/versions/latest/download'); + }); + + it('propagates MpakNotFoundError from getSkillVersionDownload', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); + + await expect(client.downloadSkillBundle('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); + }); + + it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { + const client = new MpakClient(); + const tampered = Buffer.from('tampered content'); + fetchMock + .mockResolvedValueOnce(mockResponse(skillDownloadInfoResponse)) + .mockResolvedValueOnce(mockBinaryResponse(tampered)); + + await expect( + client.downloadSkillBundle('@test/skill', '1.0.0'), + ).rejects.toThrow(MpakIntegrityError); + }); + }); + describe('detectPlatform', () => { it('returns current platform', () => { const platform = MpakClient.detectPlatform(); From fedb9c103e9d4b218edd50aeb4802f2394c8299d Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 12:50:04 -0500 Subject: [PATCH 03/10] refactor(sdk-typescript): comment out dead code not used by CLI Comment out the following methods and types that are not used by the CLI or any external consumer, marked with TODO for removal once approved: Methods removed from client.ts: - downloadSkillContent: replaced by downloadContent + downloadSkillBundle - resolveSkillRef: not used outside SDK - resolveMpakSkill, resolveGithubSkill, resolveUrlSkill: private helpers for resolveSkillRef - extractSkillFromZip: peeked inside zip content, no longer needed - verifyIntegrityOrThrow, extractHash: private helpers for resolve methods Types commented out from index.ts exports: - SkillReference, MpakSkillReference, GithubSkillReference, UrlSkillReference, ResolvedSkill Also: - Remove jszip mention from class doc (no longer used) - Update index.ts module example to show downloadBundle/downloadSkillBundle - Comment out downloadSkillContent tests (replaced by downloadContent tests) - Add TODO comment noting getSkillDownload and getSkillVersionDownload should be merged to mirror getBundleDownload once CLI is updated SDK build size reduced from 16.83KB to 12.34KB (ESM). Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 237 ++----------------- packages/sdk-typescript/src/index.ts | 31 +-- packages/sdk-typescript/tests/client.test.ts | 60 +---- 3 files changed, 41 insertions(+), 287 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index c231279..d6b16ac 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -10,10 +10,11 @@ import type { SkillDownloadResponse, SkillSearchParams, Platform, - SkillReference, - GithubSkillReference, - UrlSkillReference, - ResolvedSkill, + // TODO: remove once approved + // SkillReference, + // GithubSkillReference, + // UrlSkillReference, + // ResolvedSkill, } from './types.js'; import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas'; import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from './errors.js'; @@ -25,7 +26,6 @@ const DEFAULT_TIMEOUT = 30000; * Client for interacting with the mpak registry * * Requires Node.js 18+ for native fetch support. - * Uses jszip for skill bundle extraction. */ export class MpakClient { private readonly registryUrl: string; @@ -216,6 +216,9 @@ export class MpakClient { return response.json() as Promise; } + // TODO: The next 2 functions `getSkillDownload` and `getSkillVersionDownload` should be merged into 1, making the skill download functionality consistent with bundle download function. We can not do it right now because both of these functions are used by CLI. + // In future, the single merged function should mirror `getBundleDownload` + /** * Get download info for a skill (latest version) */ @@ -262,215 +265,21 @@ export class MpakClient { return response.json() as Promise; } - /** - * Download skill content and verify integrity - * - * @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed) - */ - async downloadSkillContent( - downloadUrl: string, - expectedSha256?: string, - ): Promise<{ content: string; verified: boolean }> { - const response = await this.fetchWithTimeout(downloadUrl); - - if (!response.ok) { - throw new MpakNetworkError(`Failed to download skill: HTTP ${response.status}`); - } - - const content = await response.text(); - - if (expectedSha256) { - const actualHash = this.computeSha256(content); - if (actualHash !== expectedSha256) { - throw new MpakIntegrityError(expectedSha256, actualHash); - } - return { content, verified: true }; - } - - return { content, verified: false }; - } - - /** - * Resolve a skill reference to actual content - * - * Supports mpak, github, and url sources. This is the main method for - * fetching skill content from any supported source. - * - * @throws {MpakNotFoundError} If skill not found - * @throws {MpakIntegrityError} If integrity check fails (fail-closed) - * @throws {MpakNetworkError} For network failures - * - * @example - * ```typescript - * // Resolve from mpak registry - * const skill = await client.resolveSkillRef({ - * source: 'mpak', - * name: '@nimblebraininc/folk-crm', - * version: '1.3.0', - * }); - * - * // Resolve from GitHub - * const skill = await client.resolveSkillRef({ - * source: 'github', - * name: '@example/my-skill', - * version: 'v1.0.0', - * repo: 'owner/repo', - * path: 'skills/my-skill/SKILL.md', - * }); - * - * // Resolve from URL - * const skill = await client.resolveSkillRef({ - * source: 'url', - * name: '@example/custom', - * version: '1.0.0', - * url: 'https://example.com/skill.md', - * }); - * ``` - */ - async resolveSkillRef(ref: SkillReference): Promise { - switch (ref.source) { - case 'mpak': - return this.resolveMpakSkill(ref); - case 'github': - return this.resolveGithubSkill(ref); - case 'url': - return this.resolveUrlSkill(ref); - default: { - const _exhaustive: never = ref; - throw new Error(`Unknown skill source: ${(_exhaustive as SkillReference).source}`); - } - } - } - - /** - * Resolve a skill from mpak registry - * - * The API returns a ZIP bundle containing SKILL.md and metadata. - */ - 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); - - if (response.status === 404) { - throw new MpakNotFoundError(`${ref.name}@${ref.version}`); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to fetch skill: HTTP ${response.status}`); - } - - // Response is a ZIP file - extract SKILL.md - const zipBuffer = await response.arrayBuffer(); - const content = await this.extractSkillFromZip(zipBuffer, ref.name); - - 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: false }; - } - - /** - * Resolve a skill from GitHub releases - */ - 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}`); - } - - const content = await response.text(); - - if (ref.integrity) { - this.verifyIntegrityOrThrow(content, ref.integrity); - return { - content, - version: ref.version, - source: 'github', - verified: true, - }; - } - - return { - content, - version: ref.version, - source: 'github', - verified: false, - }; - } - - /** - * Resolve a skill from a direct URL - */ - private async resolveUrlSkill(ref: UrlSkillReference): Promise { - const response = await this.fetchWithTimeout(ref.url); - - if (!response.ok) { - throw new MpakNotFoundError(`url:${ref.url}`); - } - - const content = await response.text(); - - 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: false }; - } - - /** - * Extract SKILL.md content from a skill bundle ZIP - */ - 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 skillPath = `${folderName}/SKILL.md`; - - const skillFile = zip.file(skillPath); - if (!skillFile) { - // Try without folder prefix - const altFile = zip.file('SKILL.md'); - if (!altFile) { - throw new MpakNotFoundError(`SKILL.md not found in bundle for ${skillName}`); - } - return altFile.async('string'); - } - - return skillFile.async('string'); - } - - /** - * Verify content integrity and throw if mismatch (fail-closed) - */ - private verifyIntegrityOrThrow(content: string, integrity: string): void { - const expectedHash = this.extractHash(integrity); - const actualHash = this.computeSha256(content); - - if (actualHash !== expectedHash) { - throw new MpakIntegrityError(expectedHash, actualHash); - } - } - - /** - * Extract hash from integrity string (removes prefix) - */ - private extractHash(integrity: string): string { - if (integrity.startsWith('sha256:')) { - return integrity.slice(7); - } - if (integrity.startsWith('sha256-')) { - return integrity.slice(7); - } - return integrity; - } + // TODO: remove once approved — replaced by downloadContent + downloadSkillBundle + // async downloadSkillContent( + // downloadUrl: string, + // expectedSha256?: string, + // ): Promise<{ content: string; verified: boolean }> { ... } + + // TODO: remove once approved — resolveSkillRef and all supporting methods + // are not used by the CLI or any external consumer + // async resolveSkillRef(ref: SkillReference): Promise { ... } + // private async resolveMpakSkill(ref: SkillReference & { source: 'mpak' }): Promise { ... } + // private async resolveGithubSkill(ref: GithubSkillReference): Promise { ... } + // private async resolveUrlSkill(ref: UrlSkillReference): Promise { ... } + // private async extractSkillFromZip(zipBuffer: ArrayBuffer, skillName: string): Promise { ... } + // private verifyIntegrityOrThrow(content: string, integrity: string): void { ... } + // private extractHash(integrity: string): string { ... } // =========================================================================== // Download Methods diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index d6c9a10..3a626b6 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -7,27 +7,18 @@ * * @example * ```typescript - * import { MpakClient, SkillReference } from '@nimblebrain/mpak-sdk'; + * import { MpakClient } from '@nimblebrain/mpak-sdk'; * * const client = new MpakClient(); * * // Search for bundles * const bundles = await client.searchBundles({ q: 'mcp' }); * - * // Get bundle details - * const bundle = await client.getBundle('@nimbletools/echo'); + * // Download a bundle (latest version, auto-detected platform) + * const { bundleRaw, bundleMetadata } = await client.downloadBundle('@nimbletools/echo'); * - * // Search for skills - * const skills = await client.searchSkills({ q: 'crm' }); - * - * // Resolve a skill reference to content (recommended) - * const ref: SkillReference = { - * source: 'mpak', - * name: '@nimblebraininc/folk-crm', - * version: '1.3.0', - * }; - * const resolved = await client.resolveSkillRef(ref); - * console.log(resolved.content); // Skill markdown content + * // Download a skill bundle + * const { skillRaw, skillMetadata } = await client.downloadSkillBundle('@nimblebraininc/folk-crm'); * ``` */ @@ -62,12 +53,12 @@ export type { SkillDetail, SkillDownloadInfo, SkillVersion, - // Skill reference types (for resolveSkillRef) - SkillReference, - MpakSkillReference, - GithubSkillReference, - UrlSkillReference, - ResolvedSkill, + // TODO: remove once approved — resolveSkillRef types not used by CLI or external consumers + // SkillReference, + // MpakSkillReference, + // GithubSkillReference, + // UrlSkillReference, + // ResolvedSkill, } from './types.js'; // Re-export SkillSearchResponse from schemas diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index 67e1d9c..8a8c791 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -384,59 +384,13 @@ describe('MpakClient', () => { }); }); - 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(); - }); - }); + // TODO: remove once approved — downloadSkillContent replaced by downloadContent + downloadSkillBundle + // describe('downloadSkillContent', () => { + // it('downloads content without verification', async () => { ... }); + // it('verifies integrity when hash provided', async () => { ... }); + // it('throws MpakIntegrityError on hash mismatch (fail-closed)', async () => { ... }); + // it('does not return content when integrity fails', async () => { ... }); + // }); describe('downloadContent', () => { it('downloads and verifies SHA-256', async () => { From 2e038d97bd0354d82589358d010f1817d3435731 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 12:51:50 -0500 Subject: [PATCH 04/10] refactor(sdk-typescript): comment out dead skill reference types Comment out SkillReferenceBase, MpakSkillReference, GithubSkillReference, UrlSkillReference, SkillReference, and ResolvedSkill from types.ts. These types supported the now-commented-out resolveSkillRef functionality and are not used by the CLI or any external consumer. Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/types.ts | 65 +++------------------------- 1 file changed, 7 insertions(+), 58 deletions(-) diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index 7f23368..78c5049 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -147,64 +147,13 @@ export interface MpakClientConfig { userAgent?: string; } +// TODO: remove once approved — resolveSkillRef types not used by CLI or external consumers // ============================================================================= // Skill Reference Types (for resolveSkillRef) // ============================================================================= - -/** - * Base fields shared by all skill reference types - */ -interface SkillReferenceBase { - /** Skill artifact identifier (e.g., '@nimbletools/folk-crm') */ - name: string; - /** Semver version (e.g., '1.0.0') or 'latest' */ - version: string; - /** SHA256 integrity hash (format: 'sha256-hexdigest') */ - integrity?: string; -} - -/** - * Skill reference from mpak registry - */ -export interface MpakSkillReference extends SkillReferenceBase { - source: 'mpak'; -} - -/** - * Skill reference from GitHub repository - */ -export interface GithubSkillReference extends SkillReferenceBase { - source: 'github'; - /** GitHub repository (owner/repo) */ - repo: string; - /** Path to skill file in repo */ - path: string; -} - -/** - * Skill reference from direct URL - */ -export interface UrlSkillReference extends SkillReferenceBase { - source: 'url'; - /** Direct download URL */ - url: string; -} - -/** - * Discriminated union of skill reference types - */ -export type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference; - -/** - * Result of resolving a skill reference - */ -export interface ResolvedSkill { - /** The markdown content of the skill */ - content: string; - /** Version that was resolved */ - version: string; - /** Source the skill was fetched from */ - source: 'mpak' | 'github' | 'url'; - /** Whether integrity was verified */ - verified: boolean; -} +// interface SkillReferenceBase { name: string; version: string; integrity?: string; } +// export interface MpakSkillReference extends SkillReferenceBase { source: 'mpak'; } +// export interface GithubSkillReference extends SkillReferenceBase { source: 'github'; repo: string; path: string; } +// export interface UrlSkillReference extends SkillReferenceBase { source: 'url'; url: string; } +// export type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference; +// export interface ResolvedSkill { content: string; version: string; source: 'mpak' | 'github' | 'url'; verified: boolean; } From 2ebe848741bd627ecb364d5e9455790f9e1b2d31 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 13:39:46 -0500 Subject: [PATCH 05/10] style(sdk-typescript): fix prettier formatting Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 4 +-- packages/sdk-typescript/tests/client.test.ts | 30 +++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index d6b16ac..4cb3fed 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -216,8 +216,8 @@ export class MpakClient { return response.json() as Promise; } - // TODO: The next 2 functions `getSkillDownload` and `getSkillVersionDownload` should be merged into 1, making the skill download functionality consistent with bundle download function. We can not do it right now because both of these functions are used by CLI. - // In future, the single merged function should mirror `getBundleDownload` + // TODO: The next 2 functions `getSkillDownload` and `getSkillVersionDownload` should be merged into 1, making the skill download functionality consistent with bundle download function. We can not do it right now because both of these functions are used by CLI. + // In future, the single merged function should mirror `getBundleDownload` /** * Get download info for a skill (latest version) diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index 8a8c791..a4642fc 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -29,7 +29,10 @@ function mockBinaryResponse( init: { status?: number; ok?: boolean } = {}, ): Response { return { - arrayBuffer: () => Promise.resolve(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)), + arrayBuffer: () => + Promise.resolve( + buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), + ), status: init.status ?? 200, ok: init.ok ?? (init.status === undefined || init.status < 400), } as Response; @@ -417,7 +420,9 @@ describe('MpakClient', () => { it('throws MpakNetworkError on fetch failure', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce(mockBinaryResponse(Buffer.from(''), { status: 500, ok: false })); + fetchMock.mockResolvedValueOnce( + mockBinaryResponse(Buffer.from(''), { status: 500, ok: false }), + ); await expect( client.downloadContent('https://example.com/file.mcpb', 'anyhash'), @@ -446,7 +451,10 @@ describe('MpakClient', () => { .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) .mockResolvedValueOnce(mockBinaryResponse(bundleContent)); - const result = await client.downloadBundle('@test/bundle', '1.0.0', { os: 'darwin', arch: 'arm64' }); + const result = await client.downloadBundle('@test/bundle', '1.0.0', { + os: 'darwin', + arch: 'arm64', + }); expect(Buffer.isBuffer(result.bundleRaw)).toBe(true); expect(result.bundleRaw.toString()).toBe('fake mcpb bundle'); @@ -483,9 +491,9 @@ describe('MpakClient', () => { .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) .mockResolvedValueOnce(mockBinaryResponse(tampered)); - await expect( - client.downloadBundle('@test/bundle', '1.0.0'), - ).rejects.toThrow(MpakIntegrityError); + await expect(client.downloadBundle('@test/bundle', '1.0.0')).rejects.toThrow( + MpakIntegrityError, + ); }); }); @@ -534,7 +542,9 @@ describe('MpakClient', () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce(mockResponse('', { status: 404 })); - await expect(client.downloadSkillBundle('@test/nonexistent')).rejects.toThrow(MpakNotFoundError); + await expect(client.downloadSkillBundle('@test/nonexistent')).rejects.toThrow( + MpakNotFoundError, + ); }); it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { @@ -544,9 +554,9 @@ describe('MpakClient', () => { .mockResolvedValueOnce(mockResponse(skillDownloadInfoResponse)) .mockResolvedValueOnce(mockBinaryResponse(tampered)); - await expect( - client.downloadSkillBundle('@test/skill', '1.0.0'), - ).rejects.toThrow(MpakIntegrityError); + await expect(client.downloadSkillBundle('@test/skill', '1.0.0')).rejects.toThrow( + MpakIntegrityError, + ); }); }); From 080b14c3fd75d278626b3a768c43e8f3777640a8 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 5 Mar 2026 15:44:40 -0500 Subject: [PATCH 06/10] test(sdk-typescript): add smoke tests for downloadBundle and downloadSkillBundle Co-Authored-By: Claude Opus 4.6 --- .../tests/client.integration.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts index 3c5745b..4d9511a 100644 --- a/packages/sdk-typescript/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -14,8 +14,9 @@ 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 +// Known bundle and skill that exist in the registry const KNOWN_BUNDLE = '@nimblebraininc/echo'; +const KNOWN_SKILL = '@nimblebraininc/ipinfo'; describe('MpakClient Integration Tests', () => { const client = new MpakClient(); @@ -119,6 +120,17 @@ describe('MpakClient Integration Tests', () => { expect(manifest.server).toBeDefined(); }); + it('downloads bundle with verified integrity', async () => { + const { bundleRaw, bundleMetadata } = await client.downloadBundle(KNOWN_BUNDLE); + + expect(Buffer.isBuffer(bundleRaw)).toBe(true); + expect(bundleRaw.byteLength).toBeGreaterThan(0); + expect(bundleMetadata.name).toBe(KNOWN_BUNDLE); + expect(bundleMetadata.version).toBeDefined(); + expect(bundleMetadata.sha256).toBeDefined(); + expect(bundleMetadata.size).toBeGreaterThan(0); + }); + it('throws MpakNotFoundError for nonexistent bundle', async () => { await expect(client.getBundle('@nonexistent/bundle-that-does-not-exist')).rejects.toThrow( MpakNotFoundError, @@ -145,6 +157,17 @@ describe('MpakClient Integration Tests', () => { expect(result.pagination).toBeDefined(); }); + it('downloads skill bundle with verified integrity', async () => { + const { skillRaw, skillMetadata } = await client.downloadSkillBundle(KNOWN_SKILL); + + expect(Buffer.isBuffer(skillRaw)).toBe(true); + expect(skillRaw.byteLength).toBeGreaterThan(0); + expect(skillMetadata.name).toBe(KNOWN_SKILL); + expect(skillMetadata.version).toBeDefined(); + expect(skillMetadata.sha256).toBeDefined(); + expect(skillMetadata.size).toBeGreaterThan(0); + }); + it('throws MpakNotFoundError for nonexistent skill', async () => { await expect(client.getSkill('@nonexistent/skill-that-does-not-exist')).rejects.toThrow( MpakNotFoundError, From 111ab04f95ee48e6cd3c7948685613442dd22f2f Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 10:50:23 -0400 Subject: [PATCH 07/10] refactor(sdk-typescript): delete commented-out dead code and move jszip to devDependencies Remove all commented-out methods, types, and TODO markers per PR review. Move jszip from dependencies to devDependencies since it's only used in integration tests. Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/package.json | 4 ++-- packages/sdk-typescript/src/client.ts | 24 -------------------- packages/sdk-typescript/src/index.ts | 6 ----- packages/sdk-typescript/src/types.ts | 11 --------- packages/sdk-typescript/tests/client.test.ts | 8 ------- 5 files changed, 2 insertions(+), 51 deletions(-) diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 97056ad..9e34a43 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -61,11 +61,11 @@ "node": ">=22" }, "dependencies": { - "@nimblebrain/mpak-schemas": "workspace:*", - "jszip": "^3.10.1" + "@nimblebrain/mpak-schemas": "workspace:*" }, "devDependencies": { "@types/node": "^22.0.0", + "jszip": "^3.10.1", "tsup": "^8.4.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 4cb3fed..aef8cf4 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -10,11 +10,6 @@ import type { SkillDownloadResponse, SkillSearchParams, Platform, - // TODO: remove once approved - // SkillReference, - // GithubSkillReference, - // UrlSkillReference, - // ResolvedSkill, } from './types.js'; import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas'; import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from './errors.js'; @@ -216,9 +211,6 @@ export class MpakClient { return response.json() as Promise; } - // TODO: The next 2 functions `getSkillDownload` and `getSkillVersionDownload` should be merged into 1, making the skill download functionality consistent with bundle download function. We can not do it right now because both of these functions are used by CLI. - // In future, the single merged function should mirror `getBundleDownload` - /** * Get download info for a skill (latest version) */ @@ -265,22 +257,6 @@ export class MpakClient { return response.json() as Promise; } - // TODO: remove once approved — replaced by downloadContent + downloadSkillBundle - // async downloadSkillContent( - // downloadUrl: string, - // expectedSha256?: string, - // ): Promise<{ content: string; verified: boolean }> { ... } - - // TODO: remove once approved — resolveSkillRef and all supporting methods - // are not used by the CLI or any external consumer - // async resolveSkillRef(ref: SkillReference): Promise { ... } - // private async resolveMpakSkill(ref: SkillReference & { source: 'mpak' }): Promise { ... } - // private async resolveGithubSkill(ref: GithubSkillReference): Promise { ... } - // private async resolveUrlSkill(ref: UrlSkillReference): Promise { ... } - // private async extractSkillFromZip(zipBuffer: ArrayBuffer, skillName: string): Promise { ... } - // private verifyIntegrityOrThrow(content: string, integrity: string): void { ... } - // private extractHash(integrity: string): string { ... } - // =========================================================================== // Download Methods // =========================================================================== diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 3a626b6..3b01a31 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -53,12 +53,6 @@ export type { SkillDetail, SkillDownloadInfo, SkillVersion, - // TODO: remove once approved — resolveSkillRef types not used by CLI or external consumers - // SkillReference, - // MpakSkillReference, - // GithubSkillReference, - // UrlSkillReference, - // ResolvedSkill, } from './types.js'; // Re-export SkillSearchResponse from schemas diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index 78c5049..fd13f74 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -146,14 +146,3 @@ export interface MpakClientConfig { */ userAgent?: string; } - -// TODO: remove once approved — resolveSkillRef types not used by CLI or external consumers -// ============================================================================= -// Skill Reference Types (for resolveSkillRef) -// ============================================================================= -// interface SkillReferenceBase { name: string; version: string; integrity?: string; } -// export interface MpakSkillReference extends SkillReferenceBase { source: 'mpak'; } -// export interface GithubSkillReference extends SkillReferenceBase { source: 'github'; repo: string; path: string; } -// export interface UrlSkillReference extends SkillReferenceBase { source: 'url'; url: string; } -// export type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference; -// export interface ResolvedSkill { content: string; version: string; source: 'mpak' | 'github' | 'url'; verified: boolean; } diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index a4642fc..2bab3fe 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -387,14 +387,6 @@ describe('MpakClient', () => { }); }); - // TODO: remove once approved — downloadSkillContent replaced by downloadContent + downloadSkillBundle - // describe('downloadSkillContent', () => { - // it('downloads content without verification', async () => { ... }); - // it('verifies integrity when hash provided', async () => { ... }); - // it('throws MpakIntegrityError on hash mismatch (fail-closed)', async () => { ... }); - // it('does not return content when integrity fails', async () => { ... }); - // }); - describe('downloadContent', () => { it('downloads and verifies SHA-256', async () => { const client = new MpakClient(); From 9e122025e0ea5a7e1a114b972c3eda7c0bf7d050 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 11:24:54 -0400 Subject: [PATCH 08/10] refactor(sdk-typescript): replace Buffer with Uint8Array and rename return fields Switch download methods from Node-specific Buffer to standard Uint8Array for cross-runtime compatibility. Rename return fields from bundleRaw/bundleMetadata and skillRaw/skillMetadata to data/metadata. Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 875 ++++++++++-------- packages/sdk-typescript/src/index.ts | 4 +- .../tests/client.integration.test.ts | 30 +- packages/sdk-typescript/tests/client.test.ts | 47 +- 4 files changed, 507 insertions(+), 449 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index aef8cf4..cf24605 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -1,20 +1,27 @@ -import { createHash } from 'crypto'; import type { - MpakClientConfig, - BundleSearchParams, - BundleDetailResponse, - BundleVersionsResponse, - BundleVersionResponse, - BundleDownloadResponse, - SkillDetailResponse, - SkillDownloadResponse, - SkillSearchParams, - Platform, -} 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'; + BundleSearchResponse, + SkillSearchResponse, +} from "@nimblebrain/mpak-schemas"; +import { createHash } from "crypto"; +import { + MpakIntegrityError, + MpakNetworkError, + MpakNotFoundError, +} from "./errors.js"; +import type { + BundleDetailResponse, + BundleDownloadResponse, + BundleSearchParams, + BundleVersionResponse, + BundleVersionsResponse, + MpakClientConfig, + Platform, + SkillDetailResponse, + SkillDownloadResponse, + SkillSearchParams, +} from "./types.js"; + +const DEFAULT_REGISTRY_URL = "https://registry.mpak.dev"; const DEFAULT_TIMEOUT = 30000; /** @@ -23,395 +30,449 @@ const DEFAULT_TIMEOUT = 30000; * Requires Node.js 18+ for native fetch support. */ export class MpakClient { - private readonly registryUrl: string; - private readonly timeout: number; - private readonly userAgent: string | undefined; - - constructor(config: MpakClientConfig = {}) { - this.registryUrl = config.registryUrl ?? DEFAULT_REGISTRY_URL; - this.timeout = config.timeout ?? DEFAULT_TIMEOUT; - this.userAgent = config.userAgent; - } - - // =========================================================================== - // Bundle API - // =========================================================================== - - /** - * Search for bundles - */ - 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)); - - const queryString = searchParams.toString(); - 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}`); - } - - return response.json() as Promise; - } - - /** - * Get bundle details - */ - async getBundle(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get bundle: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - /** - * Get all versions of a bundle - */ - async getBundleVersions(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}/versions`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get bundle versions: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - /** - * Get a specific version of a bundle - */ - async getBundleVersion(name: string, version: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get bundle version: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - /** - * Get download info for a bundle - */ - async getBundleDownload( - name: string, - version: string, - platform?: Platform, - ): Promise { - this.validateScopedName(name); - - const params = new URLSearchParams(); - if (platform) { - 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 response = await this.fetchWithTimeout(url, { - headers: { Accept: 'application/json' }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get bundle download: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - // =========================================================================== - // Skill API - // =========================================================================== - - /** - * Search for skills - */ - 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)); - - const queryString = searchParams.toString(); - 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}`); - } - - return response.json() as Promise; - } - - /** - * Get skill details - */ - async getSkill(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/skills/${name}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get skill: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - /** - * Get download info for a skill (latest version) - */ - async getSkillDownload(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/skills/${name}/download`; - - const response = await this.fetchWithTimeout(url, { - headers: { Accept: 'application/json' }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - /** - * Get download info for a specific skill version - */ - 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' }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - // =========================================================================== - // Download Methods - // =========================================================================== - - /** - * Download content from a URL and verify its SHA-256 integrity. - * - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadContent(url: string, sha256: string): Promise { - const response = await this.fetchWithTimeout(url); - - if (!response.ok) { - throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - - const actualHash = this.computeSha256(buffer); - if (actualHash !== sha256) { - throw new MpakIntegrityError(sha256, actualHash); - } - - return buffer; - } - - /** - * Download a bundle by name, with optional version and platform. - * Defaults to latest version and auto-detected platform. - * - * @throws {MpakNotFoundError} If bundle not found - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadBundle( - name: string, - version?: string, - platform?: Platform, - ): Promise<{ bundleRaw: Buffer; bundleMetadata: BundleDownloadResponse['bundle'] }> { - const resolvedPlatform = platform ?? MpakClient.detectPlatform(); - const resolvedVersion = version ?? 'latest'; - - const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform); - const bundleRaw = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256); - - return { bundleRaw, bundleMetadata: downloadInfo.bundle }; - } - - /** - * Download a skill bundle by name, with optional version. - * Defaults to latest version. - * - * @throws {MpakNotFoundError} If skill not found - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadSkillBundle( - name: string, - version?: string, - ): Promise<{ skillRaw: Buffer; skillMetadata: SkillDownloadResponse['skill'] }> { - const resolvedVersion = version ?? 'latest'; - - const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion); - const skillRaw = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256); - - return { skillRaw, skillMetadata: downloadInfo.skill }; - } - - // =========================================================================== - // Utility Methods - // =========================================================================== - - /** - * Detect the current platform - */ - static detectPlatform(): Platform { - const nodePlatform = process.platform; - const nodeArch = process.arch; - - let os: string; - switch (nodePlatform) { - case 'darwin': - os = 'darwin'; - break; - case 'win32': - os = 'win32'; - break; - case 'linux': - os = 'linux'; - break; - default: - os = 'any'; - } - - let arch: string; - switch (nodeArch) { - case 'x64': - arch = 'x64'; - break; - case 'arm64': - arch = 'arm64'; - break; - default: - arch = 'any'; - } - - return { os, arch }; - } - - /** - * Compute SHA256 hash of content - */ - private computeSha256(content: string | Buffer): string { - return createHash('sha256').update(content).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)'); - } - } - - /** - * Fetch with timeout support - */ - private async fetchWithTimeout(url: string, init?: RequestInit): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, this.timeout); - - const headers: Record = { - ...(init?.headers as Record), - }; - if (this.userAgent) { - headers['User-Agent'] = this.userAgent; - } - - try { - return await fetch(url, { - ...init, - headers, - signal: controller.signal, - }); - } catch (error) { - 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'); - } finally { - clearTimeout(timeoutId); - } - } + private readonly registryUrl: string; + private readonly timeout: number; + private readonly userAgent: string | undefined; + + constructor(config: MpakClientConfig = {}) { + this.registryUrl = config.registryUrl ?? DEFAULT_REGISTRY_URL; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT; + this.userAgent = config.userAgent; + } + + // =========================================================================== + // Bundle API + // =========================================================================== + + /** + * Search for bundles + */ + 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)); + + const queryString = searchParams.toString(); + 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}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get bundle details + */ + async getBundle(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get bundle: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get all versions of a bundle + */ + async getBundleVersions(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}/versions`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get bundle versions: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get a specific version of a bundle + */ + async getBundleVersion( + name: string, + version: string, + ): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get bundle version: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get download info for a bundle + */ + async getBundleDownload( + name: string, + version: string, + platform?: Platform, + ): Promise { + this.validateScopedName(name); + + const params = new URLSearchParams(); + if (platform) { + 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 response = await this.fetchWithTimeout(url, { + headers: { Accept: "application/json" }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get bundle download: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + // =========================================================================== + // Skill API + // =========================================================================== + + /** + * Search for skills + */ + 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)); + + const queryString = searchParams.toString(); + 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}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get skill details + */ + async getSkill(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/skills/${name}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get skill: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get download info for a skill (latest version) + */ + async getSkillDownload(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/skills/${name}/download`; + + const response = await this.fetchWithTimeout(url, { + headers: { Accept: "application/json" }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get skill download: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + /** + * Get download info for a specific skill version + */ + 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" }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError( + `Failed to get skill download: HTTP ${response.status}`, + ); + } + + return response.json() as Promise; + } + + // =========================================================================== + // Download Methods + // =========================================================================== + + /** + * Download content from a URL and verify its SHA-256 integrity. + * + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadContent(url: string, sha256: string): Promise { + const response = await this.fetchWithTimeout(url); + + if (!response.ok) { + throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`); + } + + const downloadedRawData = new Uint8Array(await response.arrayBuffer()); + + const computedHash = this.computeSha256(downloadedRawData); + if (computedHash !== sha256) { + throw new MpakIntegrityError(sha256, computedHash); + } + + return downloadedRawData; + } + + /** + * Download a bundle by name, with optional version and platform. + * Defaults to latest version and auto-detected platform. + * + * @throws {MpakNotFoundError} If bundle not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadBundle( + name: string, + version?: string, + platform?: Platform, + ): Promise<{ + data: Uint8Array; + metadata: BundleDownloadResponse["bundle"]; + }> { + const resolvedPlatform = platform ?? MpakClient.detectPlatform(); + const resolvedVersion = version ?? "latest"; + + const downloadInfo = await this.getBundleDownload( + name, + resolvedVersion, + resolvedPlatform, + ); + const data = await this.downloadContent( + downloadInfo.url, + downloadInfo.bundle.sha256, + ); + + return { data, metadata: downloadInfo.bundle }; + } + + /** + * Download a skill bundle by name, with optional version. + * Defaults to latest version. + * + * @throws {MpakNotFoundError} If skill not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadSkillBundle( + name: string, + version?: string, + ): Promise<{ + data: Uint8Array; + metadata: SkillDownloadResponse["skill"]; + }> { + const resolvedVersion = version ?? "latest"; + + const downloadInfo = await this.getSkillVersionDownload( + name, + resolvedVersion, + ); + const data = await this.downloadContent( + downloadInfo.url, + downloadInfo.skill.sha256, + ); + + return { data, metadata: downloadInfo.skill }; + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Detect the current platform + */ + static detectPlatform(): Platform { + const nodePlatform = process.platform; + const nodeArch = process.arch; + + let os: string; + switch (nodePlatform) { + case "darwin": + os = "darwin"; + break; + case "win32": + os = "win32"; + break; + case "linux": + os = "linux"; + break; + default: + os = "any"; + } + + let arch: string; + switch (nodeArch) { + case "x64": + arch = "x64"; + break; + case "arm64": + arch = "arm64"; + break; + default: + arch = "any"; + } + + return { os, arch }; + } + + /** + * Compute SHA256 hash of content + */ + private computeSha256(content: string | Uint8Array): string { + return createHash("sha256").update(content).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)", + ); + } + } + + /** + * Fetch with timeout support + */ + private async fetchWithTimeout( + url: string, + init?: RequestInit, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, this.timeout); + + const headers: Record = { + ...(init?.headers as Record), + }; + if (this.userAgent) { + headers["User-Agent"] = this.userAgent; + } + + try { + return await fetch(url, { + ...init, + headers, + signal: controller.signal, + }); + } catch (error) { + 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", + ); + } finally { + clearTimeout(timeoutId); + } + } } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 3b01a31..86d19b3 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -15,10 +15,10 @@ * const bundles = await client.searchBundles({ q: 'mcp' }); * * // Download a bundle (latest version, auto-detected platform) - * const { bundleRaw, bundleMetadata } = await client.downloadBundle('@nimbletools/echo'); + * const { data, metadata } = await client.downloadBundle('@nimbletools/echo'); * * // Download a skill bundle - * const { skillRaw, skillMetadata } = await client.downloadSkillBundle('@nimblebraininc/folk-crm'); + * const { data, metadata } = await client.downloadSkillBundle('@nimblebraininc/folk-crm'); * ``` */ diff --git a/packages/sdk-typescript/tests/client.integration.test.ts b/packages/sdk-typescript/tests/client.integration.test.ts index 4d9511a..d827ed4 100644 --- a/packages/sdk-typescript/tests/client.integration.test.ts +++ b/packages/sdk-typescript/tests/client.integration.test.ts @@ -121,14 +121,14 @@ describe('MpakClient Integration Tests', () => { }); it('downloads bundle with verified integrity', async () => { - const { bundleRaw, bundleMetadata } = await client.downloadBundle(KNOWN_BUNDLE); + const { data, metadata } = await client.downloadBundle(KNOWN_BUNDLE); - expect(Buffer.isBuffer(bundleRaw)).toBe(true); - expect(bundleRaw.byteLength).toBeGreaterThan(0); - expect(bundleMetadata.name).toBe(KNOWN_BUNDLE); - expect(bundleMetadata.version).toBeDefined(); - expect(bundleMetadata.sha256).toBeDefined(); - expect(bundleMetadata.size).toBeGreaterThan(0); + expect(data).toBeInstanceOf(Uint8Array); + expect(data.byteLength).toBeGreaterThan(0); + expect(metadata.name).toBe(KNOWN_BUNDLE); + expect(metadata.version).toBeDefined(); + expect(metadata.sha256).toBeDefined(); + expect(metadata.size).toBeGreaterThan(0); }); it('throws MpakNotFoundError for nonexistent bundle', async () => { @@ -158,14 +158,14 @@ describe('MpakClient Integration Tests', () => { }); it('downloads skill bundle with verified integrity', async () => { - const { skillRaw, skillMetadata } = await client.downloadSkillBundle(KNOWN_SKILL); - - expect(Buffer.isBuffer(skillRaw)).toBe(true); - expect(skillRaw.byteLength).toBeGreaterThan(0); - expect(skillMetadata.name).toBe(KNOWN_SKILL); - expect(skillMetadata.version).toBeDefined(); - expect(skillMetadata.sha256).toBeDefined(); - expect(skillMetadata.size).toBeGreaterThan(0); + const { data, metadata } = await client.downloadSkillBundle(KNOWN_SKILL); + + expect(data).toBeInstanceOf(Uint8Array); + expect(data.byteLength).toBeGreaterThan(0); + expect(metadata.name).toBe(KNOWN_SKILL); + expect(metadata.version).toBeDefined(); + expect(metadata.sha256).toBeDefined(); + expect(metadata.size).toBeGreaterThan(0); }); it('throws MpakNotFoundError for nonexistent skill', async () => { diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index 2bab3fe..de03311 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -4,7 +4,7 @@ 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 | Buffer): string { +function sha256(content: string | Uint8Array): string { return createHash('sha256').update(content).digest('hex'); } @@ -25,14 +25,11 @@ function mockResponse( // Helper to create a mock binary Response function mockBinaryResponse( - buffer: Buffer, + data: Uint8Array, init: { status?: number; ok?: boolean } = {}, ): Response { return { - arrayBuffer: () => - Promise.resolve( - buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), - ), + arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)), status: init.status ?? 200, ok: init.ok ?? (init.status === undefined || init.status < 400), } as Response; @@ -390,19 +387,19 @@ describe('MpakClient', () => { describe('downloadContent', () => { it('downloads and verifies SHA-256', async () => { const client = new MpakClient(); - const content = Buffer.from('bundle binary data'); + const content = new TextEncoder().encode('bundle binary data'); const hash = sha256(content); fetchMock.mockResolvedValueOnce(mockBinaryResponse(content)); const result = await client.downloadContent('https://example.com/file.mcpb', hash); - expect(Buffer.isBuffer(result)).toBe(true); - expect(result.toString()).toBe('bundle binary data'); + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe('bundle binary data'); }); it('throws MpakIntegrityError on SHA-256 mismatch', async () => { const client = new MpakClient(); - const content = Buffer.from('some data'); + const content = new TextEncoder().encode('some data'); fetchMock.mockResolvedValueOnce(mockBinaryResponse(content)); await expect( @@ -413,7 +410,7 @@ describe('MpakClient', () => { it('throws MpakNetworkError on fetch failure', async () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce( - mockBinaryResponse(Buffer.from(''), { status: 500, ok: false }), + mockBinaryResponse(new Uint8Array(0), { status: 500, ok: false }), ); await expect( @@ -423,7 +420,7 @@ describe('MpakClient', () => { }); describe('downloadBundle', () => { - const bundleContent = Buffer.from('fake mcpb bundle'); + const bundleContent = new TextEncoder().encode('fake mcpb bundle'); const bundleHash = sha256(bundleContent); const downloadInfoResponse = { url: 'https://storage.example.com/bundle.mcpb', @@ -448,11 +445,11 @@ describe('MpakClient', () => { arch: 'arm64', }); - expect(Buffer.isBuffer(result.bundleRaw)).toBe(true); - expect(result.bundleRaw.toString()).toBe('fake mcpb bundle'); - expect(result.bundleMetadata.name).toBe('@test/bundle'); - expect(result.bundleMetadata.version).toBe('1.0.0'); - expect(result.bundleMetadata.sha256).toBe(bundleHash); + expect(result.data).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result.data)).toBe('fake mcpb bundle'); + expect(result.metadata.name).toBe('@test/bundle'); + expect(result.metadata.version).toBe('1.0.0'); + expect(result.metadata.sha256).toBe(bundleHash); }); it('defaults version to latest and auto-detects platform', async () => { @@ -478,7 +475,7 @@ describe('MpakClient', () => { it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { const client = new MpakClient(); - const tampered = Buffer.from('tampered content'); + const tampered = new TextEncoder().encode('tampered content'); fetchMock .mockResolvedValueOnce(mockResponse(downloadInfoResponse)) .mockResolvedValueOnce(mockBinaryResponse(tampered)); @@ -490,7 +487,7 @@ describe('MpakClient', () => { }); describe('downloadSkillBundle', () => { - const skillContent = Buffer.from('fake skill bundle'); + const skillContent = new TextEncoder().encode('fake skill bundle'); const skillHash = sha256(skillContent); const skillDownloadInfoResponse = { url: 'https://storage.example.com/skill.skill', @@ -511,11 +508,11 @@ describe('MpakClient', () => { const result = await client.downloadSkillBundle('@test/skill', '1.0.0'); - expect(Buffer.isBuffer(result.skillRaw)).toBe(true); - expect(result.skillRaw.toString()).toBe('fake skill bundle'); - expect(result.skillMetadata.name).toBe('@test/skill'); - expect(result.skillMetadata.version).toBe('1.0.0'); - expect(result.skillMetadata.sha256).toBe(skillHash); + expect(result.data).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result.data)).toBe('fake skill bundle'); + expect(result.metadata.name).toBe('@test/skill'); + expect(result.metadata.version).toBe('1.0.0'); + expect(result.metadata.sha256).toBe(skillHash); }); it('defaults version to latest', async () => { @@ -541,7 +538,7 @@ describe('MpakClient', () => { it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { const client = new MpakClient(); - const tampered = Buffer.from('tampered content'); + const tampered = new TextEncoder().encode('tampered content'); fetchMock .mockResolvedValueOnce(mockResponse(skillDownloadInfoResponse)) .mockResolvedValueOnce(mockBinaryResponse(tampered)); From e4befc4771f875ce005eea6176442250040ed1a1 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 11:33:17 -0400 Subject: [PATCH 09/10] chore: update lockfile after moving jszip to devDependencies Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40d5eb3..0d1d31f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,13 +288,13 @@ importers: '@nimblebrain/mpak-schemas': specifier: workspace:* version: link:../schemas - jszip: - specifier: ^3.10.1 - version: 3.10.1 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.10 + jszip: + specifier: ^3.10.1 + version: 3.10.1 tsup: specifier: ^8.4.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) From cd2c016d9e7a8c11b3a26d960675ec9daf3f5237 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 11:52:56 -0400 Subject: [PATCH 10/10] style(sdk-typescript): fix prettier formatting Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/client.ts | 881 +++++++++---------- packages/sdk-typescript/tests/client.test.ts | 3 +- 2 files changed, 415 insertions(+), 469 deletions(-) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index cf24605..25748d7 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -1,27 +1,20 @@ +import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas'; +import { createHash } from 'crypto'; +import { MpakIntegrityError, MpakNetworkError, MpakNotFoundError } from './errors.js'; import type { - BundleSearchResponse, - SkillSearchResponse, -} from "@nimblebrain/mpak-schemas"; -import { createHash } from "crypto"; -import { - MpakIntegrityError, - MpakNetworkError, - MpakNotFoundError, -} from "./errors.js"; -import type { - BundleDetailResponse, - BundleDownloadResponse, - BundleSearchParams, - BundleVersionResponse, - BundleVersionsResponse, - MpakClientConfig, - Platform, - SkillDetailResponse, - SkillDownloadResponse, - SkillSearchParams, -} from "./types.js"; - -const DEFAULT_REGISTRY_URL = "https://registry.mpak.dev"; + BundleDetailResponse, + BundleDownloadResponse, + BundleSearchParams, + BundleVersionResponse, + BundleVersionsResponse, + MpakClientConfig, + Platform, + SkillDetailResponse, + SkillDownloadResponse, + SkillSearchParams, +} from './types.js'; + +const DEFAULT_REGISTRY_URL = 'https://registry.mpak.dev'; const DEFAULT_TIMEOUT = 30000; /** @@ -30,449 +23,401 @@ const DEFAULT_TIMEOUT = 30000; * Requires Node.js 18+ for native fetch support. */ export class MpakClient { - private readonly registryUrl: string; - private readonly timeout: number; - private readonly userAgent: string | undefined; - - constructor(config: MpakClientConfig = {}) { - this.registryUrl = config.registryUrl ?? DEFAULT_REGISTRY_URL; - this.timeout = config.timeout ?? DEFAULT_TIMEOUT; - this.userAgent = config.userAgent; - } - - // =========================================================================== - // Bundle API - // =========================================================================== - - /** - * Search for bundles - */ - 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)); - - const queryString = searchParams.toString(); - 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}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get bundle details - */ - async getBundle(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get all versions of a bundle - */ - async getBundleVersions(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}/versions`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle versions: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get a specific version of a bundle - */ - async getBundleVersion( - name: string, - version: string, - ): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle version: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get download info for a bundle - */ - async getBundleDownload( - name: string, - version: string, - platform?: Platform, - ): Promise { - this.validateScopedName(name); - - const params = new URLSearchParams(); - if (platform) { - 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 response = await this.fetchWithTimeout(url, { - headers: { Accept: "application/json" }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get bundle download: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - // =========================================================================== - // Skill API - // =========================================================================== - - /** - * Search for skills - */ - 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)); - - const queryString = searchParams.toString(); - 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}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get skill details - */ - async getSkill(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/skills/${name}`; - const response = await this.fetchWithTimeout(url); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get download info for a skill (latest version) - */ - async getSkillDownload(name: string): Promise { - this.validateScopedName(name); - - const url = `${this.registryUrl}/v1/skills/${name}/download`; - - const response = await this.fetchWithTimeout(url, { - headers: { Accept: "application/json" }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(name); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill download: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - /** - * Get download info for a specific skill version - */ - 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" }, - }); - - if (response.status === 404) { - throw new MpakNotFoundError(`${name}@${version}`); - } - - if (!response.ok) { - throw new MpakNetworkError( - `Failed to get skill download: HTTP ${response.status}`, - ); - } - - return response.json() as Promise; - } - - // =========================================================================== - // Download Methods - // =========================================================================== - - /** - * Download content from a URL and verify its SHA-256 integrity. - * - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadContent(url: string, sha256: string): Promise { - const response = await this.fetchWithTimeout(url); - - if (!response.ok) { - throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`); - } - - const downloadedRawData = new Uint8Array(await response.arrayBuffer()); - - const computedHash = this.computeSha256(downloadedRawData); - if (computedHash !== sha256) { - throw new MpakIntegrityError(sha256, computedHash); - } - - return downloadedRawData; - } - - /** - * Download a bundle by name, with optional version and platform. - * Defaults to latest version and auto-detected platform. - * - * @throws {MpakNotFoundError} If bundle not found - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadBundle( - name: string, - version?: string, - platform?: Platform, - ): Promise<{ - data: Uint8Array; - metadata: BundleDownloadResponse["bundle"]; - }> { - const resolvedPlatform = platform ?? MpakClient.detectPlatform(); - const resolvedVersion = version ?? "latest"; - - const downloadInfo = await this.getBundleDownload( - name, - resolvedVersion, - resolvedPlatform, - ); - const data = await this.downloadContent( - downloadInfo.url, - downloadInfo.bundle.sha256, - ); - - return { data, metadata: downloadInfo.bundle }; - } - - /** - * Download a skill bundle by name, with optional version. - * Defaults to latest version. - * - * @throws {MpakNotFoundError} If skill not found - * @throws {MpakIntegrityError} If SHA-256 doesn't match - * @throws {MpakNetworkError} For network failures - */ - async downloadSkillBundle( - name: string, - version?: string, - ): Promise<{ - data: Uint8Array; - metadata: SkillDownloadResponse["skill"]; - }> { - const resolvedVersion = version ?? "latest"; - - const downloadInfo = await this.getSkillVersionDownload( - name, - resolvedVersion, - ); - const data = await this.downloadContent( - downloadInfo.url, - downloadInfo.skill.sha256, - ); - - return { data, metadata: downloadInfo.skill }; - } - - // =========================================================================== - // Utility Methods - // =========================================================================== - - /** - * Detect the current platform - */ - static detectPlatform(): Platform { - const nodePlatform = process.platform; - const nodeArch = process.arch; - - let os: string; - switch (nodePlatform) { - case "darwin": - os = "darwin"; - break; - case "win32": - os = "win32"; - break; - case "linux": - os = "linux"; - break; - default: - os = "any"; - } - - let arch: string; - switch (nodeArch) { - case "x64": - arch = "x64"; - break; - case "arm64": - arch = "arm64"; - break; - default: - arch = "any"; - } - - return { os, arch }; - } - - /** - * Compute SHA256 hash of content - */ - private computeSha256(content: string | Uint8Array): string { - return createHash("sha256").update(content).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)", - ); - } - } - - /** - * Fetch with timeout support - */ - private async fetchWithTimeout( - url: string, - init?: RequestInit, - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, this.timeout); - - const headers: Record = { - ...(init?.headers as Record), - }; - if (this.userAgent) { - headers["User-Agent"] = this.userAgent; - } - - try { - return await fetch(url, { - ...init, - headers, - signal: controller.signal, - }); - } catch (error) { - 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", - ); - } finally { - clearTimeout(timeoutId); - } - } + private readonly registryUrl: string; + private readonly timeout: number; + private readonly userAgent: string | undefined; + + constructor(config: MpakClientConfig = {}) { + this.registryUrl = config.registryUrl ?? DEFAULT_REGISTRY_URL; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT; + this.userAgent = config.userAgent; + } + + // =========================================================================== + // Bundle API + // =========================================================================== + + /** + * Search for bundles + */ + 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)); + + const queryString = searchParams.toString(); + 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}`); + } + + return response.json() as Promise; + } + + /** + * Get bundle details + */ + async getBundle(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get bundle: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + /** + * Get all versions of a bundle + */ + async getBundleVersions(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}/versions`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get bundle versions: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + /** + * Get a specific version of a bundle + */ + async getBundleVersion(name: string, version: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/bundles/${name}/versions/${version}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get bundle version: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + /** + * Get download info for a bundle + */ + async getBundleDownload( + name: string, + version: string, + platform?: Platform, + ): Promise { + this.validateScopedName(name); + + const params = new URLSearchParams(); + if (platform) { + 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 response = await this.fetchWithTimeout(url, { + headers: { Accept: 'application/json' }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get bundle download: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + // =========================================================================== + // Skill API + // =========================================================================== + + /** + * Search for skills + */ + 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)); + + const queryString = searchParams.toString(); + 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}`); + } + + return response.json() as Promise; + } + + /** + * Get skill details + */ + async getSkill(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/skills/${name}`; + const response = await this.fetchWithTimeout(url); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get skill: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + /** + * Get download info for a skill (latest version) + */ + async getSkillDownload(name: string): Promise { + this.validateScopedName(name); + + const url = `${this.registryUrl}/v1/skills/${name}/download`; + + const response = await this.fetchWithTimeout(url, { + headers: { Accept: 'application/json' }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + /** + * Get download info for a specific skill version + */ + 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' }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + + if (!response.ok) { + throw new MpakNetworkError(`Failed to get skill download: HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + // =========================================================================== + // Download Methods + // =========================================================================== + + /** + * Download content from a URL and verify its SHA-256 integrity. + * + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadContent(url: string, sha256: string): Promise { + const response = await this.fetchWithTimeout(url); + + if (!response.ok) { + throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`); + } + + const downloadedRawData = new Uint8Array(await response.arrayBuffer()); + + const computedHash = this.computeSha256(downloadedRawData); + if (computedHash !== sha256) { + throw new MpakIntegrityError(sha256, computedHash); + } + + return downloadedRawData; + } + + /** + * Download a bundle by name, with optional version and platform. + * Defaults to latest version and auto-detected platform. + * + * @throws {MpakNotFoundError} If bundle not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadBundle( + name: string, + version?: string, + platform?: Platform, + ): Promise<{ + data: Uint8Array; + metadata: BundleDownloadResponse['bundle']; + }> { + const resolvedPlatform = platform ?? MpakClient.detectPlatform(); + const resolvedVersion = version ?? 'latest'; + + const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform); + const data = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256); + + return { data, metadata: downloadInfo.bundle }; + } + + /** + * Download a skill bundle by name, with optional version. + * Defaults to latest version. + * + * @throws {MpakNotFoundError} If skill not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadSkillBundle( + name: string, + version?: string, + ): Promise<{ + data: Uint8Array; + metadata: SkillDownloadResponse['skill']; + }> { + const resolvedVersion = version ?? 'latest'; + + const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion); + const data = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256); + + return { data, metadata: downloadInfo.skill }; + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Detect the current platform + */ + static detectPlatform(): Platform { + const nodePlatform = process.platform; + const nodeArch = process.arch; + + let os: string; + switch (nodePlatform) { + case 'darwin': + os = 'darwin'; + break; + case 'win32': + os = 'win32'; + break; + case 'linux': + os = 'linux'; + break; + default: + os = 'any'; + } + + let arch: string; + switch (nodeArch) { + case 'x64': + arch = 'x64'; + break; + case 'arm64': + arch = 'arm64'; + break; + default: + arch = 'any'; + } + + return { os, arch }; + } + + /** + * Compute SHA256 hash of content + */ + private computeSha256(content: string | Uint8Array): string { + return createHash('sha256').update(content).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)'); + } + } + + /** + * Fetch with timeout support + */ + private async fetchWithTimeout(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, this.timeout); + + const headers: Record = { + ...(init?.headers as Record), + }; + if (this.userAgent) { + headers['User-Agent'] = this.userAgent; + } + + try { + return await fetch(url, { + ...init, + headers, + signal: controller.signal, + }); + } catch (error) { + 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'); + } finally { + clearTimeout(timeoutId); + } + } } diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index de03311..c81fed7 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -29,7 +29,8 @@ function mockBinaryResponse( init: { status?: number; ok?: boolean } = {}, ): Response { return { - arrayBuffer: () => Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)), + arrayBuffer: () => + Promise.resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)), status: init.status ?? 200, ok: init.ok ?? (init.status === undefined || init.status < 400), } as Response;