diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 122bbdb..f779f6d 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,24 +1,18 @@ -import { ConfigManager } from "../utils/config-manager.js"; -import type { PackageConfig } from "../utils/config-manager.js"; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ConfigSetOptions {} +import type { PackageConfig } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../utils/config.js'; export interface ConfigGetOptions { json?: boolean; } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ConfigClearOptions {} - /** * Mask sensitive values for display (show first 4 chars, rest as *) */ function maskValue(value: string): string { if (value.length <= 4) { - return "*".repeat(value.length); + return '*'.repeat(value.length); } - return value.substring(0, 4) + "*".repeat(value.length - 4); + return value.substring(0, 4) + '*'.repeat(value.length - 4); } /** @@ -26,30 +20,19 @@ function maskValue(value: string): string { * @example mpak config set @scope/name api_key=xxx * @example mpak config set @scope/name api_key=xxx other_key=yyy */ -export async function handleConfigSet( - packageName: string, - keyValuePairs: string[], - _options: ConfigSetOptions = {}, -): Promise { +export async function handleConfigSet(packageName: string, keyValuePairs: string[]): Promise { if (keyValuePairs.length === 0) { - process.stderr.write( - "Error: At least one key=value pair is required\n", - ); - process.stderr.write( - "Usage: mpak config set = [=...]\n", - ); + process.stderr.write('Error: At least one key=value pair is required\n'); + process.stderr.write('Usage: mpak config set = [=...]\n'); process.exit(1); } - const configManager = new ConfigManager(); let setCount = 0; for (const pair of keyValuePairs) { - const eqIndex = pair.indexOf("="); + const eqIndex = pair.indexOf('='); if (eqIndex === -1) { - process.stderr.write( - `Error: Invalid format "${pair}". Expected key=value\n`, - ); + process.stderr.write(`Error: Invalid format "${pair}". Expected key=value\n`); process.exit(1); } @@ -61,7 +44,7 @@ export async function handleConfigSet( process.exit(1); } - configManager.setPackageConfigValue(packageName, key, value); + mpak.configManager.setPackageConfigValue(packageName, key, value); setCount++; } @@ -77,19 +60,18 @@ export async function handleConfigGet( packageName: string, options: ConfigGetOptions = {}, ): Promise { - const configManager = new ConfigManager(); - const config = configManager.getPackageConfig(packageName); + const config = mpak.configManager.getPackageConfig(packageName); + const isOutputJson = !!options?.json; + // If no config or config is {} if (!config || Object.keys(config).length === 0) { - if (options.json) { + if (isOutputJson) { console.log(JSON.stringify({}, null, 2)); } else { console.log(`No config stored for ${packageName}`); } return; - } - - if (options.json) { + } else if (isOutputJson) { // Mask values in JSON output too const masked: PackageConfig = {}; for (const [key, value] of Object.entries(config)) { @@ -108,31 +90,27 @@ export async function handleConfigGet( * List all packages with stored config * @example mpak config list */ -export async function handleConfigList( - options: ConfigGetOptions = {}, -): Promise { - const configManager = new ConfigManager(); - const packages = configManager.listPackagesWithConfig(); +export async function handleConfigList(options: ConfigGetOptions = {}): Promise { + const packages = mpak.configManager.getPackageNames(); + const isOutputJson = !!options?.json; if (packages.length === 0) { - if (options.json) { + if (isOutputJson) { console.log(JSON.stringify([], null, 2)); } else { - console.log("No packages have stored config"); + console.log('No packages have stored config'); } return; } - if (options.json) { + if (isOutputJson) { console.log(JSON.stringify(packages, null, 2)); } else { - console.log("Packages with stored config:"); + console.log('Packages with stored config:'); for (const pkg of packages) { - const config = configManager.getPackageConfig(pkg); + const config = mpak.configManager.getPackageConfig(pkg); const keyCount = config ? Object.keys(config).length : 0; - console.log( - ` ${pkg} (${keyCount} value${keyCount === 1 ? "" : "s"})`, - ); + console.log(`${pkg} (${keyCount} value${keyCount === 1 ? '' : 's'})`); } } } @@ -142,16 +120,10 @@ export async function handleConfigList( * @example mpak config clear @scope/name # clears all * @example mpak config clear @scope/name api_key # clears specific key */ -export async function handleConfigClear( - packageName: string, - key?: string, - _options: ConfigClearOptions = {}, -): Promise { - const configManager = new ConfigManager(); - +export async function handleConfigClear(packageName: string, key?: string): Promise { if (key) { // Clear specific key - const cleared = configManager.clearPackageConfigValue(packageName, key); + const cleared = mpak.configManager.clearPackageConfigValue(packageName, key); if (cleared) { console.log(`Cleared ${key} for ${packageName}`); } else { @@ -159,7 +131,7 @@ export async function handleConfigClear( } } else { // Clear all config for package - const cleared = configManager.clearPackageConfig(packageName); + const cleared = mpak.configManager.clearPackageConfig(packageName); if (cleared) { console.log(`Cleared all config for ${packageName}`); } else { diff --git a/packages/cli/src/commands/packages/outdated.test.ts b/packages/cli/src/commands/packages/outdated.test.ts deleted file mode 100644 index 350f9a1..0000000 --- a/packages/cli/src/commands/packages/outdated.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getOutdatedBundles } from "./outdated.js"; - -vi.mock("../../utils/cache.js", async (importOriginal) => ({ - ...((await importOriginal()) as Record), - listCachedBundles: vi.fn(), -})); - -vi.mock("../../utils/client.js", () => ({ - createClient: vi.fn(), -})); - -import { listCachedBundles } from "../../utils/cache.js"; -import { createClient } from "../../utils/client.js"; - -const mockListCachedBundles = vi.mocked(listCachedBundles); -const mockCreateClient = vi.mocked(createClient); - -function makeMockClient(registry: Record) { - return { - getBundle: vi.fn(async (name: string) => { - const version = registry[name]; - if (!version) throw new Error(`Not found: ${name}`); - return { latest_version: version }; - }), - }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("getOutdatedBundles", () => { - it("returns empty array when no bundles are cached", async () => { - mockListCachedBundles.mockReturnValue([]); - - const result = await getOutdatedBundles(); - expect(result).toEqual([]); - expect(mockCreateClient).not.toHaveBeenCalled(); - }); - - it("returns empty array when all bundles are up to date", async () => { - mockListCachedBundles.mockReturnValue([ - { name: "@scope/a", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/a" }, - { name: "@scope/b", version: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/b" }, - ]); - mockCreateClient.mockReturnValue(makeMockClient({ - "@scope/a": "1.0.0", - "@scope/b": "2.0.0", - }) as never); - - const result = await getOutdatedBundles(); - expect(result).toEqual([]); - }); - - it("returns outdated bundles with current and latest versions", async () => { - mockListCachedBundles.mockReturnValue([ - { name: "@scope/a", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/a" }, - { name: "@scope/b", version: "2.0.0", pulledAt: "2025-02-01T00:00:00.000Z", cacheDir: "/cache/b" }, - ]); - mockCreateClient.mockReturnValue(makeMockClient({ - "@scope/a": "1.1.0", - "@scope/b": "2.0.0", - }) as never); - - const result = await getOutdatedBundles(); - expect(result).toEqual([ - { - name: "@scope/a", - current: "1.0.0", - latest: "1.1.0", - pulledAt: "2025-01-01T00:00:00.000Z", - }, - ]); - }); - - it("returns multiple outdated bundles sorted by name", async () => { - mockListCachedBundles.mockReturnValue([ - { name: "@scope/zebra", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/z" }, - { name: "@scope/alpha", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/a" }, - ]); - mockCreateClient.mockReturnValue(makeMockClient({ - "@scope/zebra": "2.0.0", - "@scope/alpha": "1.1.0", - }) as never); - - const result = await getOutdatedBundles(); - expect(result).toHaveLength(2); - expect(result[0]!.name).toBe("@scope/alpha"); - expect(result[1]!.name).toBe("@scope/zebra"); - }); - - it("skips bundles that fail to resolve from registry", async () => { - mockListCachedBundles.mockReturnValue([ - { name: "@scope/exists", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/e" }, - { name: "@scope/deleted", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/d" }, - ]); - mockCreateClient.mockReturnValue(makeMockClient({ - "@scope/exists": "2.0.0", - // @scope/deleted not in registry — getBundle will throw - }) as never); - - const result = await getOutdatedBundles(); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe("@scope/exists"); - }); - - it("checks all bundles in parallel", async () => { - const getBundle = vi.fn(async (name: string) => { - return { latest_version: name === "@scope/a" ? "2.0.0" : "1.0.0" }; - }); - mockListCachedBundles.mockReturnValue([ - { name: "@scope/a", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/a" }, - { name: "@scope/b", version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", cacheDir: "/cache/b" }, - ]); - mockCreateClient.mockReturnValue({ getBundle } as never); - - await getOutdatedBundles(); - expect(getBundle).toHaveBeenCalledTimes(2); - expect(getBundle).toHaveBeenCalledWith("@scope/a"); - expect(getBundle).toHaveBeenCalledWith("@scope/b"); - }); -}); diff --git a/packages/cli/src/commands/packages/outdated.ts b/packages/cli/src/commands/packages/outdated.ts index be90c5f..3d5c044 100644 --- a/packages/cli/src/commands/packages/outdated.ts +++ b/packages/cli/src/commands/packages/outdated.ts @@ -1,6 +1,5 @@ -import { isSemverEqual, listCachedBundles } from "../../utils/cache.js"; -import { createClient } from "../../utils/client.js"; -import { table } from "../../utils/format.js"; +import { mpak } from '../../utils/config.js'; +import { logger, table } from '../../utils/format.js'; export interface OutdatedEntry { name: string; @@ -18,26 +17,27 @@ export interface OutdatedOptions { * that have a newer version available. */ export async function getOutdatedBundles(): Promise { - const cached = listCachedBundles(); + const cached = mpak.bundleCache.listCachedBundles(); if (cached.length === 0) return []; - const client = createClient(); const results: OutdatedEntry[] = []; await Promise.all( cached.map(async (bundle) => { try { - const detail = await client.getBundle(bundle.name); - if (!isSemverEqual(detail.latest_version, bundle.version)) { + const latest = await mpak.bundleCache.checkForUpdate(bundle.name, { force: true }); + if (latest) { results.push({ name: bundle.name, current: bundle.version, - latest: detail.latest_version, + latest, pulledAt: bundle.pulledAt, }); } } catch { - process.stderr.write(`=> Warning: could not check ${bundle.name} (may have been removed from registry)\n`); + process.stderr.write( + `=> Warning: could not check ${bundle.name} (may have been removed from registry)\n`, + ); } }), ); @@ -46,7 +46,7 @@ export async function getOutdatedBundles(): Promise { } export async function handleOutdated(options: OutdatedOptions = {}): Promise { - process.stderr.write("=> Checking for updates...\n"); + process.stderr.write('=> Checking for updates...\n'); const outdated = await getOutdatedBundles(); @@ -56,15 +56,15 @@ export async function handleOutdated(options: OutdatedOptions = {}): Promise [e.name, e.current, e.latest, e.pulledAt]), ), ); - console.log(`\n${outdated.length} bundle(s) can be updated. Run 'mpak update' to update all.`); + logger.info(`\n${outdated.length} bundle(s) can be updated. Run 'mpak update' to update all.`); } diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index c375254..7602f7c 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -1,8 +1,8 @@ -import { writeFileSync } from "fs"; -import { resolve } from "path"; -import { MpakClient } from "@nimblebrain/mpak-sdk"; -import { fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { rmSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { MpakClient, parsePackageSpec } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../../utils/config.js'; +import { formatSize, logger } from '../../utils/format.js'; export interface PullOptions { output?: string; @@ -12,99 +12,51 @@ export interface PullOptions { } /** - * Parse package specification into name and version - * Examples: - * @scope/name -> { name: '@scope/name', version: undefined } - * @scope/name@1.0.0 -> { name: '@scope/name', version: '1.0.0' } + * Pull (download) a bundle from the registry to disk. */ -function parsePackageSpec(spec: string): { name: string; version?: string } { - // Find the last @ which separates version from package name - // Package names start with @, so we need to find the second @ - const lastAtIndex = spec.lastIndexOf("@"); - - if (lastAtIndex <= 0) { - // No version specified or invalid format - return { name: spec }; - } - - const name = spec.substring(0, lastAtIndex); - const version = spec.substring(lastAtIndex + 1); - - // Validate that the name still starts with @ - if (!name.startsWith("@")) { - // This means the @ was part of the package name, not a version separator - return { name: spec }; - } - - return { name, version }; -} - -/** - * Pull (download) a package from the registry - */ -export async function handlePull( - packageSpec: string, - options: PullOptions = {}, -): Promise { +export async function handlePull(packageSpec: string, options: PullOptions = {}): Promise { + let outputPath: string | undefined; try { const { name, version } = parsePackageSpec(packageSpec); - const client = createClient(); - - // Detect platform (or use explicit overrides) const detectedPlatform = MpakClient.detectPlatform(); const platform = { os: options.os || detectedPlatform.os, arch: options.arch || detectedPlatform.arch, }; - console.log( - `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, - ); - console.log(` Platform: ${platform.os}-${platform.arch}`); + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); + logger.info(` Platform: ${platform.os}-${platform.arch}`); - // Get download info with platform - const downloadInfo = await client.getBundleDownload( - name, - version || "latest", - platform, - ); + const { data, metadata } = await mpak.client.downloadBundle(name, version, platform); if (options.json) { - console.log(JSON.stringify(downloadInfo, null, 2)); + console.log(JSON.stringify(metadata, null, 2)); return; } - const bundle = downloadInfo.bundle; - console.log(` Version: ${bundle.version}`); - console.log( - ` Artifact: ${bundle.platform.os}-${bundle.platform.arch}`, - ); - console.log( - ` Size: ${(bundle.size / (1024 * 1024)).toFixed(2)} MB`, - ); + logger.info(` Version: ${metadata.version}`); + logger.info(` Artifact: ${metadata.platform.os}-${metadata.platform.arch}`); + logger.info(` Size: ${formatSize(metadata.size)}`); - // Determine output filename (include platform in name) - const platformSuffix = `${bundle.platform.os}-${bundle.platform.arch}`; - const defaultFilename = `${name.replace("@", "").replace("/", "-")}-${bundle.version}-${platformSuffix}.mcpb`; - const outputPath = options.output - ? resolve(options.output) - : resolve(defaultFilename); + const platformSuffix = `${metadata.platform.os}-${metadata.platform.arch}`; + const defaultFilename = `${name.replace('@', '').replace('/', '-')}-${metadata.version}-${platformSuffix}.mcpb`; + outputPath = options.output ? resolve(options.output) : resolve(defaultFilename); - console.log(`\n=> Downloading to ${outputPath}...`); + logger.info(`\n=> Downloading to ${outputPath}...`); + writeFileSync(outputPath, data); - // Download the bundle - const response = await fetch(downloadInfo.url); - if (!response.ok) { - throw new Error(`Failed to download bundle: ${response.statusText}`); - } - const arrayBuffer = await response.arrayBuffer(); - writeFileSync(outputPath, Buffer.from(arrayBuffer)); - - console.log(`\n=> Bundle downloaded successfully!`); - console.log(` File: ${outputPath}`); - console.log(` SHA256: ${bundle.sha256.substring(0, 16)}...`); + logger.info(`\n=> Bundle downloaded successfully!`); + logger.info(` File: ${outputPath}`); + logger.info(` SHA256: ${metadata.sha256.substring(0, 16)}...`); } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to pull bundle"); + if (outputPath) { + try { + rmSync(outputPath, { force: true }); + } catch (_e) { + /* ignore */ + } + } + logger.error(error instanceof Error ? error.message : 'Failed to pull bundle'); } } diff --git a/packages/cli/src/commands/packages/run.test.ts b/packages/cli/src/commands/packages/run.test.ts deleted file mode 100644 index 6038059..0000000 --- a/packages/cli/src/commands/packages/run.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { homedir } from "os"; -import { join } from "path"; -import { - parsePackageSpec, - resolveArgs, - resolveWorkspace, - substituteUserConfig, - substituteEnvVars, - getLocalCacheDir, - localBundleNeedsExtract, -} from "./run.js"; -import { getCacheDir } from "../../utils/cache.js"; - -describe("parsePackageSpec", () => { - describe("scoped packages", () => { - it("parses @scope/name without version", () => { - expect(parsePackageSpec("@scope/name")).toEqual({ - name: "@scope/name", - }); - }); - - it("parses @scope/name@1.0.0", () => { - expect(parsePackageSpec("@scope/name@1.0.0")).toEqual({ - name: "@scope/name", - version: "1.0.0", - }); - }); - - it("parses prerelease versions @scope/name@1.0.0-beta.1", () => { - expect( - parsePackageSpec("@scope/name@1.0.0-beta.1"), - ).toEqual({ - name: "@scope/name", - version: "1.0.0-beta.1", - }); - }); - - it("parses version with build metadata @scope/name@1.0.0+build.123", () => { - expect( - parsePackageSpec("@scope/name@1.0.0+build.123"), - ).toEqual({ - name: "@scope/name", - version: "1.0.0+build.123", - }); - }); - }); - - describe("edge cases", () => { - it("handles package name with multiple slashes @org/sub/name", () => { - // This is technically invalid per npm spec, but we should handle gracefully - const result = parsePackageSpec("@org/sub/name"); - expect(result.name).toBe("@org/sub/name"); - }); - - it("handles unscoped package name", () => { - expect(parsePackageSpec("simple-name")).toEqual({ - name: "simple-name", - }); - }); - - it("treats unscoped@version as invalid (mpak requires scoped packages)", () => { - // mpak only supports scoped packages (@scope/name) - // An unscoped name with @ is treated as the full name, not name@version - expect(parsePackageSpec("unscoped@1.0.0")).toEqual({ - name: "unscoped@1.0.0", - }); - }); - - it("handles empty string", () => { - expect(parsePackageSpec("")).toEqual({ name: "" }); - }); - - it("handles @ only", () => { - expect(parsePackageSpec("@")).toEqual({ name: "@" }); - }); - }); -}); - -describe("getCacheDir", () => { - const expectedBase = join(homedir(), ".mpak", "cache"); - - it("converts @scope/name to scope-name", () => { - expect(getCacheDir("@nimblebraininc/echo")).toBe( - join(expectedBase, "nimblebraininc-echo"), - ); - }); - - it("handles simple scoped names", () => { - expect(getCacheDir("@foo/bar")).toBe( - join(expectedBase, "foo-bar"), - ); - }); - - it("handles unscoped names", () => { - expect(getCacheDir("simple")).toBe( - join(expectedBase, "simple"), - ); - }); -}); - -describe("resolveArgs", () => { - const cacheDir = "/Users/test/.mpak/cache/scope-name"; - - it("resolves ${__dirname} placeholder", () => { - expect( - resolveArgs(["${__dirname}/dist/index.js"], cacheDir), - ).toEqual([`${cacheDir}/dist/index.js`]); - }); - - it("resolves multiple ${__dirname} in single arg", () => { - expect( - resolveArgs( - ["--config=${__dirname}/config.json"], - cacheDir, - ), - ).toEqual([`--config=${cacheDir}/config.json`]); - }); - - it("resolves ${__dirname} in multiple args", () => { - expect( - resolveArgs( - [ - "${__dirname}/index.js", - "--config", - "${__dirname}/config.json", - ], - cacheDir, - ), - ).toEqual([ - `${cacheDir}/index.js`, - "--config", - `${cacheDir}/config.json`, - ]); - }); - - it("leaves args without placeholders unchanged", () => { - expect( - resolveArgs(["-m", "mcp_echo.server"], cacheDir), - ).toEqual(["-m", "mcp_echo.server"]); - }); - - it("handles empty args array", () => { - expect(resolveArgs([], cacheDir)).toEqual([]); - }); - - it("handles Windows-style paths in cacheDir", () => { - const winPath = - "C:\\Users\\test\\.mpak\\cache\\scope-name"; - expect( - resolveArgs(["${__dirname}\\dist\\index.js"], winPath), - ).toEqual([`${winPath}\\dist\\index.js`]); - }); -}); - -describe("substituteUserConfig", () => { - it("substitutes single user_config variable", () => { - expect( - substituteUserConfig("${user_config.api_key}", { - api_key: "secret123", - }), - ).toBe("secret123"); - }); - - it("substitutes multiple user_config variables", () => { - expect( - substituteUserConfig( - "key=${user_config.key}&secret=${user_config.secret}", - { - key: "mykey", - secret: "mysecret", - }, - ), - ).toBe("key=mykey&secret=mysecret"); - }); - - it("leaves unmatched variables unchanged", () => { - expect( - substituteUserConfig("${user_config.missing}", { - other: "value", - }), - ).toBe("${user_config.missing}"); - }); - - it("handles mixed matched and unmatched variables", () => { - expect( - substituteUserConfig( - "${user_config.found}-${user_config.missing}", - { - found: "yes", - }, - ), - ).toBe("yes-${user_config.missing}"); - }); - - it("handles empty config values", () => { - expect( - substituteUserConfig("${user_config.empty}", { - empty: "", - }), - ).toBe(""); - }); - - it("handles values with special characters", () => { - expect( - substituteUserConfig("${user_config.key}", { - key: "abc$def{ghi}", - }), - ).toBe("abc$def{ghi}"); - }); - - it("leaves non-user_config placeholders unchanged", () => { - expect( - substituteUserConfig("${__dirname}/path", { - dirname: "/cache", - }), - ).toBe("${__dirname}/path"); - }); -}); - -describe("substituteEnvVars", () => { - it("substitutes user_config in all env vars", () => { - const env = { - API_KEY: "${user_config.api_key}", - DEBUG: "true", - TOKEN: "${user_config.token}", - }; - const values = { api_key: "key123", token: "tok456" }; - - expect(substituteEnvVars(env, values)).toEqual({ - API_KEY: "key123", - DEBUG: "true", - TOKEN: "tok456", - }); - }); - - it("handles undefined env", () => { - expect( - substituteEnvVars(undefined, { key: "value" }), - ).toEqual({}); - }); - - it("handles empty env", () => { - expect(substituteEnvVars({}, { key: "value" })).toEqual({}); - }); - - it("preserves env vars without placeholders", () => { - const env = { PATH: "/usr/bin", HOME: "/home/user" }; - expect(substituteEnvVars(env, {})).toEqual(env); - }); - - it("leaves unsubstituted placeholders as-is", () => { - const env = { - API_KEY: "${user_config.api_key}", - DEBUG: "true", - }; - // api_key not provided, so placeholder remains - // (process.env will override this at merge time) - expect(substituteEnvVars(env, {})).toEqual({ - API_KEY: "${user_config.api_key}", - DEBUG: "true", - }); - }); -}); - -describe("getLocalCacheDir", () => { - const expectedBase = join( - homedir(), - ".mpak", - "cache", - "_local", - ); - - it("returns consistent hash for same path", () => { - const dir1 = getLocalCacheDir("/path/to/bundle.mcpb"); - const dir2 = getLocalCacheDir("/path/to/bundle.mcpb"); - expect(dir1).toBe(dir2); - }); - - it("returns different hash for different paths", () => { - const dir1 = getLocalCacheDir("/path/to/bundle1.mcpb"); - const dir2 = getLocalCacheDir("/path/to/bundle2.mcpb"); - expect(dir1).not.toBe(dir2); - }); - - it("includes _local in path", () => { - const dir = getLocalCacheDir("/path/to/bundle.mcpb"); - expect(dir).toContain("_local"); - expect(dir.startsWith(expectedBase)).toBe(true); - }); - - it("produces a 12-character hash suffix", () => { - const dir = getLocalCacheDir("/path/to/bundle.mcpb"); - const hashPart = dir.split("/").pop(); - expect(hashPart).toHaveLength(12); - }); -}); - -describe("localBundleNeedsExtract", () => { - it("returns true when cache directory does not exist", () => { - expect( - localBundleNeedsExtract( - "/any/path.mcpb", - "/nonexistent/cache", - ), - ).toBe(true); - }); - - it("returns true when meta file does not exist in cache dir", () => { - // Using a directory that exists but has no .mpak-meta.json - expect( - localBundleNeedsExtract("/any/path.mcpb", "/tmp"), - ).toBe(true); - }); -}); - -describe("resolveWorkspace", () => { - it("defaults to $cwd/.mpak when no override", () => { - expect(resolveWorkspace(undefined, "/home/user/project")).toBe( - join("/home/user/project", ".mpak"), - ); - }); - - it("uses override when provided", () => { - expect( - resolveWorkspace("/data/custom", "/home/user/project"), - ).toBe("/data/custom"); - }); - - it("treats empty string as no override", () => { - expect(resolveWorkspace("", "/home/user/project")).toBe( - join("/home/user/project", ".mpak"), - ); - }); -}); diff --git a/packages/cli/src/commands/packages/run.ts b/packages/cli/src/commands/packages/run.ts index 17d6b5e..9f2432d 100644 --- a/packages/cli/src/commands/packages/run.ts +++ b/packages/cli/src/commands/packages/run.ts @@ -1,193 +1,25 @@ -import { spawn, spawnSync } from "child_process"; -import { createInterface } from "readline"; -import { - existsSync, - readFileSync, - writeFileSync, - chmodSync, - rmSync, - statSync, -} from "fs"; -import { createHash } from "crypto"; -import { homedir } from "os"; -import { join, resolve, basename } from "path"; -import { createClient } from "../../utils/client.js"; -import { - getCacheDir, - getCacheMetadata, - checkForUpdateAsync, - extractZip, - resolveBundle, - downloadAndExtract, - isSemverEqual, -} from "../../utils/cache.js"; -import type { CacheMetadata } from "../../utils/cache.js"; -import { ConfigManager } from "../../utils/config-manager.js"; +import type { PrepareServerSpec, ServerCommand } from '@nimblebrain/mpak-sdk'; +import { MpakConfigError, parsePackageSpec } from '@nimblebrain/mpak-sdk'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { createInterface } from 'readline'; +import { mpak } from '../../utils/config.js'; export interface RunOptions { update?: boolean; local?: string; // Path to local .mcpb file } -interface McpConfig { - command: string; - args: string[]; - env?: Record; -} - /** - * User configuration field definition (MCPB v0.3 spec) + * Prompt user for a missing config value (interactive terminal input) */ -interface UserConfigField { - type: "string" | "number" | "boolean"; - title?: string; +async function promptForValue(field: { + key: string; + title: string; description?: string; - sensitive?: boolean; - required?: boolean; - default?: string | number | boolean; -} - -interface McpbManifest { - manifest_version: string; - name: string; - version: string; - description: string; - user_config?: Record; - server: { - type: "node" | "python" | "binary"; - entry_point: string; - mcp_config: McpConfig; - }; -} - -/** - * Parse package specification into name and version - * @example parsePackageSpec('@scope/name') => { name: '@scope/name' } - * @example parsePackageSpec('@scope/name@1.0.0') => { name: '@scope/name', version: '1.0.0' } - */ -export function parsePackageSpec(spec: string): { - name: string; - version?: string; -} { - const lastAtIndex = spec.lastIndexOf("@"); - - if (lastAtIndex <= 0) { - return { name: spec }; - } - - const name = spec.substring(0, lastAtIndex); - const version = spec.substring(lastAtIndex + 1); - - if (!name.startsWith("@")) { - return { name: spec }; - } - - return { name, version }; -} - -/** - * Read manifest from extracted bundle - */ -function readManifest(cacheDir: string): McpbManifest { - const manifestPath = join(cacheDir, "manifest.json"); - if (!existsSync(manifestPath)) { - throw new Error(`Manifest not found in bundle: ${manifestPath}`); - } - return JSON.parse(readFileSync(manifestPath, "utf8")); -} - -/** - * Resolve placeholders in args (e.g., ${__dirname}) - * @example resolveArgs(['${__dirname}/index.js'], '/cache') => ['/cache/index.js'] - */ -export function resolveArgs(args: string[], cacheDir: string): string[] { - return args.map((arg) => - arg.replace(/\$\{__dirname\}/g, cacheDir), - ); -} - -/** - * Resolve the MPAK_WORKSPACE value. - * If an override is provided (via env), use it. Otherwise default to $cwd/.mpak. - */ -export function resolveWorkspace( - override: string | undefined, - cwd: string, -): string { - return override || join(cwd, ".mpak"); -} - -/** - * Substitute ${user_config.*} placeholders in a string - * @example substituteUserConfig('${user_config.api_key}', { api_key: 'secret' }) => 'secret' - */ -export function substituteUserConfig( - value: string, - userConfigValues: Record, -): string { - return value.replace( - /\$\{user_config\.([^}]+)\}/g, - (match, key: string) => { - return userConfigValues[key] ?? match; - }, - ); -} - -/** - * Substitute ${user_config.*} placeholders in env vars - */ -export function substituteEnvVars( - env: Record | undefined, - userConfigValues: Record, -): Record { - if (!env) return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - result[key] = substituteUserConfig(value, userConfigValues); - } - return result; -} - -/** - * Get cache directory for a local bundle. - * Uses hash of absolute path to avoid collisions. - */ -export function getLocalCacheDir(bundlePath: string): string { - const absolutePath = resolve(bundlePath); - const hash = createHash("md5") - .update(absolutePath) - .digest("hex") - .slice(0, 12); - return join(homedir(), ".mpak", "cache", "_local", hash); -} - -/** - * Check if local bundle needs re-extraction. - * Returns true if cache doesn't exist or bundle was modified after extraction. - */ -export function localBundleNeedsExtract( - bundlePath: string, - cacheDir: string, -): boolean { - const metaPath = join(cacheDir, ".mpak-meta.json"); - if (!existsSync(metaPath)) return true; - - try { - const meta = JSON.parse(readFileSync(metaPath, "utf8")); - const bundleStat = statSync(bundlePath); - return bundleStat.mtimeMs > new Date(meta.extractedAt).getTime(); - } catch { - return true; - } -} - -/** - * Prompt user for a config value (interactive terminal input) - */ -async function promptForValue( - field: UserConfigField, - key: string, -): Promise { + sensitive: boolean; +}): Promise { return new Promise((resolvePrompt) => { const rl = createInterface({ input: process.stdin, @@ -195,26 +27,17 @@ async function promptForValue( terminal: true, }); - const label = field.title || key; - const hint = field.description ? ` (${field.description})` : ""; - const defaultHint = - field.default !== undefined ? ` [${field.default}]` : ""; - const prompt = `=> ${label}${hint}${defaultHint}: `; + const label = field.title; + const hint = field.description ? ` (${field.description})` : ''; + const prompt = `=> ${label}${hint}: `; - // For sensitive fields, we'd ideally hide input, but Node's readline - // doesn't support this natively. We'll just note it's sensitive. if (field.sensitive) { process.stderr.write(`=> (sensitive input)\n`); } rl.question(prompt, (answer) => { rl.close(); - // Use default if empty and default exists - if (!answer && field.default !== undefined) { - resolvePrompt(String(field.default)); - } else { - resolvePrompt(answer); - } + resolvePrompt(answer); }); }); } @@ -227,337 +50,140 @@ function isInteractive(): boolean { } /** - * Gather user config values from stored config - * Prompts for missing required values if interactive + * Handle MpakConfigError by prompting for missing values interactively. + * Saves provided values to config, then retries prepareServer. */ -async function gatherUserConfigValues( - packageName: string, - userConfig: Record, - configManager: ConfigManager, -): Promise> { - const result: Record = {}; - const storedConfig = configManager.getPackageConfig(packageName) || {}; - const missingRequired: Array<{ key: string; field: UserConfigField }> = - []; - - for (const [key, field] of Object.entries(userConfig)) { - // Priority: 1) stored config, 2) default value - const storedValue = storedConfig[key]; - - if (storedValue !== undefined) { - result[key] = storedValue; - } else if (field.default !== undefined) { - result[key] = String(field.default); - } else if (field.required) { - missingRequired.push({ key, field }); - } +async function handleMissingConfig( + err: MpakConfigError, + spec: PrepareServerSpec, + options: RunOptions, +): Promise { + if (!isInteractive()) { + const missingKeys = err.missingFields.map((f) => f.key).join(', '); + process.stderr.write(`=> Error: Missing required config: ${missingKeys}\n`); + process.stderr.write( + `=> Run 'mpak config set ${err.packageName} =' to set values\n`, + ); + process.exit(1); } - // Prompt for missing required values if interactive - if (missingRequired.length > 0) { - if (!isInteractive()) { - const missingKeys = missingRequired - .map((m) => m.key) - .join(", "); - process.stderr.write( - `=> Error: Missing required config: ${missingKeys}\n`, - ); - process.stderr.write( - `=> Run 'mpak config set ${packageName} =' to set values\n`, - ); + process.stderr.write(`=> Package requires configuration:\n`); + for (const field of err.missingFields) { + const value = await promptForValue(field); + if (!value) { + process.stderr.write(`=> Error: ${field.title} is required\n`); process.exit(1); } - process.stderr.write(`=> Package requires configuration:\n`); - for (const { key, field } of missingRequired) { - const value = await promptForValue(field, key); - if (!value && field.required) { - process.stderr.write( - `=> Error: ${field.title || key} is required\n`, - ); - process.exit(1); - } - result[key] = value; - - // Offer to save the value - if (value) { - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true, - }); - await new Promise((resolvePrompt) => { - rl.question( - `=> Save ${field.title || key} for future runs? [Y/n]: `, - (answer) => { - rl.close(); - if (answer.toLowerCase() !== "n") { - configManager.setPackageConfigValue( - packageName, - key, - value, - ); - process.stderr.write( - `=> Saved to ~/.mpak/config.json\n`, - ); - } - resolvePrompt(); - }, - ); - }); - } - } + // Offer to save the value + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + terminal: true, + }); + await new Promise((resolvePrompt) => { + rl.question(`=> Save ${field.title} for future runs? [Y/n]: `, (answer) => { + rl.close(); + if (answer.toLowerCase() !== 'n') { + mpak.configManager.setPackageConfigValue(err.packageName, field.key, value); + process.stderr.write(`=> Saved to ~/.mpak/config.json\n`); + } + resolvePrompt(); + }); + }); } - return result; -} - -/** - * Find Python executable (tries python3 first, then python) - */ -function findPythonCommand(): string { - // Try python3 first (preferred on macOS/Linux) - const result = spawnSync("python3", ["--version"], { stdio: "pipe" }); - if (result.status === 0) { - return "python3"; - } - // Fall back to python - return "python"; + // Retry now that config values are saved + return mpak.prepareServer(spec, options.update ? { force: true } : {}); } /** * Run a package from the registry or a local bundle file */ -export async function handleRun( - packageSpec: string, - options: RunOptions = {}, -): Promise { +export async function handleRun(packageSpec: string, options: RunOptions = {}): Promise { // Validate that either --local or package spec is provided if (!options.local && !packageSpec) { - process.stderr.write( - `=> Error: Either provide a package name or use --local \n`, - ); + process.stderr.write(`=> Error: Either provide a package name or use --local \n`); process.exit(1); } - let cacheDir: string; - let packageName: string; - let registryClient: ReturnType | null = null; - let cachedMeta: CacheMetadata | null = null; - + // CLI-level validation for --local if (options.local) { - // === LOCAL BUNDLE MODE === const bundlePath = resolve(options.local); - // Validate bundle exists if (!existsSync(bundlePath)) { - process.stderr.write( - `=> Error: Bundle not found: ${bundlePath}\n`, - ); + process.stderr.write(`=> Error: Bundle not found: ${bundlePath}\n`); process.exit(1); } - // Validate .mcpb extension - if (!bundlePath.endsWith(".mcpb")) { - process.stderr.write( - `=> Error: Not an MCPB bundle: ${bundlePath}\n`, - ); + if (!bundlePath.endsWith('.mcpb')) { + process.stderr.write(`=> Error: Not an MCPB bundle: ${bundlePath}\n`); process.exit(1); } - - cacheDir = getLocalCacheDir(bundlePath); - const needsExtract = - options.update || - localBundleNeedsExtract(bundlePath, cacheDir); - - if (needsExtract) { - // Clear old extraction - if (existsSync(cacheDir)) { - rmSync(cacheDir, { recursive: true, force: true }); - } - - process.stderr.write( - `=> Extracting ${basename(bundlePath)}...\n`, - ); - extractZip(bundlePath, cacheDir); - - // Write local metadata - writeFileSync( - join(cacheDir, ".mpak-meta.json"), - JSON.stringify({ - localPath: bundlePath, - extractedAt: new Date().toISOString(), - }), - ); - } - - // Read manifest to get package name for config lookup - const manifest = readManifest(cacheDir); - packageName = manifest.name; - process.stderr.write(`=> Running ${packageName} (local)\n`); - } else { - // === REGISTRY MODE === - const { name, version: requestedVersion } = - parsePackageSpec(packageSpec); - packageName = name; - registryClient = createClient(); - cacheDir = getCacheDir(name); - - let needsPull = true; - cachedMeta = getCacheMetadata(cacheDir); - - // Check if we have a cached version - if (cachedMeta && !options.update) { - if (requestedVersion) { - // Specific version requested - check if cached version matches - needsPull = !isSemverEqual(cachedMeta.version, requestedVersion); - } else { - // Latest requested - use cache (user can --update to refresh) - needsPull = false; - } - } - - if (needsPull) { - const downloadInfo = await resolveBundle(name, registryClient, requestedVersion); - - // Check if cached version is already the latest - if ( - cachedMeta && - isSemverEqual(cachedMeta.version, downloadInfo.bundle.version) && - !options.update - ) { - needsPull = false; - } - - if (needsPull) { - ({ cacheDir } = await downloadAndExtract(name, downloadInfo)); - } - } } - // Read manifest and execute - const manifest = readManifest(cacheDir); - const { type, entry_point, mcp_config } = manifest.server; + // Build the spec + const spec: PrepareServerSpec = options.local + ? { local: resolve(options.local) } + : parsePackageSpec(packageSpec); - // Handle user_config substitution - let userConfigValues: Record = {}; - if ( - manifest.user_config && - Object.keys(manifest.user_config).length > 0 - ) { - const configManager = new ConfigManager(); - userConfigValues = await gatherUserConfigValues( - packageName, - manifest.user_config, - configManager, - ); - } - - // Substitute user_config placeholders in env vars - // Priority: process.env (from parent like Claude Desktop) > substituted values (from mpak config) - const substitutedEnv = substituteEnvVars( - mcp_config.env, - userConfigValues, - ); - - let command: string; - let args: string[]; - const env: Record = { - ...substitutedEnv, - ...process.env, - }; - - switch (type) { - case "binary": { - // For binary, the entry_point is the executable path relative to bundle - command = join(cacheDir, entry_point); - args = resolveArgs(mcp_config.args || [], cacheDir); - - // Ensure binary is executable - try { - chmodSync(command, 0o755); - } catch { - // Ignore chmod errors on Windows - } - break; - } - - case "node": { - command = mcp_config.command || "node"; - // Use mcp_config.args directly if provided, otherwise fall back to entry_point - if (mcp_config.args && mcp_config.args.length > 0) { - args = resolveArgs(mcp_config.args, cacheDir); - } else { - args = [join(cacheDir, entry_point)]; - } - break; - } - - case "python": { - // Use manifest command if specified, otherwise auto-detect python - command = - mcp_config.command === "python" - ? findPythonCommand() - : mcp_config.command || findPythonCommand(); - - // Use mcp_config.args directly if provided, otherwise fall back to entry_point - if (mcp_config.args && mcp_config.args.length > 0) { - args = resolveArgs(mcp_config.args, cacheDir); - } else { - args = [join(cacheDir, entry_point)]; - } - - // Set PYTHONPATH to deps/ directory for dependency resolution - const depsDir = join(cacheDir, "deps"); - const existingPythonPath = process.env["PYTHONPATH"]; - env["PYTHONPATH"] = existingPythonPath - ? `${depsDir}:${existingPythonPath}` - : depsDir; - break; + // Prepare server — handle missing config interactively + let server: ServerCommand; + try { + server = await mpak.prepareServer(spec, options.update ? { force: true } : {}); + } catch (err) { + if (err instanceof MpakConfigError) { + server = await handleMissingConfig(err, spec, options); + } else { + throw err; } - - default: - throw new Error(`Unsupported server type: ${type as string}`); } - // Provide a project-local workspace directory for stateful bundles. - // Defaults to $CWD/.mpak — user can override via MPAK_WORKSPACE in their environment. - env["MPAK_WORKSPACE"] = resolveWorkspace(env["MPAK_WORKSPACE"], process.cwd()); - // Spawn with stdio passthrough for MCP - const child = spawn(command, args, { - stdio: ["inherit", "inherit", "inherit"], - env, - cwd: cacheDir, + const child = spawn(server.command, server.args, { + stdio: ['inherit', 'inherit', 'inherit'], + env: { ...server.env, ...process.env }, + cwd: server.cwd, }); // Fire-and-forget update check for registry bundles let updateCheckPromise: Promise | null = null; - if (!options.local && registryClient && cachedMeta) { - updateCheckPromise = checkForUpdateAsync(packageName, cachedMeta, cacheDir, registryClient); + if (!options.local && !options.update) { + updateCheckPromise = mpak.bundleCache + .checkForUpdate(server.name) + .then((latestVersion) => { + if (latestVersion) { + process.stderr.write( + `\n=> Update available: ${server.name} ${server.version} -> ${latestVersion}\n` + + ` Run 'mpak run ${server.name} --update' to update\n`, + ); + } + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`=> Debug: update check failed: ${msg}\n`); + }); } // Forward signals - process.on("SIGINT", () => child.kill("SIGINT")); - process.on("SIGTERM", () => child.kill("SIGTERM")); + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); // Wait for exit - child.on("exit", async (code) => { - // Let the update check finish before exiting (but don't block indefinitely) + child.on('exit', async (code) => { if (updateCheckPromise) { try { await Promise.race([updateCheckPromise, new Promise((r) => setTimeout(r, 3000))]); - } catch { - // Silently swallow — update check is best-effort and should not affect UX + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`=> Debug: update check failed: ${msg}\n`); } } process.exit(code ?? 0); }); - child.on("error", (error) => { - process.stderr.write( - `=> Failed to start server: ${error.message}\n`, - ); + child.on('error', (error) => { + process.stderr.write(`=> Failed to start server: ${error.message}\n`); process.exit(1); }); } diff --git a/packages/cli/src/commands/packages/search.ts b/packages/cli/src/commands/packages/search.ts index 578821f..885fe56 100644 --- a/packages/cli/src/commands/packages/search.ts +++ b/packages/cli/src/commands/packages/search.ts @@ -1,61 +1,50 @@ -import { table, certLabel, truncate, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; - -export interface SearchOptions { - type?: string; - sort?: "downloads" | "recent" | "name"; - limit?: number; - offset?: number; +import type { BundleSearchParamsInput } from '@nimblebrain/mpak-schemas'; +import { mpak } from '../../utils/config.js'; +import { certLabel, logger, table, truncate } from '../../utils/format.js'; + +export type SearchOptions = Omit & { json?: boolean; -} +}; /** * Search bundles (v1 API) */ -export async function handleSearch( - query: string, - options: SearchOptions = {}, -): Promise { +export async function handleSearch(query: string, options: SearchOptions = {}): Promise { try { - const client = createClient(); - const params: Record = { q: query }; - if (options.type) params["type"] = options.type; - if (options.sort) params["sort"] = options.sort; - if (options.limit) params["limit"] = options.limit; - if (options.offset) params["offset"] = options.offset; - const result = await client.searchBundles(params as Parameters[0]); + const result = await mpak.client.searchBundles({ + q: query, + ...options, + }); - if (options.json) { - console.log(JSON.stringify(result, null, 2)); + if (result.bundles.length === 0) { + logger.info(`\nNo bundles found for "${query}"`); return; } - if (result.bundles.length === 0) { - console.log(`\nNo bundles found for "${query}"`); + logger.info(`\nFound ${result.total} bundle(s) for "${query}":\n`); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); return; } - console.log(`\nFound ${result.total} bundle(s) for "${query}":\n`); - const rows = result.bundles.map((b) => [ b.name, `v${b.latest_version}`, certLabel(b.certification_level), - truncate(b.description || "", 50), + truncate(b.description || '', 50), ]); - console.log(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], rows)); - console.log(); + logger.info(table(['NAME', 'VERSION', 'TRUST', 'DESCRIPTION'], rows)); + logger.info(''); if (result.pagination.has_more) { const nextOffset = (options.offset || 0) + (options.limit || 20); - console.log( - `More results available. Use --offset ${nextOffset} to see more.`, - ); + logger.info(`More results available. Use --offset ${nextOffset} to see more.`); } - console.log('Use "mpak show " for more details'); + logger.info('Use "mpak show " for more details'); } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to search bundles"); + logger.error(error instanceof Error ? error.message : 'Failed to search bundles'); } } diff --git a/packages/cli/src/commands/packages/show.ts b/packages/cli/src/commands/packages/show.ts index 0d21367..6613261 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -1,73 +1,62 @@ -import { fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from '../../utils/config.js'; +import { logger } from '../../utils/format.js'; export interface ShowOptions { json?: boolean; } const CERT_LEVEL_LABELS: Record = { - 1: "L1 Basic", - 2: "L2 Verified", - 3: "L3 Hardened", - 4: "L4 Certified", + 1: 'L1 Basic', + 2: 'L2 Verified', + 3: 'L3 Hardened', + 4: 'L4 Certified', }; /** * Show detailed information about a bundle (v1 API) */ -export async function handleShow( - packageName: string, - options: ShowOptions = {}, -): Promise { +export async function handleShow(packageName: string, options: ShowOptions = {}): Promise { try { - const client = createClient(); - // Fetch bundle details and versions in parallel const [bundle, versionsInfo] = await Promise.all([ - client.getBundle(packageName), - client.getBundleVersions(packageName), + mpak.client.getBundle(packageName), + mpak.client.getBundleVersions(packageName), ]); if (options.json) { - console.log( - JSON.stringify( - { ...bundle, versions_detail: versionsInfo.versions }, - null, - 2, - ), - ); + console.log(JSON.stringify({ ...bundle, versions_detail: versionsInfo.versions }, null, 2)); return; } // Header - const verified = bundle.verified ? "\u2713 " : ""; - const provenance = bundle.provenance ? "\uD83D\uDD12 " : ""; - console.log( + const verified = bundle.verified ? '\u2713 ' : ''; + const provenance = bundle.provenance ? '\uD83D\uDD12 ' : ''; + logger.info( `\n${verified}${provenance}${bundle.display_name || bundle.name} v${bundle.latest_version}\n`, ); // Description if (bundle.description) { - console.log(bundle.description); - console.log(); + logger.info(bundle.description); + logger.info(''); } // Basic info - console.log("Bundle Information:"); - console.log(` Name: ${bundle.name}`); + logger.info('Bundle Information:'); + logger.info(` Name: ${bundle.name}`); if (bundle.author?.name) { - console.log(` Author: ${bundle.author.name}`); + logger.info(` Author: ${bundle.author.name}`); } if (bundle.server_type) { - console.log(` Type: ${bundle.server_type}`); + logger.info(` Type: ${bundle.server_type}`); } if (bundle.license) { - console.log(` License: ${bundle.license}`); + logger.info(` License: ${bundle.license}`); } if (bundle.homepage) { - console.log(` Homepage: ${bundle.homepage}`); + logger.info(` Homepage: ${bundle.homepage}`); } - console.log(); + logger.info(''); // Trust / Certification const certLevel = bundle.certification_level; @@ -75,95 +64,80 @@ export async function handleShow( if (certLevel != null) { const label = CERT_LEVEL_LABELS[certLevel] ?? `L${certLevel}`; - console.log(`Trust: ${label}`); + logger.info(`Trust: ${label}`); if (certification?.controls_passed != null && certification?.controls_total != null) { - console.log(` Controls: ${certification.controls_passed}/${certification.controls_total} passed`); + logger.info( + ` Controls: ${certification.controls_passed}/${certification.controls_total} passed`, + ); } - console.log(); + logger.info(''); } // Provenance info if (bundle.provenance) { - console.log("Provenance:"); - console.log(` Repository: ${bundle.provenance.repository}`); - console.log(` Commit: ${bundle.provenance.sha.substring(0, 12)}`); - console.log(` Provider: ${bundle.provenance.provider}`); - console.log(); + logger.info('Provenance:'); + logger.info(` Repository: ${bundle.provenance.repository}`); + logger.info(` Commit: ${bundle.provenance.sha.substring(0, 12)}`); + logger.info(` Provider: ${bundle.provenance.provider}`); + logger.info(''); } // Stats - console.log("Statistics:"); - console.log(` Downloads: ${bundle.downloads.toLocaleString()}`); - console.log( - ` Published: ${new Date(bundle.published_at as string).toLocaleDateString()}`, - ); - console.log(); + logger.info('Statistics:'); + logger.info(` Downloads: ${bundle.downloads.toLocaleString()}`); + logger.info(` Published: ${new Date(bundle.published_at).toLocaleDateString()}`); + logger.info(''); // Tools if (bundle.tools && bundle.tools.length > 0) { - console.log(`Tools (${bundle.tools.length}):`); + logger.info(`Tools (${bundle.tools.length}):`); for (const tool of bundle.tools) { - console.log(` - ${tool.name}`); + logger.info(` - ${tool.name}`); if (tool.description) { - console.log(` ${tool.description}`); + logger.info(` ${tool.description}`); } } - console.log(); + logger.info(''); } // Versions with platforms if (versionsInfo.versions && versionsInfo.versions.length > 0) { - console.log(`Versions (${versionsInfo.versions.length}):`); + logger.info(`Versions (${versionsInfo.versions.length}):`); const recentVersions = versionsInfo.versions.slice(0, 5); for (const version of recentVersions) { - const date = new Date( - version.published_at as string, - ).toLocaleDateString(); + const date = new Date(version.published_at).toLocaleDateString(); const downloads = version.downloads.toLocaleString(); - const isLatest = - version.version === versionsInfo.latest ? " (latest)" : ""; - const provTag = version.provenance ? " \uD83D\uDD12" : ""; + const isLatest = version.version === versionsInfo.latest ? ' (latest)' : ''; + const provTag = version.provenance ? ' \uD83D\uDD12' : ''; // Format platforms - const platformStrs = version.platforms.map( - (p) => `${p.os}-${p.arch}`, - ); - const platformsDisplay = - platformStrs.length > 0 - ? ` [${platformStrs.join(", ")}]` - : ""; + const platformStrs = version.platforms.map((p) => `${p.os}-${p.arch}`); + const platformsDisplay = platformStrs.length > 0 ? ` [${platformStrs.join(', ')}]` : ''; - console.log( + logger.info( ` ${version.version}${isLatest}${provTag} - ${date} - ${downloads} downloads${platformsDisplay}`, ); } if (versionsInfo.versions.length > 5) { - console.log( - ` ... and ${versionsInfo.versions.length - 5} more`, - ); + logger.info(` ... and ${versionsInfo.versions.length - 5} more`); } - console.log(); + logger.info(''); } // Available platforms for latest version - const latestVersion = versionsInfo.versions.find( - (v) => v.version === versionsInfo.latest, - ); + const latestVersion = versionsInfo.versions.find((v) => v.version === versionsInfo.latest); if (latestVersion && latestVersion.platforms.length > 0) { - console.log("Available Platforms:"); + logger.info('Available Platforms:'); for (const platform of latestVersion.platforms) { - console.log(` - ${platform.os}-${platform.arch}`); + logger.info(` - ${platform.os}-${platform.arch}`); } - console.log(); + logger.info(''); } // Install instructions - console.log("Install:"); - console.log(` mpak install ${bundle.name}`); - console.log(); - console.log("Pull (download only):"); - console.log(` mpak pull ${bundle.name}`); + logger.info('Pull (download only):'); + logger.info(` mpak pull ${bundle.name}`); } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to get bundle details"); + logger.error(error instanceof Error ? error.message : 'Failed to get bundle details'); } } diff --git a/packages/cli/src/commands/packages/update.test.ts b/packages/cli/src/commands/packages/update.test.ts deleted file mode 100644 index ee28007..0000000 --- a/packages/cli/src/commands/packages/update.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { handleUpdate } from "./update.js"; - -vi.mock("../../utils/cache.js", () => ({ - resolveBundle: vi.fn(), - downloadAndExtract: vi.fn(), -})); - -vi.mock("../../utils/client.js", () => ({ - createClient: vi.fn(() => ({ getBundle: vi.fn() })), -})); - -vi.mock("./outdated.js", () => ({ - getOutdatedBundles: vi.fn(), -})); - -import { resolveBundle, downloadAndExtract } from "../../utils/cache.js"; -import { getOutdatedBundles } from "./outdated.js"; - -const mockResolveBundle = vi.mocked(resolveBundle); -const mockDownloadAndExtract = vi.mocked(downloadAndExtract); -const mockGetOutdatedBundles = vi.mocked(getOutdatedBundles); - -const fakeDownloadInfo = { - url: "https://example.com/bundle.mcpb", - bundle: { version: "2.0.0", platform: { os: "darwin", arch: "arm64" } }, -}; - -const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - -beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "log").mockImplementation(() => {}); - vi.spyOn(process.stderr, "write").mockImplementation(() => true); -}); - -describe("handleUpdate", () => { - describe("single bundle", () => { - it("resolves then downloads and reports the updated version", async () => { - mockResolveBundle.mockResolvedValue(fakeDownloadInfo); - mockDownloadAndExtract.mockResolvedValue({ cacheDir: "/cache/a", version: "2.0.0" }); - - await handleUpdate("@scope/a", {}); - - expect(mockResolveBundle).toHaveBeenCalledTimes(1); - expect(mockResolveBundle.mock.calls[0]![0]).toBe("@scope/a"); - expect(mockDownloadAndExtract).toHaveBeenCalledWith("@scope/a", fakeDownloadInfo); - expect(console.log).toHaveBeenCalledWith("Updated @scope/a to 2.0.0"); - }); - - it("outputs JSON when --json flag is set", async () => { - mockResolveBundle.mockResolvedValue(fakeDownloadInfo); - mockDownloadAndExtract.mockResolvedValue({ cacheDir: "/cache/a", version: "2.0.0" }); - - await handleUpdate("@scope/a", { json: true }); - - expect(console.log).toHaveBeenCalledWith( - JSON.stringify({ name: "@scope/a", version: "2.0.0" }, null, 2), - ); - }); - - it("does not call getOutdatedBundles", async () => { - mockResolveBundle.mockResolvedValue(fakeDownloadInfo); - mockDownloadAndExtract.mockResolvedValue({ cacheDir: "/cache/a", version: "2.0.0" }); - - await handleUpdate("@scope/a", {}); - - expect(mockGetOutdatedBundles).not.toHaveBeenCalled(); - }); - }); - - describe("update all", () => { - it("reports all up to date when nothing is outdated", async () => { - mockGetOutdatedBundles.mockResolvedValue([]); - - await handleUpdate(undefined, {}); - - expect(console.log).toHaveBeenCalledWith("All cached bundles are up to date."); - expect(mockDownloadAndExtract).not.toHaveBeenCalled(); - }); - - it("updates all outdated bundles", async () => { - mockGetOutdatedBundles.mockResolvedValue([ - { name: "@scope/a", current: "1.0.0", latest: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - { name: "@scope/b", current: "1.0.0", latest: "1.1.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - ]); - const infoA = { ...fakeDownloadInfo, bundle: { ...fakeDownloadInfo.bundle, version: "2.0.0" } }; - const infoB = { ...fakeDownloadInfo, bundle: { ...fakeDownloadInfo.bundle, version: "1.1.0" } }; - mockResolveBundle - .mockResolvedValueOnce(infoA) - .mockResolvedValueOnce(infoB); - mockDownloadAndExtract - .mockResolvedValueOnce({ cacheDir: "/cache/a", version: "2.0.0" }) - .mockResolvedValueOnce({ cacheDir: "/cache/b", version: "1.1.0" }); - - await handleUpdate(undefined, {}); - - expect(mockDownloadAndExtract).toHaveBeenCalledTimes(2); - expect(console.log).toHaveBeenCalledWith("Updated @scope/a: 1.0.0 -> 2.0.0"); - expect(console.log).toHaveBeenCalledWith("Updated @scope/b: 1.0.0 -> 1.1.0"); - }); - - it("continues updating when one bundle fails", async () => { - mockGetOutdatedBundles.mockResolvedValue([ - { name: "@scope/a", current: "1.0.0", latest: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - { name: "@scope/b", current: "1.0.0", latest: "1.1.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - ]); - mockResolveBundle - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce(fakeDownloadInfo); - mockDownloadAndExtract - .mockResolvedValueOnce({ cacheDir: "/cache/b", version: "1.1.0" }); - - await handleUpdate(undefined, {}); - - expect(process.stderr.write).toHaveBeenCalledWith( - expect.stringContaining("Failed to update @scope/a"), - ); - expect(console.log).toHaveBeenCalledWith("Updated @scope/b: 1.0.0 -> 1.1.0"); - }); - - it("outputs JSON when --json flag is set", async () => { - mockGetOutdatedBundles.mockResolvedValue([ - { name: "@scope/a", current: "1.0.0", latest: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - ]); - mockResolveBundle.mockResolvedValue(fakeDownloadInfo); - mockDownloadAndExtract.mockResolvedValue({ cacheDir: "/cache/a", version: "2.0.0" }); - - await handleUpdate(undefined, { json: true }); - - expect(console.log).toHaveBeenCalledWith( - JSON.stringify([{ name: "@scope/a", from: "1.0.0", to: "2.0.0" }], null, 2), - ); - }); - - it("exits non-zero when all updates fail", async () => { - mockGetOutdatedBundles.mockResolvedValue([ - { name: "@scope/a", current: "1.0.0", latest: "2.0.0", pulledAt: "2025-01-01T00:00:00.000Z" }, - ]); - mockResolveBundle.mockRejectedValueOnce(new Error("Network error")); - - await handleUpdate(undefined, {}); - - expect(process.stderr.write).toHaveBeenCalledWith( - expect.stringContaining("Failed to update @scope/a"), - ); - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it("outputs empty JSON array when nothing is outdated with --json", async () => { - mockGetOutdatedBundles.mockResolvedValue([]); - - await handleUpdate(undefined, { json: true }); - - expect(console.log).toHaveBeenCalledWith(JSON.stringify([], null, 2)); - }); - }); -}); diff --git a/packages/cli/src/commands/packages/update.ts b/packages/cli/src/commands/packages/update.ts index 4e5359e..44c6424 100644 --- a/packages/cli/src/commands/packages/update.ts +++ b/packages/cli/src/commands/packages/update.ts @@ -1,77 +1,87 @@ -import { downloadAndExtract, resolveBundle } from "../../utils/cache.js"; -import { createClient } from "../../utils/client.js"; -import { fmtError } from "../../utils/format.js"; -import { getOutdatedBundles } from "./outdated.js"; +import { MpakNetworkError, MpakNotFoundError } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../../utils/config.js'; +import { logger } from '../../utils/format.js'; +import { getOutdatedBundles } from './outdated.js'; export interface UpdateOptions { json?: boolean; } +async function forceUpdateBundle(name: string): Promise<{ name: string; version: string }> { + try { + const { version } = await mpak.bundleCache.loadBundle(name, { + force: true, + }); + return { name, version }; + } catch (err) { + if (err instanceof MpakNotFoundError) { + throw new Error(`Bundle "${name}" not found in the registry`); + } + if (err instanceof MpakNetworkError) { + throw new Error(`Network error updating "${name}": ${err.message}`); + } + throw err; + } +} + export async function handleUpdate( packageName: string | undefined, options: UpdateOptions = {}, ): Promise { - const client = createClient(); - if (packageName) { - // Update a single bundle - const downloadInfo = await resolveBundle(packageName, client); - const { version } = await downloadAndExtract(packageName, downloadInfo); + const { version } = await forceUpdateBundle(packageName); if (options.json) { console.log(JSON.stringify({ name: packageName, version }, null, 2)); } else { - console.log(`Updated ${packageName} to ${version}`); + logger.info(`Updated ${packageName} to ${version}`); } return; } // No name given — find and update all outdated bundles - process.stderr.write("=> Checking for updates...\n"); + logger.info('=> Checking for updates...'); const outdated = await getOutdatedBundles(); if (outdated.length === 0) { if (options.json) { console.log(JSON.stringify([], null, 2)); } else { - console.log("All cached bundles are up to date."); + logger.info('All cached bundles are up to date.'); } return; } - process.stderr.write( - `=> ${outdated.length} bundle(s) to update\n`, - ); + logger.info(`=> ${outdated.length} bundle(s) to update`); const updated: Array<{ name: string; from: string; to: string }> = []; const results = await Promise.allSettled( outdated.map(async (entry) => { - const downloadInfo = await resolveBundle(entry.name, client); - const { version } = await downloadAndExtract(entry.name, downloadInfo); + const { version } = await forceUpdateBundle(entry.name); return { name: entry.name, from: entry.current, to: version }; }), ); for (const [i, result] of results.entries()) { - if (result.status === "fulfilled") { + if (result.status === 'fulfilled') { updated.push(result.value); } else { - const message = result.reason instanceof Error ? result.reason.message : String(result.reason); - process.stderr.write(`=> Failed to update ${outdated[i]!.name}: ${message}\n`); + const message = + result.reason instanceof Error ? result.reason.message : String(result.reason); + logger.info(`=> Failed to update ${outdated[i]!.name}: ${message}`); } } - if (options.json) { - console.log(JSON.stringify(updated, null, 2)); - return; - } - if (updated.length === 0) { - fmtError("All updates failed."); + logger.error('All updates failed'); process.exit(1); } - for (const u of updated) { - console.log(`Updated ${u.name}: ${u.from} -> ${u.to}`); + if (options.json) { + console.log(JSON.stringify(updated, null, 2)); + } else { + for (const u of updated) { + logger.info(`Updated ${u.name}: ${u.from} -> ${u.to}`); + } } } diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index de1d46c..a479756 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,29 +1,21 @@ -import { table, certLabel, truncate, fmtError } from "../utils/format.js"; -import { createClient } from "../utils/client.js"; +import type { + Bundle, + BundleSearchParamsInput, + SkillSearchParamsInput, + SkillSummary, +} from '@nimblebrain/mpak-schemas'; +import { mpak } from '../utils/config.js'; +import { certLabel, logger, table, truncate } from '../utils/format.js'; export interface UnifiedSearchOptions { - type?: "bundle" | "skill"; - sort?: "downloads" | "recent" | "name"; + type?: 'bundle' | 'skill'; + sort?: 'downloads' | 'recent' | 'name'; limit?: number; offset?: number; json?: boolean; } -interface UnifiedResult { - type: "bundle" | "skill"; - name: string; - description: string; - downloads: number; - version: string; - author?: string | undefined; - certLevel?: number | null | undefined; - // Bundle-specific - serverType?: string | undefined; - verified?: boolean | undefined; - provenance?: boolean | undefined; - // Skill-specific - category?: string | undefined; -} +type UnifiedResult = (Bundle & { type: 'bundle' }) | (SkillSummary & { type: 'skill' }); /** * Unified search across bundles and skills @@ -33,70 +25,60 @@ export async function handleUnifiedSearch( options: UnifiedSearchOptions = {}, ): Promise { try { - const client = createClient(); + const client = mpak.client; const results: UnifiedResult[] = []; let bundleTotal = 0; let skillTotal = 0; // Search both in parallel (unless filtered by type) - const searchBundles = !options.type || options.type === "bundle"; - const searchSkillsFlag = !options.type || options.type === "skill"; - - const searchParams: Record = { q: query }; - if (options.sort) searchParams["sort"] = options.sort; - if (options.limit) searchParams["limit"] = options.limit; - if (options.offset) searchParams["offset"] = options.offset; + const searchBundles = !options.type || options.type === 'bundle'; + const searchSkillsFlag = !options.type || options.type === 'skill'; + + const bundleParams: BundleSearchParamsInput = { + q: query, + ...(options.sort && { sort: options.sort }), + ...(options.limit && { limit: options.limit }), + ...(options.offset && { offset: options.offset }), + }; + + const skillParams: SkillSearchParamsInput = { + q: query, + ...(options.sort && { sort: options.sort }), + ...(options.limit && { limit: options.limit }), + ...(options.offset && { offset: options.offset }), + }; const [bundleResult, skillResult] = await Promise.all([ - searchBundles - ? client.searchBundles(searchParams as Parameters[0]) - : null, - searchSkillsFlag - ? client - .searchSkills(searchParams as Parameters[0]) - .catch(() => null) // Skills API may not be deployed yet - : null, + searchBundles ? client.searchBundles(bundleParams) : null, + searchSkillsFlag ? client.searchSkills(skillParams) : null, ]); - // Process bundle results if (bundleResult) { bundleTotal = bundleResult.total; for (const bundle of bundleResult.bundles) { - results.push({ - type: "bundle", - name: bundle.name, - description: bundle.description || "", - downloads: bundle.downloads || 0, - version: bundle.latest_version, - author: bundle.author?.name || undefined, - certLevel: bundle.certification_level, - serverType: bundle.server_type || undefined, - verified: bundle.verified, - provenance: !!bundle.provenance, - }); + results.push({ type: 'bundle', ...bundle }); } } - // Process skill results if (skillResult) { skillTotal = skillResult.total; for (const skill of skillResult.skills) { - results.push({ - type: "skill", - name: skill.name, - description: skill.description || "", - downloads: skill.downloads || 0, - version: skill.latest_version, - author: skill.author?.name || undefined, - category: skill.category || undefined, - }); + results.push({ type: 'skill', ...skill }); } } + // No results + if (results.length === 0) { + logger.info(`\nNo results found for "${query}"`); + if (!searchBundles) logger.info(' (searched skills only)'); + if (!searchSkillsFlag) logger.info(' (searched bundles only)'); + return; + } + // Sort combined results - if (options.sort === "downloads") { + if (options.sort === 'downloads') { results.sort((a, b) => b.downloads - a.downloads); - } else if (options.sort === "name") { + } else if (options.sort === 'name') { results.sort((a, b) => a.name.localeCompare(b.name)); } @@ -115,62 +97,48 @@ export async function handleUnifiedSearch( return; } - // No results - if (results.length === 0) { - console.log(`\nNo results found for "${query}"`); - if (!searchBundles) console.log(" (searched skills only)"); - if (!searchSkillsFlag) console.log(" (searched bundles only)"); - return; - } - // Summary const totalResults = bundleTotal + skillTotal; - const typeFilter = options.type ? ` (${options.type}s only)` : ""; - console.log( - `\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`, - ); + const typeFilter = options.type ? ` (${options.type}s only)` : ''; + logger.info(`\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`); - const bundles = results.filter((r) => r.type === "bundle"); - const skills = results.filter((r) => r.type === "skill"); + const bundles = results.filter((r): r is Bundle & { type: 'bundle' } => r.type === 'bundle'); + const skills = results.filter((r): r is SkillSummary & { type: 'skill' } => r.type === 'skill'); // Bundles section if (bundles.length > 0) { - console.log(`\nBundles (${bundleTotal}):\n`); + logger.info(`\nBundles (${bundleTotal}):\n`); const bundleRows = bundles.map((r) => [ - r.name.length > 38 ? r.name.slice(0, 35) + "..." : r.name, - r.version || "-", - certLabel(r.certLevel), - truncate(r.description, 40), + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || '-', + certLabel(r.certification_level), + truncate(r.description ?? '', 40), ]); - console.log(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], bundleRows)); + logger.info(table(['NAME', 'VERSION', 'TRUST', 'DESCRIPTION'], bundleRows)); } // Skills section if (skills.length > 0) { - console.log(`\nSkills (${skillTotal}):\n`); + logger.info(`\nSkills (${skillTotal}):\n`); const skillRows = skills.map((r) => [ - r.name.length > 38 ? r.name.slice(0, 35) + "..." : r.name, - r.version || "-", - r.category || "-", + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || '-', + r.category || '-', truncate(r.description, 40), ]); - console.log(table(["NAME", "VERSION", "CATEGORY", "DESCRIPTION"], skillRows)); + logger.info(table(['NAME', 'VERSION', 'CATEGORY', 'DESCRIPTION'], skillRows)); } // Pagination hint const currentLimit = options.limit || 20; const currentOffset = options.offset || 0; if (bundleTotal + skillTotal > currentOffset + results.length) { - console.log( - `\n Use --offset ${currentOffset + currentLimit} to see more results.`, - ); + logger.info(`\n Use --offset ${currentOffset + currentLimit} to see more results.`); } - console.log(); - console.log( - 'Use "mpak bundle show " or "mpak skill show " for details.', - ); + logger.info(''); + logger.info('Use "mpak bundle show " or "mpak skill show " for details.'); } catch (error) { - fmtError(error instanceof Error ? error.message : "Search failed"); + logger.error(error instanceof Error ? error.message : 'Search failed'); } } diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index b414b6f..1ae97cd 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -1,36 +1,16 @@ -import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs"; -import { join, basename } from "path"; -import { homedir, tmpdir } from "os"; -import { execFileSync } from "child_process"; -import { formatSize, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { parsePackageSpec } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../../utils/config.js'; +import { formatSize, logger } from '../../utils/format.js'; /** * Get the Claude Code skills directory */ function getSkillsDir(): string { - return join(homedir(), ".claude", "skills"); -} - -/** - * Parse skill spec into name and version - */ -function parseSkillSpec(spec: string): { - name: string; - version?: string; -} { - const atIndex = spec.lastIndexOf("@"); - if (atIndex <= 0) { - return { name: spec }; - } - const slashIndex = spec.indexOf("/"); - if (atIndex > slashIndex) { - return { - name: spec.slice(0, atIndex), - version: spec.slice(atIndex + 1), - }; - } - return { name: spec }; + return join(homedir(), '.claude', 'skills'); } /** @@ -38,7 +18,7 @@ function parseSkillSpec(spec: string): { * @scope/skill-name -> skill-name */ function getShortName(scopedName: string): string { - const parts = scopedName.replace("@", "").split("/"); + const parts = scopedName.replace('@', '').split('/'); return parts[parts.length - 1]!; } @@ -52,63 +32,35 @@ export interface InstallOptions { */ export async function handleSkillInstall( skillSpec: string, - options: InstallOptions, + options: InstallOptions = {}, ): Promise { try { - const { name, version } = parseSkillSpec(skillSpec); + const { name, version } = parsePackageSpec(skillSpec); + + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); - // Get download info - const client = createClient(); - const downloadInfo = version - ? await client.getSkillVersionDownload(name, version) - : await client.getSkillDownload(name); - const shortName = getShortName(downloadInfo.skill.name); + const { data, metadata } = await mpak.client.downloadSkillBundle(name, version); + + const shortName = getShortName(metadata.name); const skillsDir = getSkillsDir(); const installPath = join(skillsDir, shortName); // Check if already installed if (existsSync(installPath) && !options.force) { - console.error( - `Skill "${shortName}" is already installed at ${installPath}`, - ); - console.error("Use --force to overwrite"); + logger.error(`Skill "${shortName}" is already installed at ${installPath}`); + logger.error('Use --force to overwrite'); process.exit(1); } - console.log( - `Pulling ${downloadInfo.skill.name}@${downloadInfo.skill.version}...`, - ); - - // Download the bundle - const response = await fetch(downloadInfo.url); - if (!response.ok) { - throw new Error(`Download failed (${response.status})`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - - // Verify SHA256 - if (downloadInfo.skill.sha256) { - const { createHash } = await import("crypto"); - const hash = createHash("sha256").update(buffer).digest("hex"); - if (hash !== downloadInfo.skill.sha256) { - throw new Error( - `SHA256 mismatch: expected ${downloadInfo.skill.sha256}, got ${hash}`, - ); - } - } - - console.log( - `Downloaded ${basename(downloadInfo.skill.name)}-${downloadInfo.skill.version}.skill (${formatSize(downloadInfo.skill.size)})`, - ); + logger.info(` Version: ${metadata.version}`); + logger.info(` Size: ${formatSize(metadata.size)}`); // Ensure skills directory exists - if (!existsSync(skillsDir)) { - mkdirSync(skillsDir, { recursive: true }); - } + mkdirSync(skillsDir, { recursive: true }); - // Write to temp file + // Write to temp file for extraction const tempPath = join(tmpdir(), `skill-${Date.now()}.skill`); - writeFileSync(tempPath, buffer); + writeFileSync(tempPath, data); // Remove existing installation if force if (existsSync(installPath)) { @@ -116,16 +68,13 @@ export async function handleSkillInstall( } // Extract using unzip - // The .skill bundle contains: skillName/SKILL.md, skillName/... - // We extract to the skills directory try { execFileSync('unzip', ['-o', tempPath, '-d', skillsDir], { - stdio: "pipe", + stdio: 'pipe', }); } catch (err) { throw new Error(`Failed to extract skill bundle: ${err}`); } finally { - // Clean up temp file rmSync(tempPath, { force: true }); } @@ -134,9 +83,9 @@ export async function handleSkillInstall( JSON.stringify( { installed: true, - name: downloadInfo.skill.name, + name: metadata.name, shortName, - version: downloadInfo.skill.version, + version: metadata.version, path: installPath, }, null, @@ -144,14 +93,12 @@ export async function handleSkillInstall( ), ); } else { - console.log(`Extracting to ${installPath}/`); - console.log(`\u2713 Installed: ${shortName}`); - console.log(""); - console.log( - "Skill available in Claude Code. Restart to activate.", - ); + logger.info(`\n=> Installed to ${installPath}/`); + logger.info(` \u2713 ${shortName}@${metadata.version}`); + logger.info(''); + logger.info('Skill available in Claude Code. Restart to activate.'); } } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); + logger.error(err instanceof Error ? err.message : 'Failed to install skill'); } } diff --git a/packages/cli/src/commands/skills/pack.test.ts b/packages/cli/src/commands/skills/pack.test.ts deleted file mode 100644 index 378b4a4..0000000 --- a/packages/cli/src/commands/skills/pack.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { - mkdirSync, - writeFileSync, - rmSync, - existsSync, -} from "fs"; -import { join, basename } from "path"; -import { tmpdir } from "os"; -import { packSkill } from "./pack.js"; -import { execSync } from "child_process"; - -describe("packSkill", () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `skill-pack-test-${Date.now()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - describe("validation before packing", () => { - it("fails for invalid skill", async () => { - const skillDir = join(testDir, "invalid-skill"); - mkdirSync(skillDir); - // No SKILL.md - - const result = await packSkill(skillDir); - expect(result.success).toBe(false); - expect(result.error).toContain("Validation failed"); - }); - - it("fails when name format is invalid", async () => { - const skillDir = join(testDir, "BadName"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: BadName -description: Invalid name ---- -# Bad`, - ); - - const result = await packSkill(skillDir); - expect(result.success).toBe(false); - expect(result.error).toContain("Validation failed"); - }); - }); - - describe("successful packing", () => { - it("creates a .skill bundle", async () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: test-skill -description: A test skill for packing -metadata: - version: "1.0.0" ---- -# Test Skill - -Instructions here.`, - ); - - const outputPath = join( - testDir, - "test-skill-1.0.0.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(result.name).toBe("test-skill"); - expect(result.version).toBe("1.0.0"); - expect(result.path).toBe(outputPath); - expect(result.path!.endsWith(".skill")).toBe(true); - expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); - expect(result.size).toBeGreaterThan(0); - - // Verify bundle exists - expect(existsSync(result.path!)).toBe(true); - }); - - it("uses 0.0.0 version when metadata version is missing", async () => { - const skillDir = join(testDir, "no-version"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: no-version -description: A skill without version ---- -# No Version`, - ); - - const outputPath = join( - testDir, - "no-version-0.0.0.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(result.version).toBe("0.0.0"); - expect(basename(result.path!)).toBe( - "no-version-0.0.0.skill", - ); - }); - - it("creates bundle in current directory by default", async () => { - const skillDir = join(testDir, "output-test"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: output-test -description: Testing output path -metadata: - version: "1.0.0" ---- -# Output Test`, - ); - - // Change to temp directory to test default output location - const originalCwd = process.cwd(); - process.chdir(testDir); - - try { - const result = await packSkill(skillDir); - - expect(result.success).toBe(true); - expect(result.path).toContain(testDir); - } finally { - process.chdir(originalCwd); - } - }); - - it("uses custom output path when provided", async () => { - const skillDir = join(testDir, "custom-output"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: custom-output -description: Custom output path test -metadata: - version: "2.0.0" ---- -# Custom Output`, - ); - - const outputPath = join(testDir, "custom-name.skill"); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(result.path).toBe(outputPath); - expect(existsSync(outputPath)).toBe(true); - }); - - it("includes skill directory structure in bundle", async () => { - const skillDir = join(testDir, "structured-skill"); - mkdirSync(skillDir); - mkdirSync(join(skillDir, "scripts")); - mkdirSync(join(skillDir, "references")); - - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: structured-skill -description: A skill with structure -metadata: - version: "1.0.0" ---- -# Structured Skill`, - ); - writeFileSync( - join(skillDir, "scripts", "helper.py"), - "# Python helper", - ); - writeFileSync( - join(skillDir, "references", "PATTERNS.md"), - "# Patterns", - ); - - const outputPath = join( - testDir, - "structured-skill-1.0.0.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - - // Verify bundle contents using unzip -l - try { - const listing = execSync( - `unzip -l "${result.path}"`, - { encoding: "utf-8" }, - ); - expect(listing).toContain( - "structured-skill/SKILL.md", - ); - expect(listing).toContain( - "structured-skill/scripts/helper.py", - ); - expect(listing).toContain( - "structured-skill/references/PATTERNS.md", - ); - } catch { - // unzip may not be available, skip this check - } - }); - - it("calculates correct SHA256", async () => { - const skillDir = join(testDir, "hash-test"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: hash-test -description: Testing SHA256 calculation -metadata: - version: "1.0.0" ---- -# Hash Test`, - ); - - const outputPath = join( - testDir, - "hash-test-1.0.0.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(result.sha256).toHaveLength(64); - - // Verify hash using shasum if available - try { - const shasum = execSync( - `shasum -a 256 "${result.path}"`, - { encoding: "utf-8" }, - ); - const computedHash = shasum.split(" ")[0]; - expect(result.sha256).toBe(computedHash); - } catch { - // shasum may not be available, skip this check - } - }); - }); - - describe("bundle naming", () => { - it("creates bundle with name-version.skill format", async () => { - const skillDir = join(testDir, "naming-test"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: naming-test -description: Testing bundle naming -metadata: - version: "3.2.1" ---- -# Naming Test`, - ); - - const outputPath = join( - testDir, - "naming-test-3.2.1.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(basename(result.path!)).toBe( - "naming-test-3.2.1.skill", - ); - }); - - it("handles prerelease versions", async () => { - const skillDir = join(testDir, "prerelease-test"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: prerelease-test -description: Testing prerelease version -metadata: - version: "1.0.0-beta.1" ---- -# Prerelease Test`, - ); - - const outputPath = join( - testDir, - "prerelease-test-1.0.0-beta.1.skill", - ); - const result = await packSkill(skillDir, outputPath); - - expect(result.success).toBe(true); - expect(basename(result.path!)).toBe( - "prerelease-test-1.0.0-beta.1.skill", - ); - }); - }); -}); diff --git a/packages/cli/src/commands/skills/pull.ts b/packages/cli/src/commands/skills/pull.ts index 8f2ceb1..f05f2dc 100644 --- a/packages/cli/src/commands/skills/pull.ts +++ b/packages/cli/src/commands/skills/pull.ts @@ -1,35 +1,8 @@ -import { writeFileSync } from "fs"; -import { basename, join } from "path"; -import { formatSize, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; - -/** - * Parse skill spec into name and version - * Examples: @scope/name, @scope/name@1.0.0 - */ -function parseSkillSpec(spec: string): { - name: string; - version?: string; -} { - // Handle @scope/name@version format - const atIndex = spec.lastIndexOf("@"); - - // If @ is at position 0, it's just the scope prefix - if (atIndex <= 0) { - return { name: spec }; - } - - // Check if the @ is part of version (after the /) - const slashIndex = spec.indexOf("/"); - if (atIndex > slashIndex) { - return { - name: spec.slice(0, atIndex), - version: spec.slice(atIndex + 1), - }; - } - - return { name: spec }; -} +import { rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parsePackageSpec } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../../utils/config.js'; +import { formatSize, logger } from '../../utils/format.js'; export interface PullOptions { output?: string; @@ -37,73 +10,41 @@ export interface PullOptions { } /** - * Handle the skill pull command + * Pull (download) a skill from the registry to disk. */ -export async function handleSkillPull( - skillSpec: string, - options: PullOptions, -): Promise { +export async function handleSkillPull(skillSpec: string, options: PullOptions = {}): Promise { + let outputPath: string | undefined; try { - const { name, version } = parseSkillSpec(skillSpec); + const { name, version } = parsePackageSpec(skillSpec); - // Get download info - const client = createClient(); - const downloadInfo = version - ? await client.getSkillVersionDownload(name, version) - : await client.getSkillDownload(name); + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); - console.log( - `Pulling ${downloadInfo.skill.name}@${downloadInfo.skill.version}...`, - ); + const { data, metadata } = await mpak.client.downloadSkillBundle(name, version); - // Download the bundle - const response = await fetch(downloadInfo.url); - if (!response.ok) { - throw new Error(`Download failed (${response.status})`); + if (options.json) { + console.log(JSON.stringify(metadata, null, 2)); + return; } - const buffer = Buffer.from(await response.arrayBuffer()); - // Verify SHA256 - if (downloadInfo.skill.sha256) { - const { createHash } = await import("crypto"); - const hash = createHash("sha256").update(buffer).digest("hex"); - if (hash !== downloadInfo.skill.sha256) { - throw new Error( - `SHA256 mismatch: expected ${downloadInfo.skill.sha256}, got ${hash}`, - ); - } - } + logger.info(` Version: ${metadata.version}`); + logger.info(` Size: ${formatSize(metadata.size)}`); - // Determine output path - const filename = `${basename(downloadInfo.skill.name.replace("@", "").replace("/", "-"))}-${downloadInfo.skill.version}.skill`; - const outputPath = - options.output || join(process.cwd(), filename); + const defaultFilename = `${name.replace('@', '').replace('/', '-')}-${metadata.version}.skill`; + outputPath = options.output ? resolve(options.output) : resolve(defaultFilename); - // Write to disk - writeFileSync(outputPath, buffer); + writeFileSync(outputPath, data); - if (options.json) { - console.log( - JSON.stringify( - { - path: outputPath, - name: downloadInfo.skill.name, - version: downloadInfo.skill.version, - size: downloadInfo.skill.size, - sha256: downloadInfo.skill.sha256, - }, - null, - 2, - ), - ); - } else { - console.log( - `Downloaded ${filename} (${formatSize(downloadInfo.skill.size)})`, - ); - console.log(` SHA256: ${downloadInfo.skill.sha256}`); - console.log(` Path: ${outputPath}`); + logger.info(`\n=> Skill downloaded successfully!`); + logger.info(` File: ${outputPath}`); + logger.info(` SHA256: ${metadata.sha256.substring(0, 16)}...`); + } catch (error) { + if (outputPath) { + try { + rmSync(outputPath, { force: true }); + } catch (_e) { + /* ignore */ + } } - } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); + logger.error(error instanceof Error ? error.message : 'Failed to pull skill'); } } diff --git a/packages/cli/src/commands/skills/search.ts b/packages/cli/src/commands/skills/search.ts index fb1dbee..78facdd 100644 --- a/packages/cli/src/commands/skills/search.ts +++ b/packages/cli/src/commands/skills/search.ts @@ -1,62 +1,45 @@ -import { table, truncate, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; - -export interface SearchOptions { - tags?: string; - category?: string; - sort?: string; - limit?: number; - offset?: number; - json?: boolean; -} +import type { SkillSearchParamsInput } from '@nimblebrain/mpak-schemas'; +import { mpak } from '../../utils/config.js'; +import { logger, table, truncate } from '../../utils/format.js'; + +export type SearchOptions = SkillSearchParamsInput & { json?: boolean }; -/** - * Handle the skill search command - */ -export async function handleSkillSearch( - query: string, - options: SearchOptions, -): Promise { +export async function handleSkillSearch(query: string, options: SearchOptions): Promise { try { - const client = createClient(); - const params: Record = { q: query }; - if (options.tags) params["tags"] = options.tags; - if (options.category) params["category"] = options.category; - if (options.sort) params["sort"] = options.sort; - if (options.limit) params["limit"] = options.limit; - if (options.offset) params["offset"] = options.offset; - const result = await client.searchSkills(params as Parameters[0]); - - if (options.json) { + const { json, ...searchParams } = options; + const result = await mpak.client.searchSkills({ + q: query, + ...searchParams, + }); + + if (json) { console.log(JSON.stringify(result, null, 2)); return; } if (result.skills.length === 0) { - console.log(`No skills found for "${query}"`); + logger.info(`No skills found for "${query}"`); return; } - console.log(); + logger.info(''); const rows = result.skills.map((s) => [ - s.name.length > 42 ? s.name.slice(0, 39) + "..." : s.name, - s.latest_version || "-", - s.category || "-", - truncate(s.description || "", 40), + s.name.length > 42 ? s.name.slice(0, 39) + '...' : s.name, + s.latest_version || '-', + s.category || '-', + truncate(s.description || '', 40), ]); - console.log( - table(["NAME", "VERSION", "CATEGORY", "DESCRIPTION"], rows), - ); + logger.info(table(['NAME', 'VERSION', 'CATEGORY', 'DESCRIPTION'], rows)); if (result.pagination.has_more) { - console.log(); - console.log( + logger.info(''); + logger.info( `Showing ${result.skills.length} of ${result.total} results. Use --offset to see more.`, ); } } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); + logger.error(err instanceof Error ? err.message : String(err)); } } diff --git a/packages/cli/src/commands/skills/show.ts b/packages/cli/src/commands/skills/show.ts index 7608aa9..bd391d6 100644 --- a/packages/cli/src/commands/skills/show.ts +++ b/packages/cli/src/commands/skills/show.ts @@ -1,5 +1,5 @@ -import { fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from '../../utils/config.js'; +import { logger } from '../../utils/format.js'; export interface ShowOptions { json?: boolean; @@ -8,92 +8,66 @@ export interface ShowOptions { /** * Handle the skill show command */ -export async function handleSkillShow( - name: string, - options: ShowOptions, -): Promise { +export async function handleSkillShow(name: string, options: ShowOptions): Promise { try { - const client = createClient(); - const skill = await client.getSkill(name); + const skill = await mpak.client.getSkill(name); if (options.json) { console.log(JSON.stringify(skill, null, 2)); return; } - console.log(""); - console.log(`${skill.name}@${skill.latest_version}`); - console.log(""); - console.log(skill.description); - console.log(""); + logger.info(''); + logger.info(`${skill.name}@${skill.latest_version}`); + logger.info(''); + logger.info(skill.description); + logger.info(''); // Metadata section - console.log("Metadata:"); - if (skill.license) console.log(` License: ${skill.license}`); - if (skill.category) - console.log(` Category: ${skill.category}`); - if (skill.tags && skill.tags.length > 0) - console.log(` Tags: ${skill.tags.join(", ")}`); + logger.info('Metadata:'); + if (skill.license) logger.info(` License: ${skill.license}`); + if (skill.category) logger.info(` Category: ${skill.category}`); + if (skill.tags && skill.tags.length > 0) logger.info(` Tags: ${skill.tags.join(', ')}`); if (skill.author) - console.log( - ` Author: ${skill.author.name}${skill.author.url ? ` (${skill.author.url})` : ""}`, + logger.info( + ` Author: ${skill.author.name}${skill.author.url ? ` (${skill.author.url})` : ''}`, ); - console.log( - ` Downloads: ${skill.downloads.toLocaleString()}`, - ); - console.log( - ` Published: ${new Date(skill.published_at).toLocaleDateString()}`, - ); + logger.info(` Downloads: ${skill.downloads.toLocaleString()}`); + logger.info(` Published: ${new Date(skill.published_at).toLocaleDateString()}`); // Triggers if (skill.triggers && skill.triggers.length > 0) { - console.log(""); - console.log("Triggers:"); - skill.triggers.forEach((t: string) => - console.log(` - ${t}`), - ); + logger.info(''); + logger.info('Triggers:'); + skill.triggers.forEach((t) => logger.info(` - ${t}`)); } // Examples if (skill.examples && skill.examples.length > 0) { - console.log(""); - console.log("Examples:"); - skill.examples.forEach( - (ex: { prompt: string; context?: string | undefined }) => { - console.log( - ` - "${ex.prompt}"${ex.context ? ` (${ex.context})` : ""}`, - ); - }, - ); + logger.info(''); + logger.info('Examples:'); + skill.examples.forEach((ex) => { + logger.info(` - "${ex.prompt}"${ex.context ? ` (${ex.context})` : ''}`); + }); } // Versions if (skill.versions && skill.versions.length > 0) { - console.log(""); - console.log("Versions:"); - skill.versions - .slice(0, 5) - .forEach( - (v: { - version: string; - published_at: string; - downloads: number; - }) => { - console.log( - ` ${v.version.padEnd(12)} ${new Date(v.published_at).toLocaleDateString().padEnd(12)} ${v.downloads.toLocaleString()} downloads`, - ); - }, + logger.info(''); + logger.info('Versions:'); + skill.versions.slice(0, 5).forEach((v) => { + logger.info( + ` ${v.version.padEnd(12)} ${new Date(v.published_at).toLocaleDateString().padEnd(12)} ${v.downloads.toLocaleString()} downloads`, ); + }); if (skill.versions.length > 5) { - console.log( - ` ... and ${skill.versions.length - 5} more`, - ); + logger.info(` ... and ${skill.versions.length - 5} more`); } } - console.log(""); - console.log(`Install: mpak skill install ${skill.name}`); + logger.info(''); + logger.info(`Install: mpak skill install ${skill.name}`); } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); + logger.error(err instanceof Error ? err.message : String(err)); } } diff --git a/packages/cli/src/commands/skills/validate.test.ts b/packages/cli/src/commands/skills/validate.test.ts deleted file mode 100644 index 6c4d632..0000000 --- a/packages/cli/src/commands/skills/validate.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, writeFileSync, rmSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import { - validateSkillDirectory, - formatValidationResult, -} from "./validate.js"; - -describe("validateSkillDirectory", () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `skill-test-${Date.now()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - describe("directory checks", () => { - it("fails for non-existent directory", () => { - const result = validateSkillDirectory( - "/non/existent/path", - ); - expect(result.valid).toBe(false); - expect(result.errors).toContain( - "Directory not found: /non/existent/path", - ); - }); - - it("fails for file instead of directory", () => { - const filePath = join(testDir, "not-a-dir"); - writeFileSync(filePath, "content"); - - const result = validateSkillDirectory(filePath); - expect(result.valid).toBe(false); - expect(result.errors[0]).toMatch( - /Path is not a directory/, - ); - }); - }); - - describe("SKILL.md checks", () => { - it("fails when SKILL.md is missing", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect(result.errors).toContain("SKILL.md not found"); - }); - - it("fails when frontmatter is missing", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - "# Just content\nNo frontmatter here", - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect(result.errors).toContain( - "No frontmatter found in SKILL.md", - ); - }); - - it("fails when frontmatter is empty", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - "---\n---\n# Content", - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect(result.errors).toContain( - "No frontmatter found in SKILL.md", - ); - }); - }); - - describe("frontmatter validation", () => { - it("fails when name is missing", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -description: A test skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect( - result.errors.some((e) => e.includes("name")), - ).toBe(true); - }); - - it("fails when description is missing", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: test-skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect( - result.errors.some((e) => e.includes("description")), - ).toBe(true); - }); - - it("fails when name format is invalid", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: Test_Skill -description: A test skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect( - result.errors.some((e) => - e.toLowerCase().includes("lowercase"), - ), - ).toBe(true); - }); - - it("fails when name has uppercase", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: TestSkill -description: A test skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - }); - - it("fails when name starts with hyphen", () => { - const skillDir = join(testDir, "-test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: -test-skill -description: A test skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - }); - - it("fails when name does not match directory", () => { - const skillDir = join(testDir, "dir-name"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: different-name -description: A test skill ---- -# Test`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(false); - expect( - result.errors.some((e) => - e.includes("does not match directory"), - ), - ).toBe(true); - }); - }); - - describe("valid skills", () => { - it("validates minimal skill", () => { - const skillDir = join(testDir, "test-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: test-skill -description: A test skill for validation ---- -# Test Skill - -Instructions here.`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect(result.name).toBe("test-skill"); - expect(result.frontmatter?.description).toBe( - "A test skill for validation", - ); - }); - - it("validates skill with optional fields", () => { - const skillDir = join(testDir, "full-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: full-skill -description: A fully featured test skill -license: MIT -compatibility: Works with Claude Code -allowed-tools: Read Grep Bash ---- -# Full Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect(result.frontmatter?.license).toBe("MIT"); - expect(result.frontmatter?.compatibility).toBe( - "Works with Claude Code", - ); - expect(result.frontmatter?.["allowed-tools"]).toBe( - "Read Grep Bash", - ); - }); - - it("validates skill with metadata", () => { - const skillDir = join(testDir, "meta-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: meta-skill -description: A skill with metadata -metadata: - version: "1.0.0" - category: development - tags: - - testing - - validation - author: - name: Test Author ---- -# Meta Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect(result.frontmatter?.metadata?.version).toBe( - "1.0.0", - ); - expect(result.frontmatter?.metadata?.category).toBe( - "development", - ); - expect(result.frontmatter?.metadata?.tags).toEqual([ - "testing", - "validation", - ]); - expect( - result.frontmatter?.metadata?.author?.name, - ).toBe("Test Author"); - }); - }); - - describe("warnings", () => { - it("warns when metadata is missing", () => { - const skillDir = join(testDir, "basic-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: basic-skill -description: A basic skill without metadata ---- -# Basic Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => - w.includes("No metadata field"), - ), - ).toBe(true); - }); - - it("warns when version is missing from metadata", () => { - const skillDir = join(testDir, "no-version-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: no-version-skill -description: A skill without version -metadata: - category: development ---- -# No Version Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => w.includes("No version")), - ).toBe(true); - }); - - it("warns when tags are missing", () => { - const skillDir = join(testDir, "no-tags-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: no-tags-skill -description: A skill without tags -metadata: - version: "1.0.0" ---- -# No Tags Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => w.includes("No tags")), - ).toBe(true); - }); - - it("warns about invalid optional directory", () => { - const skillDir = join(testDir, "file-as-dir-skill"); - mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: file-as-dir-skill -description: A skill with scripts as file ---- -# Skill`, - ); - writeFileSync( - join(skillDir, "scripts"), - "not a directory", - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => w.includes("scripts")), - ).toBe(true); - }); - }); - - describe("optional directories", () => { - it("accepts valid optional directories", () => { - const skillDir = join(testDir, "dirs-skill"); - mkdirSync(skillDir); - mkdirSync(join(skillDir, "scripts")); - mkdirSync(join(skillDir, "references")); - mkdirSync(join(skillDir, "assets")); - writeFileSync( - join(skillDir, "SKILL.md"), - `--- -name: dirs-skill -description: A skill with all optional dirs ---- -# Skill`, - ); - - const result = validateSkillDirectory(skillDir); - expect(result.valid).toBe(true); - expect( - result.warnings.filter((w) => - w.includes("not a directory"), - ), - ).toHaveLength(0); - }); - }); -}); - -describe("formatValidationResult", () => { - it("formats valid result correctly", () => { - const result = { - valid: true, - name: "test-skill", - path: "/path/to/skill", - frontmatter: { - name: "test-skill", - description: "A test skill description", - }, - errors: [], - warnings: [], - }; - - const output = formatValidationResult(result); - expect(output).toContain("\u2713 Valid: test-skill"); - expect(output).toContain("\u2713 SKILL.md found"); - expect(output).toContain("name: test-skill"); - expect(output).toContain("description:"); - }); - - it("formats invalid result correctly", () => { - const result = { - valid: false, - name: null, - path: "/path/to/skill", - frontmatter: null, - errors: ["SKILL.md not found"], - warnings: [], - }; - - const output = formatValidationResult(result); - expect(output).toContain( - "\u2717 Invalid: /path/to/skill", - ); - expect(output).toContain("Errors:"); - expect(output).toContain("SKILL.md not found"); - }); - - it("formats warnings correctly", () => { - const result = { - valid: true, - name: "test-skill", - path: "/path/to/skill", - frontmatter: { - name: "test-skill", - description: "A test skill", - }, - errors: [], - warnings: ["No metadata field"], - }; - - const output = formatValidationResult(result); - expect(output).toContain("Warnings:"); - expect(output).toContain("No metadata field"); - }); - - it("formats optional fields", () => { - const result = { - valid: true, - name: "test-skill", - path: "/path/to/skill", - frontmatter: { - name: "test-skill", - description: "A test skill", - license: "MIT", - compatibility: "Claude Code", - "allowed-tools": "Read Grep", - }, - errors: [], - warnings: [], - }; - - const output = formatValidationResult(result); - expect(output).toContain("license: MIT"); - expect(output).toContain("compatibility: Claude Code"); - expect(output).toContain("allowed-tools: Read Grep"); - }); - - it("formats metadata correctly", () => { - const result = { - valid: true, - name: "test-skill", - path: "/path/to/skill", - frontmatter: { - name: "test-skill", - description: "A test skill", - metadata: { - version: "1.0.0", - category: "development" as const, - tags: ["test", "validation"], - triggers: ["test trigger"], - author: { name: "Test Author" }, - }, - }, - errors: [], - warnings: [], - }; - - const output = formatValidationResult(result); - expect(output).toContain("Discovery metadata"); - expect(output).toContain("version: 1.0.0"); - expect(output).toContain("category: development"); - expect(output).toContain("tags: [test, validation]"); - expect(output).toContain("triggers: 1 defined"); - expect(output).toContain("author: Test Author"); - }); - - it("truncates long descriptions", () => { - const longDescription = "A".repeat(100); - const result = { - valid: true, - name: "test-skill", - path: "/path/to/skill", - frontmatter: { - name: "test-skill", - description: longDescription, - }, - errors: [], - warnings: [], - }; - - const output = formatValidationResult(result); - expect(output).toContain("..."); - expect(output).toContain("(100 chars)"); - }); -}); diff --git a/packages/cli/src/program.test.ts b/packages/cli/src/program.test.ts deleted file mode 100644 index 58b1d0f..0000000 --- a/packages/cli/src/program.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createProgram } from "./program.js"; - -describe("createProgram", () => { - it("should create a program with correct name", () => { - const program = createProgram(); - expect(program.name()).toBe("mpak"); - }); - - it("should have a description", () => { - const program = createProgram(); - expect(program.description()).toBe( - "CLI for MCP bundles and Agent Skills", - ); - }); - - it("should have version option", () => { - const program = createProgram(); - const versionOption = program.options.find( - (opt) => opt.short === "-v" || opt.long === "--version", - ); - expect(versionOption).toBeDefined(); - }); -}); diff --git a/packages/cli/src/utils/cache.test.ts b/packages/cli/src/utils/cache.test.ts deleted file mode 100644 index 9fb6e1a..0000000 --- a/packages/cli/src/utils/cache.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, rmSync, writeFileSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import { listCachedBundles } from "./cache.js"; - -/** - * Creates a fake cached bundle directory with manifest.json and .mpak-meta.json. - */ -function seedBundle( - cacheBase: string, - dirName: string, - manifest: { name: string; version: string }, - meta: { version: string; pulledAt: string; platform: { os: string; arch: string } }, -): void { - const dir = join(cacheBase, dirName); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); - writeFileSync(join(dir, ".mpak-meta.json"), JSON.stringify(meta)); -} - -describe("listCachedBundles", () => { - let tempCacheBase: string; - const originalHome = process.env["HOME"]; - - beforeEach(() => { - // Create a temp dir that acts as ~/.mpak/cache/ - const tempHome = join(tmpdir(), `mpak-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - tempCacheBase = join(tempHome, ".mpak", "cache"); - mkdirSync(tempCacheBase, { recursive: true }); - process.env["HOME"] = tempHome; - }); - - afterEach(() => { - process.env["HOME"] = originalHome; - // Clean up temp dir (parent of .mpak) - const tempHome = tempCacheBase.replace("/.mpak/cache", ""); - rmSync(tempHome, { recursive: true, force: true }); - }); - - it("returns empty array when cache dir does not exist", () => { - // Point HOME to a dir with no .mpak/cache - const emptyHome = join(tmpdir(), `mpak-empty-${Date.now()}`); - mkdirSync(emptyHome, { recursive: true }); - process.env["HOME"] = emptyHome; - - expect(listCachedBundles()).toEqual([]); - - rmSync(emptyHome, { recursive: true, force: true }); - }); - - it("returns empty array when cache dir is empty", () => { - expect(listCachedBundles()).toEqual([]); - }); - - it("returns cached bundles with correct metadata", () => { - seedBundle(tempCacheBase, "nimblebraininc-echo", { - name: "@nimblebraininc/echo", - version: "1.0.0", - }, { - version: "1.0.0", - pulledAt: "2025-02-16T00:00:00.000Z", - platform: { os: "darwin", arch: "arm64" }, - }); - - const result = listCachedBundles(); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - name: "@nimblebraininc/echo", - version: "1.0.0", - pulledAt: "2025-02-16T00:00:00.000Z", - cacheDir: join(tempCacheBase, "nimblebraininc-echo"), - }); - }); - - it("returns multiple cached bundles", () => { - seedBundle(tempCacheBase, "nimblebraininc-echo", { - name: "@nimblebraininc/echo", - version: "1.0.0", - }, { - version: "1.0.0", - pulledAt: "2025-02-16T00:00:00.000Z", - platform: { os: "darwin", arch: "arm64" }, - }); - - seedBundle(tempCacheBase, "nimblebraininc-todoist", { - name: "@nimblebraininc/todoist", - version: "2.1.0", - }, { - version: "2.1.0", - pulledAt: "2025-03-14T00:00:00.000Z", - platform: { os: "darwin", arch: "arm64" }, - }); - - const result = listCachedBundles(); - expect(result).toHaveLength(2); - expect(result.map((b) => b.name).sort()).toEqual([ - "@nimblebraininc/echo", - "@nimblebraininc/todoist", - ]); - }); - - it("skips _local directory", () => { - // Create a _local dir with bundle-like contents - const localDir = join(tempCacheBase, "_local"); - mkdirSync(localDir, { recursive: true }); - writeFileSync(join(localDir, "manifest.json"), JSON.stringify({ name: "local-dev" })); - writeFileSync(join(localDir, ".mpak-meta.json"), JSON.stringify({ version: "0.0.1" })); - - seedBundle(tempCacheBase, "nimblebraininc-echo", { - name: "@nimblebraininc/echo", - version: "1.0.0", - }, { - version: "1.0.0", - pulledAt: "2025-02-16T00:00:00.000Z", - platform: { os: "darwin", arch: "arm64" }, - }); - - const result = listCachedBundles(); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe("@nimblebraininc/echo"); - }); - - it("skips directories without .mpak-meta.json", () => { - const dir = join(tempCacheBase, "no-meta"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "manifest.json"), JSON.stringify({ name: "@scope/no-meta" })); - - expect(listCachedBundles()).toEqual([]); - }); - - it("skips directories without manifest.json", () => { - const dir = join(tempCacheBase, "no-manifest"); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, ".mpak-meta.json"), - JSON.stringify({ version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", platform: { os: "darwin", arch: "arm64" } }), - ); - - expect(listCachedBundles()).toEqual([]); - }); - - it("skips directories with corrupt manifest.json", () => { - const dir = join(tempCacheBase, "corrupt"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "manifest.json"), "not json{{{"); - writeFileSync( - join(dir, ".mpak-meta.json"), - JSON.stringify({ version: "1.0.0", pulledAt: "2025-01-01T00:00:00.000Z", platform: { os: "darwin", arch: "arm64" } }), - ); - - expect(listCachedBundles()).toEqual([]); - }); - - it("skips files in cache dir (only reads directories)", () => { - writeFileSync(join(tempCacheBase, "stray-file.txt"), "hello"); - - seedBundle(tempCacheBase, "nimblebraininc-echo", { - name: "@nimblebraininc/echo", - version: "1.0.0", - }, { - version: "1.0.0", - pulledAt: "2025-02-16T00:00:00.000Z", - platform: { os: "darwin", arch: "arm64" }, - }); - - const result = listCachedBundles(); - expect(result).toHaveLength(1); - }); - - it("reads name from manifest.json, not directory name", () => { - seedBundle(tempCacheBase, "weird-dir-name", { - name: "@actual/package-name", - version: "3.0.0", - }, { - version: "3.0.0", - pulledAt: "2025-01-01T00:00:00.000Z", - platform: { os: "linux", arch: "x64" }, - }); - - const result = listCachedBundles(); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe("@actual/package-name"); - }); -}); diff --git a/packages/cli/src/utils/cache.ts b/packages/cli/src/utils/cache.ts deleted file mode 100644 index d1afff1..0000000 --- a/packages/cli/src/utils/cache.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "fs"; -import { execFileSync } from "child_process"; -import { randomUUID } from "crypto"; -import { homedir } from "os"; -import { dirname, join } from "path"; -import { MpakClient } from "@nimblebrain/mpak-sdk"; - -/** - * Compare two semver strings for equality, ignoring leading 'v' prefix. - */ -export function isSemverEqual(a: string, b: string): boolean { - return a.replace(/^v/, "") === b.replace(/^v/, ""); -} - -export interface CacheMetadata { - version: string; - pulledAt: string; - lastCheckedAt?: string; - platform: { os: string; arch: string }; -} - -/** - * Get cache directory for a package - * @example getCacheDir('@scope/name') => '~/.mpak/cache/scope-name' - */ -export function getCacheDir(packageName: string): string { - const cacheBase = join(homedir(), ".mpak", "cache"); - // @scope/name -> scope/name - const safeName = packageName.replace("@", "").replace("/", "-"); - return join(cacheBase, safeName); -} - -/** - * Read cache metadata - */ -export function getCacheMetadata(cacheDir: string): CacheMetadata | null { - const metaPath = join(cacheDir, ".mpak-meta.json"); - if (!existsSync(metaPath)) { - return null; - } - try { - return JSON.parse(readFileSync(metaPath, "utf8")); - } catch { - return null; - } -} - -/** - * Write cache metadata - */ -export function writeCacheMetadata( - cacheDir: string, - metadata: CacheMetadata, -): void { - const metaPath = join(cacheDir, ".mpak-meta.json"); - writeFileSync(metaPath, JSON.stringify(metadata, null, 2)); -} - -const UPDATE_CHECK_TTL_MS = 60 * 60 * 1000; // 1 hour - -/** - * Fire-and-forget background check for bundle updates. - * Prints a notice to stderr if a newer version exists. - * Silently swallows all errors. - */ -export async function checkForUpdateAsync( - packageName: string, - cachedMeta: CacheMetadata, - cacheDir: string, - client: MpakClient, -): Promise { - try { - // Skip if checked within the TTL - if (cachedMeta.lastCheckedAt) { - const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime(); - if (elapsed < UPDATE_CHECK_TTL_MS) { - return; - } - } - - const detail = await client.getBundle(packageName); - - // Update lastCheckedAt regardless of whether there's an update - writeCacheMetadata(cacheDir, { - ...cachedMeta, - lastCheckedAt: new Date().toISOString(), - }); - - if (!isSemverEqual(detail.latest_version, cachedMeta.version)) { - process.stderr.write( - `\n=> Update available: ${packageName} ${cachedMeta.version} -> ${detail.latest_version}\n` + - ` Run 'mpak run ${packageName} --update' to update\n`, - ); - } - } catch { - // Silently swallow all errors (network down, registry unreachable, etc.) - } -} - -export interface CachedBundle { - name: string; - version: string; - pulledAt: string; - cacheDir: string; -} - -/** - * Scan ~/.mpak/cache/ and return metadata for every cached registry bundle. - * Skips the _local/ directory (local dev bundles). - */ -export function listCachedBundles(): CachedBundle[] { - const cacheBase = join(homedir(), ".mpak", "cache"); - if (!existsSync(cacheBase)) return []; - - const entries = readdirSync(cacheBase, { withFileTypes: true }); - const bundles: CachedBundle[] = []; - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === "_local") continue; - - const dir = join(cacheBase, entry.name); - const meta = getCacheMetadata(dir); - if (!meta) continue; - - const manifestPath = join(dir, "manifest.json"); - if (!existsSync(manifestPath)) continue; - - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - bundles.push({ - name: manifest.name, - version: meta.version, - pulledAt: meta.pulledAt, - cacheDir: dir, - }); - } catch { - // Skip corrupt bundles - } - } - - return bundles; -} - -/** - * Maximum allowed uncompressed size for a bundle (500MB). - */ -const MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024; - -/** - * Check uncompressed size and extract a ZIP file to a directory. - * Rejects bundles exceeding MAX_UNCOMPRESSED_SIZE (zip bomb protection). - */ -export function extractZip(zipPath: string, destDir: string): void { - // Check uncompressed size before extraction - try { - const listOutput = execFileSync("unzip", ["-l", zipPath], { - stdio: "pipe", - encoding: "utf8", - }); - const totalMatch = listOutput.match(/^\s*(\d+)\s+\d+\s+files?$/m); - if (totalMatch) { - const totalSize = parseInt(totalMatch[1]!, 10); - if (totalSize > MAX_UNCOMPRESSED_SIZE) { - throw new Error( - `Bundle uncompressed size (${Math.round(totalSize / 1024 / 1024)}MB) exceeds maximum allowed (${MAX_UNCOMPRESSED_SIZE / (1024 * 1024)}MB)`, - ); - } - } - } catch (error: unknown) { - if ( - error instanceof Error && - error.message.includes("exceeds maximum allowed") - ) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot verify bundle size before extraction: ${message}`); - } - - mkdirSync(destDir, { recursive: true }); - execFileSync("unzip", ["-o", "-q", zipPath, "-d", destDir], { - stdio: "pipe", - }); -} - -export interface BundleDownloadInfo { - url: string; - bundle: { version: string; platform: { os: string; arch: string } }; -} - -/** - * Resolve a bundle from the registry without downloading it. - * Returns the download URL and resolved version/platform metadata. - */ -export async function resolveBundle( - name: string, - client: MpakClient, - requestedVersion?: string, -): Promise { - const platform = MpakClient.detectPlatform(); - return client.getBundleDownload( - name, - requestedVersion || "latest", - platform, - ); -} - -/** - * Download a bundle using pre-resolved download info, extract it into the - * cache, and write metadata. Returns the cache directory path. - */ -export async function downloadAndExtract( - name: string, - downloadInfo: BundleDownloadInfo, -): Promise<{ cacheDir: string; version: string }> { - const bundle = downloadInfo.bundle; - const cacheDir = getCacheDir(name); - - // Download to temp file - const tempPath = join(homedir(), ".mpak", "tmp", `${Date.now()}-${randomUUID().slice(0, 8)}.mcpb`); - mkdirSync(dirname(tempPath), { recursive: true }); - - process.stderr.write(`=> Pulling ${name}@${bundle.version}...\n`); - - const response = await fetch(downloadInfo.url); - if (!response.ok) { - throw new Error(`Failed to download bundle: ${response.statusText}`); - } - const arrayBuffer = await response.arrayBuffer(); - writeFileSync(tempPath, Buffer.from(arrayBuffer)); - - // Clear old cache and extract - if (existsSync(cacheDir)) { - rmSync(cacheDir, { recursive: true, force: true }); - } - - extractZip(tempPath, cacheDir); - - // Write metadata - writeCacheMetadata(cacheDir, { - version: bundle.version, - pulledAt: new Date().toISOString(), - platform: bundle.platform, - }); - - // Cleanup temp file - rmSync(tempPath, { force: true }); - - process.stderr.write(`=> Cached ${name}@${bundle.version}\n`); - - return { cacheDir, version: bundle.version }; -} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts deleted file mode 100644 index 0c10208..0000000 --- a/packages/cli/src/utils/client.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MpakClient } from "@nimblebrain/mpak-sdk"; -import { ConfigManager } from "./config-manager.js"; -import { getVersion } from "./version.js"; - -/** - * Create an MpakClient with standard CLI configuration - * (registry URL from config, User-Agent with CLI version). - */ -export function createClient(): MpakClient { - const configManager = new ConfigManager(); - const version = getVersion(); - return new MpakClient({ - registryUrl: configManager.getRegistryUrl(), - userAgent: `mpak-cli/${version}`, - }); -} diff --git a/packages/cli/src/utils/config-manager.test.ts b/packages/cli/src/utils/config-manager.test.ts deleted file mode 100644 index 536ae2c..0000000 --- a/packages/cli/src/utils/config-manager.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, -} from "vitest"; -import { - ConfigManager, - ConfigCorruptedError, -} from "./config-manager.js"; -import { - existsSync, - rmSync, - writeFileSync, - mkdirSync, -} from "fs"; -import { join } from "path"; -import { homedir } from "os"; - -describe("ConfigManager", () => { - const testConfigDir = join(homedir(), ".mpak"); - const testConfigFile = join(testConfigDir, "config.json"); - - beforeEach(() => { - // Clean up test config before each test - if (existsSync(testConfigFile)) { - rmSync(testConfigFile, { force: true }); - } - }); - - afterEach(() => { - // Clean up test config after each test - if (existsSync(testConfigFile)) { - rmSync(testConfigFile, { force: true }); - } - }); - - describe("loadConfig", () => { - it("should create a new config if none exists", () => { - const manager = new ConfigManager(); - const config = manager.loadConfig(); - - expect(config).toBeDefined(); - expect(config.version).toBe("1.0.0"); - expect(config.lastUpdated).toBeTruthy(); - }); - - it("should set registryUrl in config", () => { - const manager = new ConfigManager(); - manager.setRegistryUrl("http://test.example.com"); - - // Get the config to verify it's set - expect(manager.getRegistryUrl()).toBe( - "http://test.example.com", - ); - }); - }); - - describe("getRegistryUrl", () => { - it("should return default registry URL", () => { - const manager = new ConfigManager(); - const url = manager.getRegistryUrl(); - - expect(url).toBe("https://registry.mpak.dev"); - }); - - it("should return configured registry URL", () => { - const manager = new ConfigManager(); - manager.setRegistryUrl("http://custom.example.com"); - - expect(manager.getRegistryUrl()).toBe( - "http://custom.example.com", - ); - }); - }); - - describe("package config", () => { - it("should set and get package config value", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "api_key", - "test-value", - ); - - expect( - manager.getPackageConfigValue("@scope/name", "api_key"), - ).toBe("test-value"); - }); - - it("should return undefined for non-existent package", () => { - const manager = new ConfigManager(); - - expect( - manager.getPackageConfig("@nonexistent/pkg"), - ).toBeUndefined(); - }); - - it("should return undefined for non-existent key", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "existing", - "value", - ); - - expect( - manager.getPackageConfigValue( - "@scope/name", - "nonexistent", - ), - ).toBeUndefined(); - }); - - it("should get all package config", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "key1", - "value1", - ); - manager.setPackageConfigValue( - "@scope/name", - "key2", - "value2", - ); - - const config = manager.getPackageConfig("@scope/name"); - expect(config).toEqual({ - key1: "value1", - key2: "value2", - }); - }); - - it("should clear specific package config value", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "key1", - "value1", - ); - manager.setPackageConfigValue( - "@scope/name", - "key2", - "value2", - ); - - const cleared = manager.clearPackageConfigValue( - "@scope/name", - "key1", - ); - expect(cleared).toBe(true); - expect( - manager.getPackageConfigValue("@scope/name", "key1"), - ).toBeUndefined(); - expect( - manager.getPackageConfigValue("@scope/name", "key2"), - ).toBe("value2"); - }); - - it("should return false when clearing non-existent key", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "key1", - "value1", - ); - - const cleared = manager.clearPackageConfigValue( - "@scope/name", - "nonexistent", - ); - expect(cleared).toBe(false); - }); - - it("should clear all package config", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "key1", - "value1", - ); - manager.setPackageConfigValue( - "@scope/name", - "key2", - "value2", - ); - - const cleared = manager.clearPackageConfig("@scope/name"); - expect(cleared).toBe(true); - expect( - manager.getPackageConfig("@scope/name"), - ).toBeUndefined(); - }); - - it("should return false when clearing non-existent package", () => { - const manager = new ConfigManager(); - - const cleared = manager.clearPackageConfig( - "@nonexistent/pkg", - ); - expect(cleared).toBe(false); - }); - - it("should list packages with config", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/pkg1", - "key", - "value", - ); - manager.setPackageConfigValue( - "@scope/pkg2", - "key", - "value", - ); - - const packages = manager.listPackagesWithConfig(); - expect(packages).toContain("@scope/pkg1"); - expect(packages).toContain("@scope/pkg2"); - expect(packages).toHaveLength(2); - }); - - it("should clean up empty package entry after clearing last key", () => { - const manager = new ConfigManager(); - manager.setPackageConfigValue( - "@scope/name", - "only_key", - "value", - ); - manager.clearPackageConfigValue("@scope/name", "only_key"); - - expect( - manager.getPackageConfig("@scope/name"), - ).toBeUndefined(); - expect( - manager.listPackagesWithConfig(), - ).not.toContain("@scope/name"); - }); - }); - - describe("config validation", () => { - beforeEach(() => { - // Ensure config directory exists for writing test files - if (!existsSync(testConfigDir)) { - mkdirSync(testConfigDir, { - recursive: true, - mode: 0o700, - }); - } - }); - - it("should throw ConfigCorruptedError for invalid JSON", () => { - writeFileSync( - testConfigFile, - "not valid json {{{", - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow(/invalid JSON/); - }); - - it("should throw ConfigCorruptedError when version is missing", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - lastUpdated: "2024-01-01T00:00:00Z", - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow(/version/); - }); - - it("should throw ConfigCorruptedError when lastUpdated is missing", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ version: "1.0.0" }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow(/lastUpdated/); - }); - - it("should throw ConfigCorruptedError when registryUrl is not a string", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - registryUrl: 12345, - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow( - /registryUrl must be a string/, - ); - }); - - it("should throw ConfigCorruptedError when packages is not an object", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - packages: "not an object", - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow( - /packages must be an object/, - ); - }); - - it("should throw ConfigCorruptedError when package config is not an object", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - packages: { - "@scope/pkg": "not an object", - }, - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow( - /packages.@scope\/pkg must be an object/, - ); - }); - - it("should throw ConfigCorruptedError when package config value is not a string", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - packages: { - "@scope/pkg": { - api_key: 12345, - }, - }, - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow( - /packages.@scope\/pkg.api_key must be a string/, - ); - }); - - it("should throw ConfigCorruptedError for unknown fields", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - unknownField: "should not be here", - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - expect(() => manager.loadConfig()).toThrow( - ConfigCorruptedError, - ); - expect(() => manager.loadConfig()).toThrow( - /unknown field: unknownField/, - ); - }); - - it("should include config path in error", () => { - writeFileSync(testConfigFile, "invalid json", { - mode: 0o600, - }); - - const manager = new ConfigManager(); - try { - manager.loadConfig(); - expect.fail("Should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(ConfigCorruptedError); - expect( - (err as ConfigCorruptedError).configPath, - ).toBe(testConfigFile); - } - }); - - it("should load valid minimal config", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - const config = manager.loadConfig(); - expect(config.version).toBe("1.0.0"); - expect(config.lastUpdated).toBe("2024-01-01T00:00:00Z"); - }); - - it("should load valid full config", () => { - writeFileSync( - testConfigFile, - JSON.stringify({ - version: "1.0.0", - lastUpdated: "2024-01-01T00:00:00Z", - registryUrl: "https://custom.registry.com", - packages: { - "@scope/pkg": { - api_key: "secret", - other_key: "value", - }, - }, - }), - { mode: 0o600 }, - ); - - const manager = new ConfigManager(); - const config = manager.loadConfig(); - expect(config.version).toBe("1.0.0"); - expect(config.registryUrl).toBe( - "https://custom.registry.com", - ); - expect( - config.packages?.["@scope/pkg"]?.["api_key"], - ).toBe("secret"); - }); - }); -}); diff --git a/packages/cli/src/utils/config-manager.ts b/packages/cli/src/utils/config-manager.ts deleted file mode 100644 index 6ba586f..0000000 --- a/packages/cli/src/utils/config-manager.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; - -/** - * Current config schema version - */ -export const CONFIG_VERSION = "1.0.0"; - -/** - * Per-package user configuration (stores user_config values) - */ -export interface PackageConfig { - [key: string]: string; -} - -/** - * Configuration structure (v1.0.0) - */ -export interface MpakConfig { - version: string; - lastUpdated: string; - registryUrl?: string; - packages?: Record; -} - -/** - * Error thrown when config file is corrupted or invalid - */ -export class ConfigCorruptedError extends Error { - constructor( - message: string, - public readonly configPath: string, - public override readonly cause?: Error, - ) { - super(message); - this.name = "ConfigCorruptedError"; - } -} - -/** - * Validates that a parsed object conforms to the MpakConfig schema - */ -function validateConfig(data: unknown, configPath: string): MpakConfig { - if (typeof data !== "object" || data === null) { - throw new ConfigCorruptedError( - "Config file must be a JSON object", - configPath, - ); - } - - const obj = data as Record; - - // Required fields - if (typeof obj["version"] !== "string") { - throw new ConfigCorruptedError( - "Config missing required field: version (string)", - configPath, - ); - } - - if (typeof obj["lastUpdated"] !== "string") { - throw new ConfigCorruptedError( - "Config missing required field: lastUpdated (string)", - configPath, - ); - } - - // Optional fields with type validation - if ( - obj["registryUrl"] !== undefined && - typeof obj["registryUrl"] !== "string" - ) { - throw new ConfigCorruptedError( - "Config field registryUrl must be a string", - configPath, - ); - } - - if (obj["packages"] !== undefined) { - if (typeof obj["packages"] !== "object" || obj["packages"] === null) { - throw new ConfigCorruptedError( - "Config field packages must be an object", - configPath, - ); - } - - // Validate each package config - for (const [pkgName, pkgConfig] of Object.entries( - obj["packages"] as Record, - )) { - if (typeof pkgConfig !== "object" || pkgConfig === null) { - throw new ConfigCorruptedError( - `Config packages.${pkgName} must be an object`, - configPath, - ); - } - - for (const [key, value] of Object.entries( - pkgConfig as Record, - )) { - if (typeof value !== "string") { - throw new ConfigCorruptedError( - `Config packages.${pkgName}.${key} must be a string`, - configPath, - ); - } - } - } - } - - // Check for unknown fields (additionalProperties: false in schema) - const knownFields = new Set([ - "version", - "lastUpdated", - "registryUrl", - "packages", - ]); - for (const key of Object.keys(obj)) { - if (!knownFields.has(key)) { - throw new ConfigCorruptedError( - `Config contains unknown field: ${key}`, - configPath, - ); - } - } - - return data as MpakConfig; -} - -/** - * Configuration manager for CLI settings in ~/.mpak/config.json - */ -export class ConfigManager { - private configDir: string; - private configFile: string; - private config: MpakConfig | null = null; - - constructor() { - this.configDir = join(homedir(), ".mpak"); - this.configFile = join(this.configDir, "config.json"); - this.ensureConfigDir(); - } - - private ensureConfigDir(): void { - if (!existsSync(this.configDir)) { - mkdirSync(this.configDir, { recursive: true, mode: 0o700 }); - } - } - - loadConfig(): MpakConfig { - if (this.config) { - return this.config; - } - - if (!existsSync(this.configFile)) { - this.config = { - version: CONFIG_VERSION, - lastUpdated: new Date().toISOString(), - }; - this.saveConfig(); - return this.config; - } - - let configJson: string; - try { - configJson = readFileSync(this.configFile, "utf8"); - } catch (err) { - throw new ConfigCorruptedError( - `Failed to read config file: ${err instanceof Error ? err.message : String(err)}`, - this.configFile, - err instanceof Error ? err : undefined, - ); - } - - let parsed: unknown; - try { - parsed = JSON.parse(configJson); - } catch (err) { - throw new ConfigCorruptedError( - `Config file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`, - this.configFile, - err instanceof Error ? err : undefined, - ); - } - - // Validate structure against schema - this.config = validateConfig(parsed, this.configFile); - return this.config; - } - - private saveConfig(): void { - if (!this.config) { - return; - } - this.config.lastUpdated = new Date().toISOString(); - const configJson = JSON.stringify(this.config, null, 2); - writeFileSync(this.configFile, configJson, { mode: 0o600 }); - } - - setRegistryUrl(url: string): void { - const config = this.loadConfig(); - config.registryUrl = url; - this.saveConfig(); - } - - getRegistryUrl(): string { - const config = this.loadConfig(); - return ( - config.registryUrl || - process.env["MPAK_REGISTRY_URL"] || - "https://registry.mpak.dev" - ); - } - - /** - * Get all stored config values for a package - */ - getPackageConfig(packageName: string): PackageConfig | undefined { - const config = this.loadConfig(); - return config.packages?.[packageName]; - } - - /** - * Get a specific config value for a package - */ - getPackageConfigValue( - packageName: string, - key: string, - ): string | undefined { - const packageConfig = this.getPackageConfig(packageName); - return packageConfig?.[key]; - } - - /** - * Set a config value for a package - */ - setPackageConfigValue( - packageName: string, - key: string, - value: string, - ): void { - const config = this.loadConfig(); - if (!config.packages) { - config.packages = {}; - } - if (!config.packages[packageName]) { - config.packages[packageName] = {}; - } - config.packages[packageName][key] = value; - this.saveConfig(); - } - - /** - * Clear all config values for a package - */ - clearPackageConfig(packageName: string): boolean { - const config = this.loadConfig(); - if (config.packages?.[packageName]) { - delete config.packages[packageName]; - this.saveConfig(); - return true; - } - return false; - } - - /** - * Clear a specific config value for a package - */ - clearPackageConfigValue(packageName: string, key: string): boolean { - const config = this.loadConfig(); - if (config.packages?.[packageName]?.[key] !== undefined) { - delete config.packages[packageName][key]; - // Clean up empty package entries - if (Object.keys(config.packages[packageName]).length === 0) { - delete config.packages[packageName]; - } - this.saveConfig(); - return true; - } - return false; - } - - /** - * List all packages with stored config - */ - listPackagesWithConfig(): string[] { - const config = this.loadConfig(); - return Object.keys(config.packages || {}); - } -} diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts new file mode 100644 index 0000000..e0d2c1f --- /dev/null +++ b/packages/cli/src/utils/config.ts @@ -0,0 +1,11 @@ +import { Mpak } from '@nimblebrain/mpak-sdk'; +import { getVersion } from './version.js'; + +const mpakHome = process.env['MPAK_HOME']; +const registryUrl = process.env['MPAK_REGISTRY_URL']; + +export const mpak = new Mpak({ + ...(mpakHome ? { mpakHome } : {}), + ...(registryUrl ? { registryUrl } : {}), + userAgent: `mpak-cli/${getVersion()}`, +}); diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts deleted file mode 100644 index 9106604..0000000 --- a/packages/cli/src/utils/errors.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { CLIError } from "./errors.js"; - -describe("CLIError", () => { - it("should create error with message", () => { - const error = new CLIError("Test error"); - expect(error.message).toBe("Test error"); - expect(error.name).toBe("CLIError"); - }); - - it("should have default exit code of 1", () => { - const error = new CLIError("Test error"); - expect(error.exitCode).toBe(1); - }); - - it("should allow custom exit code", () => { - const error = new CLIError("Test error", 2); - expect(error.exitCode).toBe(2); - }); - - it("should be instance of Error", () => { - const error = new CLIError("Test error"); - expect(error).toBeInstanceOf(Error); - }); -}); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 8ebba07..507f9a5 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -6,19 +6,12 @@ export interface TableOptions { /** * Render an aligned text table with auto-calculated column widths. */ -export function table( - headers: string[], - rows: string[][], - opts?: TableOptions, -): string { +export function table(headers: string[], rows: string[][], opts?: TableOptions): string { const rightAlign = new Set(opts?.rightAlign ?? []); // Calculate column widths from headers and data const widths = headers.map((h, i) => { - const maxData = rows.reduce( - (max, row) => Math.max(max, (row[i] ?? "").length), - 0, - ); + const maxData = rows.reduce((max, row) => Math.max(max, (row[i] ?? '').length), 0); return Math.max(h.length, maxData); }); @@ -28,27 +21,21 @@ export function table( const lines: string[] = []; // Header - lines.push( - headers.map((h, i) => pad(h, widths[i]!, i)).join(" "), - ); + lines.push(headers.map((h, i) => pad(h, widths[i]!, i)).join(' ')); // Rows for (const row of rows) { - lines.push( - headers - .map((_, i) => pad(row[i] ?? "", widths[i]!, i)) - .join(" "), - ); + lines.push(headers.map((_, i) => pad(row[i] ?? '', widths[i]!, i)).join(' ')); } - return lines.join("\n"); + return lines.join('\n'); } /** * Return a short trust label for a certification level. */ export function certLabel(level: number | null | undefined): string { - if (level == null) return "-"; + if (level == null) return '-'; return `L${level}`; } @@ -66,13 +53,20 @@ export function formatSize(bytes: number): string { */ export function truncate(text: string, max: number): string { if (text.length <= max) return text; - return text.slice(0, max - 3) + "..."; + return text.slice(0, max - 3) + '...'; } /** - * Print a standardized error message and exit. + * Print a standardized error message. */ -export function fmtError(message: string): never { +export function logError(message: string): void { console.error(`Error: ${message}`); - process.exit(1); } + +/** @deprecated Use {@link logError} instead. */ +export const fmtError = logError; + +export const logger = { + error: (msg: string) => console.error(`[Error] ${msg}`), + info: (msg: string) => console.error(msg), +}; diff --git a/packages/cli/src/utils/version.test.ts b/packages/cli/src/utils/version.test.ts deleted file mode 100644 index 19fbbf4..0000000 --- a/packages/cli/src/utils/version.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { getVersion } from "./version.js"; - -describe("getVersion", () => { - it("should return a valid version string", () => { - const version = getVersion(); - expect(version).toBeTruthy(); - expect(typeof version).toBe("string"); - }); - - it('should match semver format or be "unknown"', () => { - const version = getVersion(); - const semverRegex = /^\d+\.\d+\.\d+/; - expect(version === "unknown" || semverRegex.test(version)).toBe( - true, - ); - }); -}); diff --git a/packages/cli/tests/bundles/outdated.test.ts b/packages/cli/tests/bundles/outdated.test.ts new file mode 100644 index 0000000..6592954 --- /dev/null +++ b/packages/cli/tests/bundles/outdated.test.ts @@ -0,0 +1,183 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MpakBundleCache, type MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getOutdatedBundles } from '../../src/commands/packages/outdated.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const validManifest = (name: string, version: string) => ({ + manifest_version: '0.3', + name, + version, + description: 'Test bundle', + server: { + type: 'node' as const, + entry_point: 'index.js', + mcp_config: { command: 'node', args: ['${__dirname}/index.js'] }, + }, +}); + +const validMetadata = (version: string) => ({ + version, + pulledAt: '2025-01-01T00:00:00.000Z', + platform: { os: 'darwin', arch: 'arm64' }, +}); + +function seedCacheEntry( + mpakHome: string, + dirName: string, + opts: { manifest?: object; metadata?: object }, +) { + const dir = join(mpakHome, 'cache', dirName); + mkdirSync(dir, { recursive: true }); + if (opts.manifest) { + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(opts.manifest)); + } + if (opts.metadata) { + writeFileSync(join(dir, '.mpak-meta.json'), JSON.stringify(opts.metadata)); + } +} + +function mockClient(registry: Record): MpakClient { + return { + getBundle: vi.fn(async (name: string) => { + const version = registry[name]; + if (!version) throw new Error(`Not found: ${name}`); + return { latest_version: version }; + }), + } as unknown as MpakClient; +} + +// --------------------------------------------------------------------------- +// Mock the mpak singleton — replaced per-test via `currentCache` +// --------------------------------------------------------------------------- + +let currentCache: MpakBundleCache; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { bundleCache: currentCache }; + }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('getOutdatedBundles', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-outdated-test-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns empty array when no bundles are cached', async () => { + currentCache = new MpakBundleCache(mockClient({}), { mpakHome: testDir }); + + expect(await getOutdatedBundles()).toEqual([]); + }); + + it('returns empty array when all bundles are up to date', async () => { + seedCacheEntry(testDir, 'scope-a', { + manifest: validManifest('@scope/a', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + seedCacheEntry(testDir, 'scope-b', { + manifest: validManifest('@scope/b', '2.0.0'), + metadata: validMetadata('2.0.0'), + }); + currentCache = new MpakBundleCache(mockClient({ '@scope/a': '1.0.0', '@scope/b': '2.0.0' }), { + mpakHome: testDir, + }); + + expect(await getOutdatedBundles()).toEqual([]); + }); + + it('returns outdated bundles with current and latest versions', async () => { + seedCacheEntry(testDir, 'scope-a', { + manifest: validManifest('@scope/a', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + seedCacheEntry(testDir, 'scope-b', { + manifest: validManifest('@scope/b', '2.0.0'), + metadata: validMetadata('2.0.0'), + }); + currentCache = new MpakBundleCache(mockClient({ '@scope/a': '1.1.0', '@scope/b': '2.0.0' }), { + mpakHome: testDir, + }); + + const result = await getOutdatedBundles(); + expect(result).toEqual([ + { + name: '@scope/a', + current: '1.0.0', + latest: '1.1.0', + pulledAt: '2025-01-01T00:00:00.000Z', + }, + ]); + }); + + it('returns multiple outdated bundles sorted by name', async () => { + seedCacheEntry(testDir, 'scope-zebra', { + manifest: validManifest('@scope/zebra', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + seedCacheEntry(testDir, 'scope-alpha', { + manifest: validManifest('@scope/alpha', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + currentCache = new MpakBundleCache( + mockClient({ '@scope/zebra': '2.0.0', '@scope/alpha': '1.1.0' }), + { mpakHome: testDir }, + ); + + const result = await getOutdatedBundles(); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe('@scope/alpha'); + expect(result[1]!.name).toBe('@scope/zebra'); + }); + + it('skips bundles that fail to resolve from registry', async () => { + seedCacheEntry(testDir, 'scope-exists', { + manifest: validManifest('@scope/exists', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + seedCacheEntry(testDir, 'scope-deleted', { + manifest: validManifest('@scope/deleted', '1.0.0'), + metadata: validMetadata('1.0.0'), + }); + currentCache = new MpakBundleCache( + mockClient({ '@scope/exists': '2.0.0' }), // @scope/deleted not in registry + { mpakHome: testDir }, + ); + + const result = await getOutdatedBundles(); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('@scope/exists'); + }); + + it('ignores TTL and always checks the registry', async () => { + seedCacheEntry(testDir, 'scope-a', { + manifest: validManifest('@scope/a', '1.0.0'), + metadata: { + ...validMetadata('1.0.0'), + lastCheckedAt: new Date().toISOString(), // just checked + }, + }); + const client = mockClient({ '@scope/a': '2.0.0' }); + currentCache = new MpakBundleCache(client, { mpakHome: testDir }); + + const result = await getOutdatedBundles(); + expect(result).toHaveLength(1); + expect(result[0]!.latest).toBe('2.0.0'); + expect(client.getBundle).toHaveBeenCalledWith('@scope/a'); + }); +}); diff --git a/packages/cli/tests/bundles/pull.test.ts b/packages/cli/tests/bundles/pull.test.ts new file mode 100644 index 0000000..6ebdf58 --- /dev/null +++ b/packages/cli/tests/bundles/pull.test.ts @@ -0,0 +1,143 @@ +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handlePull } from '../../src/commands/packages/pull.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })); + +let mockDownloadBundle: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { downloadBundle: mockDownloadBundle } as unknown as MpakClient, + }; + }, +})); + +vi.mock('@nimblebrain/mpak-sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MpakClient: { + detectPlatform: () => ({ os: 'darwin', arch: 'arm64' }), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const bundleData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +const metadata = { + name: '@scope/test-bundle', + version: '1.2.0', + platform: { os: 'darwin', arch: 'arm64' }, + sha256: 'abcdef1234567890abcdef1234567890', + size: 2_500_000, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handlePull', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.mocked(writeFileSync).mockClear(); + mockDownloadBundle = vi.fn().mockResolvedValue({ data: bundleData, metadata }); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls downloadBundle with parsed name and version', async () => { + await handlePull('@scope/test-bundle@1.2.0'); + + expect(mockDownloadBundle).toHaveBeenCalledWith('@scope/test-bundle', '1.2.0', { + os: 'darwin', + arch: 'arm64', + }); + }); + + it('passes undefined version when none specified', async () => { + await handlePull('@scope/test-bundle'); + + expect(mockDownloadBundle).toHaveBeenCalledWith('@scope/test-bundle', undefined, { + os: 'darwin', + arch: 'arm64', + }); + }); + + it('writes downloaded data to default filename in cwd', async () => { + await handlePull('@scope/test-bundle'); + + expect(writeFileSync).toHaveBeenCalledWith( + resolve('scope-test-bundle-1.2.0-darwin-arm64.mcpb'), + bundleData, + ); + }); + + it('writes to --output path when specified', async () => { + await handlePull('@scope/test-bundle', { output: '/tmp/my-bundle.mcpb' }); + + expect(writeFileSync).toHaveBeenCalledWith('/tmp/my-bundle.mcpb', bundleData); + }); + + it('uses explicit --os and --arch overrides', async () => { + await handlePull('@scope/test-bundle', { os: 'linux', arch: 'x64' }); + + expect(mockDownloadBundle).toHaveBeenCalledWith('@scope/test-bundle', undefined, { + os: 'linux', + arch: 'x64', + }); + }); + + it('prints metadata and SHA in normal output', async () => { + await handlePull('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Version: 1.2.0'); + expect(allOutput).toContain('darwin-arm64'); + expect(allOutput).toContain('2.4 MB'); + expect(allOutput).toContain('SHA256: abcdef1234567890...'); + expect(allOutput).toContain('downloaded successfully'); + }); + + it('prints JSON and skips file write when --json is set', async () => { + await handlePull('@scope/test-bundle', { json: true }); + + expect(writeFileSync).not.toHaveBeenCalled(); + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.version).toBe('1.2.0'); + }); + + it('logs error when downloadBundle throws', async () => { + mockDownloadBundle.mockRejectedValue(new Error('Bundle not found')); + + await handlePull('@scope/nonexistent'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Bundle not found')); + }); +}); diff --git a/packages/cli/tests/bundles/run.test.ts b/packages/cli/tests/bundles/run.test.ts new file mode 100644 index 0000000..6ca50eb --- /dev/null +++ b/packages/cli/tests/bundles/run.test.ts @@ -0,0 +1,537 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { McpbManifest } from '@nimblebrain/mpak-schemas'; +import type { ServerCommand } from '@nimblebrain/mpak-sdk'; +import { MpakConfigError, MpakNetworkError, MpakNotFoundError } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleRun } from '../../src/commands/packages/run.js'; + +/** Sentinel thrown by the process.exit mock so code halts like a real exit. */ +class ExitError extends Error { + constructor(public readonly code: number) { + super(`process.exit(${code})`); + this.name = 'ExitError'; + } +} + +// =========================================================================== +// Mock child_process.spawn +// =========================================================================== + +interface MockChildProcess { + kill: ReturnType; + on: ReturnType; + _listeners: Record void)[]>; + _emit: (event: string, ...args: unknown[]) => void; +} + +function createMockChild(): MockChildProcess { + const listeners: Record void)[]> = {}; + return { + kill: vi.fn(), + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }), + _listeners: listeners, + _emit: (event: string, ...args: unknown[]) => { + for (const cb of listeners[event] || []) { + cb(...args); + } + }, + }; +} + +let mockChild: MockChildProcess; +const mockSpawn = vi.fn(); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }; +}); + +// =========================================================================== +// Mock the mpak singleton +// =========================================================================== + +const mockPrepareServer = vi.fn(); +const mockCheckForUpdate = vi.fn(); +const mockSetPackageConfigValue = vi.fn(); + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + prepareServer: mockPrepareServer, + bundleCache: { + checkForUpdate: mockCheckForUpdate, + }, + configManager: { + setPackageConfigValue: mockSetPackageConfigValue, + }, + }; + }, +})); + +// =========================================================================== +// Fixtures +// =========================================================================== + +const nodeManifest: McpbManifest = { + manifest_version: '0.3', + name: '@scope/echo', + version: '1.0.0', + description: 'Echo server', + server: { + type: 'node', + entry_point: 'index.js', + mcp_config: { + command: 'node', + args: ['${__dirname}/index.js'], + env: {}, + }, + }, +}; + +function makeServerCommand(overrides?: Partial): ServerCommand { + return { + command: 'node', + args: ['/cache/scope-echo/index.js'], + env: { MPAK_WORKSPACE: '/project/.mpak' }, + cwd: '/cache/scope-echo', + name: '@scope/echo', + version: '1.0.0', + ...overrides, + }; +} + +let testDir: string; + +function createMcpbBundle(dir: string, manifest: McpbManifest): string { + const srcDir = join(dir, 'bundle-src'); + mkdirSync(srcDir, { recursive: true }); + writeFileSync(join(srcDir, 'manifest.json'), JSON.stringify(manifest)); + writeFileSync(join(srcDir, 'index.js'), 'console.log("hello")'); + + const mcpbPath = join(dir, 'test-bundle.mcpb'); + execFileSync('zip', ['-j', mcpbPath, join(srcDir, 'manifest.json'), join(srcDir, 'index.js')], { + stdio: 'pipe', + }); + return mcpbPath; +} + +// =========================================================================== +// Capture stderr, mock process.exit +// =========================================================================== + +let stderr: string; +let exitCode: number | undefined; + +beforeEach(() => { + stderr = ''; + exitCode = undefined; + testDir = mkdtempSync(join(tmpdir(), 'mpak-run-test-')); + + mockChild = createMockChild(); + mockSpawn.mockReset(); + mockSpawn.mockReturnValue(mockChild); + mockPrepareServer.mockReset(); + mockCheckForUpdate.mockReset(); + mockSetPackageConfigValue.mockReset(); + mockPrepareServer.mockResolvedValue(makeServerCommand()); + mockCheckForUpdate.mockResolvedValue(null); + + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCode = code ?? 0; + // Throw to halt execution (matching real process.exit behavior). + // Tests that call handleRun where exit happens in async callbacks + // (like child "exit" event) should catch this. + throw new ExitError(exitCode); + }) as never); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +// =========================================================================== +// Registry — bundle in cache (no pull needed) +// =========================================================================== + +describe('registry run — cached bundle', () => { + it('calls prepareServer with parsed name and spawns the server', async () => { + const server = makeServerCommand(); + mockPrepareServer.mockResolvedValue(server); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1)); + + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/echo' }, {}); + expect(mockSpawn).toHaveBeenCalledWith('node', ['/cache/scope-echo/index.js'], { + stdio: ['inherit', 'inherit', 'inherit'], + env: expect.objectContaining({ MPAK_WORKSPACE: '/project/.mpak' }), + cwd: '/cache/scope-echo', + }); + }); + + it('parses version from package spec', async () => { + mockPrepareServer.mockResolvedValue(makeServerCommand({ version: '2.0.0' })); + + handleRun('@scope/echo@2.0.0'); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/echo', version: '2.0.0' }, {}); + }); + + it('merges process.env on top of server env', async () => { + mockPrepareServer.mockResolvedValue( + makeServerCommand({ env: { FROM_SDK: 'yes', PATH: '/sdk/path' } }), + ); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + + const spawnEnv = mockSpawn.mock.calls[0][2].env; + // process.env PATH wins over SDK's PATH + expect(spawnEnv['PATH']).toBe(process.env['PATH']); + // SDK-only keys survive the merge + expect(spawnEnv['FROM_SDK']).toBe('yes'); + }); +}); + +// =========================================================================== +// Registry — bundle not in cache (needs pull) +// =========================================================================== + +describe('registry run — uncached bundle', () => { + it('calls prepareServer without force (SDK handles download)', async () => { + handleRun('@scope/new-bundle'); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/new-bundle' }, {}); + }); + + it('throws when bundle is not found in registry', async () => { + mockPrepareServer.mockRejectedValue(new MpakNotFoundError('@scope/nonexistent@latest')); + + await expect(handleRun('@scope/nonexistent')).rejects.toThrow(MpakNotFoundError); + }); + + it('throws on network error', async () => { + mockPrepareServer.mockRejectedValue(new MpakNetworkError('connection refused')); + + await expect(handleRun('@scope/echo')).rejects.toThrow(MpakNetworkError); + }); +}); + +// =========================================================================== +// Registry — --update flag +// =========================================================================== + +describe('registry run — --update flag', () => { + it('passes force: true when --update is set', async () => { + handleRun('@scope/echo', { update: true }); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/echo' }, { force: true }); + }); + + it('does not fire update check when --update is set', async () => { + handleRun('@scope/echo', { update: true }); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + // Give async code time to settle + await new Promise((r) => setTimeout(r, 50)); + + expect(mockCheckForUpdate).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Local — first run (uncached) +// =========================================================================== + +describe('local run — uncached bundle', () => { + it('calls prepareServer with resolved absolute path', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + handleRun('', { local: mcpbPath }); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, {}); + }); + + it('does not fire update check for local bundles', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + handleRun('', { local: mcpbPath }); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 50)); + + expect(mockCheckForUpdate).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Local — cached bundle (re-use) +// =========================================================================== + +describe('local run — cached bundle', () => { + it('calls prepareServer without force (SDK handles mtime check)', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + handleRun('', { local: mcpbPath }); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, {}); + }); +}); + +// =========================================================================== +// Local — --update forces re-extract +// =========================================================================== + +describe('local run — --update flag', () => { + it('passes force: true for local with --update', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + handleRun('', { local: mcpbPath, update: true }); + await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); + + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, { force: true }); + }); +}); + +// =========================================================================== +// Async fire-and-forget update check +// =========================================================================== + +describe('async update check', () => { + it('prints update notice when newer version is available', async () => { + mockCheckForUpdate.mockResolvedValue('2.0.0'); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockCheckForUpdate).toHaveBeenCalled()); + // Let the .then() handler run + await new Promise((r) => setTimeout(r, 10)); + + expect(stderr).toContain('Update available'); + expect(stderr).toContain('@scope/echo 1.0.0 -> 2.0.0'); + expect(stderr).toContain('mpak run @scope/echo --update'); + }); + + it('prints nothing when bundle is up to date', async () => { + mockCheckForUpdate.mockResolvedValue(null); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockCheckForUpdate).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 10)); + + expect(stderr).not.toContain('Update available'); + }); + + it('logs debug message when update check fails', async () => { + mockCheckForUpdate.mockRejectedValue(new Error('network timeout')); + + handleRun('@scope/echo'); + + await vi.waitFor(() => expect(stderr).toContain('Debug: update check failed: network timeout')); + }); + + it('skips update check for local bundles', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + handleRun('', { local: mcpbPath }); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 50)); + + expect(mockCheckForUpdate).not.toHaveBeenCalled(); + }); + + it('skips update check when --update was used', async () => { + handleRun('@scope/echo', { update: true }); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 50)); + + expect(mockCheckForUpdate).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// MpakConfigError — registry (non-interactive) +// =========================================================================== + +describe('missing config — registry (non-interactive)', () => { + it('exits with error listing missing keys', async () => { + mockPrepareServer.mockRejectedValue( + new MpakConfigError('@scope/echo', [ + { key: 'api_key', title: 'API Key', sensitive: true }, + { key: 'endpoint', title: 'Endpoint', sensitive: false }, + ]), + ); + + await expect(handleRun('@scope/echo')).rejects.toThrow(ExitError); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Missing required config: api_key, endpoint'); + expect(stderr).toContain('mpak config set @scope/echo'); + }); +}); + +// =========================================================================== +// MpakConfigError — local (non-interactive) +// =========================================================================== + +describe('missing config — local (non-interactive)', () => { + it('exits with error listing missing keys for local bundle', async () => { + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + mockPrepareServer.mockRejectedValue( + new MpakConfigError('@scope/echo', [{ key: 'token', title: 'Auth Token', sensitive: true }]), + ); + + await expect(handleRun('', { local: mcpbPath })).rejects.toThrow(ExitError); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Missing required config: token'); + expect(stderr).toContain('mpak config set @scope/echo'); + }); +}); + +// =========================================================================== +// CLI-level validation errors +// =========================================================================== + +describe('CLI input validation', () => { + it('exits when neither package spec nor --local is provided', async () => { + await expect(handleRun('')).rejects.toThrow(ExitError); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Either provide a package name or use --local'); + expect(mockPrepareServer).not.toHaveBeenCalled(); + }); + + it('exits when --local path does not exist', async () => { + await expect(handleRun('', { local: '/nonexistent/bundle.mcpb' })).rejects.toThrow(ExitError); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Bundle not found'); + expect(mockPrepareServer).not.toHaveBeenCalled(); + }); + + it('exits when --local file is not .mcpb', async () => { + const notMcpb = join(testDir, 'bundle.zip'); + writeFileSync(notMcpb, 'fake'); + + await expect(handleRun('', { local: notMcpb })).rejects.toThrow(ExitError); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Not an MCPB bundle'); + expect(mockPrepareServer).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Process spawning +// =========================================================================== + +describe('process spawning', () => { + it('forwards SIGINT and SIGTERM to child process', async () => { + const sigintListeners: (() => void)[] = []; + const sigtermListeners: (() => void)[] = []; + vi.spyOn(process, 'on').mockImplementation(((event: string, cb: () => void) => { + if (event === 'SIGINT') sigintListeners.push(cb); + if (event === 'SIGTERM') sigtermListeners.push(cb); + return process; + }) as typeof process.on); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + + for (const cb of sigintListeners) cb(); + for (const cb of sigtermListeners) cb(); + + expect(mockChild.kill).toHaveBeenCalledWith('SIGINT'); + expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it("calls process.exit with child's exit code", async () => { + // Capture unhandled rejections from the async exit handler + const unhandled: unknown[] = []; + const handler = (err: unknown) => unhandled.push(err); + process.on('unhandledRejection', handler); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + + mockChild._emit('exit', 42); + await new Promise((r) => setTimeout(r, 50)); + + process.removeListener('unhandledRejection', handler); + expect(exitCode).toBe(42); + }); + + it('calls process.exit(0) when child exit code is null', async () => { + const unhandled: unknown[] = []; + const handler = (err: unknown) => unhandled.push(err); + process.on('unhandledRejection', handler); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + + mockChild._emit('exit', null); + await new Promise((r) => setTimeout(r, 50)); + + process.removeListener('unhandledRejection', handler); + expect(exitCode).toBe(0); + }); + + it('prints error and exits 1 when spawn fails', async () => { + const unhandled: unknown[] = []; + const handler = (err: unknown) => unhandled.push(err); + process.on('unhandledRejection', handler); + process.on('uncaughtException', handler); + + handleRun('@scope/echo'); + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); + + try { + mockChild._emit('error', new Error('ENOENT')); + } catch { + // ExitError thrown synchronously from the "error" handler + } + await new Promise((r) => setTimeout(r, 50)); + + process.removeListener('unhandledRejection', handler); + process.removeListener('uncaughtException', handler); + expect(exitCode).toBe(1); + expect(stderr).toContain('Failed to start server: ENOENT'); + }); +}); + +// =========================================================================== +// SDK error propagation +// =========================================================================== + +describe('SDK error propagation', () => { + it('propagates MpakNotFoundError from local bundle', async () => { + const mcpbPath = join(testDir, 'corrupt.mcpb'); + writeFileSync(mcpbPath, 'not a zip'); + mockPrepareServer.mockRejectedValue(new MpakNotFoundError(mcpbPath)); + + await expect(handleRun('', { local: mcpbPath })).rejects.toThrow(MpakNotFoundError); + }); + + it('propagates unexpected errors as-is', async () => { + mockPrepareServer.mockRejectedValue(new Error('something unexpected')); + + await expect(handleRun('@scope/echo')).rejects.toThrow('something unexpected'); + }); +}); diff --git a/packages/cli/tests/bundles/search.test.ts b/packages/cli/tests/bundles/search.test.ts new file mode 100644 index 0000000..8ef5468 --- /dev/null +++ b/packages/cli/tests/bundles/search.test.ts @@ -0,0 +1,114 @@ +import type { BundleSearchResponse } from '@nimblebrain/mpak-schemas'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSearch } from '../../src/commands/packages/search.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let mockSearchBundles: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { searchBundles: mockSearchBundles } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const makeBundle = (name: string, version: string) => ({ + name, + display_name: null, + description: `${name} description`, + author: { name: 'test-author' }, + latest_version: version, + icon: null, + server_type: 'node', + tools: [], + downloads: 42, + published_at: '2025-01-01T00:00:00.000Z', + verified: false, + provenance: null, + certification_level: null, +}); + +const emptyResponse: BundleSearchResponse = { + bundles: [], + total: 0, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const twoResultsResponse: BundleSearchResponse = { + bundles: [makeBundle('@scope/alpha', '1.0.0'), makeBundle('@scope/beta', '2.3.1')], + total: 2, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleSearch', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockSearchBundles = vi.fn(); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('prints no-results message when search returns 0 bundles', async () => { + mockSearchBundles.mockResolvedValue(emptyResponse); + + await handleSearch('nonexistent'); + + expect(mockSearchBundles).toHaveBeenCalledWith(expect.objectContaining({ q: 'nonexistent' })); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('No bundles found for "nonexistent"'), + ); + }); + + it('prints table output when results exist and json is not set', async () => { + mockSearchBundles.mockResolvedValue(twoResultsResponse); + + await handleSearch('test'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 bundle(s)')); + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('@scope/alpha'); + expect(allOutput).toContain('@scope/beta'); + }); + + it('prints JSON output when json option is set', async () => { + mockSearchBundles.mockResolvedValue(twoResultsResponse); + + await handleSearch('test', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.bundles).toHaveLength(2); + expect(parsed.total).toBe(2); + }); + + it('logs error when searchBundles throws (e.g. 404)', async () => { + mockSearchBundles.mockRejectedValue(new Error('Resource not found: bundles/search endpoint')); + + await handleSearch('anything'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Resource not found')); + }); +}); diff --git a/packages/cli/tests/bundles/show.test.ts b/packages/cli/tests/bundles/show.test.ts new file mode 100644 index 0000000..a39eb37 --- /dev/null +++ b/packages/cli/tests/bundles/show.test.ts @@ -0,0 +1,238 @@ +import type { BundleDetail, VersionsResponse } from '@nimblebrain/mpak-schemas'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleShow } from '../../src/commands/packages/show.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let mockGetBundle: ReturnType; +let mockGetBundleVersions: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + getBundle: mockGetBundle, + getBundleVersions: mockGetBundleVersions, + } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const bundleDetail: BundleDetail = { + name: '@scope/test-bundle', + display_name: 'Test Bundle', + description: 'A test bundle for unit tests', + author: { name: 'test-author' }, + latest_version: '1.2.0', + icon: null, + server_type: 'node', + tools: [ + { name: 'tool-one', description: 'First tool' }, + { name: 'tool-two', description: 'Second tool' }, + ], + downloads: 1234, + published_at: '2025-06-01T00:00:00.000Z', + verified: true, + provenance: { + schema_version: '1.0', + provider: 'github-actions', + repository: 'https://github.com/scope/test-bundle', + sha: 'abc123def456789012', + }, + certification_level: 2, + homepage: 'https://example.com', + license: 'MIT', + certification: { + level: 2, + level_name: 'Verified', + controls_passed: 8, + controls_failed: 2, + controls_total: 10, + }, + versions: [ + { version: '1.2.0', published_at: '2025-06-01T00:00:00.000Z', downloads: 500 }, + { version: '1.1.0', published_at: '2025-05-01T00:00:00.000Z', downloads: 734 }, + ], +}; + +const versionsResponse: VersionsResponse = { + name: '@scope/test-bundle', + latest: '1.2.0', + versions: [ + { + version: '1.2.0', + artifacts_count: 2, + platforms: [ + { os: 'darwin', arch: 'arm64' }, + { os: 'linux', arch: 'x64' }, + ], + published_at: '2025-06-01T00:00:00.000Z', + downloads: 500, + publish_method: 'github-actions', + provenance: null, + }, + { + version: '1.1.0', + artifacts_count: 1, + platforms: [{ os: 'linux', arch: 'x64' }], + published_at: '2025-05-01T00:00:00.000Z', + downloads: 734, + publish_method: 'manual', + provenance: null, + }, + ], +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleShow', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockGetBundle = vi.fn(); + mockGetBundleVersions = vi.fn(); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls getBundle and getBundleVersions with the package name', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + expect(mockGetBundle).toHaveBeenCalledWith('@scope/test-bundle'); + expect(mockGetBundleVersions).toHaveBeenCalledWith('@scope/test-bundle'); + }); + + it('prints bundle header with verified mark and display name', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('\u2713'); // verified checkmark + expect(allOutput).toContain('Test Bundle v1.2.0'); + }); + + it('prints bundle information section', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Name: @scope/test-bundle'); + expect(allOutput).toContain('Author: test-author'); + expect(allOutput).toContain('Type: node'); + expect(allOutput).toContain('License: MIT'); + expect(allOutput).toContain('Homepage: https://example.com'); + }); + + it('prints trust and certification details', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Trust: L2 Verified'); + expect(allOutput).toContain('Controls: 8/10 passed'); + }); + + it('prints tools list', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Tools (2):'); + expect(allOutput).toContain('tool-one'); + expect(allOutput).toContain('tool-two'); + }); + + it('prints versions with platforms', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Versions (2):'); + expect(allOutput).toContain('1.2.0'); + expect(allOutput).toContain('(latest)'); + expect(allOutput).toContain('darwin-arm64'); + }); + + it('prints JSON output when json option is set', async () => { + mockGetBundle.mockResolvedValue(bundleDetail); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.name).toBe('@scope/test-bundle'); + expect(parsed.versions_detail).toHaveLength(2); + }); + + it('skips optional sections when data is absent', async () => { + const minimalBundle: BundleDetail = { + ...bundleDetail, + display_name: null, + description: null, + author: null, + tools: [], + provenance: null, + certification_level: null, + certification: null, + homepage: null, + license: null, + }; + mockGetBundle.mockResolvedValue(minimalBundle); + mockGetBundleVersions.mockResolvedValue(versionsResponse); + + await handleShow('@scope/test-bundle'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).not.toContain('Author:'); + expect(allOutput).not.toContain('License:'); + expect(allOutput).not.toContain('Homepage:'); + expect(allOutput).not.toContain('Trust:'); + expect(allOutput).not.toContain('Provenance:'); + expect(allOutput).not.toContain('Tools'); + }); + + it('logs error when API call throws', async () => { + mockGetBundle.mockRejectedValue(new Error('Bundle not found')); + mockGetBundleVersions.mockRejectedValue(new Error('Bundle not found')); + + await handleShow('@scope/nonexistent'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Bundle not found')); + }); +}); diff --git a/packages/cli/tests/bundles/update.test.ts b/packages/cli/tests/bundles/update.test.ts new file mode 100644 index 0000000..3b48414 --- /dev/null +++ b/packages/cli/tests/bundles/update.test.ts @@ -0,0 +1,218 @@ +import { MpakNetworkError, MpakNotFoundError } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleUpdate } from '../../src/commands/packages/update.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +const mockLoadBundle = vi.fn(); +const mockListCachedBundles = vi.fn(); +const mockCheckForUpdate = vi.fn(); + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + bundleCache: { + loadBundle: mockLoadBundle, + listCachedBundles: mockListCachedBundles, + checkForUpdate: mockCheckForUpdate, + }, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Capture stdout/stderr +// --------------------------------------------------------------------------- + +let stdout: string; +let stderr: string; + +beforeEach(() => { + stdout = ''; + stderr = ''; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout += args.join(' ') + '\n'; + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderr += args.join(' ') + '\n'; + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// =========================================================================== +// Single bundle update +// =========================================================================== + +describe('handleUpdate — single bundle', () => { + it('updates a single bundle and prints result', async () => { + mockLoadBundle.mockResolvedValue({ + cacheDir: '/cache/scope-a', + version: '2.0.0', + pulled: true, + }); + + await handleUpdate('@scope/a'); + + expect(mockLoadBundle).toHaveBeenCalledWith('@scope/a', { force: true }); + expect(stderr).toContain('Updated @scope/a to 2.0.0'); + }); + + it('outputs JSON when --json is set', async () => { + mockLoadBundle.mockResolvedValue({ + cacheDir: '/cache/scope-a', + version: '2.0.0', + pulled: true, + }); + + await handleUpdate('@scope/a', { json: true }); + + const parsed = JSON.parse(stdout); + expect(parsed).toEqual({ name: '@scope/a', version: '2.0.0' }); + }); + + it('throws user-friendly error when bundle is not found', async () => { + mockLoadBundle.mockRejectedValue(new MpakNotFoundError('@scope/missing@latest')); + + await expect(handleUpdate('@scope/missing')).rejects.toThrow( + 'Bundle "@scope/missing" not found in the registry', + ); + }); + + it('throws user-friendly error on network failure', async () => { + mockLoadBundle.mockRejectedValue(new MpakNetworkError('connection refused')); + + await expect(handleUpdate('@scope/a')).rejects.toThrow( + 'Network error updating "@scope/a": connection refused', + ); + }); + + it('lets unexpected errors propagate as-is', async () => { + mockLoadBundle.mockRejectedValue(new Error('something unexpected')); + + await expect(handleUpdate('@scope/a')).rejects.toThrow('something unexpected'); + }); +}); + +// =========================================================================== +// Bulk update (no package name) +// =========================================================================== + +describe('handleUpdate — bulk update', () => { + it('prints up-to-date message when nothing is outdated', async () => { + mockListCachedBundles.mockReturnValue([]); + + await handleUpdate(undefined); + + expect(stderr).toContain('All cached bundles are up to date.'); + }); + + it('outputs empty JSON array when nothing is outdated and --json is set', async () => { + mockListCachedBundles.mockReturnValue([]); + + await handleUpdate(undefined, { json: true }); + + expect(JSON.parse(stdout)).toEqual([]); + }); + + it('updates all outdated bundles', async () => { + mockListCachedBundles.mockReturnValue([ + { + name: '@scope/a', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/a', + }, + { + name: '@scope/b', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/b', + }, + ]); + mockCheckForUpdate.mockImplementation(async (name: string) => { + return name === '@scope/a' ? '2.0.0' : '3.0.0'; + }); + mockLoadBundle.mockImplementation(async (name: string) => { + const versions: Record = { '@scope/a': '2.0.0', '@scope/b': '3.0.0' }; + return { cacheDir: `/cache/${name}`, version: versions[name], pulled: true }; + }); + + await handleUpdate(undefined); + + expect(mockLoadBundle).toHaveBeenCalledWith('@scope/a', { force: true }); + expect(mockLoadBundle).toHaveBeenCalledWith('@scope/b', { force: true }); + expect(stderr).toContain('Updated @scope/a: 1.0.0 -> 2.0.0'); + expect(stderr).toContain('Updated @scope/b: 1.0.0 -> 3.0.0'); + }); + + it('continues updating when some bundles fail', async () => { + mockListCachedBundles.mockReturnValue([ + { + name: '@scope/good', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/good', + }, + { + name: '@scope/bad', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/bad', + }, + ]); + mockCheckForUpdate.mockImplementation(async () => '2.0.0'); + mockLoadBundle.mockImplementation(async (name: string) => { + if (name === '@scope/bad') throw new MpakNotFoundError('@scope/bad@latest'); + return { cacheDir: '/cache/good', version: '2.0.0', pulled: true }; + }); + + await handleUpdate(undefined); + + expect(stderr).toContain('Updated @scope/good: 1.0.0 -> 2.0.0'); + expect(stderr).toContain('Failed to update @scope/bad'); + }); + + it('exits with error when all bulk updates fail', async () => { + mockListCachedBundles.mockReturnValue([ + { + name: '@scope/a', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/a', + }, + ]); + mockCheckForUpdate.mockResolvedValue('2.0.0'); + mockLoadBundle.mockRejectedValue(new MpakNetworkError('timeout')); + + await expect(handleUpdate(undefined)).rejects.toThrow('process.exit called'); + + expect(stderr).toContain('Failed to update @scope/a'); + expect(stderr).toContain('All updates failed'); + }); + + it('outputs JSON for bulk update with --json', async () => { + mockListCachedBundles.mockReturnValue([ + { + name: '@scope/a', + version: '1.0.0', + pulledAt: '2025-01-01T00:00:00.000Z', + cacheDir: '/cache/a', + }, + ]); + mockCheckForUpdate.mockResolvedValue('2.0.0'); + mockLoadBundle.mockResolvedValue({ cacheDir: '/cache/a', version: '2.0.0', pulled: true }); + + await handleUpdate(undefined, { json: true }); + + const parsed = JSON.parse(stdout); + expect(parsed).toEqual([{ name: '@scope/a', from: '1.0.0', to: '2.0.0' }]); + }); +}); diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts new file mode 100644 index 0000000..cecf779 --- /dev/null +++ b/packages/cli/tests/config.test.ts @@ -0,0 +1,309 @@ +import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const CLI = join(__dirname, '..', 'dist', 'index.js'); + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function run(args: string, env: Record = {}): RunResult { + const opts: ExecSyncOptionsWithStringEncoding = { + encoding: 'utf8', + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }; + try { + const stdout = execSync(`node ${CLI} ${args}`, opts).trim(); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout: string; stderr: string; status: number }; + return { + stdout: (e.stdout ?? '').trim(), + stderr: (e.stderr ?? '').trim(), + exitCode: e.status ?? 1, + }; + } +} + +function readConfig(mpakHome: string): Record { + return JSON.parse(readFileSync(join(mpakHome, 'config.json'), 'utf8')); +} + +function getPackages(mpakHome: string): Record> { + const config = readConfig(mpakHome); + return (config.packages ?? {}) as Record>; +} + +describe('config set', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mpak-config-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // --- Happy path --- + + it('should set a single key=value pair', () => { + const result = run('config set @scope/name api_key=test-value', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Set 1 config value(s) for @scope/name'); + expect(getPackages(tmpDir)['@scope/name']).toEqual({ + api_key: 'test-value', + }); + }); + + it('should set multiple key=value pairs in one call', () => { + const result = run('config set @scope/name key1=value1 key2=value2', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Set 2 config value(s) for @scope/name'); + expect(getPackages(tmpDir)['@scope/name']).toEqual({ + key1: 'value1', + key2: 'value2', + }); + }); + + it('should overwrite an existing value', () => { + run('config set @scope/name api_key=old-value', { MPAK_HOME: tmpDir }); + const result = run('config set @scope/name api_key=new-value', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).toBe(0); + expect(getPackages(tmpDir)['@scope/name']['api_key']).toBe('new-value'); + }); + + it('should handle value containing equals sign', () => { + const result = run('config set @scope/name token=abc=def=ghi', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).toBe(0); + expect(getPackages(tmpDir)['@scope/name']['token']).toBe('abc=def=ghi'); + }); + + it('should handle empty value', () => { + const result = run('config set @scope/name api_key=', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).toBe(0); + expect(getPackages(tmpDir)['@scope/name']['api_key']).toBe(''); + }); + + it('should set config for multiple packages independently', () => { + run('config set @scope/pkg1 key=value1', { MPAK_HOME: tmpDir }); + run('config set @scope/pkg2 key=value2', { MPAK_HOME: tmpDir }); + + const packages = getPackages(tmpDir); + expect(packages['@scope/pkg1']['key']).toBe('value1'); + expect(packages['@scope/pkg2']['key']).toBe('value2'); + }); + + // --- Error cases --- + + it('should reject missing key=value pair (no args)', () => { + const result = run('config set @scope/name', { MPAK_HOME: tmpDir }); + expect(result.exitCode).not.toBe(0); + }); + + it('should reject invalid format (no equals sign)', () => { + const result = run('config set @scope/name badformat', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Invalid format'); + }); + + it('should reject empty key', () => { + const result = run('config set @scope/name =value', { + MPAK_HOME: tmpDir, + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Empty key'); + }); + + it('should not create config file on validation error', () => { + run('config set @scope/name badformat', { MPAK_HOME: tmpDir }); + expect(existsSync(join(tmpDir, 'config.json'))).toBe(false); + }); +}); + +describe('config get', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mpak-config-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // --- Happy path --- + + it('should display config values in plain text', () => { + run('config set @scope/name api_key=secret123', { MPAK_HOME: tmpDir }); + const result = run('config get @scope/name', { MPAK_HOME: tmpDir }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Config for @scope/name:'); + expect(result.stdout).toContain('api_key:'); + // Value should be masked (first 4 chars visible) + expect(result.stdout).toContain('secr'); + expect(result.stdout).not.toContain('secret123'); + }); + + it('should display config values as JSON with --json', () => { + run('config set @scope/name api_key=secret123', { MPAK_HOME: tmpDir }); + const result = run('config get @scope/name --json', { MPAK_HOME: tmpDir }); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('api_key'); + // Masked — should not contain the raw value + expect(parsed.api_key).not.toBe('secret123'); + expect(parsed.api_key).toMatch(/^secr/); + }); + + // --- Empty / missing --- + + it("should show 'no config' message for unknown package", () => { + const result = run('config get @scope/unknown', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No config stored for @scope/unknown'); + }); + + it('should return empty JSON for unknown package with --json', () => { + const result = run('config get @scope/unknown --json', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({}); + }); +}); + +describe('config list', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mpak-config-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // --- Happy path --- + + it('should list packages with stored config', () => { + run('config set @scope/pkg1 key=value1', { MPAK_HOME: tmpDir }); + run('config set @scope/pkg2 key1=a key2=b', { MPAK_HOME: tmpDir }); + + const result = run('config list', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('@scope/pkg1'); + expect(result.stdout).toContain('1 value'); + expect(result.stdout).toContain('@scope/pkg2'); + expect(result.stdout).toContain('2 values'); + }); + + it('should list packages as JSON with --json', () => { + run('config set @scope/pkg1 key=value1', { MPAK_HOME: tmpDir }); + run('config set @scope/pkg2 key=value2', { MPAK_HOME: tmpDir }); + + const result = run('config list --json', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout) as string[]; + expect(parsed).toContain('@scope/pkg1'); + expect(parsed).toContain('@scope/pkg2'); + expect(parsed).toHaveLength(2); + }); + + // --- Empty --- + + it("should show 'no packages' message when empty", () => { + const result = run('config list', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No packages have stored config'); + }); + + it('should return empty JSON array when empty with --json', () => { + const result = run('config list --json', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([]); + }); +}); + +describe('config clear', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mpak-config-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // --- Clear specific key --- + + it('should clear a specific key', () => { + run('config set @scope/name key1=value1 key2=value2', { MPAK_HOME: tmpDir }); + const result = run('config clear @scope/name key1', { MPAK_HOME: tmpDir }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Cleared key1 for @scope/name'); + + const packages = getPackages(tmpDir); + expect(packages['@scope/name']).toEqual({ key2: 'value2' }); + }); + + it('should report when clearing a non-existent key', () => { + run('config set @scope/name key1=value1', { MPAK_HOME: tmpDir }); + const result = run('config clear @scope/name nokey', { MPAK_HOME: tmpDir }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No value found for nokey'); + }); + + // --- Clear all config for a package --- + + it('should clear all config for a package', () => { + run('config set @scope/name key1=value1 key2=value2', { MPAK_HOME: tmpDir }); + const result = run('config clear @scope/name', { MPAK_HOME: tmpDir }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Cleared all config for @scope/name'); + + const packages = getPackages(tmpDir); + expect(packages['@scope/name']).toBeUndefined(); + }); + + it('should report when clearing a non-existent package', () => { + const result = run('config clear @scope/unknown', { MPAK_HOME: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No config found for @scope/unknown'); + }); + + // --- Clear then verify list --- + + it('should remove package from list after clearing all config', () => { + run('config set @scope/pkg1 key=value1', { MPAK_HOME: tmpDir }); + run('config set @scope/pkg2 key=value2', { MPAK_HOME: tmpDir }); + run('config clear @scope/pkg1', { MPAK_HOME: tmpDir }); + + const result = run('config list --json', { MPAK_HOME: tmpDir }); + const parsed = JSON.parse(result.stdout) as string[]; + expect(parsed).not.toContain('@scope/pkg1'); + expect(parsed).toContain('@scope/pkg2'); + }); +}); diff --git a/packages/cli/tests/errors.test.ts b/packages/cli/tests/errors.test.ts new file mode 100644 index 0000000..7370cd7 --- /dev/null +++ b/packages/cli/tests/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { CLIError } from '../src/utils/errors.js'; + +describe('CLIError', () => { + it('should create error with message', () => { + const error = new CLIError('Test error'); + expect(error.message).toBe('Test error'); + expect(error.name).toBe('CLIError'); + }); + + it('should have default exit code of 1', () => { + const error = new CLIError('Test error'); + expect(error.exitCode).toBe(1); + }); + + it('should allow custom exit code', () => { + const error = new CLIError('Test error', 2); + expect(error.exitCode).toBe(2); + }); + + it('should be instance of Error', () => { + const error = new CLIError('Test error'); + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/packages/cli/tests/integration/bundle.integration.test.ts b/packages/cli/tests/integration/bundle.integration.test.ts new file mode 100644 index 0000000..bc91375 --- /dev/null +++ b/packages/cli/tests/integration/bundle.integration.test.ts @@ -0,0 +1,51 @@ +import { existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { run } from './helpers.js'; + +/** + * Integration smoke tests for bundle pull against the live registry. + * + * Run with: pnpm test -- tests/integration + */ + +const TEST_BUNDLE = '@nimblebraininc/echo'; + +describe('bundle pull', () => { + let outputPath: string; + + afterEach(() => { + if (outputPath && existsSync(outputPath)) { + rmSync(outputPath); + } + }); + + it('downloads a .mcpb file to the specified output path', async () => { + outputPath = join(tmpdir(), `mpak-test-${Date.now()}.mcpb`); + + const { stderr, exitCode } = await run( + `bundle pull ${TEST_BUNDLE} --os linux --arch x64 --output ${outputPath}`, + ); + + expect(exitCode).toBe(0); + expect(existsSync(outputPath)).toBe(true); + expect(stderr).toContain('Bundle downloaded successfully'); + expect(stderr).not.toContain('[Error]'); + }, 30000); + + it('outputs valid JSON metadata with --json flag', async () => { + outputPath = join(tmpdir(), `mpak-test-json-${Date.now()}.mcpb`); + + const { stdout, exitCode } = await run( + `bundle pull ${TEST_BUNDLE} --os linux --arch x64 --output ${outputPath} --json`, + ); + + expect(exitCode).toBe(0); + const meta = JSON.parse(stdout); + expect(meta.version).toMatch(/^\d+\.\d+\.\d+/); + expect(meta.platform.os).toBe('linux'); + expect(meta.platform.arch).toBe('x64'); + expect(meta.sha256).toBeTruthy(); + }, 30000); +}); diff --git a/packages/cli/tests/integration/helpers.ts b/packages/cli/tests/integration/helpers.ts new file mode 100644 index 0000000..b419716 --- /dev/null +++ b/packages/cli/tests/integration/helpers.ts @@ -0,0 +1,32 @@ +import { exec } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +/** Absolute path to the built CLI entry point. */ +export const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); + +export interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Run the mpak CLI with the given args and return stdout, stderr, and exit code. + * Never throws — non-zero exits are returned as exitCode. + */ +export async function run(args: string): Promise { + try { + const { stdout, stderr } = await execAsync(`node ${CLI} ${args}`); + return { stdout, stderr, exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; code?: number }; + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + exitCode: e.code ?? 1, + }; + } +} diff --git a/packages/cli/tests/integration/registry-client.test.ts b/packages/cli/tests/integration/registry-client.test.ts index 66362a1..d80be22 100644 --- a/packages/cli/tests/integration/registry-client.test.ts +++ b/packages/cli/tests/integration/registry-client.test.ts @@ -1,171 +1,64 @@ -import { describe, it, expect } from "vitest"; -import { MpakClient } from "@nimblebrain/mpak-sdk"; +import { describe, expect, it } from 'vitest'; +import { run } from './helpers.js'; /** - * Integration tests for the mpak registry via the SDK client. - * - * These tests hit the live registry.mpak.dev registry using the @nimblebraininc/echo - * bundle as a known fixture. They verify the full flow from search to download. + * Integration tests for bundle search and show commands against the live registry. * * Run with: pnpm test -- tests/integration */ -describe("MpakClient Integration", () => { - const client = new MpakClient(); - const testBundle = "@nimblebraininc/echo"; - - describe("searchBundles", () => { - it('should find echo bundle when searching for "echo"', async () => { - const result = await client.searchBundles({ q: "echo" }); - - expect(result.bundles).toBeDefined(); - expect(result.bundles.length).toBeGreaterThan(0); - - const echoBundle = result.bundles.find( - (b) => b.name === testBundle, - ); - expect(echoBundle).toBeDefined(); - expect(echoBundle?.description).toContain("Echo"); - }, 15000); // Allow extra time for API cold start - - it("should return empty results for nonsense query", async () => { - const result = await client.searchBundles({ - q: "xyznonexistent12345", - }); - - expect(result.bundles).toBeDefined(); - expect(result.bundles.length).toBe(0); - }); - - it("should respect limit parameter", async () => { - const result = await client.searchBundles({ - q: "", - limit: 1, - }); - - expect(result.bundles.length).toBeLessThanOrEqual(1); - }); - }); - - describe("getBundle", () => { - it("should return bundle details for @nimblebraininc/echo", async () => { - const bundle = await client.getBundle(testBundle); - - expect(bundle.name).toBe(testBundle); - expect(bundle.description).toBeDefined(); - expect(bundle.server_type).toBe("python"); - expect(bundle.author).toBeDefined(); - expect(bundle.latest_version).toBeDefined(); - }); - - it("should include provenance information", async () => { - const bundle = await client.getBundle(testBundle); - expect(bundle.provenance).toBeDefined(); - expect(bundle.provenance?.provider).toBe( - "github_oidc", - ); - expect(bundle.provenance?.repository).toContain( - "mcp-echo", - ); - }); +const TEST_BUNDLE = '@nimblebraininc/echo'; - it("should throw error for non-existent bundle", async () => { - await expect( - client.getBundle("@nonexistent/bundle-xyz"), - ).rejects.toThrow(); - }); +describe('bundle search', () => { + it("finds echo bundle when searching for 'echo'", async () => { + const { stdout, exitCode } = await run('bundle search echo --json'); - it("should throw error for unscoped package name", async () => { - await expect( - client.getBundle("unscoped-name"), - ).rejects.toThrow("Package name must be scoped"); - }); - }); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(result.bundles.some((b: { name: string }) => b.name === TEST_BUNDLE)).toBe(true); + }, 15000); - describe("getBundleVersions", () => { - it("should return version list with platforms", async () => { - const result = - await client.getBundleVersions(testBundle); + it('returns empty results gracefully for a nonsense query', async () => { + const { stderr, exitCode } = await run('bundle search xyznonexistent12345'); - expect(result.versions).toBeDefined(); - expect(result.versions.length).toBeGreaterThan(0); + expect(exitCode).toBe(0); + expect(stderr).toContain('No bundles found'); + }, 15000); - const latestVersion = result.versions[0]; - expect(latestVersion.version).toBeDefined(); - expect(latestVersion.platforms).toBeDefined(); - expect( - latestVersion.platforms.length, - ).toBeGreaterThan(0); - }); + it('table output contains bundle name and version', async () => { + const { stderr, exitCode } = await run('bundle search echo'); - it("should include linux platforms for echo bundle", async () => { - const result = - await client.getBundleVersions(testBundle); - - const latestVersion = result.versions[0]; - const platforms = latestVersion.platforms.map( - (p) => `${p.os}-${p.arch}`, - ); - - expect(platforms).toContain("linux-x64"); - expect(platforms).toContain("linux-arm64"); - }); - }); - - describe("getBundleDownload", () => { - it("should return download URL for a version", async () => { - const versions = - await client.getBundleVersions(testBundle); - const version = versions.versions[0].version; - - const info = await client.getBundleDownload( - testBundle, - version, - { - os: "linux", - arch: "x64", - }, - ); - - expect(info.url).toBeDefined(); - expect(info.url).toMatch(/^https?:\/\//); - expect(info.bundle.version).toBeDefined(); - expect(info.bundle.platform).toBeDefined(); - expect(info.bundle.size).toBeGreaterThan(0); - expect(info.bundle.sha256).toBeDefined(); - }); - - it("should return correct artifact for requested platform", async () => { - const versions = - await client.getBundleVersions(testBundle); - const version = versions.versions[0].version; - - const info = await client.getBundleDownload( - testBundle, - version, - { - os: "linux", - arch: "arm64", - }, - ); - - expect(info.bundle.platform.os).toBe("linux"); - expect(info.bundle.platform.arch).toBe("arm64"); - }); - }); - - describe("detectPlatform", () => { - it("should return valid platform object", () => { - const platform = MpakClient.detectPlatform(); + expect(exitCode).toBe(0); + expect(stderr).toContain(TEST_BUNDLE); + expect(stderr).toMatch(/v\d+\.\d+\.\d+/); + }, 15000); +}); - expect(platform.os).toBeDefined(); - expect(platform.arch).toBeDefined(); - expect(["darwin", "linux", "win32", "any"]).toContain( - platform.os, - ); - expect(["x64", "arm64", "any"]).toContain( - platform.arch, - ); - }); - }); +describe('bundle show', () => { + it('outputs valid JSON with expected fields', async () => { + const { stdout, exitCode } = await run(`bundle show ${TEST_BUNDLE} --json`); + + expect(exitCode).toBe(0); + const bundle = JSON.parse(stdout); + expect(bundle.name).toBe(TEST_BUNDLE); + expect(bundle.server_type).toBe('python'); + expect(Array.isArray(bundle.versions_detail)).toBe(true); + expect(bundle.versions_detail.length).toBeGreaterThan(0); + }, 15000); + + it('outputs human-readable details to stderr', async () => { + const { stderr, exitCode } = await run(`bundle show ${TEST_BUNDLE}`); + + expect(exitCode).toBe(0); + expect(stderr).toContain(TEST_BUNDLE); + expect(stderr).toContain('Bundle Information:'); + expect(stderr).toContain('Statistics:'); + }, 15000); + + it('exits cleanly and logs an error for a nonexistent bundle', async () => { + const { stderr } = await run('bundle show @nonexistent/bundle-xyz-abc'); + + // handler catches and logs via logger.error, does not throw + expect(stderr).toContain('[Error]'); + }, 15000); }); diff --git a/packages/cli/tests/integration/skill.integration.test.ts b/packages/cli/tests/integration/skill.integration.test.ts new file mode 100644 index 0000000..85ed0b2 --- /dev/null +++ b/packages/cli/tests/integration/skill.integration.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { run } from './helpers.js'; + +/** + * Integration smoke tests for skill search and show commands against the live registry. + * + * Run with: pnpm test -- tests/integration + */ + +describe('skill search', () => { + it('returns a valid response shape for a broad query', async () => { + const { stdout, exitCode } = await run("skill search '' --json"); + + expect(exitCode).toBe(0); + const result = JSON.parse(stdout); + expect(Array.isArray(result.skills)).toBe(true); + expect(typeof result.total).toBe('number'); + expect(result.pagination).toBeDefined(); + }, 15000); + + it('handles a nonsense query gracefully', async () => { + const { stderr, exitCode } = await run('skill search xyznonexistent12345abc'); + + expect(exitCode).toBe(0); + expect(stderr).not.toContain('[Error]'); + }, 15000); +}); + +describe('skill show', () => { + it('outputs valid JSON for a skill found via search', async () => { + // Find a real skill name first so we don't hardcode registry state + const searchRun = await run("skill search '' --json --limit 1"); + expect(searchRun.exitCode).toBe(0); + const searchResult = JSON.parse(searchRun.stdout); + + if (searchResult.skills.length === 0) return; // nothing to show + + const skillName: string = searchResult.skills[0].name; + + const showRun = await run(`skill show ${skillName} --json`); + expect(showRun.exitCode).toBe(0); + const detail = JSON.parse(showRun.stdout); + expect(detail.name).toBe(skillName); + expect(detail.description).toBeTruthy(); + }, 15000); +}); diff --git a/packages/cli/tests/integration/update-flow.test.ts b/packages/cli/tests/integration/update-flow.test.ts index ab0cb5d..9336fe1 100644 --- a/packages/cli/tests/integration/update-flow.test.ts +++ b/packages/cli/tests/integration/update-flow.test.ts @@ -1,71 +1,62 @@ -import { describe, it, expect, afterEach } from "vitest"; -import { readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { getOutdatedBundles } from "../../src/commands/packages/outdated.js"; -import { handleUpdate } from "../../src/commands/packages/update.js"; -import { - getCacheDir, - getCacheMetadata, - downloadAndExtract, - resolveBundle, -} from "../../src/utils/cache.js"; -import { createClient } from "../../src/utils/client.js"; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { mpak } from '../../src/utils/config.js'; +import { run } from './helpers.js'; /** - * Integration test for the outdated → update flow. + * Integration test for the outdated → update command flow. * - * Uses the live registry with @nimblebraininc/echo as a fixture. - * Downgrades cached metadata to simulate an outdated bundle, then - * verifies that outdated detection and update work end-to-end. + * Setup uses mpak.bundleCache directly to seed and manipulate the local cache. + * Assertions run the actual CLI commands as subprocesses. * * Run with: pnpm test -- tests/integration */ -describe("Update Flow Integration", () => { - const testBundle = "@nimblebraininc/echo"; + +const TEST_BUNDLE = '@nimblebraininc/echo'; + +describe('outdated + update flow', () => { let originalMeta: string | null = null; let metaPath: string; afterEach(() => { - // Restore original metadata if we modified it if (originalMeta && metaPath) { writeFileSync(metaPath, originalMeta); } + originalMeta = null; }); - it("should detect outdated bundle and update it", async () => { - const client = createClient(); + it('detects an outdated bundle and updates it to latest', async () => { + // 1. Seed the cache via SDK (setup, not what we're testing) + await mpak.bundleCache.loadBundle(TEST_BUNDLE); - // 1. Ensure bundle is cached (pull latest if not already cached) - const cacheDir = getCacheDir(testBundle); - let meta = getCacheMetadata(cacheDir); - if (!meta) { - const downloadInfo = await resolveBundle(testBundle, client); - await downloadAndExtract(testBundle, downloadInfo); - meta = getCacheMetadata(cacheDir)!; - } + // 2. Save real metadata for restoration + const meta = mpak.bundleCache.getBundleMetadata(TEST_BUNDLE); + expect(meta).not.toBeNull(); + if (!meta) return; - // 2. Save original metadata for cleanup - metaPath = join(cacheDir, ".mpak-meta.json"); - originalMeta = readFileSync(metaPath, "utf8"); + const cacheDir = mpak.bundleCache.getBundleCacheDirName(TEST_BUNDLE); + metaPath = join(cacheDir, '.mpak-meta.json'); + originalMeta = readFileSync(metaPath, 'utf8'); const realVersion = meta.version; - // 3. Downgrade version in metadata - const downgraded = { ...meta, version: "0.0.1" }; - writeFileSync(metaPath, JSON.stringify(downgraded)); + // 3. Downgrade version to simulate a stale cache entry + writeFileSync(metaPath, JSON.stringify({ ...meta, version: '0.0.1' })); - // 4. Verify outdated detects it - const outdated = await getOutdatedBundles(); - const entry = outdated.find((e) => e.name === testBundle); + // 4. `mpak outdated --json` should detect the entry + const outdatedRun = await run('outdated --json'); + expect(outdatedRun.exitCode).toBe(0); + const outdated = JSON.parse(outdatedRun.stdout); + const entry = outdated.find((e: { name: string }) => e.name === TEST_BUNDLE); expect(entry).toBeDefined(); - expect(entry!.current).toBe("0.0.1"); - expect(entry!.latest).toBe(realVersion); - - // 5. Run update - await handleUpdate(testBundle); - - // 6. Verify no longer outdated - const afterUpdate = await getOutdatedBundles(); - const stillOutdated = afterUpdate.find((e) => e.name === testBundle); - expect(stillOutdated).toBeUndefined(); - }, 30000); + expect(entry.current).toBe('0.0.1'); + expect(entry.latest).toBe(realVersion); + + // 5. `mpak update @nimblebraininc/echo --json` should bring it current + const updateRun = await run(`update ${TEST_BUNDLE} --json`); + expect(updateRun.exitCode).toBe(0); + const updated = JSON.parse(updateRun.stdout); + expect(updated.name).toBe(TEST_BUNDLE); + expect(updated.version).toBe(realVersion); + }, 60000); }); diff --git a/packages/cli/tests/program.test.ts b/packages/cli/tests/program.test.ts new file mode 100644 index 0000000..1102f2c --- /dev/null +++ b/packages/cli/tests/program.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { createProgram } from '../src/program.js'; + +describe('createProgram', () => { + it('should create a program with correct name', () => { + const program = createProgram(); + expect(program.name()).toBe('mpak'); + }); + + it('should have a description', () => { + const program = createProgram(); + expect(program.description()).toBe('CLI for MCP bundles and Agent Skills'); + }); + + it('should have version option', () => { + const program = createProgram(); + const versionOption = program.options.find( + (opt) => opt.short === '-v' || opt.long === '--version', + ); + expect(versionOption).toBeDefined(); + }); +}); diff --git a/packages/cli/tests/search.test.ts b/packages/cli/tests/search.test.ts new file mode 100644 index 0000000..10ef73b --- /dev/null +++ b/packages/cli/tests/search.test.ts @@ -0,0 +1,191 @@ +import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleUnifiedSearch } from '../src/commands/search.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let mockSearchBundles: ReturnType; +let mockSearchSkills: ReturnType; + +vi.mock('../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + searchBundles: mockSearchBundles, + searchSkills: mockSearchSkills, + } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const makeBundle = (name: string) => ({ + name, + display_name: null, + description: `${name} description`, + author: { name: 'author' }, + latest_version: '1.0.0', + icon: null, + server_type: 'node', + tools: [], + downloads: 100, + published_at: '2025-01-01T00:00:00.000Z', + verified: false, + provenance: null, + certification_level: null, +}); + +const makeSkill = (name: string) => ({ + name, + description: `${name} description`, + latest_version: '1.0.0', + tags: [], + category: undefined, + downloads: 50, + published_at: '2025-01-01T00:00:00.000Z', + author: undefined, +}); + +const emptyBundleResponse: BundleSearchResponse = { + bundles: [], + total: 0, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const emptySkillResponse: SkillSearchResponse = { + skills: [], + total: 0, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const bundleResponse: BundleSearchResponse = { + bundles: [makeBundle('@scope/bundle-a'), makeBundle('@scope/bundle-b')], + total: 2, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const skillResponse: SkillSearchResponse = { + skills: [makeSkill('@scope/skill-a')], + total: 1, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleUnifiedSearch', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockSearchBundles = vi.fn().mockResolvedValue(emptyBundleResponse); + mockSearchSkills = vi.fn().mockResolvedValue(emptySkillResponse); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('searches both bundles and skills by default', async () => { + await handleUnifiedSearch('test'); + + expect(mockSearchBundles).toHaveBeenCalledWith(expect.objectContaining({ q: 'test' })); + expect(mockSearchSkills).toHaveBeenCalledWith(expect.objectContaining({ q: 'test' })); + }); + + it('prints no-results message when both return empty', async () => { + await handleUnifiedSearch('nothing'); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('No results found for "nothing"'), + ); + }); + + it('searches only bundles when type=bundle', async () => { + mockSearchBundles.mockResolvedValue(bundleResponse); + + await handleUnifiedSearch('test', { type: 'bundle' }); + + expect(mockSearchBundles).toHaveBeenCalled(); + expect(mockSearchSkills).not.toHaveBeenCalled(); + }); + + it('searches only skills when type=skill', async () => { + mockSearchSkills.mockResolvedValue(skillResponse); + + await handleUnifiedSearch('test', { type: 'skill' }); + + expect(mockSearchSkills).toHaveBeenCalled(); + expect(mockSearchBundles).not.toHaveBeenCalled(); + }); + + it('prints bundle and skill sections when both return results', async () => { + mockSearchBundles.mockResolvedValue(bundleResponse); + mockSearchSkills.mockResolvedValue(skillResponse); + + await handleUnifiedSearch('test'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Bundles'); + expect(allOutput).toContain('@scope/bundle-a'); + expect(allOutput).toContain('Skills'); + expect(allOutput).toContain('@scope/skill-a'); + }); + + it('logs error when skill search throws', async () => { + mockSearchBundles.mockResolvedValue(bundleResponse); + mockSearchSkills.mockRejectedValue(new Error('Skills API not deployed')); + + await handleUnifiedSearch('test'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skills API not deployed')); + }); + + it('outputs JSON when --json is set', async () => { + mockSearchBundles.mockResolvedValue(bundleResponse); + mockSearchSkills.mockResolvedValue(skillResponse); + + await handleUnifiedSearch('test', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.results).toHaveLength(3); + expect(parsed.totals).toEqual({ bundles: 2, skills: 1 }); + }); + + it('passes sort, limit, offset params to both APIs', async () => { + await handleUnifiedSearch('test', { sort: 'downloads', limit: 5, offset: 10 }); + + expect(mockSearchBundles).toHaveBeenCalledWith( + expect.objectContaining({ q: 'test', sort: 'downloads', limit: 5, offset: 10 }), + ); + expect(mockSearchSkills).toHaveBeenCalledWith( + expect.objectContaining({ q: 'test', sort: 'downloads', limit: 5, offset: 10 }), + ); + }); + + it('logs error when bundle search throws', async () => { + mockSearchBundles.mockRejectedValue(new Error('Registry unavailable')); + + await handleUnifiedSearch('test'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Registry unavailable')); + }); +}); diff --git a/packages/cli/tests/skills/install.test.ts b/packages/cli/tests/skills/install.test.ts new file mode 100644 index 0000000..4a573ed --- /dev/null +++ b/packages/cli/tests/skills/install.test.ts @@ -0,0 +1,160 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSkillInstall } from '../../src/commands/skills/install.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + rmSync: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +let mockDownloadSkillBundle: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + downloadSkillBundle: mockDownloadSkillBundle, + } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const skillData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +const metadata = { + name: '@scope/test-skill', + version: '1.2.0', + sha256: 'abcdef1234567890abcdef1234567890', + size: 512_000, +}; + +const skillsDir = join(homedir(), '.claude', 'skills'); +const installPath = join(skillsDir, 'test-skill'); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleSkillInstall', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let mockExit: ReturnType; + + beforeEach(() => { + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(writeFileSync).mockClear(); + vi.mocked(mkdirSync).mockClear(); + vi.mocked(rmSync).mockClear(); + vi.mocked(execFileSync).mockClear(); + mockDownloadSkillBundle = vi.fn().mockResolvedValue({ data: skillData, metadata }); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls downloadSkillBundle with parsed name and version', async () => { + await handleSkillInstall('@scope/test-skill@1.2.0'); + + expect(mockDownloadSkillBundle).toHaveBeenCalledWith('@scope/test-skill', '1.2.0'); + }); + + it('creates skills directory and extracts with unzip', async () => { + await handleSkillInstall('@scope/test-skill'); + + expect(mkdirSync).toHaveBeenCalledWith(skillsDir, { + recursive: true, + }); + expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining('skill-'), skillData); + expect(execFileSync).toHaveBeenCalledWith( + 'unzip', + ['-o', expect.stringContaining('skill-'), '-d', skillsDir], + { stdio: 'pipe' }, + ); + }); + + it('cleans up temp file after extraction', async () => { + await handleSkillInstall('@scope/test-skill'); + + expect(rmSync).toHaveBeenCalledWith(expect.stringContaining('skill-'), { force: true }); + }); + + it('exits with error if already installed without --force', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await handleSkillInstall('@scope/test-skill'); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('already installed')); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('--force')); + }); + + it('overwrites existing installation with --force', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await handleSkillInstall('@scope/test-skill', { force: true }); + + expect(rmSync).toHaveBeenCalledWith(installPath, { + recursive: true, + }); + expect(execFileSync).toHaveBeenCalled(); + }); + + it('prints JSON output when --json is set', async () => { + await handleSkillInstall('@scope/test-skill', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.installed).toBe(true); + expect(parsed.name).toBe('@scope/test-skill'); + expect(parsed.shortName).toBe('test-skill'); + expect(parsed.version).toBe('1.2.0'); + }); + + it('prints success output in normal mode', async () => { + await handleSkillInstall('@scope/test-skill'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('test-skill@1.2.0'); + expect(allOutput).toContain('Restart to activate'); + }); + + it('logs error when downloadSkillBundle throws', async () => { + mockDownloadSkillBundle.mockRejectedValue(new Error('Skill not found')); + + await handleSkillInstall('@scope/nonexistent'); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skill not found')); + }); +}); diff --git a/packages/cli/tests/skills/pack.test.ts b/packages/cli/tests/skills/pack.test.ts new file mode 100644 index 0000000..3cae841 --- /dev/null +++ b/packages/cli/tests/skills/pack.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { tmpdir } from 'os'; +import { packSkill } from '../../src/commands/skills/pack.js'; +import { execSync } from 'child_process'; + +describe('packSkill', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `skill-pack-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('validation before packing', () => { + it('fails for invalid skill', async () => { + const skillDir = join(testDir, 'invalid-skill'); + mkdirSync(skillDir); + // No SKILL.md + + const result = await packSkill(skillDir); + expect(result.success).toBe(false); + expect(result.error).toContain('Validation failed'); + }); + + it('fails when name format is invalid', async () => { + const skillDir = join(testDir, 'BadName'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: BadName +description: Invalid name +--- +# Bad`, + ); + + const result = await packSkill(skillDir); + expect(result.success).toBe(false); + expect(result.error).toContain('Validation failed'); + }); + }); + + describe('successful packing', () => { + it('creates a .skill bundle', async () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: test-skill +description: A test skill for packing +metadata: + version: "1.0.0" +--- +# Test Skill + +Instructions here.`, + ); + + const outputPath = join(testDir, 'test-skill-1.0.0.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(result.name).toBe('test-skill'); + expect(result.version).toBe('1.0.0'); + expect(result.path).toBe(outputPath); + expect(result.path!.endsWith('.skill')).toBe(true); + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(result.size).toBeGreaterThan(0); + + // Verify bundle exists + expect(existsSync(result.path!)).toBe(true); + }); + + it('uses 0.0.0 version when metadata version is missing', async () => { + const skillDir = join(testDir, 'no-version'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: no-version +description: A skill without version +--- +# No Version`, + ); + + const outputPath = join(testDir, 'no-version-0.0.0.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(result.version).toBe('0.0.0'); + expect(basename(result.path!)).toBe('no-version-0.0.0.skill'); + }); + + it('creates bundle in current directory by default', async () => { + const skillDir = join(testDir, 'output-test'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: output-test +description: Testing output path +metadata: + version: "1.0.0" +--- +# Output Test`, + ); + + // Change to temp directory to test default output location + const originalCwd = process.cwd(); + process.chdir(testDir); + + try { + const result = await packSkill(skillDir); + + expect(result.success).toBe(true); + expect(result.path).toContain(testDir); + } finally { + process.chdir(originalCwd); + } + }); + + it('uses custom output path when provided', async () => { + const skillDir = join(testDir, 'custom-output'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: custom-output +description: Custom output path test +metadata: + version: "2.0.0" +--- +# Custom Output`, + ); + + const outputPath = join(testDir, 'custom-name.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(result.path).toBe(outputPath); + expect(existsSync(outputPath)).toBe(true); + }); + + it('includes skill directory structure in bundle', async () => { + const skillDir = join(testDir, 'structured-skill'); + mkdirSync(skillDir); + mkdirSync(join(skillDir, 'scripts')); + mkdirSync(join(skillDir, 'references')); + + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: structured-skill +description: A skill with structure +metadata: + version: "1.0.0" +--- +# Structured Skill`, + ); + writeFileSync(join(skillDir, 'scripts', 'helper.py'), '# Python helper'); + writeFileSync(join(skillDir, 'references', 'PATTERNS.md'), '# Patterns'); + + const outputPath = join(testDir, 'structured-skill-1.0.0.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + + // Verify bundle contents using unzip -l + try { + const listing = execSync(`unzip -l "${result.path}"`, { encoding: 'utf-8' }); + expect(listing).toContain('structured-skill/SKILL.md'); + expect(listing).toContain('structured-skill/scripts/helper.py'); + expect(listing).toContain('structured-skill/references/PATTERNS.md'); + } catch { + // unzip may not be available, skip this check + } + }); + + it('calculates correct SHA256', async () => { + const skillDir = join(testDir, 'hash-test'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: hash-test +description: Testing SHA256 calculation +metadata: + version: "1.0.0" +--- +# Hash Test`, + ); + + const outputPath = join(testDir, 'hash-test-1.0.0.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(result.sha256).toHaveLength(64); + + // Verify hash using shasum if available + try { + const shasum = execSync(`shasum -a 256 "${result.path}"`, { encoding: 'utf-8' }); + const computedHash = shasum.split(' ')[0]; + expect(result.sha256).toBe(computedHash); + } catch { + // shasum may not be available, skip this check + } + }); + }); + + describe('bundle naming', () => { + it('creates bundle with name-version.skill format', async () => { + const skillDir = join(testDir, 'naming-test'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: naming-test +description: Testing bundle naming +metadata: + version: "3.2.1" +--- +# Naming Test`, + ); + + const outputPath = join(testDir, 'naming-test-3.2.1.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(basename(result.path!)).toBe('naming-test-3.2.1.skill'); + }); + + it('handles prerelease versions', async () => { + const skillDir = join(testDir, 'prerelease-test'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: prerelease-test +description: Testing prerelease version +metadata: + version: "1.0.0-beta.1" +--- +# Prerelease Test`, + ); + + const outputPath = join(testDir, 'prerelease-test-1.0.0-beta.1.skill'); + const result = await packSkill(skillDir, outputPath); + + expect(result.success).toBe(true); + expect(basename(result.path!)).toBe('prerelease-test-1.0.0-beta.1.skill'); + }); + }); +}); diff --git a/packages/cli/tests/skills/pull.test.ts b/packages/cli/tests/skills/pull.test.ts new file mode 100644 index 0000000..1f74639 --- /dev/null +++ b/packages/cli/tests/skills/pull.test.ts @@ -0,0 +1,129 @@ +import { rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSkillPull } from '../../src/commands/skills/pull.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs', () => ({ writeFileSync: vi.fn(), rmSync: vi.fn() })); + +let mockDownloadSkillBundle: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + downloadSkillBundle: mockDownloadSkillBundle, + } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const skillData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + +const metadata = { + name: '@scope/test-skill', + version: '1.2.0', + sha256: 'abcdef1234567890abcdef1234567890', + size: 512_000, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleSkillPull', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.mocked(writeFileSync).mockClear(); + vi.mocked(rmSync).mockClear(); + mockDownloadSkillBundle = vi.fn().mockResolvedValue({ data: skillData, metadata }); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls downloadSkillBundle with parsed name and version', async () => { + await handleSkillPull('@scope/test-skill@1.2.0'); + + expect(mockDownloadSkillBundle).toHaveBeenCalledWith('@scope/test-skill', '1.2.0'); + }); + + it('passes undefined version when none specified', async () => { + await handleSkillPull('@scope/test-skill'); + + expect(mockDownloadSkillBundle).toHaveBeenCalledWith('@scope/test-skill', undefined); + }); + + it('writes downloaded data to default filename in cwd', async () => { + await handleSkillPull('@scope/test-skill'); + + expect(writeFileSync).toHaveBeenCalledWith(resolve('scope-test-skill-1.2.0.skill'), skillData); + }); + + it('writes to --output path when specified', async () => { + await handleSkillPull('@scope/test-skill', { + output: '/tmp/my-skill.skill', + }); + + expect(writeFileSync).toHaveBeenCalledWith('/tmp/my-skill.skill', skillData); + }); + + it('prints metadata in normal output', async () => { + await handleSkillPull('@scope/test-skill'); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Version: 1.2.0'); + expect(allOutput).toContain('downloaded successfully'); + expect(allOutput).toContain('SHA256: abcdef1234567890...'); + }); + + it('prints JSON and skips file write when --json is set', async () => { + await handleSkillPull('@scope/test-skill', { json: true }); + + expect(writeFileSync).not.toHaveBeenCalled(); + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.version).toBe('1.2.0'); + }); + + it('cleans up partial file on error after write started', async () => { + vi.mocked(writeFileSync).mockImplementation(() => { + throw new Error('Disk full'); + }); + + await handleSkillPull('@scope/test-skill'); + + expect(rmSync).toHaveBeenCalledWith(resolve('scope-test-skill-1.2.0.skill'), { force: true }); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Disk full')); + }); + + it('logs error when downloadSkillBundle throws', async () => { + mockDownloadSkillBundle.mockRejectedValue(new Error('Skill not found')); + + await handleSkillPull('@scope/nonexistent'); + + expect(rmSync).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skill not found')); + }); +}); diff --git a/packages/cli/tests/skills/search.test.ts b/packages/cli/tests/skills/search.test.ts new file mode 100644 index 0000000..08df39a --- /dev/null +++ b/packages/cli/tests/skills/search.test.ts @@ -0,0 +1,145 @@ +import type { SkillSearchResponse } from '@nimblebrain/mpak-schemas'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSkillSearch } from '../../src/commands/skills/search.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let mockSearchSkills: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { searchSkills: mockSearchSkills } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +import type { SkillCategory } from '@nimblebrain/mpak-schemas'; + +const makeSkill = (name: string, version: string, category?: SkillCategory) => ({ + name, + description: `${name} description`, + latest_version: version, + tags: ['test'], + category, + downloads: 10, + published_at: '2025-01-01T00:00:00.000Z', +}); + +const emptyResponse: SkillSearchResponse = { + skills: [], + total: 0, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const twoResultsResponse: SkillSearchResponse = { + skills: [ + makeSkill('@scope/skill-alpha', '1.0.0', 'development'), + makeSkill('@scope/skill-beta', '2.1.0', 'data'), + ], + total: 2, + pagination: { limit: 20, offset: 0, has_more: false }, +}; + +const paginatedResponse: SkillSearchResponse = { + skills: [makeSkill('@scope/skill-alpha', '1.0.0')], + total: 25, + pagination: { limit: 20, offset: 0, has_more: true }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleSkillSearch', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockSearchSkills = vi.fn(); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes query and search params to searchSkills', async () => { + mockSearchSkills.mockResolvedValue(emptyResponse); + + await handleSkillSearch('test', { tags: 'mcp', sort: 'downloads' }); + + expect(mockSearchSkills).toHaveBeenCalledWith({ + q: 'test', + tags: 'mcp', + sort: 'downloads', + }); + }); + + it('prints no-results message when search returns 0 skills', async () => { + mockSearchSkills.mockResolvedValue(emptyResponse); + + await handleSkillSearch('nonexistent', {}); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('No skills found for "nonexistent"'), + ); + }); + + it('prints table output when results exist', async () => { + mockSearchSkills.mockResolvedValue(twoResultsResponse); + + await handleSkillSearch('test', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('@scope/skill-alpha'); + expect(allOutput).toContain('@scope/skill-beta'); + expect(allOutput).toContain('NAME'); + expect(allOutput).toContain('CATEGORY'); + }); + + it('prints JSON output when json option is set', async () => { + mockSearchSkills.mockResolvedValue(twoResultsResponse); + + await handleSkillSearch('test', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.skills).toHaveLength(2); + expect(parsed.total).toBe(2); + }); + + it('shows pagination hint when has_more is true', async () => { + mockSearchSkills.mockResolvedValue(paginatedResponse); + + await handleSkillSearch('test', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('1 of 25'); + expect(allOutput).toContain('--offset'); + }); + + it('logs error when searchSkills throws', async () => { + mockSearchSkills.mockRejectedValue(new Error('Network error')); + + await handleSkillSearch('anything', {}); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Network error')); + }); +}); diff --git a/packages/cli/tests/skills/show.test.ts b/packages/cli/tests/skills/show.test.ts new file mode 100644 index 0000000..18c89f3 --- /dev/null +++ b/packages/cli/tests/skills/show.test.ts @@ -0,0 +1,187 @@ +import type { SkillDetail } from '@nimblebrain/mpak-schemas'; +import type { MpakClient } from '@nimblebrain/mpak-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleSkillShow } from '../../src/commands/skills/show.js'; + +// --------------------------------------------------------------------------- +// Mock the mpak singleton +// --------------------------------------------------------------------------- + +let mockGetSkill: ReturnType; + +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { getSkill: mockGetSkill } as unknown as MpakClient, + }; + }, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const baseSkill: SkillDetail = { + name: '@scope/test-skill', + description: 'A test skill for unit tests', + latest_version: '1.2.0', + license: 'MIT', + category: 'development', + tags: ['mcp', 'testing'], + triggers: ['test trigger', 'run tests'], + downloads: 1_500, + published_at: '2025-06-15T00:00:00.000Z', + author: { name: 'test-author', url: 'https://example.com' }, + examples: [ + { prompt: 'Run my tests', context: 'in a project directory' }, + { prompt: 'Check test coverage' }, + ], + versions: [ + { version: '1.2.0', published_at: '2025-06-15T00:00:00.000Z', downloads: 800 }, + { version: '1.1.0', published_at: '2025-05-01T00:00:00.000Z', downloads: 500 }, + { version: '1.0.0', published_at: '2025-04-01T00:00:00.000Z', downloads: 200 }, + ], +}; + +const minimalSkill: SkillDetail = { + name: '@scope/minimal-skill', + description: 'Minimal skill', + latest_version: '0.1.0', + downloads: 0, + published_at: '2025-01-01T00:00:00.000Z', + versions: [], +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('handleSkillShow', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockGetSkill = vi.fn(); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls getSkill with the skill name', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + expect(mockGetSkill).toHaveBeenCalledWith('@scope/test-skill'); + }); + + it('prints name, version, and description', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('@scope/test-skill@1.2.0'); + expect(allOutput).toContain('A test skill for unit tests'); + }); + + it('prints metadata fields', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('License: MIT'); + expect(allOutput).toContain('Category: development'); + expect(allOutput).toContain('Tags: mcp, testing'); + expect(allOutput).toContain('Author: test-author (https://example.com)'); + expect(allOutput).toContain('1,500'); + }); + + it('prints triggers', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Triggers:'); + expect(allOutput).toContain('test trigger'); + expect(allOutput).toContain('run tests'); + }); + + it('prints examples with context', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Examples:'); + expect(allOutput).toContain('"Run my tests" (in a project directory)'); + expect(allOutput).toContain('"Check test coverage"'); + }); + + it('prints version history', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Versions:'); + expect(allOutput).toContain('1.2.0'); + expect(allOutput).toContain('1.1.0'); + expect(allOutput).toContain('1.0.0'); + }); + + it('prints install hint', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('Install: mpak skill install @scope/test-skill'); + }); + + it('handles minimal skill without optional fields', async () => { + mockGetSkill.mockResolvedValue(minimalSkill); + + await handleSkillShow('@scope/minimal-skill', {}); + + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(allOutput).toContain('@scope/minimal-skill@0.1.0'); + expect(allOutput).not.toContain('License:'); + expect(allOutput).not.toContain('Category:'); + expect(allOutput).not.toContain('Triggers:'); + expect(allOutput).not.toContain('Examples:'); + expect(allOutput).not.toContain('Versions:'); + }); + + it('prints JSON output when json option is set', async () => { + mockGetSkill.mockResolvedValue(baseSkill); + + await handleSkillShow('@scope/test-skill', { json: true }); + + const jsonCall = stdoutSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(c[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse((jsonCall as unknown[])[0] as string); + expect(parsed.name).toBe('@scope/test-skill'); + expect(parsed.latest_version).toBe('1.2.0'); + }); + + it('logs error when getSkill throws', async () => { + mockGetSkill.mockRejectedValue(new Error('Skill not found')); + + await handleSkillShow('@scope/nonexistent', {}); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skill not found')); + }); +}); diff --git a/packages/cli/tests/skills/validate.test.ts b/packages/cli/tests/skills/validate.test.ts new file mode 100644 index 0000000..2efc598 --- /dev/null +++ b/packages/cli/tests/skills/validate.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { + validateSkillDirectory, + formatValidationResult, +} from '../../src/commands/skills/validate.js'; + +describe('validateSkillDirectory', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `skill-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('directory checks', () => { + it('fails for non-existent directory', () => { + const result = validateSkillDirectory('/non/existent/path'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Directory not found: /non/existent/path'); + }); + + it('fails for file instead of directory', () => { + const filePath = join(testDir, 'not-a-dir'); + writeFileSync(filePath, 'content'); + + const result = validateSkillDirectory(filePath); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/Path is not a directory/); + }); + }); + + describe('SKILL.md checks', () => { + it('fails when SKILL.md is missing', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors).toContain('SKILL.md not found'); + }); + + it('fails when frontmatter is missing', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync(join(skillDir, 'SKILL.md'), '# Just content\nNo frontmatter here'); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors).toContain('No frontmatter found in SKILL.md'); + }); + + it('fails when frontmatter is empty', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync(join(skillDir, 'SKILL.md'), '---\n---\n# Content'); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors).toContain('No frontmatter found in SKILL.md'); + }); + }); + + describe('frontmatter validation', () => { + it('fails when name is missing', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +description: A test skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('name'))).toBe(true); + }); + + it('fails when description is missing', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: test-skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('description'))).toBe(true); + }); + + it('fails when name format is invalid', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: Test_Skill +description: A test skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.toLowerCase().includes('lowercase'))).toBe(true); + }); + + it('fails when name has uppercase', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: TestSkill +description: A test skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + }); + + it('fails when name starts with hyphen', () => { + const skillDir = join(testDir, '-test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: -test-skill +description: A test skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + }); + + it('fails when name does not match directory', () => { + const skillDir = join(testDir, 'dir-name'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: different-name +description: A test skill +--- +# Test`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('does not match directory'))).toBe(true); + }); + }); + + describe('valid skills', () => { + it('validates minimal skill', () => { + const skillDir = join(testDir, 'test-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: test-skill +description: A test skill for validation +--- +# Test Skill + +Instructions here.`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.name).toBe('test-skill'); + expect(result.frontmatter?.description).toBe('A test skill for validation'); + }); + + it('validates skill with optional fields', () => { + const skillDir = join(testDir, 'full-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: full-skill +description: A fully featured test skill +license: MIT +compatibility: Works with Claude Code +allowed-tools: Read Grep Bash +--- +# Full Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.frontmatter?.license).toBe('MIT'); + expect(result.frontmatter?.compatibility).toBe('Works with Claude Code'); + expect(result.frontmatter?.['allowed-tools']).toBe('Read Grep Bash'); + }); + + it('validates skill with metadata', () => { + const skillDir = join(testDir, 'meta-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: meta-skill +description: A skill with metadata +metadata: + version: "1.0.0" + category: development + tags: + - testing + - validation + author: + name: Test Author +--- +# Meta Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.frontmatter?.metadata?.version).toBe('1.0.0'); + expect(result.frontmatter?.metadata?.category).toBe('development'); + expect(result.frontmatter?.metadata?.tags).toEqual(['testing', 'validation']); + expect(result.frontmatter?.metadata?.author?.name).toBe('Test Author'); + }); + }); + + describe('warnings', () => { + it('warns when metadata is missing', () => { + const skillDir = join(testDir, 'basic-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: basic-skill +description: A basic skill without metadata +--- +# Basic Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('No metadata field'))).toBe(true); + }); + + it('warns when version is missing from metadata', () => { + const skillDir = join(testDir, 'no-version-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: no-version-skill +description: A skill without version +metadata: + category: development +--- +# No Version Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('No version'))).toBe(true); + }); + + it('warns when tags are missing', () => { + const skillDir = join(testDir, 'no-tags-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: no-tags-skill +description: A skill without tags +metadata: + version: "1.0.0" +--- +# No Tags Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('No tags'))).toBe(true); + }); + + it('warns about invalid optional directory', () => { + const skillDir = join(testDir, 'file-as-dir-skill'); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: file-as-dir-skill +description: A skill with scripts as file +--- +# Skill`, + ); + writeFileSync(join(skillDir, 'scripts'), 'not a directory'); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('scripts'))).toBe(true); + }); + }); + + describe('optional directories', () => { + it('accepts valid optional directories', () => { + const skillDir = join(testDir, 'dirs-skill'); + mkdirSync(skillDir); + mkdirSync(join(skillDir, 'scripts')); + mkdirSync(join(skillDir, 'references')); + mkdirSync(join(skillDir, 'assets')); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: dirs-skill +description: A skill with all optional dirs +--- +# Skill`, + ); + + const result = validateSkillDirectory(skillDir); + expect(result.valid).toBe(true); + expect(result.warnings.filter((w) => w.includes('not a directory'))).toHaveLength(0); + }); + }); +}); + +describe('formatValidationResult', () => { + it('formats valid result correctly', () => { + const result = { + valid: true, + name: 'test-skill', + path: '/path/to/skill', + frontmatter: { + name: 'test-skill', + description: 'A test skill description', + }, + errors: [], + warnings: [], + }; + + const output = formatValidationResult(result); + expect(output).toContain('\u2713 Valid: test-skill'); + expect(output).toContain('\u2713 SKILL.md found'); + expect(output).toContain('name: test-skill'); + expect(output).toContain('description:'); + }); + + it('formats invalid result correctly', () => { + const result = { + valid: false, + name: null, + path: '/path/to/skill', + frontmatter: null, + errors: ['SKILL.md not found'], + warnings: [], + }; + + const output = formatValidationResult(result); + expect(output).toContain('\u2717 Invalid: /path/to/skill'); + expect(output).toContain('Errors:'); + expect(output).toContain('SKILL.md not found'); + }); + + it('formats warnings correctly', () => { + const result = { + valid: true, + name: 'test-skill', + path: '/path/to/skill', + frontmatter: { + name: 'test-skill', + description: 'A test skill', + }, + errors: [], + warnings: ['No metadata field'], + }; + + const output = formatValidationResult(result); + expect(output).toContain('Warnings:'); + expect(output).toContain('No metadata field'); + }); + + it('formats optional fields', () => { + const result = { + valid: true, + name: 'test-skill', + path: '/path/to/skill', + frontmatter: { + name: 'test-skill', + description: 'A test skill', + license: 'MIT', + compatibility: 'Claude Code', + 'allowed-tools': 'Read Grep', + }, + errors: [], + warnings: [], + }; + + const output = formatValidationResult(result); + expect(output).toContain('license: MIT'); + expect(output).toContain('compatibility: Claude Code'); + expect(output).toContain('allowed-tools: Read Grep'); + }); + + it('formats metadata correctly', () => { + const result = { + valid: true, + name: 'test-skill', + path: '/path/to/skill', + frontmatter: { + name: 'test-skill', + description: 'A test skill', + metadata: { + version: '1.0.0', + category: 'development' as const, + tags: ['test', 'validation'], + triggers: ['test trigger'], + author: { name: 'Test Author' }, + }, + }, + errors: [], + warnings: [], + }; + + const output = formatValidationResult(result); + expect(output).toContain('Discovery metadata'); + expect(output).toContain('version: 1.0.0'); + expect(output).toContain('category: development'); + expect(output).toContain('tags: [test, validation]'); + expect(output).toContain('triggers: 1 defined'); + expect(output).toContain('author: Test Author'); + }); + + it('truncates long descriptions', () => { + const longDescription = 'A'.repeat(100); + const result = { + valid: true, + name: 'test-skill', + path: '/path/to/skill', + frontmatter: { + name: 'test-skill', + description: longDescription, + }, + errors: [], + warnings: [], + }; + + const output = formatValidationResult(result); + expect(output).toContain('...'); + expect(output).toContain('(100 chars)'); + }); +}); diff --git a/packages/cli/tests/version.test.ts b/packages/cli/tests/version.test.ts new file mode 100644 index 0000000..ece5822 --- /dev/null +++ b/packages/cli/tests/version.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { getVersion } from '../src/utils/version.js'; + +describe('getVersion', () => { + it('should return a valid version string', () => { + const version = getVersion(); + expect(version).toBeTruthy(); + expect(typeof version).toBe('string'); + }); + + it('should match semver format or be "unknown"', () => { + const version = getVersion(); + const semverRegex = /^\d+\.\d+\.\d+/; + expect(version === 'unknown' || semverRegex.test(version)).toBe(true); + }); +}); diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index 0b19efc..2b2d782 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; +import { z } from 'zod'; -import { ServerTypeSchema } from "./manifest.js"; +import { ServerTypeSchema } from './manifest.js'; // Re-export manifest schemas so existing consumers of package.ts are not broken. export { @@ -17,17 +17,17 @@ export { type McpConfig, type ServerType, type UserConfigField, -} from "./manifest.js"; +} from './manifest.js'; // ============================================================================= // Enums & Search Params // ============================================================================= /** Supported operating system platforms */ -export const PlatformSchema = z.enum(["darwin", "win32", "linux"]); +export const PlatformSchema = z.enum(['darwin', 'win32', 'linux']); /** Sort options for package listings */ -export const PackageSortSchema = z.enum(["downloads", "recent", "name"]); +export const PackageSortSchema = z.enum(['downloads', 'recent', 'name']); /** * Package search query parameters. @@ -48,15 +48,15 @@ export const PackageSearchParamsSchema = z.object({ export const BundleSearchParamsSchema = z.object({ q: z.string().max(200).optional(), type: ServerTypeSchema.optional(), - sort: PackageSortSchema.optional().default("downloads"), + sort: PackageSortSchema.optional().default('downloads'), limit: z.number().min(1).max(100).optional().default(20), offset: z.number().min(0).optional().default(0), }); /** Bundle download query parameters (os + arch). */ export const BundleDownloadParamsSchema = z.object({ - os: z.enum(["darwin", "linux", "win32"]).describe("Target OS (darwin, linux, win32)").optional(), - arch: z.enum(["x64", "arm64"]).describe("Target arch (x64, arm64)").optional(), + os: z.enum(['darwin', 'linux', 'win32']).describe('Target OS (darwin, linux, win32)').optional(), + arch: z.enum(['x64', 'arm64']).describe('Target arch (x64, arm64)').optional(), }); // ============================================================================= @@ -67,4 +67,6 @@ export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; export type BundleSearchParams = z.infer; +/** Input type — all fields optional (before defaults are applied). */ +export type BundleSearchParamsInput = z.input; export type BundleDownloadParams = z.infer; diff --git a/packages/schemas/src/skill.ts b/packages/schemas/src/skill.ts index a0a3f0f..d050658 100644 --- a/packages/schemas/src/skill.ts +++ b/packages/schemas/src/skill.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; // ============================================================================= // Agent Skills Specification - Skill Frontmatter Schema @@ -16,7 +16,7 @@ export const SkillNameSchema = z .max(64) .regex( /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, - "Lowercase alphanumeric with single hyphens, cannot start/end with hyphen", + 'Lowercase alphanumeric with single hyphens, cannot start/end with hyphen', ); /** Skill description (1-1024 characters) */ @@ -28,15 +28,15 @@ export const SkillDescriptionSchema = z.string().min(1).max(1024); /** Category taxonomy for skill discovery */ export const SkillCategorySchema = z.enum([ - "development", - "writing", - "research", - "consulting", - "data", - "design", - "operations", - "security", - "other", + 'development', + 'writing', + 'research', + 'consulting', + 'data', + 'design', + 'operations', + 'security', + 'other', ]); /** Author information for attribution */ @@ -82,7 +82,7 @@ export const SkillFrontmatterSchema = z.object({ version: z.string().optional(), license: z.string().optional(), compatibility: z.string().max(500).optional(), - "allowed-tools": z.string().optional(), + 'allowed-tools': z.string().optional(), metadata: SkillDiscoveryMetadataSchema.optional(), }); @@ -93,14 +93,11 @@ export const SkillFrontmatterSchema = z.object({ /** Scoped skill name for registry (e.g., @nimblebraininc/strategic-thought-partner) */ export const ScopedSkillNameSchema = z .string() - .regex( - /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, - "Scoped name format: @scope/name", - ); + .regex(/^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, 'Scoped name format: @scope/name'); /** Skill artifact info for announce endpoint */ export const SkillArtifactSchema = z.object({ - filename: z.string().regex(/\.skill$/, "Must have .skill extension"), + filename: z.string().regex(/\.skill$/, 'Must have .skill extension'), sha256: z.string().length(64), size: z.number().int().positive(), }); @@ -119,7 +116,7 @@ export const SkillAnnounceRequestSchema = z.object({ export const SkillAnnounceResponseSchema = z.object({ skill: z.string(), version: z.string(), - status: z.enum(["created", "exists"]), + status: z.enum(['created', 'exists']), }); // ============================================================================= @@ -131,7 +128,7 @@ export const SkillSearchParamsSchema = z.object({ q: z.string().optional(), tags: z.string().optional(), category: SkillCategorySchema.optional(), - sort: z.enum(["downloads", "recent", "name"]).optional(), + sort: z.enum(['downloads', 'recent', 'name']).optional(), limit: z.union([z.string(), z.number()]).optional(), offset: z.union([z.string(), z.number()]).optional(), }); @@ -203,15 +200,15 @@ export type SkillName = z.infer; export type SkillCategory = z.infer; export type SkillAuthor = z.infer; export type SkillExample = z.infer; -export type SkillDiscoveryMetadata = z.infer< - typeof SkillDiscoveryMetadataSchema ->; +export type SkillDiscoveryMetadata = z.infer; export type SkillFrontmatter = z.infer; export type ScopedSkillName = z.infer; export type SkillArtifact = z.infer; export type SkillAnnounceRequest = z.infer; export type SkillAnnounceResponse = z.infer; export type SkillSearchParams = z.infer; +/** Input type — all fields optional (before defaults are applied). */ +export type SkillSearchParamsInput = z.input; export type SkillSummary = z.infer; export type SkillSearchResponse = z.infer; export type SkillDetail = z.infer; diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index 1e4efa5..432e771 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -230,12 +230,12 @@ export class MpakBundleCache { * The caller can just check `if (result) { console.log("update available: " + result) }` * @param packageName - Scoped package name (e.g. `@scope/bundle`) */ - async checkForUpdate(packageName: string): Promise { + async checkForUpdate(packageName: string, options?: { force?: boolean }): Promise { const cachedMeta = this.getBundleMetadata(packageName); if (!cachedMeta) return null; - // Skip if checked within the TTL - if (cachedMeta.lastCheckedAt) { + // Skip if checked within the TTL (unless force is set) + if (!options?.force && cachedMeta.lastCheckedAt) { const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime(); if (elapsed < UPDATE_CHECK_TTL_MS) return null; } diff --git a/packages/sdk-typescript/src/errors.ts b/packages/sdk-typescript/src/errors.ts index e3af241..dbe774a 100644 --- a/packages/sdk-typescript/src/errors.ts +++ b/packages/sdk-typescript/src/errors.ts @@ -91,10 +91,34 @@ export class MpakCacheCorruptedError extends MpakError { } } +/** + * Thrown when a local `.mcpb` bundle is invalid — e.g. manifest is missing, + * contains invalid JSON, or fails schema validation. + * + * @param message - Human-readable description of what went wrong + * @param bundlePath - Absolute path to the `.mcpb` file + * @param cause - The underlying error + */ +export class MpakInvalidBundleError extends MpakError { + constructor( + message: string, + public readonly bundlePath: string, + public override readonly cause?: Error, + ) { + super(message, 'INVALID_BUNDLE'); + this.name = 'MpakInvalidBundleError'; + } +} + export class MpakConfigError extends MpakError { constructor( public readonly packageName: string, - public readonly missingFields: Array<{ key: string; title: string; sensitive: boolean }>, + public readonly missingFields: Array<{ + key: string; + title: string; + description?: string; + sensitive: boolean; + }>, ) { const fieldNames = missingFields.map((f) => f.title).join(', '); super(`Missing required config for ${packageName}: ${fieldNames}`, 'CONFIG_MISSING'); diff --git a/packages/sdk-typescript/src/helpers.ts b/packages/sdk-typescript/src/helpers.ts index d158d12..4198227 100644 --- a/packages/sdk-typescript/src/helpers.ts +++ b/packages/sdk-typescript/src/helpers.ts @@ -1,7 +1,9 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve, join } from 'node:path'; import type { z } from 'zod'; -import { MpakCacheCorruptedError } from './errors.js'; +import { MpakCacheCorruptedError, MpakError } from './errors.js'; /** * Maximum allowed uncompressed size for a bundle (500MB). @@ -63,37 +65,72 @@ export function extractZip(zipPath: string, destDir: string): void { }); } +/** + * Compute a stable, short hash for a local bundle's absolute path. + * Used to derive a unique cache directory under `_local/`. + * + * @param bundlePath - Path to the `.mcpb` file (resolved to absolute internally). + * @returns A 12-character hex string. + */ +export function hashBundlePath(bundlePath: string): string { + return createHash('md5').update(resolve(bundlePath)).digest('hex').slice(0, 12); +} + +/** + * Check whether a local bundle needs re-extraction. + * + * Returns `true` (needs extract) when: + * - The cache directory has no `.mpak-local-meta.json` + * - The `.mcpb` file's mtime is newer than the recorded extraction time + * - The metadata file is corrupt or unreadable + * + * @param bundlePath - Absolute path to the `.mcpb` file. + * @param cacheDir - The extracted cache directory for this local bundle. + */ +export function localBundleNeedsExtract(bundlePath: string, cacheDir: string): boolean { + const metaPath = join(cacheDir, '.mpak-local-meta.json'); + if (!existsSync(metaPath)) return true; + + try { + const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as { extractedAt?: string }; + if (!meta.extractedAt) return true; + const bundleStat = statSync(bundlePath); + return bundleStat.mtimeMs > new Date(meta.extractedAt).getTime(); + } catch { + return true; + } +} + /** * Read a JSON file, parse it, and validate against a Zod schema. * + * Throws generic {@link MpakError} — callers should catch and re-throw + * with a context-specific error (e.g. `MpakCacheCorruptedError`). + * * @param filePath - Absolute path to the JSON file * @param schema - Zod schema to validate the parsed content against * @returns The validated data matching the schema's output type * - * @throws {Error} If the file does not exist, contains invalid JSON, + * @throws {MpakError} If the file does not exist, contains invalid JSON, * or fails schema validation. */ export function readJsonFromFile(filePath: string, schema: T): z.output { if (!existsSync(filePath)) { - throw new MpakCacheCorruptedError(`File does not exist: ${filePath}`, filePath); + throw new MpakError(`File does not exist: ${filePath}`, 'FILE_NOT_FOUND'); } let raw: unknown; try { raw = JSON.parse(readFileSync(filePath, 'utf8')); - } catch (err) { - throw new MpakCacheCorruptedError( - `File is not valid JSON: ${filePath}`, - filePath, - err instanceof Error ? err : undefined, - ); + } catch { + throw new MpakError(`File is not valid JSON: ${filePath}`, 'INVALID_JSON'); } const result = schema.safeParse(raw); if (!result.success) { - throw new MpakCacheCorruptedError( + throw new MpakError( `File failed validation: ${filePath} — ${result.error.issues[0]?.message ?? 'unknown error'}`, - filePath, + 'VALIDATION_FAILED', ); } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 73cdb97..c3bb979 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -19,11 +19,16 @@ // Facade — primary entry point export { Mpak } from './mpakSDK.js'; -export type { MpakOptions, PrepareServerOptions, ServerCommand } from './mpakSDK.js'; +export type { + MpakOptions, + PrepareServerSpec, + PrepareServerOptions, + ServerCommand, +} from './mpakSDK.js'; // Components (standalone use) export { MpakConfigManager } from './config-manager.js'; -export type { MpakConfigManagerOptions } from './config-manager.js'; +export type { MpakConfigManagerOptions, PackageConfig } from './config-manager.js'; export { MpakBundleCache } from './cache.js'; export type { MpakBundleCacheOptions } from './cache.js'; export { MpakClient } from './client.js'; @@ -41,4 +46,5 @@ export { MpakCacheCorruptedError, MpakConfigCorruptedError, MpakConfigError, + MpakInvalidBundleError, } from './errors.js'; diff --git a/packages/sdk-typescript/src/mpakSDK.ts b/packages/sdk-typescript/src/mpakSDK.ts index a2e9022..9c2ef15 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -1,13 +1,19 @@ import { spawnSync } from 'node:child_process'; -import { chmodSync } from 'node:fs'; -import { join } from 'node:path'; +import { chmodSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import type { McpbManifest } from '@nimblebrain/mpak-schemas'; +import { McpbManifestSchema } from '@nimblebrain/mpak-schemas'; import { MpakBundleCache } from './cache.js'; import { MpakClient } from './client.js'; import { MpakConfigManager } from './config-manager.js'; -import { MpakCacheCorruptedError, MpakConfigError } from './errors.js'; +import { MpakCacheCorruptedError, MpakConfigError, MpakInvalidBundleError } from './errors.js'; +import { + extractZip, + hashBundlePath, + localBundleNeedsExtract, + readJsonFromFile, +} from './helpers.js'; import type { MpakClientConfig } from './types.js'; -import { parsePackageSpec } from './utils.js'; /** * Options for the {@link Mpak} facade. @@ -26,13 +32,20 @@ export interface MpakOptions { userAgent?: string; } +/** + * Specifies which bundle to prepare. + * + * - `{ name, version? }` — registry bundle. Omit `version` for "latest". + * - `{ local }` — a local `.mcpb` file on disk. The caller is responsible for + * validating that the path exists and has a `.mcpb` extension before calling. + */ +export type PrepareServerSpec = { name: string; version?: string } | { local: string }; + /** * Options for {@link Mpak.prepareServer}. */ export interface PrepareServerOptions { - /** Pin to a specific version. Omit for "latest". */ - version?: string; - /** Skip cache and re-download from registry. */ + /** Skip cache and re-download/re-extract. */ force?: boolean; /** Extra environment variables merged on top of the manifest env. */ env?: Record; @@ -117,48 +130,43 @@ export class Mpak { } /** - * Prepare a registry bundle for execution. + * Prepare a bundle for execution. * - * Downloads the bundle if not cached, reads its manifest, validates - * that all required user config values are present, and resolves the - * command, args, and env needed to spawn the MCP server process. + * Accepts either a registry spec (`{ name, version? }`) or a local bundle + * spec (`{ local }`). Downloads/extracts as needed, reads the manifest, + * validates user config, and resolves the command, args, and env needed + * to spawn the MCP server process. * - * @param packageName - Package name with optional version, - * e.g. `@scope/name` or `@scope/name@1.0.0`. - * @param options - Version pinning, force re-download, extra env, and workspace dir. + * @param spec - Which bundle to prepare. See {@link PrepareServerSpec}. + * @param options - Force re-download/re-extract, extra env, and workspace dir. * - * @throws If required user config values are missing. - * @throws If the manifest is missing or corrupt after download. - * @throws If the server type is unsupported. + * @throws {MpakConfigError} If required user config values are missing. + * @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download. */ - async prepareServer(packageName: string, options?: PrepareServerOptions): Promise { - const { name, version: parsedVersion } = parsePackageSpec(packageName); - const resolvedVersion = options?.version ?? parsedVersion; - - // Ensure bundle is cached - const loadOptions: { version?: string; force?: boolean } = {}; - if (resolvedVersion !== undefined) loadOptions.version = resolvedVersion; - if (options?.force !== undefined) loadOptions.force = options.force; - const loadResult = await this.bundleCache.loadBundle(name, loadOptions); - - // Read manifest - const manifest = this.bundleCache.getBundleManifest(name); - if (!manifest) { - throw new MpakCacheCorruptedError( - `Manifest file missing for ${name}`, - join(this.bundleCache.cacheHome, name), - ); + async prepareServer( + spec: PrepareServerSpec, + options?: PrepareServerOptions, + ): Promise { + let cacheDir: string; + let name: string; + let version: string; + let manifest: McpbManifest; + + if ('local' in spec) { + ({ cacheDir, name, version, manifest } = await this.prepareLocalBundle(spec.local, options)); + } else { + ({ cacheDir, name, version, manifest } = await this.prepareRegistryBundle( + spec.name, + spec.version, + options, + )); } // Gather and validate user config const userConfigValues = this.gatherUserConfig(name, manifest); // Build command/args/env - const { command, args, env } = this.resolveCommand( - manifest, - loadResult.cacheDir, - userConfigValues, - ); + const { command, args, env } = this.resolveCommand(manifest, cacheDir, userConfigValues); // Set MPAK_WORKSPACE env['MPAK_WORKSPACE'] = options?.workspaceDir ?? join(process.cwd(), '.mpak'); @@ -168,20 +176,97 @@ export class Mpak { Object.assign(env, options.env); } - return { - command, - args, - env, - cwd: loadResult.cacheDir, - name, - version: loadResult.version, - }; + return { command, args, env, cwd: cacheDir, name, version }; } // =========================================================================== // Private helpers // =========================================================================== + /** + * Load a registry bundle into cache and read its manifest. + */ + private async prepareRegistryBundle( + packageName: string, + version: string | undefined, + options?: PrepareServerOptions, + ): Promise<{ cacheDir: string; name: string; version: string; manifest: McpbManifest }> { + const loadOptions: { version?: string; force?: boolean } = {}; + if (version !== undefined) loadOptions.version = version; + if (options?.force !== undefined) loadOptions.force = options.force; + const loadResult = await this.bundleCache.loadBundle(packageName, loadOptions); + + const manifest = this.bundleCache.getBundleManifest(packageName); + if (!manifest) { + throw new MpakCacheCorruptedError( + `Manifest file missing for ${packageName}`, + join(this.bundleCache.cacheHome, packageName), + ); + } + + return { + cacheDir: loadResult.cacheDir, + name: packageName, + version: loadResult.version, + manifest, + }; + } + + /** + * Extract a local `.mcpb` bundle (if stale) and read its manifest. + * Local bundles are cached under `/_local/`. + * + * The caller is responsible for validating that `bundlePath` exists + * and has a `.mcpb` extension before calling this method. + */ + private async prepareLocalBundle( + bundlePath: string, + options?: PrepareServerOptions, + ): Promise<{ cacheDir: string; name: string; version: string; manifest: McpbManifest }> { + const absolutePath = resolve(bundlePath); + const hash = hashBundlePath(absolutePath); + const cacheDir = join(this.bundleCache.cacheHome, '_local', hash); + + const needsExtract = options?.force || localBundleNeedsExtract(absolutePath, cacheDir); + + if (needsExtract) { + if (existsSync(cacheDir)) { + rmSync(cacheDir, { recursive: true, force: true }); + } + + try { + extractZip(absolutePath, cacheDir); + } catch (err) { + throw new MpakInvalidBundleError( + err instanceof Error ? err.message : String(err), + absolutePath, + err instanceof Error ? err : undefined, + ); + } + + writeFileSync( + join(cacheDir, '.mpak-local-meta.json'), + JSON.stringify({ + localPath: absolutePath, + extractedAt: new Date().toISOString(), + }), + ); + } + + let manifest: McpbManifest; + try { + manifest = readJsonFromFile(join(cacheDir, 'manifest.json'), McpbManifestSchema); + } catch (err) { + throw new MpakInvalidBundleError( + err instanceof Error ? err.message : String(err), + absolutePath, + err instanceof Error ? err : undefined, + ); + } + + return { cacheDir, name: manifest.name, version: manifest.version, manifest }; + } + /** * Gather stored user config values and validate that all required fields are present. * @throws If required config values are missing. @@ -196,6 +281,7 @@ export class Mpak { const missingFields: Array<{ key: string; title: string; + description?: string; sensitive: boolean; }> = []; @@ -207,11 +293,15 @@ export class Mpak { } else if (fieldData.default) { result[fieldName] = String(fieldData.default); } else if (fieldData.required) { - missingFields.push({ + const field: (typeof missingFields)[number] = { key: fieldName, title: fieldData.title ?? fieldName, sensitive: fieldData.sensitive ?? false, - }); + }; + if (fieldData.description !== undefined) { + field.description = fieldData.description; + } + missingFields.push(field); } } diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index 43aca14..db1dfec 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -5,35 +5,9 @@ * This file contains only SDK-specific types (client config). */ -// ============================================================================= -// Search Params (SDK-specific: all fields optional for client-side use) -// ============================================================================= - -/** - * Query parameters for bundle search. - * - * Note: The schema's BundleSearchParams uses z.default() which makes fields - * required in the inferred output type. The SDK needs all-optional input types. - */ -export interface BundleSearchParams { - q?: string; - type?: string; - sort?: 'downloads' | 'recent' | 'name'; - limit?: number; - offset?: number; -} - -/** - * Query parameters for skill search. - */ -export interface SkillSearchParams { - q?: string; - tags?: string; - category?: string; - sort?: 'downloads' | 'recent' | 'name'; - limit?: number; - offset?: number; -} +// Re-export input types from schemas — all fields optional (pre-default). +export type { BundleSearchParamsInput as BundleSearchParams } from '@nimblebrain/mpak-schemas'; +export type { SkillSearchParamsInput as SkillSearchParams } from '@nimblebrain/mpak-schemas'; // ============================================================================= // Client Configuration diff --git a/packages/sdk-typescript/tests/cache.test.ts b/packages/sdk-typescript/tests/cache.test.ts index 4a257d0..3726508 100644 --- a/packages/sdk-typescript/tests/cache.test.ts +++ b/packages/sdk-typescript/tests/cache.test.ts @@ -361,6 +361,39 @@ describe('MpakBundleCache', () => { expect(await cache.checkForUpdate('@scope/name')).toBeNull(); }); + it('bypasses TTL when force is true', async () => { + const client = mockClient({ + getBundle: vi.fn().mockResolvedValue({ latest_version: '2.0.0' }), + }); + const cache = new MpakBundleCache(client, { mpakHome: testDir }); + seedCacheEntry(testDir, 'scope-name', { + manifest: validManifest, + metadata: { + ...validMetadata, + lastCheckedAt: new Date().toISOString(), // just checked + }, + }); + + expect(await cache.checkForUpdate('@scope/name', { force: true })).toBe('2.0.0'); + expect(client.getBundle).toHaveBeenCalledWith('@scope/name'); + }); + + it('returns null when force is true but already up to date', async () => { + const client = mockClient({ + getBundle: vi.fn().mockResolvedValue({ latest_version: '1.0.0' }), + }); + const cache = new MpakBundleCache(client, { mpakHome: testDir }); + seedCacheEntry(testDir, 'scope-name', { + manifest: validManifest, + metadata: { + ...validMetadata, + lastCheckedAt: new Date().toISOString(), + }, + }); + + expect(await cache.checkForUpdate('@scope/name', { force: true })).toBeNull(); + }); + it('updates lastCheckedAt after successful check', async () => { const client = mockClient({ getBundle: vi.fn().mockResolvedValue({ latest_version: '1.0.0' }), diff --git a/packages/sdk-typescript/tests/errors.test.ts b/packages/sdk-typescript/tests/errors.test.ts index da784fd..2fb7775 100644 --- a/packages/sdk-typescript/tests/errors.test.ts +++ b/packages/sdk-typescript/tests/errors.test.ts @@ -4,6 +4,7 @@ import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError, + MpakConfigError, } from '../src/errors.js'; describe('MpakError', () => { @@ -122,6 +123,47 @@ describe('MpakNetworkError', () => { }); }); +describe('MpakConfigError', () => { + it('uses CONFIG_MISSING code', () => { + const error = new MpakConfigError('@scope/pkg', [ + { key: 'api_key', title: 'API Key', sensitive: true }, + ]); + expect(error.code).toBe('CONFIG_MISSING'); + }); + + it('formats missing field titles in message', () => { + const error = new MpakConfigError('@scope/pkg', [ + { key: 'api_key', title: 'API Key', sensitive: true }, + { key: 'endpoint', title: 'Endpoint URL', sensitive: false }, + ]); + expect(error.message).toContain('@scope/pkg'); + expect(error.message).toContain('API Key'); + expect(error.message).toContain('Endpoint URL'); + }); + + it('stores packageName', () => { + const error = new MpakConfigError('@scope/pkg', []); + expect(error.packageName).toBe('@scope/pkg'); + }); + + it('stores missingFields with description', () => { + const fields = [ + { key: 'api_key', title: 'API Key', description: 'Your API key', sensitive: true }, + { key: 'port', title: 'Port', sensitive: false }, + ]; + const error = new MpakConfigError('@scope/pkg', fields); + expect(error.missingFields).toEqual(fields); + expect(error.missingFields[0].description).toBe('Your API key'); + expect(error.missingFields[1].description).toBeUndefined(); + }); + + it('can be caught as MpakError', () => { + const error = new MpakConfigError('@scope/pkg', []); + expect(error).toBeInstanceOf(MpakError); + expect(error).toBeInstanceOf(Error); + }); +}); + describe('Error hierarchy', () => { it('all errors inherit from MpakError', () => { const errors = [ diff --git a/packages/sdk-typescript/tests/helpers.test.ts b/packages/sdk-typescript/tests/helpers.test.ts index 51416c5..0aa67dc 100644 --- a/packages/sdk-typescript/tests/helpers.test.ts +++ b/packages/sdk-typescript/tests/helpers.test.ts @@ -1,14 +1,24 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { z } from 'zod'; import { MAX_UNCOMPRESSED_SIZE, UPDATE_CHECK_TTL_MS, extractZip, + hashBundlePath, isSemverEqual, + localBundleNeedsExtract, readJsonFromFile, } from '../src/helpers.js'; @@ -89,6 +99,107 @@ describe('extractZip', () => { }); }); +describe('hashBundlePath', () => { + it('returns a 12-character hex string', () => { + const hash = hashBundlePath('/some/path/bundle.mcpb'); + expect(hash).toMatch(/^[0-9a-f]{12}$/); + }); + + it('returns the same hash for the same absolute path', () => { + expect(hashBundlePath('/a/b/c.mcpb')).toBe(hashBundlePath('/a/b/c.mcpb')); + }); + + it('returns different hashes for different paths', () => { + expect(hashBundlePath('/a/b/c.mcpb')).not.toBe(hashBundlePath('/a/b/d.mcpb')); + }); + + it('resolves relative paths before hashing', () => { + // A relative path and its resolved absolute equivalent should produce the same hash + const relative = 'bundle.mcpb'; + const absolute = resolve(relative); + expect(hashBundlePath(relative)).toBe(hashBundlePath(absolute)); + }); +}); + +describe('localBundleNeedsExtract', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'mpak-helpers-test-')); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns true when cache dir has no metadata file', () => { + const bundlePath = join(testDir, 'bundle.mcpb'); + writeFileSync(bundlePath, 'fake bundle'); + const cacheDir = join(testDir, 'cache'); + mkdirSync(cacheDir); + + expect(localBundleNeedsExtract(bundlePath, cacheDir)).toBe(true); + }); + + it('returns true when bundle is newer than extraction', () => { + const bundlePath = join(testDir, 'bundle.mcpb'); + const cacheDir = join(testDir, 'cache'); + mkdirSync(cacheDir); + + // Write metadata with an old extractedAt + const oldTime = new Date('2020-01-01T00:00:00Z').toISOString(); + writeFileSync( + join(cacheDir, '.mpak-local-meta.json'), + JSON.stringify({ extractedAt: oldTime }), + ); + + // Write bundle "now" — its mtime will be newer than 2020 + writeFileSync(bundlePath, 'fake bundle'); + + expect(localBundleNeedsExtract(bundlePath, cacheDir)).toBe(true); + }); + + it('returns false when extraction is newer than bundle', () => { + const bundlePath = join(testDir, 'bundle.mcpb'); + writeFileSync(bundlePath, 'fake bundle'); + + // Set bundle mtime to the past + const pastTime = new Date('2020-01-01T00:00:00Z'); + utimesSync(bundlePath, pastTime, pastTime); + + const cacheDir = join(testDir, 'cache'); + mkdirSync(cacheDir); + + // Write metadata with a recent extractedAt + writeFileSync( + join(cacheDir, '.mpak-local-meta.json'), + JSON.stringify({ extractedAt: new Date().toISOString() }), + ); + + expect(localBundleNeedsExtract(bundlePath, cacheDir)).toBe(false); + }); + + it('returns true when metadata is corrupt JSON', () => { + const bundlePath = join(testDir, 'bundle.mcpb'); + writeFileSync(bundlePath, 'fake bundle'); + const cacheDir = join(testDir, 'cache'); + mkdirSync(cacheDir); + writeFileSync(join(cacheDir, '.mpak-local-meta.json'), 'not json'); + + expect(localBundleNeedsExtract(bundlePath, cacheDir)).toBe(true); + }); + + it('returns true when metadata is missing extractedAt', () => { + const bundlePath = join(testDir, 'bundle.mcpb'); + writeFileSync(bundlePath, 'fake bundle'); + const cacheDir = join(testDir, 'cache'); + mkdirSync(cacheDir); + writeFileSync(join(cacheDir, '.mpak-local-meta.json'), JSON.stringify({ other: 'field' })); + + expect(localBundleNeedsExtract(bundlePath, cacheDir)).toBe(true); + }); +}); + describe('readJsonFromFile', () => { let testDir: string; diff --git a/packages/sdk-typescript/tests/mpak.integration.test.ts b/packages/sdk-typescript/tests/mpak.integration.test.ts index 7f36fb5..1149115 100644 --- a/packages/sdk-typescript/tests/mpak.integration.test.ts +++ b/packages/sdk-typescript/tests/mpak.integration.test.ts @@ -126,7 +126,7 @@ describe('Mpak facade integration', () => { }); it('prepareServer resolves a runnable server command', async () => { - const result = await sdk.prepareServer(KNOWN_BUNDLE); + const result = await sdk.prepareServer({ name: KNOWN_BUNDLE }); expect(result.name).toBe(KNOWN_BUNDLE); expect(result.version).toMatch(/^\d+\.\d+\.\d+$/); @@ -143,19 +143,22 @@ describe('Mpak facade integration', () => { }); it('prepareServer respects workspaceDir option', async () => { - const result = await sdk.prepareServer(KNOWN_BUNDLE, { - workspaceDir: '/tmp/custom-workspace', - }); + const result = await sdk.prepareServer( + { name: KNOWN_BUNDLE }, + { + workspaceDir: '/tmp/custom-workspace', + }, + ); expect(result.env['MPAK_WORKSPACE']).toBe('/tmp/custom-workspace'); }); - it('prepareServer with inline version', async () => { + it('prepareServer with explicit version', async () => { // Get the current cached version to use as a known-good version const meta = sdk.bundleCache.getBundleMetadata(KNOWN_BUNDLE); const version = meta!.version; - const result = await sdk.prepareServer(`${KNOWN_BUNDLE}@${version}`); + const result = await sdk.prepareServer({ name: KNOWN_BUNDLE, version }); expect(result.name).toBe(KNOWN_BUNDLE); expect(result.version).toBe(version); diff --git a/packages/sdk-typescript/tests/mpak.test.ts b/packages/sdk-typescript/tests/mpak.test.ts index 9ba94bb..b8915c1 100644 --- a/packages/sdk-typescript/tests/mpak.test.ts +++ b/packages/sdk-typescript/tests/mpak.test.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,7 +8,7 @@ import { Mpak } from '../src/mpakSDK.js'; import { MpakBundleCache } from '../src/cache.js'; import { MpakClient } from '../src/client.js'; import { MpakConfigManager } from '../src/config-manager.js'; -import { MpakCacheCorruptedError, MpakConfigError } from '../src/errors.js'; +import { MpakCacheCorruptedError, MpakConfigError, MpakInvalidBundleError } from '../src/errors.js'; describe('Mpak facade', () => { let testDir: string; @@ -265,7 +266,7 @@ describe('Mpak facade', () => { it('resolves a node server', async () => { const { sdk, cacheDir } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.command).toBe('node'); expect(result.args).toEqual([`${cacheDir}/index.js`]); @@ -284,7 +285,7 @@ describe('Mpak facade', () => { }; const { sdk, cacheDir } = setupSdk(manifest); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.args).toEqual([join(cacheDir, 'index.js')]); }); @@ -300,7 +301,7 @@ describe('Mpak facade', () => { }; const { sdk, cacheDir } = setupSdk(pythonManifest); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(['python', 'python3']).toContain(result.command); expect(result.args).toEqual([`${cacheDir}/main.py`]); @@ -318,36 +319,26 @@ describe('Mpak facade', () => { }; const { sdk, cacheDir } = setupSdk(binaryManifest); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.command).toBe(join(cacheDir, 'server')); expect(result.args).toEqual(['--port', '3000']); }); - it('parses inline version from package name', async () => { + it('passes version from spec to loadBundle', async () => { const { sdk } = setupSdk(); - await sdk.prepareServer('@scope/echo@2.0.0'); + await sdk.prepareServer({ name: '@scope/echo', version: '2.0.0' }); expect(sdk.bundleCache.loadBundle).toHaveBeenCalledWith('@scope/echo', { version: '2.0.0', }); }); - it('options.version takes precedence over inline version', async () => { - const { sdk } = setupSdk(); - - await sdk.prepareServer('@scope/echo@2.0.0', { version: '3.0.0' }); - - expect(sdk.bundleCache.loadBundle).toHaveBeenCalledWith('@scope/echo', { - version: '3.0.0', - }); - }); - it('passes force option to loadBundle', async () => { const { sdk } = setupSdk(); - await sdk.prepareServer('@scope/echo', { force: true }); + await sdk.prepareServer({ name: '@scope/echo' }, { force: true }); expect(sdk.bundleCache.loadBundle).toHaveBeenCalledWith('@scope/echo', { force: true, @@ -357,8 +348,10 @@ describe('Mpak facade', () => { it('throws MpakCacheCorruptedError when manifest is null', async () => { const { sdk } = setupSdk(null); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow(MpakCacheCorruptedError); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow( + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow( + MpakCacheCorruptedError, + ); + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow( 'Manifest file missing for @scope/echo', ); }); @@ -366,9 +359,12 @@ describe('Mpak facade', () => { it('sets MPAK_WORKSPACE from workspaceDir option', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo', { - workspaceDir: '/custom/workspace', - }); + const result = await sdk.prepareServer( + { name: '@scope/echo' }, + { + workspaceDir: '/custom/workspace', + }, + ); expect(result.env['MPAK_WORKSPACE']).toBe('/custom/workspace'); }); @@ -376,7 +372,7 @@ describe('Mpak facade', () => { it('defaults MPAK_WORKSPACE to $cwd/.mpak', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.env['MPAK_WORKSPACE']).toBe(join(process.cwd(), '.mpak')); }); @@ -384,9 +380,12 @@ describe('Mpak facade', () => { it('caller env overrides MPAK_WORKSPACE default', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo', { - env: { MPAK_WORKSPACE: '/caller/wins' }, - }); + const result = await sdk.prepareServer( + { name: '@scope/echo' }, + { + env: { MPAK_WORKSPACE: '/caller/wins' }, + }, + ); expect(result.env['MPAK_WORKSPACE']).toBe('/caller/wins'); }); @@ -404,9 +403,12 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(manifestWithEnv); - const result = await sdk.prepareServer('@scope/echo', { - env: { FROM_CALLER: 'added', SHARED: 'caller-wins' }, - }); + const result = await sdk.prepareServer( + { name: '@scope/echo' }, + { + env: { FROM_CALLER: 'added', SHARED: 'caller-wins' }, + }, + ); expect(result.env['FROM_MANIFEST']).toBe('original'); expect(result.env['FROM_CALLER']).toBe('added'); @@ -430,7 +432,7 @@ describe('Mpak facade', () => { const { sdk } = setupSdk(manifestWithConfig); sdk.configManager.setPackageConfigValue('@scope/echo', 'api_key', 'sk-secret'); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.env['API_KEY']).toBe('sk-secret'); }); @@ -451,7 +453,7 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(manifestWithDefault); - const result = await sdk.prepareServer('@scope/echo'); + const result = await sdk.prepareServer({ name: '@scope/echo' }); expect(result.env['PORT']).toBe('3000'); }); @@ -469,8 +471,8 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(manifestWithRequired); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow(MpakConfigError); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow('API Key'); + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow(MpakConfigError); + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow('API Key'); }); it('MpakConfigError contains structured missingFields', async () => { @@ -493,7 +495,7 @@ describe('Mpak facade', () => { const { sdk } = setupSdk(manifestWithRequired); try { - await sdk.prepareServer('@scope/echo'); + await sdk.prepareServer({ name: '@scope/echo' }); expect.fail('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(MpakConfigError); @@ -506,6 +508,68 @@ describe('Mpak facade', () => { } }); + it('MpakConfigError includes description when present in user_config', async () => { + const manifestWithDescriptions: McpbManifest = { + ...nodeManifest, + user_config: { + api_key: { + type: 'string', + title: 'API Key', + description: 'Your OpenAI API key', + required: true, + sensitive: true, + }, + endpoint: { + type: 'string', + title: 'Endpoint URL', + description: 'The API base URL', + required: true, + }, + }, + }; + const { sdk } = setupSdk(manifestWithDescriptions); + + try { + await sdk.prepareServer({ name: '@scope/echo' }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(MpakConfigError); + const configErr = err as MpakConfigError; + expect(configErr.missingFields).toEqual([ + { key: 'api_key', title: 'API Key', description: 'Your OpenAI API key', sensitive: true }, + { + key: 'endpoint', + title: 'Endpoint URL', + description: 'The API base URL', + sensitive: false, + }, + ]); + } + }); + + it('MpakConfigError leaves description undefined when not in user_config', async () => { + const manifestNoDesc: McpbManifest = { + ...nodeManifest, + user_config: { + token: { + type: 'string', + title: 'Token', + required: true, + }, + }, + }; + const { sdk } = setupSdk(manifestNoDesc); + + try { + await sdk.prepareServer({ name: '@scope/echo' }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(MpakConfigError); + const configErr = err as MpakConfigError; + expect(configErr.missingFields[0].description).toBeUndefined(); + } + }); + it('throws MpakCacheCorruptedError for unsupported server type', async () => { const badManifest: McpbManifest = { ...nodeManifest, @@ -517,8 +581,191 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(badManifest); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow(MpakCacheCorruptedError); - await expect(sdk.prepareServer('@scope/echo')).rejects.toThrow('Unsupported server type'); + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow( + MpakCacheCorruptedError, + ); + await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow( + 'Unsupported server type', + ); + }); + }); + + // =========================================================================== + // prepareServer — local bundles + // =========================================================================== + + describe('prepareServer (local)', () => { + const nodeManifest: McpbManifest = { + manifest_version: '0.3', + name: '@scope/local-echo', + version: '2.0.0', + description: 'Local echo server', + server: { + type: 'node', + entry_point: 'index.js', + mcp_config: { + command: 'node', + args: ['${__dirname}/index.js'], + env: {}, + }, + }, + }; + + /** + * Create a valid .mcpb zip file containing a manifest.json and a dummy entry point. + */ + function createMcpbBundle(dir: string, manifest: McpbManifest): string { + const srcDir = join(dir, 'bundle-src'); + mkdirSync(srcDir, { recursive: true }); + writeFileSync(join(srcDir, 'manifest.json'), JSON.stringify(manifest)); + writeFileSync(join(srcDir, 'index.js'), 'console.log("hello")'); + + const mcpbPath = join(dir, 'test-bundle.mcpb'); + execFileSync( + 'zip', + ['-j', mcpbPath, join(srcDir, 'manifest.json'), join(srcDir, 'index.js')], + { + stdio: 'pipe', + }, + ); + return mcpbPath; + } + + it('extracts and resolves a local bundle', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + const result = await sdk.prepareServer({ local: mcpbPath }); + + expect(result.name).toBe('@scope/local-echo'); + expect(result.version).toBe('2.0.0'); + expect(result.command).toBe('node'); + expect(result.args).toEqual([`${result.cwd}/index.js`]); + expect(result.cwd).toContain('_local'); + }); + + it('reads from cache on second call (no re-extraction)', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + const result1 = await sdk.prepareServer({ local: mcpbPath }); + const result2 = await sdk.prepareServer({ local: mcpbPath }); + + expect(result1.cwd).toBe(result2.cwd); + // Metadata file should exist from first extraction + const metaPath = join(result1.cwd, '.mpak-local-meta.json'); + expect(existsSync(metaPath)).toBe(true); + }); + + it('re-extracts when force is set', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + // First call to populate cache + const result1 = await sdk.prepareServer({ local: mcpbPath }); + const meta1 = JSON.parse(readFileSync(join(result1.cwd, '.mpak-local-meta.json'), 'utf8')); + + // Small delay so extractedAt differs + await new Promise((r) => setTimeout(r, 50)); + + const result2 = await sdk.prepareServer({ local: mcpbPath }, { force: true }); + const meta2 = JSON.parse(readFileSync(join(result2.cwd, '.mpak-local-meta.json'), 'utf8')); + + expect(meta2.extractedAt).not.toBe(meta1.extractedAt); + }); + + it('writes local metadata with path and timestamp', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + const result = await sdk.prepareServer({ local: mcpbPath }); + + const meta = JSON.parse(readFileSync(join(result.cwd, '.mpak-local-meta.json'), 'utf8')); + expect(meta.localPath).toContain('test-bundle.mcpb'); + expect(meta.extractedAt).toBeDefined(); + }); + + it('throws MpakInvalidBundleError for a corrupt zip', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const badPath = join(testDir, 'bad.mcpb'); + writeFileSync(badPath, 'not a zip'); + + await expect(sdk.prepareServer({ local: badPath })).rejects.toThrow(MpakInvalidBundleError); + }); + + it('throws MpakInvalidBundleError when manifest is missing from zip', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + + // Create a zip with no manifest.json + const srcDir = join(testDir, 'no-manifest-src'); + mkdirSync(srcDir); + writeFileSync(join(srcDir, 'index.js'), 'console.log("hello")'); + const mcpbPath = join(testDir, 'no-manifest.mcpb'); + execFileSync('zip', ['-j', mcpbPath, join(srcDir, 'index.js')], { stdio: 'pipe' }); + + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow(MpakInvalidBundleError); + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow('File does not exist'); + }); + + it('throws MpakInvalidBundleError when manifest fails schema validation', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + + const srcDir = join(testDir, 'bad-manifest-src'); + mkdirSync(srcDir); + writeFileSync(join(srcDir, 'manifest.json'), JSON.stringify({ name: 'missing fields' })); + const mcpbPath = join(testDir, 'bad-manifest.mcpb'); + execFileSync('zip', ['-j', mcpbPath, join(srcDir, 'manifest.json')], { stdio: 'pipe' }); + + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow(MpakInvalidBundleError); + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow( + 'File failed validation', + ); + }); + + it('resolves a python server from local bundle', async () => { + const pythonManifest: McpbManifest = { + ...nodeManifest, + server: { + type: 'python', + entry_point: 'main.py', + mcp_config: { command: 'python', args: ['${__dirname}/main.py'], env: {} }, + }, + }; + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, pythonManifest); + + const result = await sdk.prepareServer({ local: mcpbPath }); + + expect(['python', 'python3']).toContain(result.command); + expect(result.args).toEqual([`${result.cwd}/main.py`]); + expect(result.env['PYTHONPATH']).toContain(join(result.cwd, 'deps')); + }); + + it('sets MPAK_WORKSPACE from options', async () => { + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, nodeManifest); + + const result = await sdk.prepareServer( + { local: mcpbPath }, + { + workspaceDir: '/custom/workspace', + }, + ); + + expect(result.env['MPAK_WORKSPACE']).toBe('/custom/workspace'); + }); + + it('throws MpakConfigError when required user config is missing', async () => { + const manifestWithConfig: McpbManifest = { + ...nodeManifest, + user_config: { + api_key: { type: 'string', title: 'API Key', required: true }, + }, + }; + const sdk = new Mpak({ mpakHome: testDir }); + const mcpbPath = createMcpbBundle(testDir, manifestWithConfig); + + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow(MpakConfigError); }); }); });