diff --git a/packages/musher/CHANGELOG.md b/packages/musher/CHANGELOG.md index 324c28e..e71caaf 100644 --- a/packages/musher/CHANGELOG.md +++ b/packages/musher/CHANGELOG.md @@ -1,13 +1,13 @@ -# Changelog - -## [0.1.1](https://github.com/musher-dev/typescript-sdk/compare/0.1.0...0.1.1) (2026-03-24) - - -### Features - -* scaffold @musher-dev/musher TypeScript SDK ([a05b0c7](https://github.com/musher-dev/typescript-sdk/commit/a05b0c773bd0544913ee87109a0f57b03f279df4)) - - -### Bug Fixes - -* resolve biome import ordering and shellcheck SC2155 warnings ([471936d](https://github.com/musher-dev/typescript-sdk/commit/471936d8bab48dbf34f6f3dc23b0bdf3c3729298)) +# Changelog + +## [0.1.1](https://github.com/musher-dev/typescript-sdk/compare/0.1.0...0.1.1) (2026-03-24) + + +### Features + +* scaffold @musher-dev/musher TypeScript SDK ([a05b0c7](https://github.com/musher-dev/typescript-sdk/commit/a05b0c773bd0544913ee87109a0f57b03f279df4)) + + +### Bug Fixes + +* resolve biome import ordering and shellcheck SC2155 warnings ([471936d](https://github.com/musher-dev/typescript-sdk/commit/471936d8bab48dbf34f6f3dc23b0bdf3c3729298)) diff --git a/packages/musher/package.json b/packages/musher/package.json index 9153379..2d42732 100644 --- a/packages/musher/package.json +++ b/packages/musher/package.json @@ -19,9 +19,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsup", "check": "pnpm check:format && pnpm check:lint && pnpm check:types && pnpm check:test", @@ -55,11 +53,5 @@ "url": "git+https://github.com/musher-dev/typescript-sdk.git", "directory": "packages/musher" }, - "keywords": [ - "musher", - "bundle", - "sdk", - "ai", - "agent" - ] + "keywords": ["musher", "bundle", "sdk", "ai", "agent"] } diff --git a/packages/musher/src/cache.ts b/packages/musher/src/cache.ts index d0b6d23..ea9450e 100644 --- a/packages/musher/src/cache.ts +++ b/packages/musher/src/cache.ts @@ -339,49 +339,21 @@ export class BundleCache { /** Walk manifests, remove expired, collect digests from surviving ones. */ private async cleanManifests(referencedDigests: Set): Promise { - const manifestsRoot = join(this.cacheDir, "manifests"); - if (!existsSync(manifestsRoot)) { - return; - } - - for (const hostId of await safeReaddir(manifestsRoot)) { - const hostDir = join(manifestsRoot, hostId); - if (!(await isDir(hostDir))) { - continue; + await walkCacheTree(join(this.cacheDir, "manifests"), async (ns, slug, slugDir, file) => { + if (!file.endsWith(".json") || file.endsWith(".meta.json")) { + return; } - for (const ns of await safeReaddir(hostDir)) { - const nsDir = join(hostDir, ns); - if (!(await isDir(nsDir))) { - continue; - } + const version = file.replace(JSON_EXT_RE, ""); + const fresh = await this.isFresh(ns, slug, version); - for (const slug of await safeReaddir(nsDir)) { - const slugDir = join(nsDir, slug); - if (!(await isDir(slugDir))) { - continue; - } - - for (const file of await safeReaddir(slugDir)) { - if (!file.endsWith(".json") || file.endsWith(".meta.json")) { - continue; - } - - const version = file.replace(JSON_EXT_RE, ""); - const fresh = await this.isFresh(ns, slug, version); - - if (fresh) { - // Collect blob digests from surviving manifest - await this.collectDigests(join(slugDir, file), referencedDigests); - } else { - // Remove expired manifest + meta - await safeRm(join(slugDir, file)); - await safeRm(join(slugDir, `${version}.meta.json`)); - } - } - } + if (fresh) { + await this.collectDigests(join(slugDir, file), referencedDigests); + } else { + await safeRm(join(slugDir, file)); + await safeRm(join(slugDir, `${version}.meta.json`)); } - } + }); } /** Collect blob digests referenced by a manifest file. */ @@ -401,42 +373,16 @@ export class BundleCache { /** Walk refs and remove expired entries. */ private async cleanRefs(): Promise { - const refsRoot = join(this.cacheDir, "refs"); - if (!existsSync(refsRoot)) { - return; - } - - for (const hostId of await safeReaddir(refsRoot)) { - const hostDir = join(refsRoot, hostId); - if (!(await isDir(hostDir))) { - continue; + await walkCacheTree(join(this.cacheDir, "refs"), async (ns, slug, slugDir, file) => { + if (!file.endsWith(".json")) { + return; } - - for (const ns of await safeReaddir(hostDir)) { - const nsDir = join(hostDir, ns); - if (!(await isDir(nsDir))) { - continue; - } - - for (const slug of await safeReaddir(nsDir)) { - const slugDir = join(nsDir, slug); - if (!(await isDir(slugDir))) { - continue; - } - - for (const file of await safeReaddir(slugDir)) { - if (!file.endsWith(".json")) { - continue; - } - const ref = file.replace(JSON_EXT_RE, ""); - const version = await this.resolveRef(ns, slug, ref); - if (version === null) { - await safeRm(join(slugDir, file)); - } - } - } + const ref = file.replace(JSON_EXT_RE, ""); + const version = await this.resolveRef(ns, slug, ref); + if (version === null) { + await safeRm(join(slugDir, file)); } - } + }); } /** Remove blobs not referenced by any surviving manifest. */ @@ -480,6 +426,38 @@ function computeHostId(registryUrl: string): string { } } +/** List subdirectories of a directory. */ +async function listSubdirs(parent: string): Promise> { + const result: Array<{ name: string; path: string }> = []; + for (const name of await safeReaddir(parent)) { + const p = join(parent, name); + if (await isDir(p)) { + result.push({ name, path: p }); + } + } + return result; +} + +/** Walk a host/ns/slug cache directory tree, calling visitor for each file in slug dirs. */ +async function walkCacheTree( + root: string, + visitor: (ns: string, slug: string, slugDir: string, file: string) => Promise, +): Promise { + if (!existsSync(root)) { + return; + } + + for (const host of await listSubdirs(root)) { + for (const ns of await listSubdirs(host.path)) { + for (const slug of await listSubdirs(ns.path)) { + for (const file of await safeReaddir(slug.path)) { + await visitor(ns.name, slug.name, slug.path, file); + } + } + } + } +} + async function isDir(path: string): Promise { try { return (await stat(path)).isDirectory(); diff --git a/packages/musher/src/http.ts b/packages/musher/src/http.ts index e30cc76..7960d2b 100644 --- a/packages/musher/src/http.ts +++ b/packages/musher/src/http.ts @@ -37,47 +37,58 @@ export class HttpTransport { let lastError: Error | undefined; for (let attempt = 0; attempt <= this.config.retries; attempt++) { - try { - const init: RequestInit = { method, headers }; - if (options?.body) { - init.body = JSON.stringify(options.body); - } - const response = await this.fetchWithTimeout(url, init); - - if (!response.ok) { - const error = await this.mapError(response); - // Only retry on 429 or 5xx - if (response.status === 429 || response.status >= 500) { - lastError = error; - if (attempt < this.config.retries) { - await this.backoff(attempt, error); - continue; - } - } - throw error; - } + const result = await this.attemptRequest(method, url, headers, schema, options?.body); - const json: unknown = await response.json(); - return this.parse(schema, json); - } catch (error) { - if (error instanceof ApiError) { - throw error; - } - if (error instanceof SchemaError) { - throw error; - } + if (result.ok) { + return result.value; + } - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = result.error; - if (attempt < this.config.retries) { - await this.backoff(attempt); - } + if (!result.retryable || attempt >= this.config.retries) { + throw lastError; } + + await this.backoff(attempt, lastError); } throw lastError ?? new NetworkError("Request failed after retries"); } + private async attemptRequest( + method: string, + url: string, + headers: Record, + schema: z.ZodType, + body?: unknown, + ): Promise<{ ok: true; value: T } | { ok: false; error: Error; retryable: boolean }> { + try { + const init: RequestInit = { method, headers }; + if (body) { + init.body = JSON.stringify(body); + } + const response = await this.fetchWithTimeout(url, init); + + if (!response.ok) { + const error = await this.mapError(response); + const retryable = response.status === 429 || response.status >= 500; + return { ok: false, error, retryable }; + } + + const json: unknown = await response.json(); + return { ok: true, value: this.parse(schema, json) }; + } catch (error) { + if (error instanceof ApiError || error instanceof SchemaError) { + return { ok: false, error, retryable: false }; + } + return { + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + retryable: true, + }; + } + } + private buildUrl( path: string, params?: Record, diff --git a/packages/musher/src/selection.ts b/packages/musher/src/selection.ts index 9f74bb0..f84d063 100644 --- a/packages/musher/src/selection.ts +++ b/packages/musher/src/selection.ts @@ -37,49 +37,10 @@ export class Selection { } }; - if (this._filter.skills) { - for (const name of this._filter.skills) { - const skill = this._bundle.skills().find((s) => s.name === name); - if (skill) { - for (const f of skill.files()) { - addFile(f); - } - } - } - } - - if (this._filter.prompts) { - for (const name of this._filter.prompts) { - const prompt = this._bundle.prompts().find((p) => p.name === name); - if (prompt) { - for (const f of prompt.files()) { - addFile(f); - } - } - } - } - - if (this._filter.toolsets) { - for (const name of this._filter.toolsets) { - const toolset = this._bundle.toolsets().find((t) => t.name === name); - if (toolset) { - for (const f of toolset.files()) { - addFile(f); - } - } - } - } - - if (this._filter.agentSpecs) { - for (const name of this._filter.agentSpecs) { - const spec = this._bundle.agentSpecs().find((a) => a.name === name); - if (spec) { - for (const f of spec.files()) { - addFile(f); - } - } - } - } + this.collectFromCategory(this._bundle.skills(), this._filter.skills, addFile); + this.collectFromCategory(this._bundle.prompts(), this._filter.prompts, addFile); + this.collectFromCategory(this._bundle.toolsets(), this._filter.toolsets, addFile); + this.collectFromCategory(this._bundle.agentSpecs(), this._filter.agentSpecs, addFile); if (this._filter.paths) { for (const path of this._filter.paths) { @@ -93,6 +54,24 @@ export class Selection { return result; } + private collectFromCategory( + items: T[], + names: string[] | undefined, + addFile: (fh: FileHandle) => void, + ): void { + if (!names) { + return; + } + for (const name of names) { + const item = items.find((i) => i.name === name); + if (item) { + for (const f of item.files()) { + addFile(f); + } + } + } + } + skills(): SkillHandle[] { const names = this._filter.skills; if (!names) { diff --git a/packages/musher/tests/client.test.ts b/packages/musher/tests/client.test.ts index a62b27d..e99bdd6 100644 --- a/packages/musher/tests/client.test.ts +++ b/packages/musher/tests/client.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { MusherClient } from "../src/client.js"; import { MushError } from "../src/errors.js"; +const INVALID_BUNDLE_REF_RE = /Invalid bundle ref/; + describe("MusherClient", () => { it("creates with default config", () => { const client = new MusherClient(); @@ -42,6 +44,6 @@ describe("MusherClient", () => { it("accepts versioned refs in pull()", async () => { const client = new MusherClient(); // Will fail due to network, but should not throw a ref parse error - await expect(client.pull("acme/bundle:1.0.0")).rejects.not.toThrow(/Invalid bundle ref/); + await expect(client.pull("acme/bundle:1.0.0")).rejects.not.toThrow(INVALID_BUNDLE_REF_RE); }); });