Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 129 additions & 14 deletions packages/musher/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
import { Bundle } from "./bundle.js";
import { BundleCache } from "./cache.js";
import { type ClientConfig, resolveConfig } from "./config.js";
import { ApiError, ForbiddenError, IntegrityError, NotFoundError } from "./errors.js";
import {
ApiError,
AuthenticationError,
ForbiddenError,
IntegrityError,
NotFoundError,
} from "./errors.js";
import { HttpTransport } from "./http.js";
import { BundleRef } from "./ref.js";
import { BundlesResource } from "./resources/bundles.js";
Expand Down Expand Up @@ -46,17 +52,29 @@
* @param ref - Bundle reference (e.g. "namespace/slug", "namespace/slug:version").
* @param version - Optional semver constraint. Defaults to latest.
*/
async pull(ref: string, version?: string): Promise<Bundle> {

Check warning on line 55 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (22)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 17 detected (max: 15).

Check warning on line 55 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (20)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 17 detected (max: 15).

Check warning on line 55 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (24)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 17 detected (max: 15).
const parsed = BundleRef.parse(ref);
const resolvedVersion = version ?? parsed.version;

// Resolve metadata first (needed for manifest hashes and cache keys)
const resolved = await this.bundles.resolve(
parsed.namespace,
parsed.slug,
resolvedVersion,
parsed.digest,
);
let resolved: BundleResolveOutput;
try {
resolved = await this.bundles.resolve(
parsed.namespace,
parsed.slug,
resolvedVersion,
parsed.digest,
);
} catch (error) {
if (
!(error instanceof AuthenticationError || error instanceof ForbiddenError) ||
!resolvedVersion
) {
throw error;
}
// Namespace resolve requires auth — fall back to hub-only pull
return this.pullFromHub(parsed.namespace, parsed.slug, resolvedVersion);
}

// Pull asset content — try :pull endpoint (single request), fall back to
// individual asset fetches if the caller lacks namespace access.
Expand Down Expand Up @@ -102,7 +120,7 @@
* @param ref - Bundle reference.
* @param version - Optional semver constraint.
*/
async resolve(ref: string, version?: string): Promise<BundleResolveOutput> {

Check warning on line 123 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (22)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 18 detected (max: 15).

Check warning on line 123 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (20)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 18 detected (max: 15).

Check warning on line 123 in packages/musher/src/client.ts

View workflow job for this annotation

GitHub Actions / check (24)

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 18 detected (max: 15).
const parsed = BundleRef.parse(ref);
let resolvedVersion = version ?? parsed.version;

Expand Down Expand Up @@ -130,12 +148,26 @@
}

// Cache miss or stale — call the API
const resolved = await this.bundles.resolve(
parsed.namespace,
parsed.slug,
resolvedVersion,
parsed.digest,
);
let resolved: BundleResolveOutput;
try {
resolved = await this.bundles.resolve(
parsed.namespace,
parsed.slug,
resolvedVersion,
parsed.digest,
);
} catch (error) {
if (
!(error instanceof AuthenticationError || error instanceof ForbiddenError) ||
!resolvedVersion
) {
throw error;
}
// Namespace resolve requires auth — fall back to hub pull for metadata
resolved = await this.resolveFromHub(parsed.namespace, parsed.slug, resolvedVersion);
await this._cache.writeManifest(resolved);
return resolved;
}

// Persist resolved manifest to disk cache
await this._cache.writeManifest(resolved);
Expand All @@ -149,6 +181,83 @@
return resolved;
}

/**
* Build a synthetic BundleResolveOutput from hub pull data.
* Used when namespace :resolve requires auth and we fall back to the public hub.
*/
private async resolveFromHub(
namespace: string,
slug: string,
version: string,
): Promise<BundleResolveOutput> {
const pulled = await this.bundles.pullHubVersion(namespace, slug, version);

const layers = pulled.manifest.map((asset) => {
const buf = Buffer.from(asset.contentText, "utf-8");
return {
assetId: "",
logicalPath: asset.logicalPath,
assetType: asset.assetType,
contentSha256: createHash("sha256").update(buf).digest("hex"),
sizeBytes: buf.length,
mediaType: asset.mediaType ?? null,
};
});

return {
bundleId: "",
versionId: "",
namespace,
slug,
ref: `${namespace}/${slug}`,
version: pulled.version,
sourceType: "registry",
state: "published",
manifest: { layers },
};
}

/**
* Pull a public bundle entirely via the hub endpoint (no auth required).
* Used when namespace :resolve returns 401/403.
*/
private async pullFromHub(namespace: string, slug: string, version: string): Promise<Bundle> {
const pulled = await this.bundles.pullHubVersion(namespace, slug, version);

const assets = new Map<string, Buffer>();
const layers = [];

for (const asset of pulled.manifest) {
const buf = Buffer.from(asset.contentText, "utf-8");
const hash = createHash("sha256").update(buf).digest("hex");
assets.set(asset.logicalPath, buf);
layers.push({
assetId: "",
logicalPath: asset.logicalPath,
assetType: asset.assetType,
contentSha256: hash,
sizeBytes: buf.length,
mediaType: asset.mediaType ?? null,
});
}

const resolved: BundleResolveOutput = {
bundleId: "",
versionId: "",
namespace,
slug,
ref: `${namespace}/${slug}`,
version: pulled.version,
sourceType: "registry",
state: "published",
manifest: { layers },
};

await this._cache.write(resolved, assets);

return new Bundle(resolved, assets);
}

/**
* Pull content via the :pull endpoint with automatic fallback.
*
Expand All @@ -165,7 +274,13 @@
try {
return await this.bundles.pullVersion(namespace, slug, resolved.version);
} catch (error) {
if (!(error instanceof ForbiddenError || error instanceof NotFoundError)) {
if (
!(
error instanceof AuthenticationError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
)
) {
throw error;
}
}
Expand Down
119 changes: 117 additions & 2 deletions packages/musher/tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,53 @@
import { describe, expect, it } from "vitest";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { MusherClient } from "../src/client.js";
import { MusherError } from "../src/errors.js";
import { AuthenticationError, ForbiddenError, MusherError } from "../src/errors.js";

const INVALID_BUNDLE_REF_RE = /Invalid bundle ref/;

function makeProblem(status: number, title: string) {
return { type: "about:blank", title, status, detail: title };
}

function makePullOutput(ns: string, slug: string, version: string) {
return {
namespace: ns,
slug,
version,
name: `${ns}/${slug}`,
description: null,
manifest: [
{
logicalPath: "skills/greet/SKILL.md",
assetType: "skill",
contentText: "# Greet\nSay hello",
mediaType: "text/markdown",
},
],
};
}

function makeResolveOutput(ns: string, slug: string, version: string) {
return {
bundleId: "00000000-0000-0000-0000-000000000001",
versionId: "00000000-0000-0000-0000-000000000002",
namespace: ns,
slug,
ref: `${ns}/${slug}`,
version,
sourceType: "registry" as const,
state: "published" as const,
manifest: { layers: [] },
};
}

async function makeTempClient() {
const cacheDir = await mkdtemp(join(tmpdir(), "musher-test-"));
return new MusherClient({ cacheDir });
}

describe("MusherClient", () => {
it("creates with default config", () => {
const client = new MusherClient();
Expand Down Expand Up @@ -41,4 +85,75 @@ describe("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_RE);
});

describe("pullContent fallback", () => {
it("falls back to hub pull when namespace :pull returns 401", async () => {
const client = await makeTempClient();
const pullOutput = makePullOutput("acme", "bundle", "1.0.0");

vi.spyOn(client.bundles, "resolve").mockResolvedValue(
makeResolveOutput("acme", "bundle", "1.0.0"),
);
vi.spyOn(client.bundles, "pullVersion").mockRejectedValue(
new AuthenticationError(makeProblem(401, "Unauthorized")),
);
vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput);

const bundle = await client.pull("acme/bundle:1.0.0");
expect(bundle).toBeDefined();
expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0");
});

it("falls back to hub pull when namespace :pull returns 403", async () => {
const client = await makeTempClient();
const pullOutput = makePullOutput("acme", "bundle", "1.0.0");

vi.spyOn(client.bundles, "resolve").mockResolvedValue(
makeResolveOutput("acme", "bundle", "1.0.0"),
);
vi.spyOn(client.bundles, "pullVersion").mockRejectedValue(
new ForbiddenError(makeProblem(403, "Forbidden")),
);
vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput);

const bundle = await client.pull("acme/bundle:1.0.0");
expect(bundle).toBeDefined();
expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0");
});
});

describe("resolve fallback to hub", () => {
it("pull() falls back to hub-only flow when resolve returns 401", async () => {
const client = await makeTempClient();
const pullOutput = makePullOutput("acme", "bundle", "1.0.0");

vi.spyOn(client.bundles, "resolve").mockRejectedValue(
new AuthenticationError(makeProblem(401, "Unauthorized")),
);
vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput);

const bundle = await client.pull("acme/bundle:1.0.0");
expect(bundle).toBeDefined();
expect(bundle.files().length).toBe(1);
expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0");
});

it("resolve() falls back to hub pull for metadata when namespace resolve returns 401", async () => {
const client = await makeTempClient();
const pullOutput = makePullOutput("acme", "bundle", "1.0.0");

vi.spyOn(client.bundles, "resolve").mockRejectedValue(
new AuthenticationError(makeProblem(401, "Unauthorized")),
);
vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput);

const resolved = await client.resolve("acme/bundle:1.0.0");
expect(resolved).toBeDefined();
expect(resolved.namespace).toBe("acme");
expect(resolved.slug).toBe("bundle");
expect(resolved.version).toBe("1.0.0");
expect(resolved.manifest?.layers?.length).toBe(1);
expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0");
});
});
});
Loading