From 93a7480e75ddf63f86967c1714ad252ae8aada90 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Fri, 27 Mar 2026 18:33:48 -0400 Subject: [PATCH 01/11] Migrate CLI config commands to use SDK's MpakConfigManager (#59 Phase 1A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the CLI's internal ConfigManager with the SDK's MpakConfigManager across all config command handlers (set, get, list, clear). This is the first step in dogfooding the SDK within the CLI. Changes: - Add shared `mpakConfigManager` singleton in `cli/src/utils/config.ts` that reads MPAK_HOME and MPAK_REGISTRY_URL from env vars - Rewrite `config.ts` to use the SDK singleton instead of instantiating CLI's ConfigManager per handler - Rename `listPackagesWithConfig()` → `getPackageNames()` (SDK API) - Export `PackageConfig` type from SDK's barrel (`index.ts`) - Fix bug in `handleConfigGet` where non-JSON output was silently swallowed due to a nested conditional - Remove unused `ConfigSetOptions` and `ConfigClearOptions` interfaces - Add 23 end-to-end CLI tests for all config subcommands covering happy paths, --json output, and error cases Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/config.ts | 235 ++++++++++---------- packages/cli/src/utils/config.ts | 9 + packages/cli/tests/config.test.ts | 309 +++++++++++++++++++++++++++ packages/sdk-typescript/src/index.ts | 2 +- 4 files changed, 429 insertions(+), 126 deletions(-) create mode 100644 packages/cli/src/utils/config.ts create mode 100644 packages/cli/tests/config.test.ts diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 122bbdb..844258f 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 { mpakConfigManager } from "../utils/config.js"; export interface ConfigGetOptions { - json?: boolean; + 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 value.substring(0, 4) + "*".repeat(value.length - 4); + if (value.length <= 4) { + return "*".repeat(value.length); + } + return value.substring(0, 4) + "*".repeat(value.length - 4); } /** @@ -27,45 +21,41 @@ function maskValue(value: string): string { * @example mpak config set @scope/name api_key=xxx other_key=yyy */ export async function handleConfigSet( - packageName: string, - keyValuePairs: string[], - _options: ConfigSetOptions = {}, + 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.exit(1); - } - - const configManager = new ConfigManager(); - let setCount = 0; - - for (const pair of keyValuePairs) { - const eqIndex = pair.indexOf("="); - if (eqIndex === -1) { - process.stderr.write( - `Error: Invalid format "${pair}". Expected key=value\n`, - ); - process.exit(1); - } - - const key = pair.substring(0, eqIndex); - const value = pair.substring(eqIndex + 1); - - if (!key) { - process.stderr.write(`Error: Empty key in "${pair}"\n`); - process.exit(1); - } - - configManager.setPackageConfigValue(packageName, key, value); - setCount++; - } - - console.log(`Set ${setCount} config value(s) for ${packageName}`); + 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.exit(1); + } + + let setCount = 0; + + for (const pair of keyValuePairs) { + const eqIndex = pair.indexOf("="); + if (eqIndex === -1) { + process.stderr.write( + `Error: Invalid format "${pair}". Expected key=value\n`, + ); + process.exit(1); + } + + const key = pair.substring(0, eqIndex); + const value = pair.substring(eqIndex + 1); + + if (!key) { + process.stderr.write(`Error: Empty key in "${pair}"\n`); + process.exit(1); + } + + mpakConfigManager.setPackageConfigValue(packageName, key, value); + setCount++; + } + + console.log(`Set ${setCount} config value(s) for ${packageName}`); } /** @@ -74,34 +64,33 @@ export async function handleConfigSet( * @example mpak config get @scope/name --json */ export async function handleConfigGet( - packageName: string, - options: ConfigGetOptions = {}, + packageName: string, + options: ConfigGetOptions = {}, ): Promise { - const configManager = new ConfigManager(); - const config = configManager.getPackageConfig(packageName); - - if (!config || Object.keys(config).length === 0) { - if (options.json) { - console.log(JSON.stringify({}, null, 2)); - } else { - console.log(`No config stored for ${packageName}`); - } - return; - } - - if (options.json) { - // Mask values in JSON output too - const masked: PackageConfig = {}; - for (const [key, value] of Object.entries(config)) { - masked[key] = maskValue(value); - } - console.log(JSON.stringify(masked, null, 2)); - } else { - console.log(`Config for ${packageName}:`); - for (const [key, value] of Object.entries(config)) { - console.log(` ${key}: ${maskValue(value)}`); - } - } + const config = mpakConfigManager.getPackageConfig(packageName); + const isOutputJson = !!options?.json; + + // If no config or config is {} + if (!config || Object.keys(config).length === 0) { + if (isOutputJson) { + console.log(JSON.stringify({}, null, 2)); + } else { + console.log(`No config stored for ${packageName}`); + } + return; + } else if (isOutputJson) { + // Mask values in JSON output too + const masked: PackageConfig = {}; + for (const [key, value] of Object.entries(config)) { + masked[key] = maskValue(value); + } + console.log(JSON.stringify(masked, null, 2)); + } else { + console.log(`Config for ${packageName}:`); + for (const [key, value] of Object.entries(config)) { + console.log(` ${key}: ${maskValue(value)}`); + } + } } /** @@ -109,32 +98,31 @@ export async function handleConfigGet( * @example mpak config list */ export async function handleConfigList( - options: ConfigGetOptions = {}, + options: ConfigGetOptions = {}, ): Promise { - const configManager = new ConfigManager(); - const packages = configManager.listPackagesWithConfig(); - - if (packages.length === 0) { - if (options.json) { - console.log(JSON.stringify([], null, 2)); - } else { - console.log("No packages have stored config"); - } - return; - } - - if (options.json) { - console.log(JSON.stringify(packages, null, 2)); - } else { - console.log("Packages with stored config:"); - for (const pkg of packages) { - const config = configManager.getPackageConfig(pkg); - const keyCount = config ? Object.keys(config).length : 0; - console.log( - ` ${pkg} (${keyCount} value${keyCount === 1 ? "" : "s"})`, - ); - } - } + const configManager = mpakConfigManager; + const packages = mpakConfigManager.getPackageNames(); + const isOutputJson = !!options?.json; + + if (packages.length === 0) { + if (isOutputJson) { + console.log(JSON.stringify([], null, 2)); + } else { + console.log("No packages have stored config"); + } + return; + } + + if (isOutputJson) { + console.log(JSON.stringify(packages, null, 2)); + } else { + console.log("Packages with stored config:"); + for (const pkg of packages) { + const config = configManager.getPackageConfig(pkg); + const keyCount = config ? Object.keys(config).length : 0; + console.log(`${pkg} (${keyCount} value${keyCount === 1 ? "" : "s"})`); + } + } } /** @@ -143,27 +131,24 @@ export async function handleConfigList( * @example mpak config clear @scope/name api_key # clears specific key */ export async function handleConfigClear( - packageName: string, - key?: string, - _options: ConfigClearOptions = {}, + packageName: string, + key?: string, ): Promise { - const configManager = new ConfigManager(); - - if (key) { - // Clear specific key - const cleared = configManager.clearPackageConfigValue(packageName, key); - if (cleared) { - console.log(`Cleared ${key} for ${packageName}`); - } else { - console.log(`No value found for ${key} in ${packageName}`); - } - } else { - // Clear all config for package - const cleared = configManager.clearPackageConfig(packageName); - if (cleared) { - console.log(`Cleared all config for ${packageName}`); - } else { - console.log(`No config found for ${packageName}`); - } - } + if (key) { + // Clear specific key + const cleared = mpakConfigManager.clearPackageConfigValue(packageName, key); + if (cleared) { + console.log(`Cleared ${key} for ${packageName}`); + } else { + console.log(`No value found for ${key} in ${packageName}`); + } + } else { + // Clear all config for package + const cleared = mpakConfigManager.clearPackageConfig(packageName); + if (cleared) { + console.log(`Cleared all config for ${packageName}`); + } else { + console.log(`No config found for ${packageName}`); + } + } } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts new file mode 100644 index 0000000..747a2de --- /dev/null +++ b/packages/cli/src/utils/config.ts @@ -0,0 +1,9 @@ +import { MpakConfigManager } from "@nimblebrain/mpak-sdk"; + +const mpakHome = process.env["MPAK_HOME"]; +const registryUrl = process.env["MPAK_REGISTRY_URL"]; + +export const mpakConfigManager = new MpakConfigManager({ + ...(mpakHome ? { mpakHome } : {}), + ...(registryUrl ? { registryUrl } : {}), +}); diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts new file mode 100644 index 0000000..3601629 --- /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/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 73cdb97..7e2f06f 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -23,7 +23,7 @@ export type { MpakOptions, PrepareServerOptions, ServerCommand } from './mpakSDK // 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'; From 446ff43c0d0ee712c73273665729edad6de5d2a4 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sat, 28 Mar 2026 16:01:29 -0400 Subject: [PATCH 02/11] Add local bundle support to prepareServer and standardize SDK error handling (#59 Phase 3C prereqs) Extends the Mpak facade's prepareServer() to accept both registry and local bundles via a discriminated union spec: { name, version? } for registry, { local } for local .mcpb files. This is the SDK groundwork needed before the CLI can be thinned down to a dispatcher over prepareServer(). SDK changes: - New PrepareServerSpec type replaces the old string-based packageName param - prepareLocalBundle: hash-based cache dir, mtime-aware extraction, zip bomb protection, manifest schema validation - New MpakInvalidBundleError for local bundle failures (distinct from MpakCacheCorruptedError which is for registry cache issues) - readJsonFromFile now throws generic MpakError instead of MpakCacheCorruptedError; callers wrap with context-specific errors - Helper functions: hashBundlePath, localBundleNeedsExtract - 10 new tests for local bundle path through prepareServer - All 182 tests pass, typecheck and lint clean Co-Authored-By: Claude Opus 4.6 --- packages/sdk-typescript/src/cache.ts | 9 +- packages/sdk-typescript/src/errors.ts | 19 ++ packages/sdk-typescript/src/helpers.ts | 61 ++++- packages/sdk-typescript/src/index.ts | 3 +- packages/sdk-typescript/src/mpakSDK.ts | 161 +++++++++---- packages/sdk-typescript/tests/cache.test.ts | 33 +++ packages/sdk-typescript/tests/helpers.test.ts | 104 +++++++- .../tests/mpak.integration.test.ts | 8 +- packages/sdk-typescript/tests/mpak.test.ts | 225 +++++++++++++++--- 9 files changed, 526 insertions(+), 97 deletions(-) diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index 1e4efa5..fafe7ec 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -230,12 +230,15 @@ 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..a02b4de 100644 --- a/packages/sdk-typescript/src/errors.ts +++ b/packages/sdk-typescript/src/errors.ts @@ -91,6 +91,25 @@ 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, 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 7e2f06f..8ba2e23 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -19,7 +19,7 @@ // 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'; @@ -41,4 +41,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..ff28df1 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -1,13 +1,15 @@ 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 +28,22 @@ 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,37 +128,29 @@ 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 @@ -156,7 +159,7 @@ export class Mpak { // Build command/args/env const { command, args, env } = this.resolveCommand( manifest, - loadResult.cacheDir, + cacheDir, userConfigValues, ); @@ -168,20 +171,92 @@ 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. 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/helpers.test.ts b/packages/sdk-typescript/tests/helpers.test.ts index 51416c5..b0aff3d 100644 --- a/packages/sdk-typescript/tests/helpers.test.ts +++ b/packages/sdk-typescript/tests/helpers.test.ts @@ -1,14 +1,16 @@ 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 +91,104 @@ 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..1292439 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,19 @@ describe('Mpak facade integration', () => { }); it('prepareServer respects workspaceDir option', async () => { - const result = await sdk.prepareServer(KNOWN_BUNDLE, { + 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..44e7b45 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,8 @@ 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,7 +357,7 @@ describe('Mpak facade', () => { it('sets MPAK_WORKSPACE from workspaceDir option', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo', { + const result = await sdk.prepareServer({ name: '@scope/echo' }, { workspaceDir: '/custom/workspace', }); @@ -376,7 +367,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,7 +375,7 @@ describe('Mpak facade', () => { it('caller env overrides MPAK_WORKSPACE default', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer('@scope/echo', { + const result = await sdk.prepareServer({ name: '@scope/echo' }, { env: { MPAK_WORKSPACE: '/caller/wins' }, }); @@ -404,7 +395,7 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(manifestWithEnv); - const result = await sdk.prepareServer('@scope/echo', { + const result = await sdk.prepareServer({ name: '@scope/echo' }, { env: { FROM_CALLER: 'added', SHARED: 'caller-wins' }, }); @@ -430,7 +421,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 +442,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 +460,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 +484,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); @@ -517,8 +508,178 @@ 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); }); }); }); From 7481a4695b30f0f33e3e358ff72afa9dd88fdd85 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sat, 28 Mar 2026 19:09:39 -0400 Subject: [PATCH 03/11] Rewire CLI run command to use SDK's prepareServer (#59 Phase 3C Step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~350 lines of inline cache management, manifest reading, command resolution, env substitution, and python detection in run.ts with a single call to mpak.prepareServer(). The CLI now only owns what it should: --local path validation, interactive config prompting (catching MpakConfigError), process spawning, signal forwarding, and the async update-check notice. SDK side: add `description` field to MpakConfigError.missingFields so the CLI can render prompt hints (e.g. "API Key (Your OpenAI API key):"). Key changes: - run.ts: delete parsePackageSpec, readManifest, resolveArgs, resolveWorkspace, substituteUserConfig, substituteEnvVars, getLocalCacheDir, localBundleNeedsExtract, findPythonCommand, gatherUserConfigValues, and all local type interfaces — all now in SDK - run.ts: use shared mpak singleton from utils/config.ts - run.ts: skip async update check when --update flag is set (just pulled latest) and log debug messages instead of silently swallowing errors - Delete old src/commands/packages/run.test.ts (tested deleted helpers) - Add tests/run.test.ts with 28 tests covering registry/local/update flows, async update check, config errors, CLI validation, process spawning, signal forwarding, and SDK error propagation Co-Authored-By: Claude Opus 4.6 --- .../cli/src/commands/packages/run.test.ts | 335 -------- packages/cli/src/commands/packages/run.ts | 720 +++++------------- packages/cli/tests/run.test.ts | 574 ++++++++++++++ packages/sdk-typescript/src/errors.ts | 2 +- packages/sdk-typescript/src/mpakSDK.ts | 9 +- packages/sdk-typescript/tests/errors.test.ts | 42 + packages/sdk-typescript/tests/mpak.test.ts | 57 ++ 7 files changed, 863 insertions(+), 876 deletions(-) delete mode 100644 packages/cli/src/commands/packages/run.test.ts create mode 100644 packages/cli/tests/run.test.ts 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..26a19cc 100644 --- a/packages/cli/src/commands/packages/run.ts +++ b/packages/cli/src/commands/packages/run.ts @@ -1,563 +1,207 @@ -import { spawn, spawnSync } from "child_process"; +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 { - 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 { 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) - */ -interface UserConfigField { - type: "string" | "number" | "boolean"; - 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; - }; + update?: boolean; + local?: string; // Path to local .mcpb file } /** - * 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' } + * Prompt user for a missing config value (interactive terminal input) */ -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 { - return new Promise((resolvePrompt) => { - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - 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}: `; - - // 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); - } - }); - }); +async function promptForValue(field: { + key: string; + title: string; + description?: string; + sensitive: boolean; +}): Promise { + return new Promise((resolvePrompt) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + terminal: true, + }); + + const label = field.title; + const hint = field.description ? ` (${field.description})` : ""; + const prompt = `=> ${label}${hint}: `; + + if (field.sensitive) { + process.stderr.write(`=> (sensitive input)\n`); + } + + rl.question(prompt, (answer) => { + rl.close(); + resolvePrompt(answer); + }); + }); } /** * Check if we're in an interactive terminal */ function isInteractive(): boolean { - return process.stdin.isTTY === true; -} - -/** - * Gather user config values from stored config - * Prompts for missing required values if interactive - */ -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 }); - } - } - - // 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.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(); - }, - ); - }); - } - } - } - - return result; + return process.stdin.isTTY === true; } /** - * Find Python executable (tries python3 first, then python) + * Handle MpakConfigError by prompting for missing values interactively. + * Saves provided values to config, then retries prepareServer. */ -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"; +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); + } + + 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); + } + + // 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(); + }, + ); + }); + } + + // 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 = {}, + 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.exit(1); - } - - let cacheDir: string; - let packageName: string; - let registryClient: ReturnType | null = null; - let cachedMeta: CacheMetadata | null = null; - - 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.exit(1); - } - - // Validate .mcpb extension - 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; - - // 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; - } - - 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, - }); - - // Fire-and-forget update check for registry bundles - let updateCheckPromise: Promise | null = null; - if (!options.local && registryClient && cachedMeta) { - updateCheckPromise = checkForUpdateAsync(packageName, cachedMeta, cacheDir, registryClient); - } - - // Forward signals - 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) - 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 - } - } - process.exit(code ?? 0); - }); - - child.on("error", (error) => { - process.stderr.write( - `=> Failed to start server: ${error.message}\n`, - ); - process.exit(1); - }); + // 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.exit(1); + } + + // CLI-level validation for --local + if (options.local) { + const bundlePath = resolve(options.local); + + if (!existsSync(bundlePath)) { + process.stderr.write(`=> Error: Bundle not found: ${bundlePath}\n`); + process.exit(1); + } + + if (!bundlePath.endsWith(".mcpb")) { + process.stderr.write(`=> Error: Not an MCPB bundle: ${bundlePath}\n`); + process.exit(1); + } + } + + // Build the spec + const spec: PrepareServerSpec = options.local + ? { local: resolve(options.local) } + : parsePackageSpec(packageSpec); + + // 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; + } + } + + // Spawn with stdio passthrough for MCP + 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 && !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")); + + // Wait for exit + child.on("exit", async (code) => { + if (updateCheckPromise) { + try { + await Promise.race([ + updateCheckPromise, + new Promise((r) => setTimeout(r, 3000)), + ]); + } 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`); + process.exit(1); + }); } diff --git a/packages/cli/tests/run.test.ts b/packages/cli/tests/run.test.ts new file mode 100644 index 0000000..35f79af --- /dev/null +++ b/packages/cli/tests/run.test.ts @@ -0,0 +1,574 @@ +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/sdk-typescript/src/errors.ts b/packages/sdk-typescript/src/errors.ts index a02b4de..1917344 100644 --- a/packages/sdk-typescript/src/errors.ts +++ b/packages/sdk-typescript/src/errors.ts @@ -113,7 +113,7 @@ export class MpakInvalidBundleError extends MpakError { 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/mpakSDK.ts b/packages/sdk-typescript/src/mpakSDK.ts index ff28df1..75e5279 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -271,6 +271,7 @@ export class Mpak { const missingFields: Array<{ key: string; title: string; + description?: string; sensitive: boolean; }> = []; @@ -282,11 +283,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/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/mpak.test.ts b/packages/sdk-typescript/tests/mpak.test.ts index 44e7b45..7cab049 100644 --- a/packages/sdk-typescript/tests/mpak.test.ts +++ b/packages/sdk-typescript/tests/mpak.test.ts @@ -497,6 +497,63 @@ 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, From ea2d86121074a383c5c6779813ea6de1bfd6446a Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 29 Mar 2026 10:39:21 -0400 Subject: [PATCH 04/11] Migrate CLI commands to SDK facade and deduplicate search param types (#59 Phase 4A+4B) Replace createClient() with mpak.client across all search/show/pull/install commands, delete cli/src/utils/client.ts, export z.input types from schemas so the SDK reuses them instead of hand-rolling duplicates, and add search command tests. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/packages/pull.ts | 4 +- packages/cli/src/commands/packages/search.ts | 104 ++++++++------- packages/cli/src/commands/packages/show.ts | 4 +- packages/cli/src/commands/search.ts | 4 +- packages/cli/src/commands/skills/install.ts | 4 +- packages/cli/src/commands/skills/pull.ts | 4 +- packages/cli/src/commands/skills/search.ts | 4 +- packages/cli/src/commands/skills/show.ts | 4 +- packages/cli/src/utils/client.ts | 16 --- packages/cli/src/utils/format.ts | 84 +++++++------ packages/cli/tests/search.test.ts | 125 +++++++++++++++++++ packages/schemas/src/package.ts | 2 + packages/schemas/src/skill.ts | 2 + packages/sdk-typescript/src/types.ts | 32 +---- 14 files changed, 239 insertions(+), 154 deletions(-) delete mode 100644 packages/cli/src/utils/client.ts create mode 100644 packages/cli/tests/search.test.ts diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index c375254..d86e227 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -2,7 +2,7 @@ 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 { mpak } from "../../utils/config.js"; export interface PullOptions { output?: string; @@ -49,7 +49,7 @@ export async function handlePull( try { const { name, version } = parsePackageSpec(packageSpec); - const client = createClient(); + const client = mpak.client; // Detect platform (or use explicit overrides) const detectedPlatform = MpakClient.detectPlatform(); diff --git a/packages/cli/src/commands/packages/search.ts b/packages/cli/src/commands/packages/search.ts index 578821f..c820ce6 100644 --- a/packages/cli/src/commands/packages/search.ts +++ b/packages/cli/src/commands/packages/search.ts @@ -1,61 +1,57 @@ -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; - json?: boolean; -} +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 = {}, + 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]); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - if (result.bundles.length === 0) { - console.log(`\nNo bundles found for "${query}"`); - 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), - ]); - - console.log(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], rows)); - console.log(); - - if (result.pagination.has_more) { - const nextOffset = (options.offset || 0) + (options.limit || 20); - console.log( - `More results available. Use --offset ${nextOffset} to see more.`, - ); - } - - console.log('Use "mpak show " for more details'); - } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to search bundles"); - } + try { + const result = await mpak.client.searchBundles({ + q: query, + ...options, + }); + + if (result.bundles.length === 0) { + console.log(`\nNo bundles found for "${query}"`); + return; + } + + console.log(`\nFound ${result.total} bundle(s) for "${query}":\n`); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const rows = result.bundles.map((b) => [ + b.name, + `v${b.latest_version}`, + certLabel(b.certification_level), + truncate(b.description || "", 50), + ]); + + console.log(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], rows)); + console.log(); + + if (result.pagination.has_more) { + const nextOffset = (options.offset || 0) + (options.limit || 20); + console.log( + `More results available. Use --offset ${nextOffset} to see more.`, + ); + } + + console.log('Use "mpak show " for more details'); + } catch (error) { + 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..ac5598c 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -1,5 +1,5 @@ import { fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from "../../utils/config.js"; export interface ShowOptions { json?: boolean; @@ -20,7 +20,7 @@ export async function handleShow( options: ShowOptions = {}, ): Promise { try { - const client = createClient(); + const client = mpak.client; // Fetch bundle details and versions in parallel const [bundle, versionsInfo] = await Promise.all([ diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index de1d46c..bbbacd1 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,5 +1,5 @@ import { table, certLabel, truncate, fmtError } from "../utils/format.js"; -import { createClient } from "../utils/client.js"; +import { mpak } from "../utils/config.js"; export interface UnifiedSearchOptions { type?: "bundle" | "skill"; @@ -33,7 +33,7 @@ export async function handleUnifiedSearch( options: UnifiedSearchOptions = {}, ): Promise { try { - const client = createClient(); + const client = mpak.client; const results: UnifiedResult[] = []; let bundleTotal = 0; let skillTotal = 0; diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index b414b6f..bbbc487 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -3,7 +3,7 @@ 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 { mpak } from "../../utils/config.js"; /** * Get the Claude Code skills directory @@ -58,7 +58,7 @@ export async function handleSkillInstall( const { name, version } = parseSkillSpec(skillSpec); // Get download info - const client = createClient(); + const client = mpak.client; const downloadInfo = version ? await client.getSkillVersionDownload(name, version) : await client.getSkillDownload(name); diff --git a/packages/cli/src/commands/skills/pull.ts b/packages/cli/src/commands/skills/pull.ts index 8f2ceb1..7a5501c 100644 --- a/packages/cli/src/commands/skills/pull.ts +++ b/packages/cli/src/commands/skills/pull.ts @@ -1,7 +1,7 @@ import { writeFileSync } from "fs"; import { basename, join } from "path"; import { formatSize, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from "../../utils/config.js"; /** * Parse skill spec into name and version @@ -47,7 +47,7 @@ export async function handleSkillPull( const { name, version } = parseSkillSpec(skillSpec); // Get download info - const client = createClient(); + const client = mpak.client; const downloadInfo = version ? await client.getSkillVersionDownload(name, version) : await client.getSkillDownload(name); diff --git a/packages/cli/src/commands/skills/search.ts b/packages/cli/src/commands/skills/search.ts index fb1dbee..2a01ad2 100644 --- a/packages/cli/src/commands/skills/search.ts +++ b/packages/cli/src/commands/skills/search.ts @@ -1,5 +1,5 @@ import { table, truncate, fmtError } from "../../utils/format.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from "../../utils/config.js"; export interface SearchOptions { tags?: string; @@ -18,7 +18,7 @@ export async function handleSkillSearch( options: SearchOptions, ): Promise { try { - const client = createClient(); + const client = mpak.client; const params: Record = { q: query }; if (options.tags) params["tags"] = options.tags; if (options.category) params["category"] = options.category; diff --git a/packages/cli/src/commands/skills/show.ts b/packages/cli/src/commands/skills/show.ts index 7608aa9..4dcd16d 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"; export interface ShowOptions { json?: boolean; @@ -13,7 +13,7 @@ export async function handleSkillShow( options: ShowOptions, ): Promise { try { - const client = createClient(); + const client = mpak.client; const skill = await client.getSkill(name); if (options.json) { 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/format.ts b/packages/cli/src/utils/format.ts index 8ebba07..b9aaa64 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1,78 +1,80 @@ export interface TableOptions { - /** Column indices to right-align (0-based) */ - rightAlign?: number[]; + /** Column indices to right-align (0-based) */ + rightAlign?: number[]; } /** * Render an aligned text table with auto-calculated column widths. */ export function table( - headers: string[], - rows: string[][], - opts?: TableOptions, + headers: string[], + rows: string[][], + opts?: TableOptions, ): string { - const rightAlign = new Set(opts?.rightAlign ?? []); + 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, - ); - return Math.max(h.length, maxData); - }); + // 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, + ); + return Math.max(h.length, maxData); + }); - const pad = (text: string, width: number, colIdx: number): string => - rightAlign.has(colIdx) ? text.padStart(width) : text.padEnd(width); + const pad = (text: string, width: number, colIdx: number): string => + rightAlign.has(colIdx) ? text.padStart(width) : text.padEnd(width); - const lines: string[] = []; + const lines: string[] = []; - // Header - lines.push( - headers.map((h, i) => pad(h, widths[i]!, i)).join(" "), - ); + // Header + 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(" "), - ); - } + // Rows + for (const row of rows) { + 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 "-"; - return `L${level}`; + if (level == null) return "-"; + return `L${level}`; } /** * Human-readable file size. */ export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Truncate text to a maximum length, appending "..." if truncated. */ export function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return text.slice(0, max - 3) + "..."; + if (text.length <= max) return text; + return text.slice(0, max - 3) + "..."; } /** - * Print a standardized error message and exit. + * Print a standardized error message. */ -export function fmtError(message: string): never { - console.error(`Error: ${message}`); - process.exit(1); +export function logError(message: string): void { + console.error(`Error: ${message}`); } + +/** @deprecated Use {@link logError} instead. */ +export const fmtError = logError; + +export const logger = { + error: (msg: string) => console.error(`[Error] ${msg}`), +}; diff --git a/packages/cli/tests/search.test.ts b/packages/cli/tests/search.test.ts new file mode 100644 index 0000000..1de7f63 --- /dev/null +++ b/packages/cli/tests/search.test.ts @@ -0,0 +1,125 @@ +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(stdoutSpy).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(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining("Found 2 bundle(s)"), + ); + const allOutput = stdoutSpy.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/schemas/src/package.ts b/packages/schemas/src/package.ts index 0b19efc..f63db83 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -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..ee5d0bc 100644 --- a/packages/schemas/src/skill.ts +++ b/packages/schemas/src/skill.ts @@ -212,6 +212,8 @@ 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/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 From ed57c1be66e3ec85445bec65ae8d0b27b3374c62 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 29 Mar 2026 10:48:31 -0400 Subject: [PATCH 05/11] Clean up bundle show command: remove unsafe casts, add tests (#59 Phase 4C) Drop `as string` casts on `published_at` (already typed `string | Date`), replace deprecated `fmtError` with `logger.error`, and add show.test.ts with 9 tests covering all output paths. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/packages/show.ts | 307 ++++++++++----------- packages/cli/tests/show.test.ts | 240 ++++++++++++++++ 2 files changed, 392 insertions(+), 155 deletions(-) create mode 100644 packages/cli/tests/show.test.ts diff --git a/packages/cli/src/commands/packages/show.ts b/packages/cli/src/commands/packages/show.ts index ac5598c..f50cf3e 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -1,169 +1,166 @@ -import { fmtError } from "../../utils/format.js"; import { mpak } from "../../utils/config.js"; +import { logger } from "../../utils/format.js"; export interface ShowOptions { - json?: boolean; + 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 = {}, + packageName: string, + options: ShowOptions = {}, ): Promise { - try { - const client = mpak.client; - - // Fetch bundle details and versions in parallel - const [bundle, versionsInfo] = await Promise.all([ - client.getBundle(packageName), - client.getBundleVersions(packageName), - ]); - - if (options.json) { - 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( - `\n${verified}${provenance}${bundle.display_name || bundle.name} v${bundle.latest_version}\n`, - ); - - // Description - if (bundle.description) { - console.log(bundle.description); - console.log(); - } - - // Basic info - console.log("Bundle Information:"); - console.log(` Name: ${bundle.name}`); - if (bundle.author?.name) { - console.log(` Author: ${bundle.author.name}`); - } - if (bundle.server_type) { - console.log(` Type: ${bundle.server_type}`); - } - if (bundle.license) { - console.log(` License: ${bundle.license}`); - } - if (bundle.homepage) { - console.log(` Homepage: ${bundle.homepage}`); - } - console.log(); - - // Trust / Certification - const certLevel = bundle.certification_level; - const certification = bundle.certification; - - if (certLevel != null) { - const label = CERT_LEVEL_LABELS[certLevel] ?? `L${certLevel}`; - console.log(`Trust: ${label}`); - if (certification?.controls_passed != null && certification?.controls_total != null) { - console.log(` Controls: ${certification.controls_passed}/${certification.controls_total} passed`); - } - console.log(); - } - - // 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(); - } - - // Stats - console.log("Statistics:"); - console.log(` Downloads: ${bundle.downloads.toLocaleString()}`); - console.log( - ` Published: ${new Date(bundle.published_at as string).toLocaleDateString()}`, - ); - console.log(); - - // Tools - if (bundle.tools && bundle.tools.length > 0) { - console.log(`Tools (${bundle.tools.length}):`); - for (const tool of bundle.tools) { - console.log(` - ${tool.name}`); - if (tool.description) { - console.log(` ${tool.description}`); - } - } - console.log(); - } - - // Versions with platforms - if (versionsInfo.versions && versionsInfo.versions.length > 0) { - console.log(`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 downloads = version.downloads.toLocaleString(); - 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(", ")}]` - : ""; - - console.log( - ` ${version.version}${isLatest}${provTag} - ${date} - ${downloads} downloads${platformsDisplay}`, - ); - } - if (versionsInfo.versions.length > 5) { - console.log( - ` ... and ${versionsInfo.versions.length - 5} more`, - ); - } - console.log(); - } - - // Available platforms for latest version - const latestVersion = versionsInfo.versions.find( - (v) => v.version === versionsInfo.latest, - ); - if (latestVersion && latestVersion.platforms.length > 0) { - console.log("Available Platforms:"); - for (const platform of latestVersion.platforms) { - console.log(` - ${platform.os}-${platform.arch}`); - } - console.log(); - } - - // Install instructions - console.log("Install:"); - console.log(` mpak install ${bundle.name}`); - console.log(); - console.log("Pull (download only):"); - console.log(` mpak pull ${bundle.name}`); - } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to get bundle details"); - } + try { + // Fetch bundle details and versions in parallel + const [bundle, versionsInfo] = await Promise.all([ + mpak.client.getBundle(packageName), + mpak.client.getBundleVersions(packageName), + ]); + + if (options.json) { + 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( + `\n${verified}${provenance}${bundle.display_name || bundle.name} v${bundle.latest_version}\n`, + ); + + // Description + if (bundle.description) { + console.log(bundle.description); + console.log(); + } + + // Basic info + console.log("Bundle Information:"); + console.log(` Name: ${bundle.name}`); + if (bundle.author?.name) { + console.log(` Author: ${bundle.author.name}`); + } + if (bundle.server_type) { + console.log(` Type: ${bundle.server_type}`); + } + if (bundle.license) { + console.log(` License: ${bundle.license}`); + } + if (bundle.homepage) { + console.log(` Homepage: ${bundle.homepage}`); + } + console.log(); + + // Trust / Certification + const certLevel = bundle.certification_level; + const certification = bundle.certification; + + if (certLevel != null) { + const label = CERT_LEVEL_LABELS[certLevel] ?? `L${certLevel}`; + console.log(`Trust: ${label}`); + if ( + certification?.controls_passed != null && + certification?.controls_total != null + ) { + console.log( + ` Controls: ${certification.controls_passed}/${certification.controls_total} passed`, + ); + } + console.log(); + } + + // 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(); + } + + // Stats + console.log("Statistics:"); + console.log(` Downloads: ${bundle.downloads.toLocaleString()}`); + console.log( + ` Published: ${new Date(bundle.published_at).toLocaleDateString()}`, + ); + console.log(); + + // Tools + if (bundle.tools && bundle.tools.length > 0) { + console.log(`Tools (${bundle.tools.length}):`); + for (const tool of bundle.tools) { + console.log(` - ${tool.name}`); + if (tool.description) { + console.log(` ${tool.description}`); + } + } + console.log(); + } + + // Versions with platforms + if (versionsInfo.versions && versionsInfo.versions.length > 0) { + console.log(`Versions (${versionsInfo.versions.length}):`); + const recentVersions = versionsInfo.versions.slice(0, 5); + for (const version of recentVersions) { + 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" : ""; + + // Format platforms + const platformStrs = version.platforms.map((p) => `${p.os}-${p.arch}`); + const platformsDisplay = + platformStrs.length > 0 ? ` [${platformStrs.join(", ")}]` : ""; + + console.log( + ` ${version.version}${isLatest}${provTag} - ${date} - ${downloads} downloads${platformsDisplay}`, + ); + } + if (versionsInfo.versions.length > 5) { + console.log(` ... and ${versionsInfo.versions.length - 5} more`); + } + console.log(); + } + + // Available platforms for latest version + const latestVersion = versionsInfo.versions.find( + (v) => v.version === versionsInfo.latest, + ); + if (latestVersion && latestVersion.platforms.length > 0) { + console.log("Available Platforms:"); + for (const platform of latestVersion.platforms) { + console.log(` - ${platform.os}-${platform.arch}`); + } + console.log(); + } + + // Install instructions + console.log("Install:"); + console.log(` mpak install ${bundle.name}`); + console.log(); + console.log("Pull (download only):"); + console.log(` mpak pull ${bundle.name}`); + } catch (error) { + logger.error( + error instanceof Error ? error.message : "Failed to get bundle details", + ); + } } diff --git a/packages/cli/tests/show.test.ts b/packages/cli/tests/show.test.ts new file mode 100644 index 0000000..e78a83e --- /dev/null +++ b/packages/cli/tests/show.test.ts @@ -0,0 +1,240 @@ +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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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"), + ); + }); +}); From 117b6aea353fe62a4588b7d981fcff1646a9f02c Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sun, 29 Mar 2026 11:18:57 -0400 Subject: [PATCH 06/11] Clean up bundle pull command: use SDK downloadBundle, add tests (#59 Phase 4C) Replace manual fetch with mpak.client.downloadBundle() which adds SHA-256 integrity verification. Use SDK's parsePackageSpec, formatSize helper, and logger.error. Clean up partial file on error. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/packages/pull.ts | 161 ++++++++------------- packages/cli/tests/pull.test.ts | 151 +++++++++++++++++++ 2 files changed, 213 insertions(+), 99 deletions(-) create mode 100644 packages/cli/tests/pull.test.ts diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index d86e227..8b07723 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -1,110 +1,73 @@ -import { writeFileSync } from "fs"; +import { rmSync, writeFileSync } from "fs"; import { resolve } from "path"; -import { MpakClient } from "@nimblebrain/mpak-sdk"; -import { fmtError } from "../../utils/format.js"; +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; - json?: boolean; - os?: string; - arch?: string; + output?: string; + json?: boolean; + os?: string; + arch?: string; } /** - * 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' } - */ -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 + * Pull (download) a bundle from the registry to disk. */ export async function handlePull( - packageSpec: string, - options: PullOptions = {}, + packageSpec: string, + options: PullOptions = {}, ): Promise { - try { - const { name, version } = parsePackageSpec(packageSpec); - - const client = mpak.client; - - // 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}`); - - // Get download info with platform - const downloadInfo = await client.getBundleDownload( - name, - version || "latest", - platform, - ); - - if (options.json) { - console.log(JSON.stringify(downloadInfo, 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`, - ); - - // 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); - - console.log(`\n=> Downloading to ${outputPath}...`); - - // 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)}...`); - } catch (error) { - fmtError(error instanceof Error ? error.message : "Failed to pull bundle"); - } + let outputPath: string | undefined; + try { + const { name, version } = parsePackageSpec(packageSpec); + + 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}`); + + const { data, metadata } = await mpak.client.downloadBundle( + name, + version, + platform, + ); + + if (options.json) { + console.log(JSON.stringify(metadata, null, 2)); + return; + } + + console.log(` Version: ${metadata.version}`); + console.log( + ` Artifact: ${metadata.platform.os}-${metadata.platform.arch}`, + ); + console.log(` Size: ${formatSize(metadata.size)}`); + + 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}...`); + writeFileSync(outputPath, data); + + console.log(`\n=> Bundle downloaded successfully!`); + console.log(` File: ${outputPath}`); + console.log(` SHA256: ${metadata.sha256.substring(0, 16)}...`); + } catch (error) { + if (outputPath) { + try { rmSync(outputPath, { force: true }); } catch {} + } + logger.error( + error instanceof Error ? error.message : "Failed to pull bundle", + ); + } } diff --git a/packages/cli/tests/pull.test.ts b/packages/cli/tests/pull.test.ts new file mode 100644 index 0000000..ad8163c --- /dev/null +++ b/packages/cli/tests/pull.test.ts @@ -0,0 +1,151 @@ +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 = stdoutSpy.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"), + ); + }); +}); From fd0012772b8c8a8f12394f3187a2cc686066f640 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Mon, 30 Mar 2026 10:21:13 -0400 Subject: [PATCH 07/11] Clean up skill commands and reorganize CLI tests (#59 Phase 4C) Migrates skills/install, skills/pull, skills/search, skills/show to SDK facade. Removes deleted utils (cache, config-manager) and their tests. Moves all tests to tests/ tree with bundles/ and skills/ subdirectories. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/config.ts | 15 +- .../src/commands/packages/outdated.test.ts | 123 ----- .../cli/src/commands/packages/outdated.ts | 12 +- packages/cli/src/commands/packages/pull.ts | 2 +- packages/cli/src/commands/packages/show.ts | 3 - .../cli/src/commands/packages/update.test.ts | 158 ------ packages/cli/src/commands/packages/update.ts | 137 +++--- packages/cli/src/commands/skills/install.ts | 104 ++-- packages/cli/src/commands/skills/pull.ts | 117 ++--- packages/cli/src/commands/skills/search.ts | 98 ++-- packages/cli/src/commands/skills/show.ts | 150 +++--- packages/cli/src/utils/cache.test.ts | 185 ------- packages/cli/src/utils/cache.ts | 259 ---------- packages/cli/src/utils/config-manager.test.ts | 465 ------------------ packages/cli/src/utils/config-manager.ts | 291 ----------- packages/cli/src/utils/config.ts | 6 +- packages/cli/tests/bundles/outdated.test.ts | 185 +++++++ packages/cli/tests/{ => bundles}/pull.test.ts | 4 +- packages/cli/tests/{ => bundles}/run.test.ts | 4 +- .../cli/tests/{ => bundles}/search.test.ts | 4 +- packages/cli/tests/{ => bundles}/show.test.ts | 4 +- packages/cli/tests/bundles/update.test.ts | 184 +++++++ .../cli/{src/utils => tests}/errors.test.ts | 2 +- packages/cli/{src => tests}/program.test.ts | 2 +- packages/cli/tests/skills/install.test.ts | 183 +++++++ .../commands => tests}/skills/pack.test.ts | 2 +- packages/cli/tests/skills/pull.test.ts | 154 ++++++ packages/cli/tests/skills/search.test.ts | 151 ++++++ packages/cli/tests/skills/show.test.ts | 189 +++++++ .../skills/validate.test.ts | 2 +- .../cli/{src/utils => tests}/version.test.ts | 2 +- 31 files changed, 1327 insertions(+), 1870 deletions(-) delete mode 100644 packages/cli/src/commands/packages/outdated.test.ts delete mode 100644 packages/cli/src/commands/packages/update.test.ts delete mode 100644 packages/cli/src/utils/cache.test.ts delete mode 100644 packages/cli/src/utils/cache.ts delete mode 100644 packages/cli/src/utils/config-manager.test.ts delete mode 100644 packages/cli/src/utils/config-manager.ts create mode 100644 packages/cli/tests/bundles/outdated.test.ts rename packages/cli/tests/{ => bundles}/pull.test.ts (97%) rename packages/cli/tests/{ => bundles}/run.test.ts (99%) rename packages/cli/tests/{ => bundles}/search.test.ts (96%) rename packages/cli/tests/{ => bundles}/show.test.ts (98%) create mode 100644 packages/cli/tests/bundles/update.test.ts rename packages/cli/{src/utils => tests}/errors.test.ts (93%) rename packages/cli/{src => tests}/program.test.ts (92%) create mode 100644 packages/cli/tests/skills/install.test.ts rename packages/cli/{src/commands => tests}/skills/pack.test.ts (99%) create mode 100644 packages/cli/tests/skills/pull.test.ts create mode 100644 packages/cli/tests/skills/search.test.ts create mode 100644 packages/cli/tests/skills/show.test.ts rename packages/cli/{src/commands => tests}/skills/validate.test.ts (99%) rename packages/cli/{src/utils => tests}/version.test.ts (89%) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 844258f..81183e9 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import type { PackageConfig } from "@nimblebrain/mpak-sdk"; -import { mpakConfigManager } from "../utils/config.js"; +import { mpak } from "../utils/config.js"; export interface ConfigGetOptions { json?: boolean; @@ -51,7 +51,7 @@ export async function handleConfigSet( process.exit(1); } - mpakConfigManager.setPackageConfigValue(packageName, key, value); + mpak.configManager.setPackageConfigValue(packageName, key, value); setCount++; } @@ -67,7 +67,7 @@ export async function handleConfigGet( packageName: string, options: ConfigGetOptions = {}, ): Promise { - const config = mpakConfigManager.getPackageConfig(packageName); + const config = mpak.configManager.getPackageConfig(packageName); const isOutputJson = !!options?.json; // If no config or config is {} @@ -100,8 +100,7 @@ export async function handleConfigGet( export async function handleConfigList( options: ConfigGetOptions = {}, ): Promise { - const configManager = mpakConfigManager; - const packages = mpakConfigManager.getPackageNames(); + const packages = mpak.configManager.getPackageNames(); const isOutputJson = !!options?.json; if (packages.length === 0) { @@ -118,7 +117,7 @@ export async function handleConfigList( } else { 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"})`); } @@ -136,7 +135,7 @@ export async function handleConfigClear( ): Promise { if (key) { // Clear specific key - const cleared = mpakConfigManager.clearPackageConfigValue(packageName, key); + const cleared = mpak.configManager.clearPackageConfigValue(packageName, key); if (cleared) { console.log(`Cleared ${key} for ${packageName}`); } else { @@ -144,7 +143,7 @@ export async function handleConfigClear( } } else { // Clear all config for package - const cleared = mpakConfigManager.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..a330e25 100644 --- a/packages/cli/src/commands/packages/outdated.ts +++ b/packages/cli/src/commands/packages/outdated.ts @@ -1,5 +1,4 @@ -import { isSemverEqual, listCachedBundles } from "../../utils/cache.js"; -import { createClient } from "../../utils/client.js"; +import { mpak } from "../../utils/config.js"; import { table } from "../../utils/format.js"; export interface OutdatedEntry { @@ -18,21 +17,20 @@ 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, }); } diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index 8b07723..3bc9c36 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -64,7 +64,7 @@ export async function handlePull( console.log(` SHA256: ${metadata.sha256.substring(0, 16)}...`); } catch (error) { if (outputPath) { - try { rmSync(outputPath, { force: true }); } catch {} + 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/show.ts b/packages/cli/src/commands/packages/show.ts index f50cf3e..b182ef5 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -153,9 +153,6 @@ export async function handleShow( } // Install instructions - console.log("Install:"); - console.log(` mpak install ${bundle.name}`); - console.log(); console.log("Pull (download only):"); console.log(` mpak pull ${bundle.name}`); } catch (error) { 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..8e9cae2 100644 --- a/packages/cli/src/commands/packages/update.ts +++ b/packages/cli/src/commands/packages/update.ts @@ -1,77 +1,92 @@ -import { downloadAndExtract, resolveBundle } from "../../utils/cache.js"; -import { createClient } from "../../utils/client.js"; -import { fmtError } from "../../utils/format.js"; +import { MpakNetworkError, MpakNotFoundError } from "@nimblebrain/mpak-sdk"; +import { mpak } from "../../utils/config.js"; import { getOutdatedBundles } from "./outdated.js"; export interface UpdateOptions { - json?: boolean; + 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 = {}, + 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); - if (options.json) { - console.log(JSON.stringify({ name: packageName, version }, null, 2)); - } else { - console.log(`Updated ${packageName} to ${version}`); - } - return; - } - - // No name given — find and update all outdated bundles - process.stderr.write("=> Checking for updates...\n"); - const outdated = await getOutdatedBundles(); + if (packageName) { + const { version } = await forceUpdateBundle(packageName); + if (options.json) { + console.log(JSON.stringify({ name: packageName, version }, null, 2)); + } else { + console.log(`Updated ${packageName} to ${version}`); + } + return; + } - if (outdated.length === 0) { - if (options.json) { - console.log(JSON.stringify([], null, 2)); - } else { - console.log("All cached bundles are up to date."); - } - return; - } + // No name given — find and update all outdated bundles + process.stderr.write("=> Checking for updates...\n"); + const outdated = await getOutdatedBundles(); - process.stderr.write( - `=> ${outdated.length} bundle(s) to update\n`, - ); + if (outdated.length === 0) { + if (options.json) { + console.log(JSON.stringify([], null, 2)); + } else { + console.log("All cached bundles are up to date."); + } + return; + } - const updated: Array<{ name: string; from: string; to: string }> = []; + process.stderr.write(`=> ${outdated.length} bundle(s) to update\n`); - const results = await Promise.allSettled( - outdated.map(async (entry) => { - const downloadInfo = await resolveBundle(entry.name, client); - const { version } = await downloadAndExtract(entry.name, downloadInfo); - return { name: entry.name, from: entry.current, to: version }; - }), - ); + const updated: Array<{ name: string; from: string; to: string }> = []; - for (const [i, result] of results.entries()) { - 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 results = await Promise.allSettled( + outdated.map(async (entry) => { + const { version } = await forceUpdateBundle(entry.name); + return { name: entry.name, from: entry.current, to: version }; + }), + ); - if (options.json) { - console.log(JSON.stringify(updated, null, 2)); - return; - } + for (const [i, result] of results.entries()) { + 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`, + ); + } + } - if (updated.length === 0) { - fmtError("All updates failed."); - process.exit(1); - } + if (updated.length === 0) { + console.error(`[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) { + console.log(`Updated ${u.name}: ${u.from} -> ${u.to}`); + } + } } diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index bbbc487..e081a8b 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -1,9 +1,10 @@ -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 { 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 @@ -12,27 +13,6 @@ 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 }; -} - /** * Extract skill name from scoped name * @scope/skill-name -> skill-name @@ -52,17 +32,21 @@ 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); + + console.log( + `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, + ); + + const { data, metadata } = await mpak.client.downloadSkillBundle( + name, + version, + ); - // Get download info - const client = mpak.client; - const downloadInfo = version - ? await client.getSkillVersionDownload(name, version) - : await client.getSkillDownload(name); - const shortName = getShortName(downloadInfo.skill.name); + const shortName = getShortName(metadata.name); const skillsDir = getSkillsDir(); const installPath = join(skillsDir, shortName); @@ -75,40 +59,15 @@ export async function handleSkillInstall( 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)})`, - ); + console.log(` Version: ${metadata.version}`); + console.log(` 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 +75,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], { + execFileSync("unzip", ["-o", tempPath, "-d", skillsDir], { stdio: "pipe", }); } catch (err) { throw new Error(`Failed to extract skill bundle: ${err}`); } finally { - // Clean up temp file rmSync(tempPath, { force: true }); } @@ -134,9 +90,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 +100,16 @@ export async function handleSkillInstall( ), ); } else { - console.log(`Extracting to ${installPath}/`); - console.log(`\u2713 Installed: ${shortName}`); + console.log(`\n=> Installed to ${installPath}/`); + console.log(` \u2713 ${shortName}@${metadata.version}`); console.log(""); console.log( "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/pull.ts b/packages/cli/src/commands/skills/pull.ts index 7a5501c..bca1db0 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 { rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parsePackageSpec } from "@nimblebrain/mpak-sdk"; import { mpak } from "../../utils/config.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 { formatSize, logger } from "../../utils/format.js"; export interface PullOptions { output?: string; @@ -37,73 +10,49 @@ 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, + options: PullOptions = {}, ): Promise { + let outputPath: string | undefined; try { - const { name, version } = parseSkillSpec(skillSpec); - - // Get download info - const client = mpak.client; - const downloadInfo = version - ? await client.getSkillVersionDownload(name, version) - : await client.getSkillDownload(name); + const { name, version } = parsePackageSpec(skillSpec); console.log( - `Pulling ${downloadInfo.skill.name}@${downloadInfo.skill.version}...`, + `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, ); - // 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()); + const { data, metadata } = await mpak.client.downloadSkillBundle( + name, + version, + ); - // 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}`, - ); - } + if (options.json) { + console.log(JSON.stringify(metadata, null, 2)); + return; } - // Determine output path - const filename = `${basename(downloadInfo.skill.name.replace("@", "").replace("/", "-"))}-${downloadInfo.skill.version}.skill`; - const outputPath = - options.output || join(process.cwd(), filename); + console.log(` Version: ${metadata.version}`); + console.log(` Size: ${formatSize(metadata.size)}`); - // Write to disk - writeFileSync(outputPath, buffer); + const defaultFilename = `${name.replace("@", "").replace("/", "-")}-${metadata.version}.skill`; + outputPath = options.output + ? resolve(options.output) + : resolve(defaultFilename); - 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}`); + writeFileSync(outputPath, data); + + console.log(`\n=> Skill downloaded successfully!`); + console.log(` File: ${outputPath}`); + console.log(` 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 2a01ad2..b089ece 100644 --- a/packages/cli/src/commands/skills/search.ts +++ b/packages/cli/src/commands/skills/search.ts @@ -1,62 +1,48 @@ -import { table, truncate, fmtError } from "../../utils/format.js"; +import type { SkillSearchParamsInput } from "@nimblebrain/mpak-schemas"; import { mpak } from "../../utils/config.js"; +import { logger, table, truncate } from "../../utils/format.js"; -export interface SearchOptions { - tags?: string; - category?: string; - sort?: string; - limit?: number; - offset?: number; - json?: boolean; -} +export type SearchOptions = SkillSearchParamsInput & { json?: boolean }; -/** - * Handle the skill search command - */ export async function handleSkillSearch( - query: string, - options: SearchOptions, + query: string, + options: SearchOptions, ): Promise { - try { - const client = mpak.client; - 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) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - if (result.skills.length === 0) { - console.log(`No skills found for "${query}"`); - return; - } - - console.log(); - - 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), - ]); - - console.log( - table(["NAME", "VERSION", "CATEGORY", "DESCRIPTION"], rows), - ); - - if (result.pagination.has_more) { - console.log(); - console.log( - `Showing ${result.skills.length} of ${result.total} results. Use --offset to see more.`, - ); - } - } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); - } + try { + 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}"`); + return; + } + + console.log(); + + 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), + ]); + + console.log(table(["NAME", "VERSION", "CATEGORY", "DESCRIPTION"], rows)); + + if (result.pagination.has_more) { + console.log(); + console.log( + `Showing ${result.skills.length} of ${result.total} results. Use --offset to see more.`, + ); + } + } catch (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 4dcd16d..002cb29 100644 --- a/packages/cli/src/commands/skills/show.ts +++ b/packages/cli/src/commands/skills/show.ts @@ -1,99 +1,87 @@ -import { fmtError } from "../../utils/format.js"; import { mpak } from "../../utils/config.js"; +import { logger } from "../../utils/format.js"; export interface ShowOptions { - json?: boolean; + json?: boolean; } /** * Handle the skill show command */ export async function handleSkillShow( - name: string, - options: ShowOptions, + name: string, + options: ShowOptions, ): Promise { - try { - const client = mpak.client; - const skill = await client.getSkill(name); + try { + const skill = await mpak.client.getSkill(name); - if (options.json) { - console.log(JSON.stringify(skill, null, 2)); - return; - } + 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(""); + console.log(""); + console.log(`${skill.name}@${skill.latest_version}`); + console.log(""); + console.log(skill.description); + console.log(""); - // 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(", ")}`); - if (skill.author) - console.log( - ` 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()}`, - ); + // 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(", ")}`); + if (skill.author) + console.log( + ` 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()}`, + ); - // Triggers - if (skill.triggers && skill.triggers.length > 0) { - console.log(""); - console.log("Triggers:"); - skill.triggers.forEach((t: string) => - console.log(` - ${t}`), - ); - } + // Triggers + if (skill.triggers && skill.triggers.length > 0) { + console.log(""); + console.log("Triggers:"); + skill.triggers.forEach((t: string) => console.log(` - ${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})` : ""}`, - ); - }, - ); - } + // 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})` : ""}`, + ); + }, + ); + } - // 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`, - ); - }, - ); - if (skill.versions.length > 5) { - console.log( - ` ... and ${skill.versions.length - 5} more`, - ); - } - } + // 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`, + ); + }, + ); + if (skill.versions.length > 5) { + console.log(` ... and ${skill.versions.length - 5} more`); + } + } - console.log(""); - console.log(`Install: mpak skill install ${skill.name}`); - } catch (err) { - fmtError(err instanceof Error ? err.message : String(err)); - } + console.log(""); + console.log(`Install: mpak skill install ${skill.name}`); + } catch (err) { + logger.error(err instanceof Error ? err.message : String(err)); + } } 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/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 index 747a2de..52a5d21 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -1,9 +1,11 @@ -import { MpakConfigManager } from "@nimblebrain/mpak-sdk"; +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 mpakConfigManager = new MpakConfigManager({ +export const mpak = new Mpak({ ...(mpakHome ? { mpakHome } : {}), ...(registryUrl ? { registryUrl } : {}), + userAgent: `mpak-cli/${getVersion()}`, }); diff --git a/packages/cli/tests/bundles/outdated.test.ts b/packages/cli/tests/bundles/outdated.test.ts new file mode 100644 index 0000000..fc46c56 --- /dev/null +++ b/packages/cli/tests/bundles/outdated.test.ts @@ -0,0 +1,185 @@ +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/pull.test.ts b/packages/cli/tests/bundles/pull.test.ts similarity index 97% rename from packages/cli/tests/pull.test.ts rename to packages/cli/tests/bundles/pull.test.ts index ad8163c..6f8082d 100644 --- a/packages/cli/tests/pull.test.ts +++ b/packages/cli/tests/bundles/pull.test.ts @@ -2,7 +2,7 @@ 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"; +import { handlePull } from "../../src/commands/packages/pull.js"; // --------------------------------------------------------------------------- // Mocks @@ -12,7 +12,7 @@ vi.mock("fs", () => ({ writeFileSync: vi.fn() })); let mockDownloadBundle: ReturnType; -vi.mock("../src/utils/config.js", () => ({ +vi.mock("../../src/utils/config.js", () => ({ get mpak() { return { client: { downloadBundle: mockDownloadBundle } as unknown as MpakClient, diff --git a/packages/cli/tests/run.test.ts b/packages/cli/tests/bundles/run.test.ts similarity index 99% rename from packages/cli/tests/run.test.ts rename to packages/cli/tests/bundles/run.test.ts index 35f79af..63a3715 100644 --- a/packages/cli/tests/run.test.ts +++ b/packages/cli/tests/bundles/run.test.ts @@ -10,7 +10,7 @@ import { MpakNotFoundError, } from "@nimblebrain/mpak-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { handleRun } from "../src/commands/packages/run.js"; +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 { @@ -67,7 +67,7 @@ const mockPrepareServer = vi.fn(); const mockCheckForUpdate = vi.fn(); const mockSetPackageConfigValue = vi.fn(); -vi.mock("../src/utils/config.js", () => ({ +vi.mock("../../src/utils/config.js", () => ({ get mpak() { return { prepareServer: mockPrepareServer, diff --git a/packages/cli/tests/search.test.ts b/packages/cli/tests/bundles/search.test.ts similarity index 96% rename from packages/cli/tests/search.test.ts rename to packages/cli/tests/bundles/search.test.ts index 1de7f63..dd0c85d 100644 --- a/packages/cli/tests/search.test.ts +++ b/packages/cli/tests/bundles/search.test.ts @@ -1,7 +1,7 @@ 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"; +import { handleSearch } from "../../src/commands/packages/search.js"; // --------------------------------------------------------------------------- // Mock the mpak singleton @@ -9,7 +9,7 @@ import { handleSearch } from "../src/commands/packages/search.js"; let mockSearchBundles: ReturnType; -vi.mock("../src/utils/config.js", () => ({ +vi.mock("../../src/utils/config.js", () => ({ get mpak() { return { client: { searchBundles: mockSearchBundles } as unknown as MpakClient, diff --git a/packages/cli/tests/show.test.ts b/packages/cli/tests/bundles/show.test.ts similarity index 98% rename from packages/cli/tests/show.test.ts rename to packages/cli/tests/bundles/show.test.ts index e78a83e..dd364a2 100644 --- a/packages/cli/tests/show.test.ts +++ b/packages/cli/tests/bundles/show.test.ts @@ -1,7 +1,7 @@ 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"; +import { handleShow } from "../../src/commands/packages/show.js"; // --------------------------------------------------------------------------- // Mock the mpak singleton @@ -10,7 +10,7 @@ import { handleShow } from "../src/commands/packages/show.js"; let mockGetBundle: ReturnType; let mockGetBundleVersions: ReturnType; -vi.mock("../src/utils/config.js", () => ({ +vi.mock("../../src/utils/config.js", () => ({ get mpak() { return { client: { diff --git a/packages/cli/tests/bundles/update.test.ts b/packages/cli/tests/bundles/update.test.ts new file mode 100644 index 0000000..508cfb6 --- /dev/null +++ b/packages/cli/tests/bundles/update.test.ts @@ -0,0 +1,184 @@ +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.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }); + 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(stdout).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(stdout).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(stdout).toContain("Updated @scope/a: 1.0.0 -> 2.0.0"); + expect(stdout).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(stdout).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/src/utils/errors.test.ts b/packages/cli/tests/errors.test.ts similarity index 93% rename from packages/cli/src/utils/errors.test.ts rename to packages/cli/tests/errors.test.ts index 9106604..db88bae 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/tests/errors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { CLIError } from "./errors.js"; +import { CLIError } from "../src/utils/errors.js"; describe("CLIError", () => { it("should create error with message", () => { diff --git a/packages/cli/src/program.test.ts b/packages/cli/tests/program.test.ts similarity index 92% rename from packages/cli/src/program.test.ts rename to packages/cli/tests/program.test.ts index 58b1d0f..8750017 100644 --- a/packages/cli/src/program.test.ts +++ b/packages/cli/tests/program.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createProgram } from "./program.js"; +import { createProgram } from "../src/program.js"; describe("createProgram", () => { it("should create a program with correct name", () => { diff --git a/packages/cli/tests/skills/install.test.ts b/packages/cli/tests/skills/install.test.ts new file mode 100644 index 0000000..736cdd0 --- /dev/null +++ b/packages/cli/tests/skills/install.test.ts @@ -0,0 +1,183 @@ +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 = stdoutSpy.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/src/commands/skills/pack.test.ts b/packages/cli/tests/skills/pack.test.ts similarity index 99% rename from packages/cli/src/commands/skills/pack.test.ts rename to packages/cli/tests/skills/pack.test.ts index 378b4a4..448382b 100644 --- a/packages/cli/src/commands/skills/pack.test.ts +++ b/packages/cli/tests/skills/pack.test.ts @@ -7,7 +7,7 @@ import { } from "fs"; import { join, basename } from "path"; import { tmpdir } from "os"; -import { packSkill } from "./pack.js"; +import { packSkill } from "../../src/commands/skills/pack.js"; import { execSync } from "child_process"; describe("packSkill", () => { diff --git a/packages/cli/tests/skills/pull.test.ts b/packages/cli/tests/skills/pull.test.ts new file mode 100644 index 0000000..930a5c3 --- /dev/null +++ b/packages/cli/tests/skills/pull.test.ts @@ -0,0 +1,154 @@ +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 = stdoutSpy.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..aac5fc1 --- /dev/null +++ b/packages/cli/tests/skills/search.test.ts @@ -0,0 +1,151 @@ +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(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining('No skills found for "nonexistent"'), + ); + }); + + it("prints table output when results exist", async () => { + mockSearchSkills.mockResolvedValue(twoResultsResponse); + + await handleSkillSearch("test", {}); + + const allOutput = stdoutSpy.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 = stdoutSpy.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..74ff8ae --- /dev/null +++ b/packages/cli/tests/skills/show.test.ts @@ -0,0 +1,189 @@ +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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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 = stdoutSpy.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/src/commands/skills/validate.test.ts b/packages/cli/tests/skills/validate.test.ts similarity index 99% rename from packages/cli/src/commands/skills/validate.test.ts rename to packages/cli/tests/skills/validate.test.ts index 6c4d632..f43fb3a 100644 --- a/packages/cli/src/commands/skills/validate.test.ts +++ b/packages/cli/tests/skills/validate.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "os"; import { validateSkillDirectory, formatValidationResult, -} from "./validate.js"; +} from "../../src/commands/skills/validate.js"; describe("validateSkillDirectory", () => { let testDir: string; diff --git a/packages/cli/src/utils/version.test.ts b/packages/cli/tests/version.test.ts similarity index 89% rename from packages/cli/src/utils/version.test.ts rename to packages/cli/tests/version.test.ts index 19fbbf4..77880bd 100644 --- a/packages/cli/src/utils/version.test.ts +++ b/packages/cli/tests/version.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getVersion } from "./version.js"; +import { getVersion } from "../src/utils/version.js"; describe("getVersion", () => { it("should return a valid version string", () => { From e294cd61043a45ecf33f710cb913b78b11ddf393 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Mon, 30 Mar 2026 10:45:43 -0400 Subject: [PATCH 08/11] Route all CLI human-readable output to stderr via logger.info (#59 Phase 4C) Adds logger.info (writes to stderr) and routes all status/table/progress output through it. JSON output (--json) stays on stdout via console.log, keeping stdout machine-readable. Updates all tests to assert on stderrSpy for human output and stdoutSpy for JSON. Adds unified search.ts test. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/commands/packages/outdated.ts | 8 +- packages/cli/src/commands/packages/pull.ts | 18 +- packages/cli/src/commands/packages/search.ts | 12 +- packages/cli/src/commands/packages/show.ts | 70 +++---- packages/cli/src/commands/packages/update.ts | 17 +- packages/cli/src/commands/search.ts | 53 ++--- packages/cli/src/commands/skills/install.ts | 18 +- packages/cli/src/commands/skills/pull.ts | 12 +- packages/cli/src/commands/skills/search.ts | 10 +- packages/cli/src/commands/skills/show.ts | 68 +++--- packages/cli/src/utils/format.ts | 1 + packages/cli/tests/bundles/pull.test.ts | 2 +- packages/cli/tests/bundles/search.test.ts | 6 +- packages/cli/tests/bundles/show.test.ts | 12 +- packages/cli/tests/bundles/update.test.ts | 14 +- packages/cli/tests/search.test.ts | 195 ++++++++++++++++++ packages/cli/tests/skills/install.test.ts | 2 +- packages/cli/tests/skills/pull.test.ts | 2 +- packages/cli/tests/skills/search.test.ts | 6 +- packages/cli/tests/skills/show.test.ts | 14 +- 20 files changed, 367 insertions(+), 173 deletions(-) create mode 100644 packages/cli/tests/search.test.ts diff --git a/packages/cli/src/commands/packages/outdated.ts b/packages/cli/src/commands/packages/outdated.ts index a330e25..a663e8c 100644 --- a/packages/cli/src/commands/packages/outdated.ts +++ b/packages/cli/src/commands/packages/outdated.ts @@ -1,5 +1,5 @@ import { mpak } from "../../utils/config.js"; -import { table } from "../../utils/format.js"; +import { logger, table } from "../../utils/format.js"; export interface OutdatedEntry { name: string; @@ -54,15 +54,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 3bc9c36..8c5de6d 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -28,10 +28,10 @@ export async function handlePull( arch: options.arch || detectedPlatform.arch, }; - console.log( + logger.info( `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, ); - console.log(` Platform: ${platform.os}-${platform.arch}`); + logger.info(` Platform: ${platform.os}-${platform.arch}`); const { data, metadata } = await mpak.client.downloadBundle( name, @@ -44,11 +44,11 @@ export async function handlePull( return; } - console.log(` Version: ${metadata.version}`); - console.log( + logger.info(` Version: ${metadata.version}`); + logger.info( ` Artifact: ${metadata.platform.os}-${metadata.platform.arch}`, ); - console.log(` Size: ${formatSize(metadata.size)}`); + logger.info(` Size: ${formatSize(metadata.size)}`); const platformSuffix = `${metadata.platform.os}-${metadata.platform.arch}`; const defaultFilename = `${name.replace("@", "").replace("/", "-")}-${metadata.version}-${platformSuffix}.mcpb`; @@ -56,12 +56,12 @@ export async function handlePull( ? resolve(options.output) : resolve(defaultFilename); - console.log(`\n=> Downloading to ${outputPath}...`); + logger.info(`\n=> Downloading to ${outputPath}...`); writeFileSync(outputPath, data); - console.log(`\n=> Bundle downloaded successfully!`); - console.log(` File: ${outputPath}`); - console.log(` SHA256: ${metadata.sha256.substring(0, 16)}...`); + logger.info(`\n=> Bundle 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 */ } diff --git a/packages/cli/src/commands/packages/search.ts b/packages/cli/src/commands/packages/search.ts index c820ce6..d174ea7 100644 --- a/packages/cli/src/commands/packages/search.ts +++ b/packages/cli/src/commands/packages/search.ts @@ -20,11 +20,11 @@ export async function handleSearch( }); if (result.bundles.length === 0) { - console.log(`\nNo bundles found for "${query}"`); + logger.info(`\nNo bundles found for "${query}"`); return; } - console.log(`\nFound ${result.total} bundle(s) for "${query}":\n`); + logger.info(`\nFound ${result.total} bundle(s) for "${query}":\n`); if (options.json) { console.log(JSON.stringify(result, null, 2)); @@ -38,17 +38,17 @@ export async function handleSearch( 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( + 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) { 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 b182ef5..fca6ed8 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -40,32 +40,32 @@ export async function handleShow( // Header const verified = bundle.verified ? "\u2713 " : ""; const provenance = bundle.provenance ? "\uD83D\uDD12 " : ""; - console.log( + 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; @@ -73,50 +73,50 @@ 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( + 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( + logger.info("Statistics:"); + logger.info(` Downloads: ${bundle.downloads.toLocaleString()}`); + logger.info( ` Published: ${new Date(bundle.published_at).toLocaleDateString()}`, ); - console.log(); + 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).toLocaleDateString(); @@ -130,14 +130,14 @@ export async function handleShow( 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 @@ -145,16 +145,16 @@ export async function handleShow( (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("Pull (download only):"); - console.log(` mpak pull ${bundle.name}`); + logger.info("Pull (download only):"); + logger.info(` mpak pull ${bundle.name}`); } catch (error) { logger.error( error instanceof Error ? error.message : "Failed to get bundle details", diff --git a/packages/cli/src/commands/packages/update.ts b/packages/cli/src/commands/packages/update.ts index 8e9cae2..6585b4b 100644 --- a/packages/cli/src/commands/packages/update.ts +++ b/packages/cli/src/commands/packages/update.ts @@ -1,5 +1,6 @@ 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 { @@ -34,25 +35,25 @@ export async function handleUpdate( 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 }> = []; @@ -71,14 +72,12 @@ export async function handleUpdate( result.reason instanceof Error ? result.reason.message : String(result.reason); - process.stderr.write( - `=> Failed to update ${outdated[i]!.name}: ${message}\n`, - ); + logger.info(`=> Failed to update ${outdated[i]!.name}: ${message}`); } } if (updated.length === 0) { - console.error(`[Error] All updates failed`); + logger.error("All updates failed"); process.exit(1); } @@ -86,7 +85,7 @@ export async function handleUpdate( console.log(JSON.stringify(updated, null, 2)); } else { for (const u of updated) { - console.log(`Updated ${u.name}: ${u.from} -> ${u.to}`); + 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 bbbacd1..d28229d 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,4 +1,6 @@ -import { table, certLabel, truncate, fmtError } from "../utils/format.js"; +import type { BundleSearchParamsInput } from "@nimblebrain/mpak-schemas"; +import type { SkillSearchParamsInput } from "@nimblebrain/mpak-schemas"; +import { table, certLabel, truncate, logger } from "../utils/format.js"; import { mpak } from "../utils/config.js"; export interface UnifiedSearchOptions { @@ -42,19 +44,24 @@ export async function handleUnifiedSearch( 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 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, + searchBundles ? client.searchBundles(bundleParams) : null, searchSkillsFlag - ? client - .searchSkills(searchParams as Parameters[0]) - .catch(() => null) // Skills API may not be deployed yet + ? client.searchSkills(skillParams).catch(() => null) // Skills API may not be deployed yet : null, ]); @@ -117,16 +124,16 @@ export async function handleUnifiedSearch( // 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)"); + logger.info(`\nNo results found for "${query}"`); + if (!searchBundles) logger.info(" (searched skills only)"); + if (!searchSkillsFlag) logger.info(" (searched bundles only)"); return; } // Summary const totalResults = bundleTotal + skillTotal; const typeFilter = options.type ? ` (${options.type}s only)` : ""; - console.log( + logger.info( `\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`, ); @@ -135,42 +142,42 @@ export async function handleUnifiedSearch( // 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), ]); - 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 || "-", 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( + logger.info( `\n Use --offset ${currentOffset + currentLimit} to see more results.`, ); } - console.log(); - console.log( + 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 e081a8b..46dbe0d 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -37,7 +37,7 @@ export async function handleSkillInstall( try { const { name, version } = parsePackageSpec(skillSpec); - console.log( + logger.info( `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, ); @@ -52,15 +52,15 @@ export async function handleSkillInstall( // Check if already installed if (existsSync(installPath) && !options.force) { - console.error( + logger.error( `Skill "${shortName}" is already installed at ${installPath}`, ); - console.error("Use --force to overwrite"); + logger.error("Use --force to overwrite"); process.exit(1); } - console.log(` Version: ${metadata.version}`); - console.log(` Size: ${formatSize(metadata.size)}`); + logger.info(` Version: ${metadata.version}`); + logger.info(` Size: ${formatSize(metadata.size)}`); // Ensure skills directory exists mkdirSync(skillsDir, { recursive: true }); @@ -100,10 +100,10 @@ export async function handleSkillInstall( ), ); } else { - console.log(`\n=> Installed to ${installPath}/`); - console.log(` \u2713 ${shortName}@${metadata.version}`); - console.log(""); - console.log( + logger.info(`\n=> Installed to ${installPath}/`); + logger.info(` \u2713 ${shortName}@${metadata.version}`); + logger.info(""); + logger.info( "Skill available in Claude Code. Restart to activate.", ); } diff --git a/packages/cli/src/commands/skills/pull.ts b/packages/cli/src/commands/skills/pull.ts index bca1db0..97240d0 100644 --- a/packages/cli/src/commands/skills/pull.ts +++ b/packages/cli/src/commands/skills/pull.ts @@ -20,7 +20,7 @@ export async function handleSkillPull( try { const { name, version } = parsePackageSpec(skillSpec); - console.log( + logger.info( `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, ); @@ -34,8 +34,8 @@ export async function handleSkillPull( return; } - console.log(` Version: ${metadata.version}`); - console.log(` Size: ${formatSize(metadata.size)}`); + logger.info(` Version: ${metadata.version}`); + logger.info(` Size: ${formatSize(metadata.size)}`); const defaultFilename = `${name.replace("@", "").replace("/", "-")}-${metadata.version}.skill`; outputPath = options.output @@ -44,9 +44,9 @@ export async function handleSkillPull( writeFileSync(outputPath, data); - console.log(`\n=> Skill downloaded successfully!`); - console.log(` File: ${outputPath}`); - console.log(` SHA256: ${metadata.sha256.substring(0, 16)}...`); + 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 */ } diff --git a/packages/cli/src/commands/skills/search.ts b/packages/cli/src/commands/skills/search.ts index b089ece..d8eef34 100644 --- a/packages/cli/src/commands/skills/search.ts +++ b/packages/cli/src/commands/skills/search.ts @@ -21,11 +21,11 @@ export async function handleSkillSearch( } 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, @@ -34,11 +34,11 @@ export async function handleSkillSearch( 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.`, ); } diff --git a/packages/cli/src/commands/skills/show.ts b/packages/cli/src/commands/skills/show.ts index 002cb29..0769040 100644 --- a/packages/cli/src/commands/skills/show.ts +++ b/packages/cli/src/commands/skills/show.ts @@ -20,67 +20,63 @@ export async function handleSkillShow( 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}`); + 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) - console.log(` Tags: ${skill.tags.join(", ")}`); + logger.info(` Tags: ${skill.tags.join(", ")}`); if (skill.author) - console.log( + logger.info( ` Author: ${skill.author.name}${skill.author.url ? ` (${skill.author.url})` : ""}`, ); - console.log(` Downloads: ${skill.downloads.toLocaleString()}`); - console.log( + 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:"); + logger.info(""); + logger.info("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`, - ); - }, - ); + .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) { logger.error(err instanceof Error ? err.message : String(err)); } diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index b9aaa64..2131544 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -77,4 +77,5 @@ 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/tests/bundles/pull.test.ts b/packages/cli/tests/bundles/pull.test.ts index 6f8082d..f633343 100644 --- a/packages/cli/tests/bundles/pull.test.ts +++ b/packages/cli/tests/bundles/pull.test.ts @@ -114,7 +114,7 @@ describe("handlePull", () => { it("prints metadata and SHA in normal output", async () => { await handlePull("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); diff --git a/packages/cli/tests/bundles/search.test.ts b/packages/cli/tests/bundles/search.test.ts index dd0c85d..2142dbd 100644 --- a/packages/cli/tests/bundles/search.test.ts +++ b/packages/cli/tests/bundles/search.test.ts @@ -74,7 +74,7 @@ describe("handleSearch", () => { expect(mockSearchBundles).toHaveBeenCalledWith( expect.objectContaining({ q: "nonexistent" }), ); - expect(stdoutSpy).toHaveBeenCalledWith( + expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('No bundles found for "nonexistent"'), ); }); @@ -84,10 +84,10 @@ describe("handleSearch", () => { await handleSearch("test"); - expect(stdoutSpy).toHaveBeenCalledWith( + expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining("Found 2 bundle(s)"), ); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); expect(allOutput).toContain("@scope/alpha"); expect(allOutput).toContain("@scope/beta"); }); diff --git a/packages/cli/tests/bundles/show.test.ts b/packages/cli/tests/bundles/show.test.ts index dd364a2..b5d0bed 100644 --- a/packages/cli/tests/bundles/show.test.ts +++ b/packages/cli/tests/bundles/show.test.ts @@ -125,7 +125,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); }); @@ -136,7 +136,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); @@ -150,7 +150,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); }); @@ -161,7 +161,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); @@ -173,7 +173,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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)"); @@ -218,7 +218,7 @@ describe("handleShow", () => { await handleShow("@scope/test-bundle"); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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:"); diff --git a/packages/cli/tests/bundles/update.test.ts b/packages/cli/tests/bundles/update.test.ts index 508cfb6..28c9346 100644 --- a/packages/cli/tests/bundles/update.test.ts +++ b/packages/cli/tests/bundles/update.test.ts @@ -38,10 +38,6 @@ beforeEach(() => { vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { stderr += args.join(" ") + "\n"; }); - vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { - stderr += String(chunk); - return true; - }); vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); }); @@ -62,7 +58,7 @@ describe("handleUpdate — single bundle", () => { await handleUpdate("@scope/a"); expect(mockLoadBundle).toHaveBeenCalledWith("@scope/a", { force: true }); - expect(stdout).toContain("Updated @scope/a to 2.0.0"); + expect(stderr).toContain("Updated @scope/a to 2.0.0"); }); it("outputs JSON when --json is set", async () => { @@ -107,7 +103,7 @@ describe("handleUpdate — bulk update", () => { await handleUpdate(undefined); - expect(stdout).toContain("All cached bundles are up to date."); + expect(stderr).toContain("All cached bundles are up to date."); }); it("outputs empty JSON array when nothing is outdated and --json is set", async () => { @@ -135,8 +131,8 @@ describe("handleUpdate — bulk update", () => { expect(mockLoadBundle).toHaveBeenCalledWith("@scope/a", { force: true }); expect(mockLoadBundle).toHaveBeenCalledWith("@scope/b", { force: true }); - expect(stdout).toContain("Updated @scope/a: 1.0.0 -> 2.0.0"); - expect(stdout).toContain("Updated @scope/b: 1.0.0 -> 3.0.0"); + 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 () => { @@ -152,7 +148,7 @@ describe("handleUpdate — bulk update", () => { await handleUpdate(undefined); - expect(stdout).toContain("Updated @scope/good: 1.0.0 -> 2.0.0"); + expect(stderr).toContain("Updated @scope/good: 1.0.0 -> 2.0.0"); expect(stderr).toContain("Failed to update @scope/bad"); }); diff --git a/packages/cli/tests/search.test.ts b/packages/cli/tests/search.test.ts new file mode 100644 index 0000000..e8beb6c --- /dev/null +++ b/packages/cli/tests/search.test.ts @@ -0,0 +1,195 @@ +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("swallows skill API errors and continues", async () => { + mockSearchBundles.mockResolvedValue(bundleResponse); + mockSearchSkills.mockRejectedValue(new Error("Skills API not deployed")); + + await handleUnifiedSearch("test"); + + // Should still show bundle results, not crash + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(allOutput).toContain("@scope/bundle-a"); + }); + + 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 index 736cdd0..f86b6f1 100644 --- a/packages/cli/tests/skills/install.test.ts +++ b/packages/cli/tests/skills/install.test.ts @@ -162,7 +162,7 @@ describe("handleSkillInstall", () => { it("prints success output in normal mode", async () => { await handleSkillInstall("@scope/test-skill"); - const allOutput = stdoutSpy.mock.calls + const allOutput = stderrSpy.mock.calls .map((c: unknown[]) => c[0]) .join("\n"); expect(allOutput).toContain("test-skill@1.2.0"); diff --git a/packages/cli/tests/skills/pull.test.ts b/packages/cli/tests/skills/pull.test.ts index 930a5c3..20e963a 100644 --- a/packages/cli/tests/skills/pull.test.ts +++ b/packages/cli/tests/skills/pull.test.ts @@ -98,7 +98,7 @@ describe("handleSkillPull", () => { it("prints metadata in normal output", async () => { await handleSkillPull("@scope/test-skill"); - const allOutput = stdoutSpy.mock.calls + const allOutput = stderrSpy.mock.calls .map((c: unknown[]) => c[0]) .join("\n"); expect(allOutput).toContain("Version: 1.2.0"); diff --git a/packages/cli/tests/skills/search.test.ts b/packages/cli/tests/skills/search.test.ts index aac5fc1..397ec15 100644 --- a/packages/cli/tests/skills/search.test.ts +++ b/packages/cli/tests/skills/search.test.ts @@ -89,7 +89,7 @@ describe("handleSkillSearch", () => { await handleSkillSearch("nonexistent", {}); - expect(stdoutSpy).toHaveBeenCalledWith( + expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('No skills found for "nonexistent"'), ); }); @@ -99,7 +99,7 @@ describe("handleSkillSearch", () => { await handleSkillSearch("test", {}); - const allOutput = stdoutSpy.mock.calls + const allOutput = stderrSpy.mock.calls .map((c: unknown[]) => c[0]) .join("\n"); expect(allOutput).toContain("@scope/skill-alpha"); @@ -132,7 +132,7 @@ describe("handleSkillSearch", () => { await handleSkillSearch("test", {}); - const allOutput = stdoutSpy.mock.calls + const allOutput = stderrSpy.mock.calls .map((c: unknown[]) => c[0]) .join("\n"); expect(allOutput).toContain("1 of 25"); diff --git a/packages/cli/tests/skills/show.test.ts b/packages/cli/tests/skills/show.test.ts index 74ff8ae..4a6c89a 100644 --- a/packages/cli/tests/skills/show.test.ts +++ b/packages/cli/tests/skills/show.test.ts @@ -83,7 +83,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); }); @@ -93,7 +93,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); @@ -106,7 +106,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); @@ -117,7 +117,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"'); @@ -128,7 +128,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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"); @@ -140,7 +140,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/test-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); expect(allOutput).toContain("Install: mpak skill install @scope/test-skill"); }); @@ -149,7 +149,7 @@ describe("handleSkillShow", () => { await handleSkillShow("@scope/minimal-skill", {}); - const allOutput = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + 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:"); From f3377ef6314248b1ce6a8dc563d001e2e6a1a56d Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Mon, 30 Mar 2026 11:11:56 -0400 Subject: [PATCH 09/11] Clean up unified search: discriminated union result type, remove stale skill error swallow (#59 Phase 4C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hand-shaped UnifiedResult interface with Bundle | SkillSummary discriminated union - Remove .catch(() => null) on skill search (stale workaround from pre-deployment era) - Use real schema field names (latest_version, certification_level, description ?? "") - Type predicate filters for proper TS narrowing after results.filter() - Fix biome template literal warnings in table row builders - Update test: "swallows skill errors" → "logs error when skill search throws" Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/search.ts | 317 +++++++++++++--------------- packages/cli/tests/search.test.ts | 8 +- 2 files changed, 150 insertions(+), 175 deletions(-) diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index d28229d..73dd546 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,183 +1,158 @@ -import type { BundleSearchParamsInput } from "@nimblebrain/mpak-schemas"; -import type { SkillSearchParamsInput } from "@nimblebrain/mpak-schemas"; -import { table, certLabel, truncate, logger } from "../utils/format.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"; - limit?: number; - offset?: number; - json?: boolean; + 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 */ export async function handleUnifiedSearch( - query: string, - options: UnifiedSearchOptions = {}, + query: string, + options: UnifiedSearchOptions = {}, ): Promise { - try { - 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 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(bundleParams) : null, - searchSkillsFlag - ? client.searchSkills(skillParams).catch(() => null) // Skills API may not be deployed yet - : 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, - }); - } - } - - // 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, - }); - } - } - - // Sort combined results - if (options.sort === "downloads") { - results.sort((a, b) => b.downloads - a.downloads); - } else if (options.sort === "name") { - results.sort((a, b) => a.name.localeCompare(b.name)); - } - - // JSON output - if (options.json) { - console.log( - JSON.stringify( - { - results, - totals: { bundles: bundleTotal, skills: skillTotal }, - }, - null, - 2, - ), - ); - return; - } - - // 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; - } - - // Summary - const totalResults = bundleTotal + skillTotal; - 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"); - - // Bundles section - if (bundles.length > 0) { - 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), - ]); - logger.info(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], bundleRows)); - } - - // Skills section - if (skills.length > 0) { - 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 || "-", - truncate(r.description, 40), - ]); - 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) { - logger.info( - `\n Use --offset ${currentOffset + currentLimit} to see more results.`, - ); - } - - logger.info(""); - logger.info( - 'Use "mpak bundle show " or "mpak skill show " for details.', - ); - } catch (error) { - logger.error(error instanceof Error ? error.message : "Search failed"); - } + try { + 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 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(bundleParams) : null, + searchSkillsFlag ? client.searchSkills(skillParams) : null, + ]); + + if (bundleResult) { + bundleTotal = bundleResult.total; + for (const bundle of bundleResult.bundles) { + results.push({ type: "bundle", ...bundle }); + } + } + + if (skillResult) { + skillTotal = skillResult.total; + for (const skill of skillResult.skills) { + 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") { + results.sort((a, b) => b.downloads - a.downloads); + } else if (options.sort === "name") { + results.sort((a, b) => a.name.localeCompare(b.name)); + } + + // JSON output + if (options.json) { + console.log( + JSON.stringify( + { + results, + totals: { bundles: bundleTotal, skills: skillTotal }, + }, + null, + 2, + ), + ); + return; + } + + // Summary + const totalResults = bundleTotal + skillTotal; + const typeFilter = options.type ? ` (${options.type}s only)` : ""; + logger.info( + `\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`, + ); + + 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) { + logger.info(`\nBundles (${bundleTotal}):\n`); + const bundleRows = bundles.map((r) => [ + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || "-", + certLabel(r.certification_level), + truncate(r.description ?? "", 40), + ]); + logger.info(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], bundleRows)); + } + + // Skills section + if (skills.length > 0) { + logger.info(`\nSkills (${skillTotal}):\n`); + const skillRows = skills.map((r) => [ + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || "-", + r.category || "-", + truncate(r.description, 40), + ]); + 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) { + logger.info( + `\n Use --offset ${currentOffset + currentLimit} to see more results.`, + ); + } + + logger.info(""); + logger.info( + 'Use "mpak bundle show " or "mpak skill show " for details.', + ); + } catch (error) { + logger.error(error instanceof Error ? error.message : "Search failed"); + } } diff --git a/packages/cli/tests/search.test.ts b/packages/cli/tests/search.test.ts index e8beb6c..87bc213 100644 --- a/packages/cli/tests/search.test.ts +++ b/packages/cli/tests/search.test.ts @@ -141,15 +141,15 @@ describe("handleUnifiedSearch", () => { expect(allOutput).toContain("@scope/skill-a"); }); - it("swallows skill API errors and continues", async () => { + it("logs error when skill search throws", async () => { mockSearchBundles.mockResolvedValue(bundleResponse); mockSearchSkills.mockRejectedValue(new Error("Skills API not deployed")); await handleUnifiedSearch("test"); - // Should still show bundle results, not crash - const allOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); - expect(allOutput).toContain("@scope/bundle-a"); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Skills API not deployed"), + ); }); it("outputs JSON when --json is set", async () => { From 88fbc710d567f9bee1c96313efe2a1959a09e860 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Mon, 30 Mar 2026 15:38:28 -0400 Subject: [PATCH 10/11] Rewrite integration tests to run CLI as subprocess, add bundle and skill integration tests - Rewrite registry-client.test.ts: test bundle search/show via `node dist/index.js` instead of MpakClient directly - Rewrite update-flow.test.ts: fix broken cache.js/client.js imports, test outdated/update commands as subprocesses with SDK for cache setup - Add bundle.integration.test.ts: smoke tests for bundle pull (file download + JSON metadata) - Add skill.integration.test.ts: smoke tests for skill search and show - Add helpers.ts: shared run() helper that spawns the CLI and captures stdout/stderr/exitCode Co-Authored-By: Claude Sonnet 4.6 --- .../integration/bundle.integration.test.ts | 51 +++++ packages/cli/tests/integration/helpers.ts | 32 +++ .../tests/integration/registry-client.test.ts | 207 +++++------------- .../integration/skill.integration.test.ts | 46 ++++ .../cli/tests/integration/update-flow.test.ts | 85 ++++--- 5 files changed, 217 insertions(+), 204 deletions(-) create mode 100644 packages/cli/tests/integration/bundle.integration.test.ts create mode 100644 packages/cli/tests/integration/helpers.ts create mode 100644 packages/cli/tests/integration/skill.integration.test.ts 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..8230339 --- /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..174ec00 --- /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..cf7d594 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..0d0a17e --- /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..054384e 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 + 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); }); From ff302af2ebee2aedc4f0085d81e6d7179e31f4d2 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Mon, 30 Mar 2026 15:48:59 -0400 Subject: [PATCH 11/11] Format changed files with prettier Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/config.ts | 200 +++---- .../cli/src/commands/packages/outdated.ts | 14 +- packages/cli/src/commands/packages/pull.ts | 119 ++-- packages/cli/src/commands/packages/run.ts | 338 ++++++----- packages/cli/src/commands/packages/search.ts | 93 ++- packages/cli/src/commands/packages/show.ts | 284 +++++---- packages/cli/src/commands/packages/update.ts | 144 +++-- packages/cli/src/commands/search.ts | 278 +++++---- packages/cli/src/commands/skills/install.ts | 47 +- packages/cli/src/commands/skills/pull.ts | 40 +- packages/cli/src/commands/skills/search.ts | 85 ++- packages/cli/src/commands/skills/show.ts | 126 ++-- packages/cli/src/utils/config.ts | 14 +- packages/cli/src/utils/format.ts | 67 +-- packages/cli/tests/bundles/outdated.test.ts | 142 +++-- packages/cli/tests/bundles/pull.test.ts | 242 ++++---- packages/cli/tests/bundles/run.test.ts | 365 ++++++------ packages/cli/tests/bundles/search.test.ts | 185 +++--- packages/cli/tests/bundles/show.test.ts | 428 +++++++------- packages/cli/tests/bundles/update.test.ts | 166 +++--- packages/cli/tests/config.test.ts | 544 +++++++++--------- packages/cli/tests/errors.test.ts | 26 +- .../integration/bundle.integration.test.ts | 26 +- packages/cli/tests/integration/helpers.ts | 12 +- .../tests/integration/registry-client.test.ts | 38 +- .../integration/skill.integration.test.ts | 20 +- .../cli/tests/integration/update-flow.test.ts | 26 +- packages/cli/tests/program.test.ts | 20 +- packages/cli/tests/search.test.ts | 312 +++++----- packages/cli/tests/skills/install.test.ts | 287 +++++---- packages/cli/tests/skills/pack.test.ts | 179 +++--- packages/cli/tests/skills/pull.test.ts | 235 ++++---- packages/cli/tests/skills/search.test.ts | 234 ++++---- packages/cli/tests/skills/show.test.ts | 266 +++++---- packages/cli/tests/skills/validate.test.ts | 366 +++++------- packages/cli/tests/version.test.ts | 14 +- packages/schemas/src/package.ts | 16 +- packages/schemas/src/skill.ts | 39 +- packages/sdk-typescript/src/cache.ts | 5 +- packages/sdk-typescript/src/errors.ts | 7 +- packages/sdk-typescript/src/index.ts | 7 +- packages/sdk-typescript/src/mpakSDK.ts | 36 +- packages/sdk-typescript/tests/helpers.test.ts | 15 +- .../tests/mpak.integration.test.ts | 9 +- packages/sdk-typescript/tests/mpak.test.ts | 69 ++- 45 files changed, 2960 insertions(+), 3225 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 81183e9..f779f6d 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,18 +1,18 @@ -import type { PackageConfig } from "@nimblebrain/mpak-sdk"; -import { mpak } from "../utils/config.js"; +import type { PackageConfig } from '@nimblebrain/mpak-sdk'; +import { mpak } from '../utils/config.js'; export interface ConfigGetOptions { - json?: boolean; + json?: boolean; } /** * 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 value.substring(0, 4) + "*".repeat(value.length - 4); + if (value.length <= 4) { + return '*'.repeat(value.length); + } + return value.substring(0, 4) + '*'.repeat(value.length - 4); } /** @@ -20,42 +20,35 @@ 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[], -): 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.exit(1); - } +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.exit(1); + } - let setCount = 0; + let setCount = 0; - for (const pair of keyValuePairs) { - const eqIndex = pair.indexOf("="); - if (eqIndex === -1) { - process.stderr.write( - `Error: Invalid format "${pair}". Expected key=value\n`, - ); - process.exit(1); - } + for (const pair of keyValuePairs) { + const eqIndex = pair.indexOf('='); + if (eqIndex === -1) { + process.stderr.write(`Error: Invalid format "${pair}". Expected key=value\n`); + process.exit(1); + } - const key = pair.substring(0, eqIndex); - const value = pair.substring(eqIndex + 1); + const key = pair.substring(0, eqIndex); + const value = pair.substring(eqIndex + 1); - if (!key) { - process.stderr.write(`Error: Empty key in "${pair}"\n`); - process.exit(1); - } + if (!key) { + process.stderr.write(`Error: Empty key in "${pair}"\n`); + process.exit(1); + } - mpak.configManager.setPackageConfigValue(packageName, key, value); - setCount++; - } + mpak.configManager.setPackageConfigValue(packageName, key, value); + setCount++; + } - console.log(`Set ${setCount} config value(s) for ${packageName}`); + console.log(`Set ${setCount} config value(s) for ${packageName}`); } /** @@ -64,64 +57,62 @@ export async function handleConfigSet( * @example mpak config get @scope/name --json */ export async function handleConfigGet( - packageName: string, - options: ConfigGetOptions = {}, + packageName: string, + options: ConfigGetOptions = {}, ): Promise { - const config = mpak.configManager.getPackageConfig(packageName); - const isOutputJson = !!options?.json; + const config = mpak.configManager.getPackageConfig(packageName); + const isOutputJson = !!options?.json; - // If no config or config is {} - if (!config || Object.keys(config).length === 0) { - if (isOutputJson) { - console.log(JSON.stringify({}, null, 2)); - } else { - console.log(`No config stored for ${packageName}`); - } - return; - } else if (isOutputJson) { - // Mask values in JSON output too - const masked: PackageConfig = {}; - for (const [key, value] of Object.entries(config)) { - masked[key] = maskValue(value); - } - console.log(JSON.stringify(masked, null, 2)); - } else { - console.log(`Config for ${packageName}:`); - for (const [key, value] of Object.entries(config)) { - console.log(` ${key}: ${maskValue(value)}`); - } - } + // If no config or config is {} + if (!config || Object.keys(config).length === 0) { + if (isOutputJson) { + console.log(JSON.stringify({}, null, 2)); + } else { + console.log(`No config stored for ${packageName}`); + } + return; + } else if (isOutputJson) { + // Mask values in JSON output too + const masked: PackageConfig = {}; + for (const [key, value] of Object.entries(config)) { + masked[key] = maskValue(value); + } + console.log(JSON.stringify(masked, null, 2)); + } else { + console.log(`Config for ${packageName}:`); + for (const [key, value] of Object.entries(config)) { + console.log(` ${key}: ${maskValue(value)}`); + } + } } /** * List all packages with stored config * @example mpak config list */ -export async function handleConfigList( - options: ConfigGetOptions = {}, -): Promise { - const packages = mpak.configManager.getPackageNames(); - const isOutputJson = !!options?.json; +export async function handleConfigList(options: ConfigGetOptions = {}): Promise { + const packages = mpak.configManager.getPackageNames(); + const isOutputJson = !!options?.json; - if (packages.length === 0) { - if (isOutputJson) { - console.log(JSON.stringify([], null, 2)); - } else { - console.log("No packages have stored config"); - } - return; - } + if (packages.length === 0) { + if (isOutputJson) { + console.log(JSON.stringify([], null, 2)); + } else { + console.log('No packages have stored config'); + } + return; + } - if (isOutputJson) { - console.log(JSON.stringify(packages, null, 2)); - } else { - console.log("Packages with stored config:"); - for (const pkg of packages) { - const config = mpak.configManager.getPackageConfig(pkg); - const keyCount = config ? Object.keys(config).length : 0; - console.log(`${pkg} (${keyCount} value${keyCount === 1 ? "" : "s"})`); - } - } + if (isOutputJson) { + console.log(JSON.stringify(packages, null, 2)); + } else { + console.log('Packages with stored config:'); + for (const pkg of packages) { + const config = mpak.configManager.getPackageConfig(pkg); + const keyCount = config ? Object.keys(config).length : 0; + console.log(`${pkg} (${keyCount} value${keyCount === 1 ? '' : 's'})`); + } + } } /** @@ -129,25 +120,22 @@ 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, -): Promise { - if (key) { - // Clear specific key - const cleared = mpak.configManager.clearPackageConfigValue(packageName, key); - if (cleared) { - console.log(`Cleared ${key} for ${packageName}`); - } else { - console.log(`No value found for ${key} in ${packageName}`); - } - } else { - // Clear all config for package - const cleared = mpak.configManager.clearPackageConfig(packageName); - if (cleared) { - console.log(`Cleared all config for ${packageName}`); - } else { - console.log(`No config found for ${packageName}`); - } - } +export async function handleConfigClear(packageName: string, key?: string): Promise { + if (key) { + // Clear specific key + const cleared = mpak.configManager.clearPackageConfigValue(packageName, key); + if (cleared) { + console.log(`Cleared ${key} for ${packageName}`); + } else { + console.log(`No value found for ${key} in ${packageName}`); + } + } else { + // Clear all config for package + const cleared = mpak.configManager.clearPackageConfig(packageName); + if (cleared) { + console.log(`Cleared all config for ${packageName}`); + } else { + console.log(`No config found for ${packageName}`); + } + } } diff --git a/packages/cli/src/commands/packages/outdated.ts b/packages/cli/src/commands/packages/outdated.ts index a663e8c..3d5c044 100644 --- a/packages/cli/src/commands/packages/outdated.ts +++ b/packages/cli/src/commands/packages/outdated.ts @@ -1,5 +1,5 @@ -import { mpak } from "../../utils/config.js"; -import { logger, table } from "../../utils/format.js"; +import { mpak } from '../../utils/config.js'; +import { logger, table } from '../../utils/format.js'; export interface OutdatedEntry { name: string; @@ -35,7 +35,9 @@ export async function getOutdatedBundles(): Promise { }); } } 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`, + ); } }), ); @@ -44,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(); @@ -54,13 +56,13 @@ export async function handleOutdated(options: OutdatedOptions = {}): Promise [e.name, e.current, e.latest, e.pulledAt]), ), ); diff --git a/packages/cli/src/commands/packages/pull.ts b/packages/cli/src/commands/packages/pull.ts index 8c5de6d..7602f7c 100644 --- a/packages/cli/src/commands/packages/pull.ts +++ b/packages/cli/src/commands/packages/pull.ts @@ -1,73 +1,62 @@ -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"; +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; - json?: boolean; - os?: string; - arch?: string; + output?: string; + json?: boolean; + os?: string; + arch?: string; } /** * Pull (download) a bundle from the registry to disk. */ -export async function handlePull( - packageSpec: string, - options: PullOptions = {}, -): Promise { - let outputPath: string | undefined; - try { - const { name, version } = parsePackageSpec(packageSpec); - - const detectedPlatform = MpakClient.detectPlatform(); - const platform = { - os: options.os || detectedPlatform.os, - arch: options.arch || detectedPlatform.arch, - }; - - logger.info( - `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, - ); - logger.info(` Platform: ${platform.os}-${platform.arch}`); - - const { data, metadata } = await mpak.client.downloadBundle( - name, - version, - platform, - ); - - if (options.json) { - console.log(JSON.stringify(metadata, null, 2)); - return; - } - - logger.info(` Version: ${metadata.version}`); - logger.info( - ` Artifact: ${metadata.platform.os}-${metadata.platform.arch}`, - ); - logger.info(` Size: ${formatSize(metadata.size)}`); - - 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); - - logger.info(`\n=> Downloading to ${outputPath}...`); - writeFileSync(outputPath, data); - - logger.info(`\n=> Bundle 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 */ } - } - logger.error( - error instanceof Error ? error.message : "Failed to pull bundle", - ); - } +export async function handlePull(packageSpec: string, options: PullOptions = {}): Promise { + let outputPath: string | undefined; + try { + const { name, version } = parsePackageSpec(packageSpec); + + const detectedPlatform = MpakClient.detectPlatform(); + const platform = { + os: options.os || detectedPlatform.os, + arch: options.arch || detectedPlatform.arch, + }; + + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); + logger.info(` Platform: ${platform.os}-${platform.arch}`); + + const { data, metadata } = await mpak.client.downloadBundle(name, version, platform); + + if (options.json) { + console.log(JSON.stringify(metadata, null, 2)); + return; + } + + logger.info(` Version: ${metadata.version}`); + logger.info(` Artifact: ${metadata.platform.os}-${metadata.platform.arch}`); + logger.info(` Size: ${formatSize(metadata.size)}`); + + 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); + + logger.info(`\n=> Downloading to ${outputPath}...`); + writeFileSync(outputPath, data); + + logger.info(`\n=> Bundle 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 */ + } + } + logger.error(error instanceof Error ? error.message : 'Failed to pull bundle'); + } } diff --git a/packages/cli/src/commands/packages/run.ts b/packages/cli/src/commands/packages/run.ts index 26a19cc..9f2432d 100644 --- a/packages/cli/src/commands/packages/run.ts +++ b/packages/cli/src/commands/packages/run.ts @@ -1,52 +1,52 @@ -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"; +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 + update?: boolean; + local?: string; // Path to local .mcpb file } /** * Prompt user for a missing config value (interactive terminal input) */ async function promptForValue(field: { - key: string; - title: string; - description?: string; - sensitive: boolean; + key: string; + title: string; + description?: string; + sensitive: boolean; }): Promise { - return new Promise((resolvePrompt) => { - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true, - }); - - const label = field.title; - const hint = field.description ? ` (${field.description})` : ""; - const prompt = `=> ${label}${hint}: `; - - if (field.sensitive) { - process.stderr.write(`=> (sensitive input)\n`); - } - - rl.question(prompt, (answer) => { - rl.close(); - resolvePrompt(answer); - }); - }); + return new Promise((resolvePrompt) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + terminal: true, + }); + + const label = field.title; + const hint = field.description ? ` (${field.description})` : ''; + const prompt = `=> ${label}${hint}: `; + + if (field.sensitive) { + process.stderr.write(`=> (sensitive input)\n`); + } + + rl.question(prompt, (answer) => { + rl.close(); + resolvePrompt(answer); + }); + }); } /** * Check if we're in an interactive terminal */ function isInteractive(): boolean { - return process.stdin.isTTY === true; + return process.stdin.isTTY === true; } /** @@ -54,154 +54,136 @@ function isInteractive(): boolean { * Saves provided values to config, then retries prepareServer. */ async function handleMissingConfig( - err: MpakConfigError, - spec: PrepareServerSpec, - options: RunOptions, + 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); - } - - 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); - } - - // 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(); - }, - ); - }); - } - - // Retry now that config values are saved - return mpak.prepareServer(spec, options.update ? { force: true } : {}); + 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); + } + + 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); + } + + // 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(); + }); + }); + } + + // 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 { - // 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.exit(1); - } - - // CLI-level validation for --local - if (options.local) { - const bundlePath = resolve(options.local); - - if (!existsSync(bundlePath)) { - process.stderr.write(`=> Error: Bundle not found: ${bundlePath}\n`); - process.exit(1); - } - - if (!bundlePath.endsWith(".mcpb")) { - process.stderr.write(`=> Error: Not an MCPB bundle: ${bundlePath}\n`); - process.exit(1); - } - } - - // Build the spec - const spec: PrepareServerSpec = options.local - ? { local: resolve(options.local) } - : parsePackageSpec(packageSpec); - - // 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; - } - } - - // Spawn with stdio passthrough for MCP - 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 && !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")); - - // Wait for exit - child.on("exit", async (code) => { - if (updateCheckPromise) { - try { - await Promise.race([ - updateCheckPromise, - new Promise((r) => setTimeout(r, 3000)), - ]); - } 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`); - process.exit(1); - }); +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.exit(1); + } + + // CLI-level validation for --local + if (options.local) { + const bundlePath = resolve(options.local); + + if (!existsSync(bundlePath)) { + process.stderr.write(`=> Error: Bundle not found: ${bundlePath}\n`); + process.exit(1); + } + + if (!bundlePath.endsWith('.mcpb')) { + process.stderr.write(`=> Error: Not an MCPB bundle: ${bundlePath}\n`); + process.exit(1); + } + } + + // Build the spec + const spec: PrepareServerSpec = options.local + ? { local: resolve(options.local) } + : parsePackageSpec(packageSpec); + + // 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; + } + } + + // Spawn with stdio passthrough for MCP + 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 && !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')); + + // Wait for exit + child.on('exit', async (code) => { + if (updateCheckPromise) { + try { + await Promise.race([updateCheckPromise, new Promise((r) => setTimeout(r, 3000))]); + } 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`); + process.exit(1); + }); } diff --git a/packages/cli/src/commands/packages/search.ts b/packages/cli/src/commands/packages/search.ts index d174ea7..885fe56 100644 --- a/packages/cli/src/commands/packages/search.ts +++ b/packages/cli/src/commands/packages/search.ts @@ -1,57 +1,50 @@ -import type { BundleSearchParamsInput } from "@nimblebrain/mpak-schemas"; -import { mpak } from "../../utils/config.js"; -import { certLabel, logger, table, truncate } from "../../utils/format.js"; +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; +export type SearchOptions = Omit & { + json?: boolean; }; /** * Search bundles (v1 API) */ -export async function handleSearch( - query: string, - options: SearchOptions = {}, -): Promise { - try { - const result = await mpak.client.searchBundles({ - q: query, - ...options, - }); - - if (result.bundles.length === 0) { - logger.info(`\nNo bundles found for "${query}"`); - return; - } - - logger.info(`\nFound ${result.total} bundle(s) for "${query}":\n`); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - const rows = result.bundles.map((b) => [ - b.name, - `v${b.latest_version}`, - certLabel(b.certification_level), - truncate(b.description || "", 50), - ]); - - logger.info(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], rows)); - logger.info(""); - - if (result.pagination.has_more) { - const nextOffset = (options.offset || 0) + (options.limit || 20); - logger.info( - `More results available. Use --offset ${nextOffset} to see more.`, - ); - } - - logger.info('Use "mpak show " for more details'); - } catch (error) { - logger.error( - error instanceof Error ? error.message : "Failed to search bundles", - ); - } +export async function handleSearch(query: string, options: SearchOptions = {}): Promise { + try { + const result = await mpak.client.searchBundles({ + q: query, + ...options, + }); + + if (result.bundles.length === 0) { + logger.info(`\nNo bundles found for "${query}"`); + return; + } + + logger.info(`\nFound ${result.total} bundle(s) for "${query}":\n`); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const rows = result.bundles.map((b) => [ + b.name, + `v${b.latest_version}`, + certLabel(b.certification_level), + truncate(b.description || '', 50), + ]); + + logger.info(table(['NAME', 'VERSION', 'TRUST', 'DESCRIPTION'], rows)); + logger.info(''); + + if (result.pagination.has_more) { + const nextOffset = (options.offset || 0) + (options.limit || 20); + logger.info(`More results available. Use --offset ${nextOffset} to see more.`); + } + + logger.info('Use "mpak show " for more details'); + } catch (error) { + 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 fca6ed8..6613261 100644 --- a/packages/cli/src/commands/packages/show.ts +++ b/packages/cli/src/commands/packages/show.ts @@ -1,163 +1,143 @@ -import { mpak } from "../../utils/config.js"; -import { logger } from "../../utils/format.js"; +import { mpak } from '../../utils/config.js'; +import { logger } from '../../utils/format.js'; export interface ShowOptions { - json?: boolean; + 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 { - try { - // Fetch bundle details and versions in parallel - const [bundle, versionsInfo] = await Promise.all([ - mpak.client.getBundle(packageName), - mpak.client.getBundleVersions(packageName), - ]); - - if (options.json) { - console.log( - JSON.stringify( - { ...bundle, versions_detail: versionsInfo.versions }, - null, - 2, - ), - ); - return; - } - - // Header - 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) { - logger.info(bundle.description); - logger.info(""); - } - - // Basic info - logger.info("Bundle Information:"); - logger.info(` Name: ${bundle.name}`); - if (bundle.author?.name) { - logger.info(` Author: ${bundle.author.name}`); - } - if (bundle.server_type) { - logger.info(` Type: ${bundle.server_type}`); - } - if (bundle.license) { - logger.info(` License: ${bundle.license}`); - } - if (bundle.homepage) { - logger.info(` Homepage: ${bundle.homepage}`); - } - logger.info(""); - - // Trust / Certification - const certLevel = bundle.certification_level; - const certification = bundle.certification; - - if (certLevel != null) { - const label = CERT_LEVEL_LABELS[certLevel] ?? `L${certLevel}`; - logger.info(`Trust: ${label}`); - if ( - certification?.controls_passed != null && - certification?.controls_total != null - ) { - logger.info( - ` Controls: ${certification.controls_passed}/${certification.controls_total} passed`, - ); - } - logger.info(""); - } - - // Provenance info - if (bundle.provenance) { - 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 - 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) { - logger.info(`Tools (${bundle.tools.length}):`); - for (const tool of bundle.tools) { - logger.info(` - ${tool.name}`); - if (tool.description) { - logger.info(` ${tool.description}`); - } - } - logger.info(""); - } - - // Versions with platforms - if (versionsInfo.versions && versionsInfo.versions.length > 0) { - 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).toLocaleDateString(); - const downloads = version.downloads.toLocaleString(); - 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(", ")}]` : ""; - - logger.info( - ` ${version.version}${isLatest}${provTag} - ${date} - ${downloads} downloads${platformsDisplay}`, - ); - } - if (versionsInfo.versions.length > 5) { - logger.info(` ... and ${versionsInfo.versions.length - 5} more`); - } - logger.info(""); - } - - // Available platforms for latest version - const latestVersion = versionsInfo.versions.find( - (v) => v.version === versionsInfo.latest, - ); - if (latestVersion && latestVersion.platforms.length > 0) { - logger.info("Available Platforms:"); - for (const platform of latestVersion.platforms) { - logger.info(` - ${platform.os}-${platform.arch}`); - } - logger.info(""); - } - - // Install instructions - logger.info("Pull (download only):"); - logger.info(` mpak pull ${bundle.name}`); - } catch (error) { - logger.error( - error instanceof Error ? error.message : "Failed to get bundle details", - ); - } +export async function handleShow(packageName: string, options: ShowOptions = {}): Promise { + try { + // Fetch bundle details and versions in parallel + const [bundle, versionsInfo] = await Promise.all([ + mpak.client.getBundle(packageName), + mpak.client.getBundleVersions(packageName), + ]); + + if (options.json) { + console.log(JSON.stringify({ ...bundle, versions_detail: versionsInfo.versions }, null, 2)); + return; + } + + // Header + 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) { + logger.info(bundle.description); + logger.info(''); + } + + // Basic info + logger.info('Bundle Information:'); + logger.info(` Name: ${bundle.name}`); + if (bundle.author?.name) { + logger.info(` Author: ${bundle.author.name}`); + } + if (bundle.server_type) { + logger.info(` Type: ${bundle.server_type}`); + } + if (bundle.license) { + logger.info(` License: ${bundle.license}`); + } + if (bundle.homepage) { + logger.info(` Homepage: ${bundle.homepage}`); + } + logger.info(''); + + // Trust / Certification + const certLevel = bundle.certification_level; + const certification = bundle.certification; + + if (certLevel != null) { + const label = CERT_LEVEL_LABELS[certLevel] ?? `L${certLevel}`; + logger.info(`Trust: ${label}`); + if (certification?.controls_passed != null && certification?.controls_total != null) { + logger.info( + ` Controls: ${certification.controls_passed}/${certification.controls_total} passed`, + ); + } + logger.info(''); + } + + // Provenance info + if (bundle.provenance) { + 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 + 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) { + logger.info(`Tools (${bundle.tools.length}):`); + for (const tool of bundle.tools) { + logger.info(` - ${tool.name}`); + if (tool.description) { + logger.info(` ${tool.description}`); + } + } + logger.info(''); + } + + // Versions with platforms + if (versionsInfo.versions && versionsInfo.versions.length > 0) { + 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).toLocaleDateString(); + const downloads = version.downloads.toLocaleString(); + 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(', ')}]` : ''; + + logger.info( + ` ${version.version}${isLatest}${provTag} - ${date} - ${downloads} downloads${platformsDisplay}`, + ); + } + if (versionsInfo.versions.length > 5) { + logger.info(` ... and ${versionsInfo.versions.length - 5} more`); + } + logger.info(''); + } + + // Available platforms for latest version + const latestVersion = versionsInfo.versions.find((v) => v.version === versionsInfo.latest); + if (latestVersion && latestVersion.platforms.length > 0) { + logger.info('Available Platforms:'); + for (const platform of latestVersion.platforms) { + logger.info(` - ${platform.os}-${platform.arch}`); + } + logger.info(''); + } + + // Install instructions + logger.info('Pull (download only):'); + logger.info(` mpak pull ${bundle.name}`); + } catch (error) { + logger.error(error instanceof Error ? error.message : 'Failed to get bundle details'); + } } diff --git a/packages/cli/src/commands/packages/update.ts b/packages/cli/src/commands/packages/update.ts index 6585b4b..44c6424 100644 --- a/packages/cli/src/commands/packages/update.ts +++ b/packages/cli/src/commands/packages/update.ts @@ -1,91 +1,87 @@ -import { MpakNetworkError, MpakNotFoundError } from "@nimblebrain/mpak-sdk"; -import { mpak } from "../../utils/config.js"; -import { logger } 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; + 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; - } +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 = {}, + packageName: string | undefined, + options: UpdateOptions = {}, ): Promise { - if (packageName) { - const { version } = await forceUpdateBundle(packageName); - if (options.json) { - console.log(JSON.stringify({ name: packageName, version }, null, 2)); - } else { - logger.info(`Updated ${packageName} to ${version}`); - } - return; - } + if (packageName) { + const { version } = await forceUpdateBundle(packageName); + if (options.json) { + console.log(JSON.stringify({ name: packageName, version }, null, 2)); + } else { + logger.info(`Updated ${packageName} to ${version}`); + } + return; + } - // No name given — find and update all outdated bundles - logger.info("=> Checking for updates..."); - const outdated = await getOutdatedBundles(); + // No name given — find and update all outdated bundles + logger.info('=> Checking for updates...'); + const outdated = await getOutdatedBundles(); - if (outdated.length === 0) { - if (options.json) { - console.log(JSON.stringify([], null, 2)); - } else { - logger.info("All cached bundles are up to date."); - } - return; - } + if (outdated.length === 0) { + if (options.json) { + console.log(JSON.stringify([], null, 2)); + } else { + logger.info('All cached bundles are up to date.'); + } + return; + } - logger.info(`=> ${outdated.length} bundle(s) to update`); + logger.info(`=> ${outdated.length} bundle(s) to update`); - const updated: Array<{ name: string; from: string; to: string }> = []; + const updated: Array<{ name: string; from: string; to: string }> = []; - const results = await Promise.allSettled( - outdated.map(async (entry) => { - const { version } = await forceUpdateBundle(entry.name); - return { name: entry.name, from: entry.current, to: version }; - }), - ); + const results = await Promise.allSettled( + outdated.map(async (entry) => { + 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") { - updated.push(result.value); - } else { - const message = - result.reason instanceof Error - ? result.reason.message - : String(result.reason); - logger.info(`=> Failed to update ${outdated[i]!.name}: ${message}`); - } - } + for (const [i, result] of results.entries()) { + if (result.status === 'fulfilled') { + updated.push(result.value); + } else { + const message = + result.reason instanceof Error ? result.reason.message : String(result.reason); + logger.info(`=> Failed to update ${outdated[i]!.name}: ${message}`); + } + } - if (updated.length === 0) { - logger.error("All updates failed"); - process.exit(1); - } + if (updated.length === 0) { + logger.error('All updates failed'); + process.exit(1); + } - 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}`); - } - } + 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 73dd546..a479756 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,158 +1,144 @@ 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"; + 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"; - limit?: number; - offset?: number; - json?: boolean; + type?: 'bundle' | 'skill'; + sort?: 'downloads' | 'recent' | 'name'; + limit?: number; + offset?: number; + json?: boolean; } -type UnifiedResult = - | (Bundle & { type: "bundle" }) - | (SkillSummary & { type: "skill" }); +type UnifiedResult = (Bundle & { type: 'bundle' }) | (SkillSummary & { type: 'skill' }); /** * Unified search across bundles and skills */ export async function handleUnifiedSearch( - query: string, - options: UnifiedSearchOptions = {}, + query: string, + options: UnifiedSearchOptions = {}, ): Promise { - try { - 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 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(bundleParams) : null, - searchSkillsFlag ? client.searchSkills(skillParams) : null, - ]); - - if (bundleResult) { - bundleTotal = bundleResult.total; - for (const bundle of bundleResult.bundles) { - results.push({ type: "bundle", ...bundle }); - } - } - - if (skillResult) { - skillTotal = skillResult.total; - for (const skill of skillResult.skills) { - 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") { - results.sort((a, b) => b.downloads - a.downloads); - } else if (options.sort === "name") { - results.sort((a, b) => a.name.localeCompare(b.name)); - } - - // JSON output - if (options.json) { - console.log( - JSON.stringify( - { - results, - totals: { bundles: bundleTotal, skills: skillTotal }, - }, - null, - 2, - ), - ); - return; - } - - // Summary - const totalResults = bundleTotal + skillTotal; - const typeFilter = options.type ? ` (${options.type}s only)` : ""; - logger.info( - `\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`, - ); - - 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) { - logger.info(`\nBundles (${bundleTotal}):\n`); - const bundleRows = bundles.map((r) => [ - r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, - r.latest_version || "-", - certLabel(r.certification_level), - truncate(r.description ?? "", 40), - ]); - logger.info(table(["NAME", "VERSION", "TRUST", "DESCRIPTION"], bundleRows)); - } - - // Skills section - if (skills.length > 0) { - logger.info(`\nSkills (${skillTotal}):\n`); - const skillRows = skills.map((r) => [ - r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, - r.latest_version || "-", - r.category || "-", - truncate(r.description, 40), - ]); - 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) { - logger.info( - `\n Use --offset ${currentOffset + currentLimit} to see more results.`, - ); - } - - logger.info(""); - logger.info( - 'Use "mpak bundle show " or "mpak skill show " for details.', - ); - } catch (error) { - logger.error(error instanceof Error ? error.message : "Search failed"); - } + try { + 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 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(bundleParams) : null, + searchSkillsFlag ? client.searchSkills(skillParams) : null, + ]); + + if (bundleResult) { + bundleTotal = bundleResult.total; + for (const bundle of bundleResult.bundles) { + results.push({ type: 'bundle', ...bundle }); + } + } + + if (skillResult) { + skillTotal = skillResult.total; + for (const skill of skillResult.skills) { + 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') { + results.sort((a, b) => b.downloads - a.downloads); + } else if (options.sort === 'name') { + results.sort((a, b) => a.name.localeCompare(b.name)); + } + + // JSON output + if (options.json) { + console.log( + JSON.stringify( + { + results, + totals: { bundles: bundleTotal, skills: skillTotal }, + }, + null, + 2, + ), + ); + return; + } + + // Summary + const totalResults = bundleTotal + skillTotal; + const typeFilter = options.type ? ` (${options.type}s only)` : ''; + logger.info(`\nFound ${totalResults} result(s) for "${query}"${typeFilter}:`); + + 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) { + logger.info(`\nBundles (${bundleTotal}):\n`); + const bundleRows = bundles.map((r) => [ + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || '-', + certLabel(r.certification_level), + truncate(r.description ?? '', 40), + ]); + logger.info(table(['NAME', 'VERSION', 'TRUST', 'DESCRIPTION'], bundleRows)); + } + + // Skills section + if (skills.length > 0) { + logger.info(`\nSkills (${skillTotal}):\n`); + const skillRows = skills.map((r) => [ + r.name.length > 38 ? `${r.name.slice(0, 35)}...` : r.name, + r.latest_version || '-', + r.category || '-', + truncate(r.description, 40), + ]); + 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) { + logger.info(`\n Use --offset ${currentOffset + currentLimit} to see more results.`); + } + + logger.info(''); + logger.info('Use "mpak bundle show " or "mpak skill show " for details.'); + } catch (error) { + 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 46dbe0d..1ae97cd 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -1,16 +1,16 @@ -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"; +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"); + return join(homedir(), '.claude', 'skills'); } /** @@ -18,7 +18,7 @@ function getSkillsDir(): 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]!; } @@ -37,14 +37,9 @@ export async function handleSkillInstall( try { const { name, version } = parsePackageSpec(skillSpec); - logger.info( - `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, - ); + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); - const { data, metadata } = await mpak.client.downloadSkillBundle( - name, - version, - ); + const { data, metadata } = await mpak.client.downloadSkillBundle(name, version); const shortName = getShortName(metadata.name); const skillsDir = getSkillsDir(); @@ -52,10 +47,8 @@ export async function handleSkillInstall( // Check if already installed if (existsSync(installPath) && !options.force) { - logger.error( - `Skill "${shortName}" is already installed at ${installPath}`, - ); - logger.error("Use --force to overwrite"); + logger.error(`Skill "${shortName}" is already installed at ${installPath}`); + logger.error('Use --force to overwrite'); process.exit(1); } @@ -76,8 +69,8 @@ export async function handleSkillInstall( // Extract using unzip try { - execFileSync("unzip", ["-o", tempPath, "-d", skillsDir], { - stdio: "pipe", + execFileSync('unzip', ['-o', tempPath, '-d', skillsDir], { + stdio: 'pipe', }); } catch (err) { throw new Error(`Failed to extract skill bundle: ${err}`); @@ -102,14 +95,10 @@ export async function handleSkillInstall( } else { logger.info(`\n=> Installed to ${installPath}/`); logger.info(` \u2713 ${shortName}@${metadata.version}`); - logger.info(""); - logger.info( - "Skill available in Claude Code. Restart to activate.", - ); + logger.info(''); + logger.info('Skill available in Claude Code. Restart to activate.'); } } catch (err) { - logger.error( - err instanceof Error ? err.message : "Failed to install skill", - ); + logger.error(err instanceof Error ? err.message : 'Failed to install skill'); } } diff --git a/packages/cli/src/commands/skills/pull.ts b/packages/cli/src/commands/skills/pull.ts index 97240d0..f05f2dc 100644 --- a/packages/cli/src/commands/skills/pull.ts +++ b/packages/cli/src/commands/skills/pull.ts @@ -1,8 +1,8 @@ -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"; +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; @@ -12,22 +12,14 @@ export interface PullOptions { /** * 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 } = parsePackageSpec(skillSpec); - logger.info( - `=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`, - ); + logger.info(`=> Fetching ${version ? `${name}@${version}` : `${name} (latest)`}...`); - const { data, metadata } = await mpak.client.downloadSkillBundle( - name, - version, - ); + const { data, metadata } = await mpak.client.downloadSkillBundle(name, version); if (options.json) { console.log(JSON.stringify(metadata, null, 2)); @@ -37,10 +29,8 @@ export async function handleSkillPull( logger.info(` Version: ${metadata.version}`); logger.info(` Size: ${formatSize(metadata.size)}`); - const defaultFilename = `${name.replace("@", "").replace("/", "-")}-${metadata.version}.skill`; - outputPath = options.output - ? resolve(options.output) - : resolve(defaultFilename); + const defaultFilename = `${name.replace('@', '').replace('/', '-')}-${metadata.version}.skill`; + outputPath = options.output ? resolve(options.output) : resolve(defaultFilename); writeFileSync(outputPath, data); @@ -49,10 +39,12 @@ export async function handleSkillPull( logger.info(` SHA256: ${metadata.sha256.substring(0, 16)}...`); } catch (error) { if (outputPath) { - try { rmSync(outputPath, { force: true }); } catch (_e) { /* ignore */ } + try { + rmSync(outputPath, { force: true }); + } catch (_e) { + /* ignore */ + } } - logger.error( - error instanceof Error ? error.message : "Failed to pull skill", - ); + 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 d8eef34..78facdd 100644 --- a/packages/cli/src/commands/skills/search.ts +++ b/packages/cli/src/commands/skills/search.ts @@ -1,48 +1,45 @@ -import type { SkillSearchParamsInput } from "@nimblebrain/mpak-schemas"; -import { mpak } from "../../utils/config.js"; -import { logger, table, truncate } from "../../utils/format.js"; +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 }; -export async function handleSkillSearch( - query: string, - options: SearchOptions, -): Promise { - try { - 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) { - logger.info(`No skills found for "${query}"`); - return; - } - - 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), - ]); - - logger.info(table(["NAME", "VERSION", "CATEGORY", "DESCRIPTION"], rows)); - - if (result.pagination.has_more) { - logger.info(""); - logger.info( - `Showing ${result.skills.length} of ${result.total} results. Use --offset to see more.`, - ); - } - } catch (err) { - logger.error(err instanceof Error ? err.message : String(err)); - } +export async function handleSkillSearch(query: string, options: SearchOptions): Promise { + try { + 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) { + logger.info(`No skills found for "${query}"`); + return; + } + + 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), + ]); + + logger.info(table(['NAME', 'VERSION', 'CATEGORY', 'DESCRIPTION'], rows)); + + if (result.pagination.has_more) { + logger.info(''); + logger.info( + `Showing ${result.skills.length} of ${result.total} results. Use --offset to see more.`, + ); + } + } catch (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 0769040..bd391d6 100644 --- a/packages/cli/src/commands/skills/show.ts +++ b/packages/cli/src/commands/skills/show.ts @@ -1,83 +1,73 @@ -import { mpak } from "../../utils/config.js"; -import { logger } from "../../utils/format.js"; +import { mpak } from '../../utils/config.js'; +import { logger } from '../../utils/format.js'; export interface ShowOptions { - json?: boolean; + json?: boolean; } /** * Handle the skill show command */ -export async function handleSkillShow( - name: string, - options: ShowOptions, -): Promise { - try { - const skill = await mpak.client.getSkill(name); +export async function handleSkillShow(name: string, options: ShowOptions): Promise { + try { + const skill = await mpak.client.getSkill(name); - if (options.json) { - console.log(JSON.stringify(skill, null, 2)); - return; - } + if (options.json) { + console.log(JSON.stringify(skill, null, 2)); + return; + } - logger.info(""); - logger.info(`${skill.name}@${skill.latest_version}`); - logger.info(""); - logger.info(skill.description); - logger.info(""); + logger.info(''); + logger.info(`${skill.name}@${skill.latest_version}`); + logger.info(''); + logger.info(skill.description); + logger.info(''); - // Metadata section - 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) - logger.info( - ` Author: ${skill.author.name}${skill.author.url ? ` (${skill.author.url})` : ""}`, - ); - logger.info(` Downloads: ${skill.downloads.toLocaleString()}`); - logger.info( - ` Published: ${new Date(skill.published_at).toLocaleDateString()}`, - ); + // Metadata section + 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) + logger.info( + ` Author: ${skill.author.name}${skill.author.url ? ` (${skill.author.url})` : ''}`, + ); + logger.info(` Downloads: ${skill.downloads.toLocaleString()}`); + logger.info(` Published: ${new Date(skill.published_at).toLocaleDateString()}`); - // Triggers - if (skill.triggers && skill.triggers.length > 0) { - logger.info(""); - logger.info("Triggers:"); - skill.triggers.forEach((t) => logger.info(` - ${t}`)); - } + // Triggers + if (skill.triggers && skill.triggers.length > 0) { + logger.info(''); + logger.info('Triggers:'); + skill.triggers.forEach((t) => logger.info(` - ${t}`)); + } - // Examples - if (skill.examples && skill.examples.length > 0) { - logger.info(""); - logger.info("Examples:"); - skill.examples.forEach((ex) => { - logger.info( - ` - "${ex.prompt}"${ex.context ? ` (${ex.context})` : ""}`, - ); - }); - } + // Examples + if (skill.examples && skill.examples.length > 0) { + 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) { - 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) { - logger.info(` ... and ${skill.versions.length - 5} more`); - } - } + // Versions + if (skill.versions && skill.versions.length > 0) { + 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) { + logger.info(` ... and ${skill.versions.length - 5} more`); + } + } - logger.info(""); - logger.info(`Install: mpak skill install ${skill.name}`); - } catch (err) { - logger.error(err instanceof Error ? err.message : String(err)); - } + logger.info(''); + logger.info(`Install: mpak skill install ${skill.name}`); + } catch (err) { + logger.error(err instanceof Error ? err.message : String(err)); + } } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 52a5d21..e0d2c1f 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -1,11 +1,11 @@ -import { Mpak } from "@nimblebrain/mpak-sdk"; -import { getVersion } from "./version.js"; +import { Mpak } from '@nimblebrain/mpak-sdk'; +import { getVersion } from './version.js'; -const mpakHome = process.env["MPAK_HOME"]; -const registryUrl = process.env["MPAK_REGISTRY_URL"]; +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()}`, + ...(mpakHome ? { mpakHome } : {}), + ...(registryUrl ? { registryUrl } : {}), + userAgent: `mpak-cli/${getVersion()}`, }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 2131544..507f9a5 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1,81 +1,72 @@ export interface TableOptions { - /** Column indices to right-align (0-based) */ - rightAlign?: number[]; + /** Column indices to right-align (0-based) */ + rightAlign?: number[]; } /** * Render an aligned text table with auto-calculated column widths. */ -export function table( - headers: string[], - rows: string[][], - opts?: TableOptions, -): string { - const rightAlign = new Set(opts?.rightAlign ?? []); +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, - ); - return Math.max(h.length, maxData); - }); + // 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); + return Math.max(h.length, maxData); + }); - const pad = (text: string, width: number, colIdx: number): string => - rightAlign.has(colIdx) ? text.padStart(width) : text.padEnd(width); + const pad = (text: string, width: number, colIdx: number): string => + rightAlign.has(colIdx) ? text.padStart(width) : text.padEnd(width); - const lines: string[] = []; + const lines: string[] = []; - // Header - lines.push(headers.map((h, i) => pad(h, widths[i]!, i)).join(" ")); + // Header + 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(" "), - ); - } + // Rows + for (const row of rows) { + 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 "-"; - return `L${level}`; + if (level == null) return '-'; + return `L${level}`; } /** * Human-readable file size. */ export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Truncate text to a maximum length, appending "..." if truncated. */ export function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return text.slice(0, max - 3) + "..."; + if (text.length <= max) return text; + return text.slice(0, max - 3) + '...'; } /** * Print a standardized error message. */ export function logError(message: string): void { - console.error(`Error: ${message}`); + console.error(`Error: ${message}`); } /** @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), + error: (msg: string) => console.error(`[Error] ${msg}`), + info: (msg: string) => console.error(msg), }; diff --git a/packages/cli/tests/bundles/outdated.test.ts b/packages/cli/tests/bundles/outdated.test.ts index fc46c56..6592954 100644 --- a/packages/cli/tests/bundles/outdated.test.ts +++ b/packages/cli/tests/bundles/outdated.test.ts @@ -1,30 +1,30 @@ -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"; +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", + manifest_version: '0.3', name, version, - description: "Test bundle", + description: 'Test bundle', server: { - type: "node" as const, - entry_point: "index.js", - mcp_config: { command: "node", args: ["${__dirname}/index.js"] }, + 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" }, + pulledAt: '2025-01-01T00:00:00.000Z', + platform: { os: 'darwin', arch: 'arm64' }, }); function seedCacheEntry( @@ -32,13 +32,13 @@ function seedCacheEntry( dirName: string, opts: { manifest?: object; metadata?: object }, ) { - const dir = join(mpakHome, "cache", dirName); + const dir = join(mpakHome, 'cache', dirName); mkdirSync(dir, { recursive: true }); if (opts.manifest) { - writeFileSync(join(dir, "manifest.json"), JSON.stringify(opts.manifest)); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify(opts.manifest)); } if (opts.metadata) { - writeFileSync(join(dir, ".mpak-meta.json"), JSON.stringify(opts.metadata)); + writeFileSync(join(dir, '.mpak-meta.json'), JSON.stringify(opts.metadata)); } } @@ -58,7 +58,7 @@ function mockClient(registry: Record): MpakClient { let currentCache: MpakBundleCache; -vi.mock("../../src/utils/config.js", () => ({ +vi.mock('../../src/utils/config.js', () => ({ get mpak() { return { bundleCache: currentCache }; }, @@ -68,118 +68,116 @@ vi.mock("../../src/utils/config.js", () => ({ // Tests // --------------------------------------------------------------------------- -describe("getOutdatedBundles", () => { +describe('getOutdatedBundles', () => { let testDir: string; beforeEach(() => { - testDir = mkdtempSync(join(tmpdir(), "mpak-outdated-test-")); + testDir = mkdtempSync(join(tmpdir(), 'mpak-outdated-test-')); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - it("returns empty array when no bundles are cached", async () => { + 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"), + 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"), + 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, }); - 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"), + 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"), + 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, }); - 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", + 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"), + 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"), + 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" }), + 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"); + 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"), + 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"), + 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 + 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"); + 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"), + 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"), + ...validMetadata('1.0.0'), lastCheckedAt: new Date().toISOString(), // just checked }, }); - const client = mockClient({ "@scope/a": "2.0.0" }); + 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"); + 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 index f633343..6ebdf58 100644 --- a/packages/cli/tests/bundles/pull.test.ts +++ b/packages/cli/tests/bundles/pull.test.ts @@ -1,33 +1,33 @@ -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"; +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() })); +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('../../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" }), - }, - }; +vi.mock('@nimblebrain/mpak-sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MpakClient: { + detectPlatform: () => ({ os: 'darwin', arch: 'arm64' }), + }, + }; }); // --------------------------------------------------------------------------- @@ -37,115 +37,107 @@ vi.mock("@nimblebrain/mpak-sdk", async (importOriginal) => { 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, + 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"), - ); - }); +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 index 63a3715..6ca50eb 100644 --- a/packages/cli/tests/bundles/run.test.ts +++ b/packages/cli/tests/bundles/run.test.ts @@ -1,22 +1,18 @@ -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"; +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"; + this.name = 'ExitError'; } } @@ -51,8 +47,8 @@ function createMockChild(): MockChildProcess { let mockChild: MockChildProcess; const mockSpawn = vi.fn(); -vi.mock("child_process", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, spawn: (...args: unknown[]) => mockSpawn(...args), @@ -67,7 +63,7 @@ const mockPrepareServer = vi.fn(); const mockCheckForUpdate = vi.fn(); const mockSetPackageConfigValue = vi.fn(); -vi.mock("../../src/utils/config.js", () => ({ +vi.mock('../../src/utils/config.js', () => ({ get mpak() { return { prepareServer: mockPrepareServer, @@ -86,16 +82,16 @@ vi.mock("../../src/utils/config.js", () => ({ // =========================================================================== const nodeManifest: McpbManifest = { - manifest_version: "0.3", - name: "@scope/echo", - version: "1.0.0", - description: "Echo server", + manifest_version: '0.3', + name: '@scope/echo', + version: '1.0.0', + description: 'Echo server', server: { - type: "node", - entry_point: "index.js", + type: 'node', + entry_point: 'index.js', mcp_config: { - command: "node", - args: ["${__dirname}/index.js"], + command: 'node', + args: ['${__dirname}/index.js'], env: {}, }, }, @@ -103,12 +99,12 @@ const nodeManifest: McpbManifest = { 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", + 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, }; } @@ -116,17 +112,15 @@ function makeServerCommand(overrides?: Partial): ServerCommand { let testDir: string; function createMcpbBundle(dir: string, manifest: McpbManifest): string { - const srcDir = join(dir, "bundle-src"); + 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" }, - ); + 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; } @@ -138,9 +132,9 @@ let stderr: string; let exitCode: number | undefined; beforeEach(() => { - stderr = ""; + stderr = ''; exitCode = undefined; - testDir = mkdtempSync(join(tmpdir(), "mpak-run-test-")); + testDir = mkdtempSync(join(tmpdir(), 'mpak-run-test-')); mockChild = createMockChild(); mockSpawn.mockReset(); @@ -151,11 +145,11 @@ beforeEach(() => { mockPrepareServer.mockResolvedValue(makeServerCommand()); mockCheckForUpdate.mockResolvedValue(null); - vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { stderr += String(chunk); return true; }); - vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + 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 @@ -173,50 +167,44 @@ afterEach(() => { // Registry — bundle in cache (no pull needed) // =========================================================================== -describe("registry run — cached bundle", () => { - it("calls prepareServer with parsed name and spawns the server", async () => { +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"); + 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", + 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" })); + it('parses version from package spec', async () => { + mockPrepareServer.mockResolvedValue(makeServerCommand({ version: '2.0.0' })); - handleRun("@scope/echo@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" }, - {}, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/echo', version: '2.0.0' }, {}); }); - it("merges process.env on top of server env", async () => { + it('merges process.env on top of server env', async () => { mockPrepareServer.mockResolvedValue( - makeServerCommand({ env: { FROM_SDK: "yes", PATH: "/sdk/path" } }), + makeServerCommand({ env: { FROM_SDK: 'yes', PATH: '/sdk/path' } }), ); - handleRun("@scope/echo"); + 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"]); + expect(spawnEnv['PATH']).toBe(process.env['PATH']); // SDK-only keys survive the merge - expect(spawnEnv["FROM_SDK"]).toBe("yes"); + expect(spawnEnv['FROM_SDK']).toBe('yes'); }); }); @@ -224,29 +212,24 @@ describe("registry run — cached bundle", () => { // 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"); +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" }, - {}, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/new-bundle' }, {}); }); - it("throws when bundle is not found in registry", async () => { - mockPrepareServer.mockRejectedValue( - new MpakNotFoundError("@scope/nonexistent@latest"), - ); + 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); + await expect(handleRun('@scope/nonexistent')).rejects.toThrow(MpakNotFoundError); }); - it("throws on network error", async () => { - mockPrepareServer.mockRejectedValue(new MpakNetworkError("connection refused")); + it('throws on network error', async () => { + mockPrepareServer.mockRejectedValue(new MpakNetworkError('connection refused')); - await expect(handleRun("@scope/echo")).rejects.toThrow(MpakNetworkError); + await expect(handleRun('@scope/echo')).rejects.toThrow(MpakNetworkError); }); }); @@ -254,19 +237,16 @@ describe("registry run — uncached bundle", () => { // Registry — --update flag // =========================================================================== -describe("registry run — --update flag", () => { - it("passes force: true when --update is set", async () => { - handleRun("@scope/echo", { update: true }); +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 }, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ name: '@scope/echo' }, { force: true }); }); - it("does not fire update check when --update is set", async () => { - handleRun("@scope/echo", { update: 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)); @@ -279,23 +259,20 @@ describe("registry run — --update flag", () => { // Local — first run (uncached) // =========================================================================== -describe("local run — uncached bundle", () => { - it("calls prepareServer with resolved absolute path", async () => { +describe('local run — uncached bundle', () => { + it('calls prepareServer with resolved absolute path', async () => { const mcpbPath = createMcpbBundle(testDir, nodeManifest); - handleRun("", { local: mcpbPath }); + handleRun('', { local: mcpbPath }); await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); - expect(mockPrepareServer).toHaveBeenCalledWith( - { local: resolve(mcpbPath) }, - {}, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, {}); }); - it("does not fire update check for local bundles", async () => { + it('does not fire update check for local bundles', async () => { const mcpbPath = createMcpbBundle(testDir, nodeManifest); - handleRun("", { local: mcpbPath }); + handleRun('', { local: mcpbPath }); await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); await new Promise((r) => setTimeout(r, 50)); @@ -307,17 +284,14 @@ describe("local run — uncached bundle", () => { // Local — cached bundle (re-use) // =========================================================================== -describe("local run — cached bundle", () => { - it("calls prepareServer without force (SDK handles mtime check)", async () => { +describe('local run — cached bundle', () => { + it('calls prepareServer without force (SDK handles mtime check)', async () => { const mcpbPath = createMcpbBundle(testDir, nodeManifest); - handleRun("", { local: mcpbPath }); + handleRun('', { local: mcpbPath }); await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); - expect(mockPrepareServer).toHaveBeenCalledWith( - { local: resolve(mcpbPath) }, - {}, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, {}); }); }); @@ -325,17 +299,14 @@ describe("local run — cached bundle", () => { // Local — --update forces re-extract // =========================================================================== -describe("local run — --update flag", () => { - it("passes force: true for local with --update", async () => { +describe('local run — --update flag', () => { + it('passes force: true for local with --update', async () => { const mcpbPath = createMcpbBundle(testDir, nodeManifest); - handleRun("", { local: mcpbPath, update: true }); + handleRun('', { local: mcpbPath, update: true }); await vi.waitFor(() => expect(mockPrepareServer).toHaveBeenCalled()); - expect(mockPrepareServer).toHaveBeenCalledWith( - { local: resolve(mcpbPath) }, - { force: true }, - ); + expect(mockPrepareServer).toHaveBeenCalledWith({ local: resolve(mcpbPath) }, { force: true }); }); }); @@ -343,52 +314,50 @@ describe("local run — --update flag", () => { // 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"); +describe('async update check', () => { + it('prints update notice when newer version is available', async () => { + mockCheckForUpdate.mockResolvedValue('2.0.0'); - handleRun("@scope/echo"); + 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"); + 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 () => { + it('prints nothing when bundle is up to date', async () => { mockCheckForUpdate.mockResolvedValue(null); - handleRun("@scope/echo"); + handleRun('@scope/echo'); await vi.waitFor(() => expect(mockCheckForUpdate).toHaveBeenCalled()); await new Promise((r) => setTimeout(r, 10)); - expect(stderr).not.toContain("Update available"); + expect(stderr).not.toContain('Update available'); }); - it("logs debug message when update check fails", async () => { - mockCheckForUpdate.mockRejectedValue(new Error("network timeout")); + it('logs debug message when update check fails', async () => { + mockCheckForUpdate.mockRejectedValue(new Error('network timeout')); - handleRun("@scope/echo"); + handleRun('@scope/echo'); - await vi.waitFor(() => - expect(stderr).toContain("Debug: update check failed: network timeout"), - ); + await vi.waitFor(() => expect(stderr).toContain('Debug: update check failed: network timeout')); }); - it("skips update check for local bundles", async () => { + it('skips update check for local bundles', async () => { const mcpbPath = createMcpbBundle(testDir, nodeManifest); - handleRun("", { local: mcpbPath }); + 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 }); + 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)); @@ -400,20 +369,20 @@ describe("async update check", () => { // MpakConfigError — registry (non-interactive) // =========================================================================== -describe("missing config — registry (non-interactive)", () => { - it("exits with error listing missing keys", async () => { +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 }, + 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); + 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"); + expect(stderr).toContain('Missing required config: api_key, endpoint'); + expect(stderr).toContain('mpak config set @scope/echo'); }); }); @@ -421,20 +390,18 @@ describe("missing config — registry (non-interactive)", () => { // MpakConfigError — local (non-interactive) // =========================================================================== -describe("missing config — local (non-interactive)", () => { - it("exits with error listing missing keys for local bundle", async () => { +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 }, - ]), + new MpakConfigError('@scope/echo', [{ key: 'token', title: 'Auth Token', sensitive: true }]), ); - await expect(handleRun("", { local: mcpbPath })).rejects.toThrow(ExitError); + 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"); + expect(stderr).toContain('Missing required config: token'); + expect(stderr).toContain('mpak config set @scope/echo'); }); }); @@ -442,31 +409,31 @@ describe("missing config — local (non-interactive)", () => { // 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); +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(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); + 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(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"); + 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); + await expect(handleRun('', { local: notMcpb })).rejects.toThrow(ExitError); expect(exitCode).toBe(1); - expect(stderr).toContain("Not an MCPB bundle"); + expect(stderr).toContain('Not an MCPB bundle'); expect(mockPrepareServer).not.toHaveBeenCalled(); }); }); @@ -475,77 +442,77 @@ describe("CLI input validation", () => { // Process spawning // =========================================================================== -describe("process spawning", () => { - it("forwards SIGINT and SIGTERM to child process", async () => { +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); + 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"); + 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"); + 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); + process.on('unhandledRejection', handler); - handleRun("@scope/echo"); + handleRun('@scope/echo'); await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); - mockChild._emit("exit", 42); + mockChild._emit('exit', 42); await new Promise((r) => setTimeout(r, 50)); - process.removeListener("unhandledRejection", handler); + process.removeListener('unhandledRejection', handler); expect(exitCode).toBe(42); }); - it("calls process.exit(0) when child exit code is null", async () => { + 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); + process.on('unhandledRejection', handler); - handleRun("@scope/echo"); + handleRun('@scope/echo'); await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); - mockChild._emit("exit", null); + mockChild._emit('exit', null); await new Promise((r) => setTimeout(r, 50)); - process.removeListener("unhandledRejection", handler); + process.removeListener('unhandledRejection', handler); expect(exitCode).toBe(0); }); - it("prints error and exits 1 when spawn fails", async () => { + 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); + process.on('unhandledRejection', handler); + process.on('uncaughtException', handler); - handleRun("@scope/echo"); + handleRun('@scope/echo'); await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()); try { - mockChild._emit("error", new Error("ENOENT")); + 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); + process.removeListener('unhandledRejection', handler); + process.removeListener('uncaughtException', handler); expect(exitCode).toBe(1); - expect(stderr).toContain("Failed to start server: ENOENT"); + expect(stderr).toContain('Failed to start server: ENOENT'); }); }); @@ -553,22 +520,18 @@ describe("process spawning", () => { // 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), - ); +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, - ); + await expect(handleRun('', { local: mcpbPath })).rejects.toThrow(MpakNotFoundError); }); - it("propagates unexpected errors as-is", async () => { - mockPrepareServer.mockRejectedValue(new Error("something unexpected")); + it('propagates unexpected errors as-is', async () => { + mockPrepareServer.mockRejectedValue(new Error('something unexpected')); - await expect(handleRun("@scope/echo")).rejects.toThrow("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 index 2142dbd..8ef5468 100644 --- a/packages/cli/tests/bundles/search.test.ts +++ b/packages/cli/tests/bundles/search.test.ts @@ -1,7 +1,7 @@ -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"; +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 @@ -9,12 +9,12 @@ import { handleSearch } from "../../src/commands/packages/search.js"; let mockSearchBundles: ReturnType; -vi.mock("../../src/utils/config.js", () => ({ - get mpak() { - return { - client: { searchBundles: mockSearchBundles } as unknown as MpakClient, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { searchBundles: mockSearchBundles } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -22,104 +22,93 @@ vi.mock("../../src/utils/config.js", () => ({ // --------------------------------------------------------------------------- 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, + 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 }, + 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 }, + 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"), - ); - }); +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 index b5d0bed..a39eb37 100644 --- a/packages/cli/tests/bundles/show.test.ts +++ b/packages/cli/tests/bundles/show.test.ts @@ -1,7 +1,7 @@ -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"; +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 @@ -10,15 +10,15 @@ import { handleShow } from "../../src/commands/packages/show.js"; let mockGetBundle: ReturnType; let mockGetBundleVersions: ReturnType; -vi.mock("../../src/utils/config.js", () => ({ - get mpak() { - return { - client: { - getBundle: mockGetBundle, - getBundleVersions: mockGetBundleVersions, - } as unknown as MpakClient, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + getBundle: mockGetBundle, + getBundleVersions: mockGetBundleVersions, + } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -26,215 +26,213 @@ vi.mock("../../src/utils/config.js", () => ({ // --------------------------------------------------------------------------- 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 }, - ], + 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, - }, - ], + 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"), - ); - }); +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 index 28c9346..3b48414 100644 --- a/packages/cli/tests/bundles/update.test.ts +++ b/packages/cli/tests/bundles/update.test.ts @@ -1,6 +1,6 @@ -import { MpakNetworkError, MpakNotFoundError } from "@nimblebrain/mpak-sdk"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { handleUpdate } from "../../src/commands/packages/update.js"; +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 @@ -10,7 +10,7 @@ const mockLoadBundle = vi.fn(); const mockListCachedBundles = vi.fn(); const mockCheckForUpdate = vi.fn(); -vi.mock("../../src/utils/config.js", () => ({ +vi.mock('../../src/utils/config.js', () => ({ get mpak() { return { bundleCache: { @@ -30,16 +30,16 @@ let stdout: string; let stderr: string; beforeEach(() => { - stdout = ""; - stderr = ""; - vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { - stdout += args.join(" ") + "\n"; + 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(console, 'error').mockImplementation((...args: unknown[]) => { + stderr += args.join(' ') + '\n'; }); - vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); }); }); @@ -51,45 +51,53 @@ afterEach(() => { // 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 }); +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"); + await handleUpdate('@scope/a'); - expect(mockLoadBundle).toHaveBeenCalledWith("@scope/a", { force: true }); - expect(stderr).toContain("Updated @scope/a to 2.0.0"); + 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 }); + 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 }); + await handleUpdate('@scope/a', { json: true }); const parsed = JSON.parse(stdout); - expect(parsed).toEqual({ name: "@scope/a", version: "2.0.0" }); + 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")); + 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( + 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")); + it('throws user-friendly error on network failure', async () => { + mockLoadBundle.mockRejectedValue(new MpakNetworkError('connection refused')); - await expect(handleUpdate("@scope/a")).rejects.toThrow( + 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")); + it('lets unexpected errors propagate as-is', async () => { + mockLoadBundle.mockRejectedValue(new Error('something unexpected')); - await expect(handleUpdate("@scope/a")).rejects.toThrow("something unexpected"); + await expect(handleUpdate('@scope/a')).rejects.toThrow('something unexpected'); }); }); @@ -97,16 +105,16 @@ describe("handleUpdate — single bundle", () => { // Bulk update (no package name) // =========================================================================== -describe("handleUpdate — bulk update", () => { - it("prints up-to-date message when nothing is outdated", async () => { +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."); + expect(stderr).toContain('All cached bundles are up to date.'); }); - it("outputs empty JSON array when nothing is outdated and --json is set", async () => { + it('outputs empty JSON array when nothing is outdated and --json is set', async () => { mockListCachedBundles.mockReturnValue([]); await handleUpdate(undefined, { json: true }); @@ -114,67 +122,97 @@ describe("handleUpdate — bulk update", () => { expect(JSON.parse(stdout)).toEqual([]); }); - it("updates all outdated bundles", async () => { + 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" }, + { + 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"; + 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" }; + 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"); + 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 () => { + 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" }, + { + 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"); + 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 }; + 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"); + 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 () => { + 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" }, + { + 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")); + mockCheckForUpdate.mockResolvedValue('2.0.0'); + mockLoadBundle.mockRejectedValue(new MpakNetworkError('timeout')); - await expect(handleUpdate(undefined)).rejects.toThrow("process.exit called"); + await expect(handleUpdate(undefined)).rejects.toThrow('process.exit called'); - expect(stderr).toContain("Failed to update @scope/a"); - expect(stderr).toContain("All updates failed"); + expect(stderr).toContain('Failed to update @scope/a'); + expect(stderr).toContain('All updates failed'); }); - it("outputs JSON for bulk update with --json", async () => { + 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" }, + { + 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 }); + 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" }]); + 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 index 3601629..cecf779 100644 --- a/packages/cli/tests/config.test.ts +++ b/packages/cli/tests/config.test.ts @@ -1,309 +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"; +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"); +const CLI = join(__dirname, '..', 'dist', 'index.js'); interface RunResult { - stdout: string; - stderr: string; - exitCode: number; + 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, - }; - } + 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")); + return JSON.parse(readFileSync(join(mpakHome, 'config.json'), 'utf8')); } function getPackages(mpakHome: string): Record> { - const config = readConfig(mpakHome); - return (config.packages ?? {}) as 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 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 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 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; +describe('config clear', () => { + let tmpDir: string; - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "mpak-config-test-")); - }); + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mpak-config-test-')); + }); - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); - // --- Clear specific key --- + // --- 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 }); + 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"); + 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" }); - }); + 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 }); + 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"); - }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No value found for nokey'); + }); - // --- Clear all config for a package --- + // --- 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 }); + 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"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Cleared all config for @scope/name'); - const packages = getPackages(tmpDir); - expect(packages["@scope/name"]).toBeUndefined(); - }); + 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"); - }); + 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 --- + // --- 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 }); + 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"); - }); + 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 index db88bae..7370cd7 100644 --- a/packages/cli/tests/errors.test.ts +++ b/packages/cli/tests/errors.test.ts @@ -1,25 +1,25 @@ -import { describe, it, expect } from "vitest"; -import { CLIError } from "../src/utils/errors.js"; +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"); +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"); + 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); + 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"); + 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 index 8230339..bc91375 100644 --- a/packages/cli/tests/integration/bundle.integration.test.ts +++ b/packages/cli/tests/integration/bundle.integration.test.ts @@ -1,8 +1,8 @@ -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"; +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. @@ -10,9 +10,9 @@ import { run } from "./helpers.js"; * Run with: pnpm test -- tests/integration */ -const TEST_BUNDLE = "@nimblebraininc/echo"; +const TEST_BUNDLE = '@nimblebraininc/echo'; -describe("bundle pull", () => { +describe('bundle pull', () => { let outputPath: string; afterEach(() => { @@ -21,7 +21,7 @@ describe("bundle pull", () => { } }); - it("downloads a .mcpb file to the specified output path", async () => { + it('downloads a .mcpb file to the specified output path', async () => { outputPath = join(tmpdir(), `mpak-test-${Date.now()}.mcpb`); const { stderr, exitCode } = await run( @@ -30,11 +30,11 @@ describe("bundle pull", () => { expect(exitCode).toBe(0); expect(existsSync(outputPath)).toBe(true); - expect(stderr).toContain("Bundle downloaded successfully"); - expect(stderr).not.toContain("[Error]"); + expect(stderr).toContain('Bundle downloaded successfully'); + expect(stderr).not.toContain('[Error]'); }, 30000); - it("outputs valid JSON metadata with --json flag", async () => { + it('outputs valid JSON metadata with --json flag', async () => { outputPath = join(tmpdir(), `mpak-test-json-${Date.now()}.mcpb`); const { stdout, exitCode } = await run( @@ -44,8 +44,8 @@ describe("bundle pull", () => { 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.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 index 174ec00..b419716 100644 --- a/packages/cli/tests/integration/helpers.ts +++ b/packages/cli/tests/integration/helpers.ts @@ -1,11 +1,11 @@ -import { exec } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +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 const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); export interface RunResult { stdout: string; @@ -24,8 +24,8 @@ export async function run(args: string): Promise { } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; code?: number }; return { - stdout: e.stdout ?? "", - stderr: e.stderr ?? "", + 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 cf7d594..d80be22 100644 --- a/packages/cli/tests/integration/registry-client.test.ts +++ b/packages/cli/tests/integration/registry-client.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { run } from "./helpers.js"; +import { describe, expect, it } from 'vitest'; +import { run } from './helpers.js'; /** * Integration tests for bundle search and show commands against the live registry. @@ -7,26 +7,26 @@ import { run } from "./helpers.js"; * Run with: pnpm test -- tests/integration */ -const TEST_BUNDLE = "@nimblebraininc/echo"; +const TEST_BUNDLE = '@nimblebraininc/echo'; -describe("bundle search", () => { +describe('bundle search', () => { it("finds echo bundle when searching for 'echo'", async () => { - const { stdout, exitCode } = await run("bundle search echo --json"); + const { stdout, exitCode } = await run('bundle search echo --json'); expect(exitCode).toBe(0); const result = JSON.parse(stdout); expect(result.bundles.some((b: { name: string }) => b.name === TEST_BUNDLE)).toBe(true); }, 15000); - it("returns empty results gracefully for a nonsense query", async () => { - const { stderr, exitCode } = await run("bundle search xyznonexistent12345"); + it('returns empty results gracefully for a nonsense query', async () => { + const { stderr, exitCode } = await run('bundle search xyznonexistent12345'); expect(exitCode).toBe(0); - expect(stderr).toContain("No bundles found"); + expect(stderr).toContain('No bundles found'); }, 15000); - it("table output contains bundle name and version", async () => { - const { stderr, exitCode } = await run("bundle search echo"); + it('table output contains bundle name and version', async () => { + const { stderr, exitCode } = await run('bundle search echo'); expect(exitCode).toBe(0); expect(stderr).toContain(TEST_BUNDLE); @@ -34,31 +34,31 @@ describe("bundle search", () => { }, 15000); }); -describe("bundle show", () => { - it("outputs valid JSON with expected fields", async () => { +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(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 () => { + 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:"); + 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"); + 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]"); + 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 index 0d0a17e..85ed0b2 100644 --- a/packages/cli/tests/integration/skill.integration.test.ts +++ b/packages/cli/tests/integration/skill.integration.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { run } from "./helpers.js"; +import { describe, expect, it } from 'vitest'; +import { run } from './helpers.js'; /** * Integration smoke tests for skill search and show commands against the live registry. @@ -7,27 +7,27 @@ import { run } from "./helpers.js"; * Run with: pnpm test -- tests/integration */ -describe("skill search", () => { - it("returns a valid response shape for a broad query", async () => { +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(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"); + it('handles a nonsense query gracefully', async () => { + const { stderr, exitCode } = await run('skill search xyznonexistent12345abc'); expect(exitCode).toBe(0); - expect(stderr).not.toContain("[Error]"); + expect(stderr).not.toContain('[Error]'); }, 15000); }); -describe("skill show", () => { - it("outputs valid JSON for a skill found via search", async () => { +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); diff --git a/packages/cli/tests/integration/update-flow.test.ts b/packages/cli/tests/integration/update-flow.test.ts index 054384e..9336fe1 100644 --- a/packages/cli/tests/integration/update-flow.test.ts +++ b/packages/cli/tests/integration/update-flow.test.ts @@ -1,8 +1,8 @@ -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"; +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 command flow. @@ -13,9 +13,9 @@ import { run } from "./helpers.js"; * Run with: pnpm test -- tests/integration */ -const TEST_BUNDLE = "@nimblebraininc/echo"; +const TEST_BUNDLE = '@nimblebraininc/echo'; -describe("outdated + update flow", () => { +describe('outdated + update flow', () => { let originalMeta: string | null = null; let metaPath: string; @@ -26,7 +26,7 @@ describe("outdated + update flow", () => { originalMeta = null; }); - it("detects an outdated bundle and updates it to latest", async () => { + 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); @@ -36,20 +36,20 @@ describe("outdated + update flow", () => { if (!meta) return; const cacheDir = mpak.bundleCache.getBundleCacheDirName(TEST_BUNDLE); - metaPath = join(cacheDir, ".mpak-meta.json"); - originalMeta = readFileSync(metaPath, "utf8"); + metaPath = join(cacheDir, '.mpak-meta.json'); + originalMeta = readFileSync(metaPath, 'utf8'); const realVersion = meta.version; // 3. Downgrade version to simulate a stale cache entry - writeFileSync(metaPath, JSON.stringify({ ...meta, version: "0.0.1" })); + writeFileSync(metaPath, JSON.stringify({ ...meta, version: '0.0.1' })); // 4. `mpak outdated --json` should detect the entry - const outdatedRun = await run("outdated --json"); + 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.current).toBe('0.0.1'); expect(entry.latest).toBe(realVersion); // 5. `mpak update @nimblebraininc/echo --json` should bring it current diff --git a/packages/cli/tests/program.test.ts b/packages/cli/tests/program.test.ts index 8750017..1102f2c 100644 --- a/packages/cli/tests/program.test.ts +++ b/packages/cli/tests/program.test.ts @@ -1,23 +1,21 @@ -import { describe, it, expect } from "vitest"; -import { createProgram } from "../src/program.js"; +import { describe, it, expect } from 'vitest'; +import { createProgram } from '../src/program.js'; -describe("createProgram", () => { - it("should create a program with correct name", () => { +describe('createProgram', () => { + it('should create a program with correct name', () => { const program = createProgram(); - expect(program.name()).toBe("mpak"); + expect(program.name()).toBe('mpak'); }); - it("should have a description", () => { + it('should have a description', () => { const program = createProgram(); - expect(program.description()).toBe( - "CLI for MCP bundles and Agent Skills", - ); + expect(program.description()).toBe('CLI for MCP bundles and Agent Skills'); }); - it("should have version option", () => { + it('should have version option', () => { const program = createProgram(); const versionOption = program.options.find( - (opt) => opt.short === "-v" || opt.long === "--version", + (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 index 87bc213..10ef73b 100644 --- a/packages/cli/tests/search.test.ts +++ b/packages/cli/tests/search.test.ts @@ -1,7 +1,7 @@ -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"; +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 @@ -10,15 +10,15 @@ import { handleUnifiedSearch } from "../src/commands/search.js"; let mockSearchBundles: ReturnType; let mockSearchSkills: ReturnType; -vi.mock("../src/utils/config.js", () => ({ - get mpak() { - return { - client: { - searchBundles: mockSearchBundles, - searchSkills: mockSearchSkills, - } as unknown as MpakClient, - }; - }, +vi.mock('../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + searchBundles: mockSearchBundles, + searchSkills: mockSearchSkills, + } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -26,170 +26,166 @@ vi.mock("../src/utils/config.js", () => ({ // --------------------------------------------------------------------------- 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, + 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, + 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 }, + 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 }, + 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 }, + 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 }, + 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"), - ); - }); +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 index f86b6f1..4a573ed 100644 --- a/packages/cli/tests/skills/install.test.ts +++ b/packages/cli/tests/skills/install.test.ts @@ -1,36 +1,36 @@ -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"; +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:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + rmSync: vi.fn(), })); -vi.mock("node:child_process", () => ({ - execFileSync: 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, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + downloadSkillBundle: mockDownloadSkillBundle, + } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -40,144 +40,121 @@ vi.mock("../../src/utils/config.js", () => ({ const skillData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); const metadata = { - name: "@scope/test-skill", - version: "1.2.0", - sha256: "abcdef1234567890abcdef1234567890", - size: 512_000, + 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"); +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"), - ); - }); +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 index 448382b..3cae841 100644 --- a/packages/cli/tests/skills/pack.test.ts +++ b/packages/cli/tests/skills/pack.test.ts @@ -1,16 +1,11 @@ -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", () => { +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(() => { @@ -22,22 +17,22 @@ describe("packSkill", () => { rmSync(testDir, { recursive: true, force: true }); }); - describe("validation before packing", () => { - it("fails for invalid skill", async () => { - const skillDir = join(testDir, "invalid-skill"); + 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"); + expect(result.error).toContain('Validation failed'); }); - it("fails when name format is invalid", async () => { - const skillDir = join(testDir, "BadName"); + it('fails when name format is invalid', async () => { + const skillDir = join(testDir, 'BadName'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: BadName description: Invalid name @@ -47,16 +42,16 @@ description: Invalid name const result = await packSkill(skillDir); expect(result.success).toBe(false); - expect(result.error).toContain("Validation failed"); + expect(result.error).toContain('Validation failed'); }); }); - describe("successful packing", () => { - it("creates a .skill bundle", async () => { - const skillDir = join(testDir, "test-skill"); + describe('successful packing', () => { + it('creates a .skill bundle', async () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: test-skill description: A test skill for packing @@ -68,17 +63,14 @@ metadata: Instructions here.`, ); - const outputPath = join( - testDir, - "test-skill-1.0.0.skill", - ); + 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.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.path!.endsWith('.skill')).toBe(true); expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.size).toBeGreaterThan(0); @@ -86,11 +78,11 @@ Instructions here.`, expect(existsSync(result.path!)).toBe(true); }); - it("uses 0.0.0 version when metadata version is missing", async () => { - const skillDir = join(testDir, "no-version"); + 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"), + join(skillDir, 'SKILL.md'), `--- name: no-version description: A skill without version @@ -98,24 +90,19 @@ description: A skill without version # No Version`, ); - const outputPath = join( - testDir, - "no-version-0.0.0.skill", - ); + 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", - ); + 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"); + it('creates bundle in current directory by default', async () => { + const skillDir = join(testDir, 'output-test'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: output-test description: Testing output path @@ -139,11 +126,11 @@ metadata: } }); - it("uses custom output path when provided", async () => { - const skillDir = join(testDir, "custom-output"); + it('uses custom output path when provided', async () => { + const skillDir = join(testDir, 'custom-output'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: custom-output description: Custom output path test @@ -153,7 +140,7 @@ metadata: # Custom Output`, ); - const outputPath = join(testDir, "custom-name.skill"); + const outputPath = join(testDir, 'custom-name.skill'); const result = await packSkill(skillDir, outputPath); expect(result.success).toBe(true); @@ -161,14 +148,14 @@ metadata: expect(existsSync(outputPath)).toBe(true); }); - it("includes skill directory structure in bundle", async () => { - const skillDir = join(testDir, "structured-skill"); + it('includes skill directory structure in bundle', async () => { + const skillDir = join(testDir, 'structured-skill'); mkdirSync(skillDir); - mkdirSync(join(skillDir, "scripts")); - mkdirSync(join(skillDir, "references")); + mkdirSync(join(skillDir, 'scripts')); + mkdirSync(join(skillDir, 'references')); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: structured-skill description: A skill with structure @@ -177,48 +164,30 @@ metadata: --- # Structured Skill`, ); - writeFileSync( - join(skillDir, "scripts", "helper.py"), - "# Python helper", - ); - writeFileSync( - join(skillDir, "references", "PATTERNS.md"), - "# Patterns", - ); + 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 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", - ); + 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"); + it('calculates correct SHA256', async () => { + const skillDir = join(testDir, 'hash-test'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: hash-test description: Testing SHA256 calculation @@ -228,10 +197,7 @@ metadata: # Hash Test`, ); - const outputPath = join( - testDir, - "hash-test-1.0.0.skill", - ); + const outputPath = join(testDir, 'hash-test-1.0.0.skill'); const result = await packSkill(skillDir, outputPath); expect(result.success).toBe(true); @@ -239,11 +205,8 @@ metadata: // Verify hash using shasum if available try { - const shasum = execSync( - `shasum -a 256 "${result.path}"`, - { encoding: "utf-8" }, - ); - const computedHash = shasum.split(" ")[0]; + 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 @@ -251,12 +214,12 @@ metadata: }); }); - describe("bundle naming", () => { - it("creates bundle with name-version.skill format", async () => { - const skillDir = join(testDir, "naming-test"); + 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"), + join(skillDir, 'SKILL.md'), `--- name: naming-test description: Testing bundle naming @@ -266,23 +229,18 @@ metadata: # Naming Test`, ); - const outputPath = join( - testDir, - "naming-test-3.2.1.skill", - ); + 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", - ); + expect(basename(result.path!)).toBe('naming-test-3.2.1.skill'); }); - it("handles prerelease versions", async () => { - const skillDir = join(testDir, "prerelease-test"); + it('handles prerelease versions', async () => { + const skillDir = join(testDir, 'prerelease-test'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: prerelease-test description: Testing prerelease version @@ -292,16 +250,11 @@ metadata: # Prerelease Test`, ); - const outputPath = join( - testDir, - "prerelease-test-1.0.0-beta.1.skill", - ); + 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", - ); + 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 index 20e963a..1f74639 100644 --- a/packages/cli/tests/skills/pull.test.ts +++ b/packages/cli/tests/skills/pull.test.ts @@ -1,25 +1,25 @@ -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"; +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() })); +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, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { + downloadSkillBundle: mockDownloadSkillBundle, + } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -29,126 +29,101 @@ vi.mock("../../src/utils/config.js", () => ({ const skillData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); const metadata = { - name: "@scope/test-skill", - version: "1.2.0", - sha256: "abcdef1234567890abcdef1234567890", - size: 512_000, + 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"), - ); - }); +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 index 397ec15..08df39a 100644 --- a/packages/cli/tests/skills/search.test.ts +++ b/packages/cli/tests/skills/search.test.ts @@ -1,7 +1,7 @@ -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"; +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 @@ -9,143 +9,137 @@ import { handleSkillSearch } from "../../src/commands/skills/search.js"; let mockSearchSkills: ReturnType; -vi.mock("../../src/utils/config.js", () => ({ - get mpak() { - return { - client: { searchSkills: mockSearchSkills } as unknown as MpakClient, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { searchSkills: mockSearchSkills } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- -import type { SkillCategory } from "@nimblebrain/mpak-schemas"; +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", + 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 }, + 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 }, + 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 }, + 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"), - ); - }); +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 index 4a6c89a..18c89f3 100644 --- a/packages/cli/tests/skills/show.test.ts +++ b/packages/cli/tests/skills/show.test.ts @@ -1,7 +1,7 @@ -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"; +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 @@ -9,12 +9,12 @@ import { handleSkillShow } from "../../src/commands/skills/show.js"; let mockGetSkill: ReturnType; -vi.mock("../../src/utils/config.js", () => ({ - get mpak() { - return { - client: { getSkill: mockGetSkill } as unknown as MpakClient, - }; - }, +vi.mock('../../src/utils/config.js', () => ({ + get mpak() { + return { + client: { getSkill: mockGetSkill } as unknown as MpakClient, + }; + }, })); // --------------------------------------------------------------------------- @@ -22,168 +22,166 @@ vi.mock("../../src/utils/config.js", () => ({ // --------------------------------------------------------------------------- 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 }, - ], + 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: [], + 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; +describe('handleSkillShow', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; - beforeEach(() => { - mockGetSkill = vi.fn(); - stdoutSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - }); + beforeEach(() => { + mockGetSkill = vi.fn(); + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - it("calls getSkill with the skill name", async () => { - mockGetSkill.mockResolvedValue(baseSkill); + it('calls getSkill with the skill name', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + await handleSkillShow('@scope/test-skill', {}); - expect(mockGetSkill).toHaveBeenCalledWith("@scope/test-skill"); - }); + expect(mockGetSkill).toHaveBeenCalledWith('@scope/test-skill'); + }); - it("prints name, version, and description", async () => { - mockGetSkill.mockResolvedValue(baseSkill); + it('prints name, version, and description', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"); - }); + 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); + it('prints metadata fields', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"); - }); + 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); + it('prints triggers', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"); - }); + 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); + it('prints examples with context', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"'); - }); + 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); + it('prints version history', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"); - }); + 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); + it('prints install hint', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", {}); + 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"); - }); + 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); + it('handles minimal skill without optional fields', async () => { + mockGetSkill.mockResolvedValue(minimalSkill); - await handleSkillShow("@scope/minimal-skill", {}); + 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:"); - }); + 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); + it('prints JSON output when json option is set', async () => { + mockGetSkill.mockResolvedValue(baseSkill); - await handleSkillShow("@scope/test-skill", { json: true }); + 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"); - }); + 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")); + 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"), - ); - }); + 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 index f43fb3a..2efc598 100644 --- a/packages/cli/tests/skills/validate.test.ts +++ b/packages/cli/tests/skills/validate.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, writeFileSync, rmSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; +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"; +} from '../../src/commands/skills/validate.js'; -describe("validateSkillDirectory", () => { +describe('validateSkillDirectory', () => { let testDir: string; beforeEach(() => { @@ -19,76 +19,60 @@ describe("validateSkillDirectory", () => { rmSync(testDir, { recursive: true, force: true }); }); - describe("directory checks", () => { - it("fails for non-existent directory", () => { - const result = validateSkillDirectory( - "/non/existent/path", - ); + 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", - ); + 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"); + 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/, - ); + 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"); + 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"); + expect(result.errors).toContain('SKILL.md not found'); }); - it("fails when frontmatter is missing", () => { - const skillDir = join(testDir, "test-skill"); + it('fails when frontmatter is missing', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - "# Just content\nNo frontmatter here", - ); + 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", - ); + expect(result.errors).toContain('No frontmatter found in SKILL.md'); }); - it("fails when frontmatter is empty", () => { - const skillDir = join(testDir, "test-skill"); + it('fails when frontmatter is empty', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); - writeFileSync( - join(skillDir, "SKILL.md"), - "---\n---\n# Content", - ); + 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", - ); + 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"); + describe('frontmatter validation', () => { + it('fails when name is missing', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- description: A test skill --- @@ -97,16 +81,14 @@ description: A test skill const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(false); - expect( - result.errors.some((e) => e.includes("name")), - ).toBe(true); + expect(result.errors.some((e) => e.includes('name'))).toBe(true); }); - it("fails when description is missing", () => { - const skillDir = join(testDir, "test-skill"); + it('fails when description is missing', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: test-skill --- @@ -115,16 +97,14 @@ name: test-skill const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(false); - expect( - result.errors.some((e) => e.includes("description")), - ).toBe(true); + expect(result.errors.some((e) => e.includes('description'))).toBe(true); }); - it("fails when name format is invalid", () => { - const skillDir = join(testDir, "test-skill"); + it('fails when name format is invalid', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: Test_Skill description: A test skill @@ -134,18 +114,14 @@ description: A test skill const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(false); - expect( - result.errors.some((e) => - e.toLowerCase().includes("lowercase"), - ), - ).toBe(true); + expect(result.errors.some((e) => e.toLowerCase().includes('lowercase'))).toBe(true); }); - it("fails when name has uppercase", () => { - const skillDir = join(testDir, "test-skill"); + it('fails when name has uppercase', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: TestSkill description: A test skill @@ -157,11 +133,11 @@ description: A test skill expect(result.valid).toBe(false); }); - it("fails when name starts with hyphen", () => { - const skillDir = join(testDir, "-test-skill"); + it('fails when name starts with hyphen', () => { + const skillDir = join(testDir, '-test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: -test-skill description: A test skill @@ -173,11 +149,11 @@ description: A test skill expect(result.valid).toBe(false); }); - it("fails when name does not match directory", () => { - const skillDir = join(testDir, "dir-name"); + it('fails when name does not match directory', () => { + const skillDir = join(testDir, 'dir-name'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: different-name description: A test skill @@ -187,20 +163,16 @@ description: A test skill const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(false); - expect( - result.errors.some((e) => - e.includes("does not match directory"), - ), - ).toBe(true); + 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"); + describe('valid skills', () => { + it('validates minimal skill', () => { + const skillDir = join(testDir, 'test-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: test-skill description: A test skill for validation @@ -212,17 +184,15 @@ 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", - ); + 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"); + it('validates skill with optional fields', () => { + const skillDir = join(testDir, 'full-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: full-skill description: A fully featured test skill @@ -235,20 +205,16 @@ allowed-tools: Read Grep Bash 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", - ); + 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"); + it('validates skill with metadata', () => { + const skillDir = join(testDir, 'meta-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: meta-skill description: A skill with metadata @@ -266,28 +232,19 @@ metadata: 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"); + 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"); + describe('warnings', () => { + it('warns when metadata is missing', () => { + const skillDir = join(testDir, 'basic-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: basic-skill description: A basic skill without metadata @@ -297,18 +254,14 @@ description: A basic skill without metadata const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => - w.includes("No metadata field"), - ), - ).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"); + it('warns when version is missing from metadata', () => { + const skillDir = join(testDir, 'no-version-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: no-version-skill description: A skill without version @@ -320,16 +273,14 @@ metadata: const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => w.includes("No version")), - ).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"); + it('warns when tags are missing', () => { + const skillDir = join(testDir, 'no-tags-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: no-tags-skill description: A skill without tags @@ -341,44 +292,37 @@ metadata: const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(true); - expect( - result.warnings.some((w) => w.includes("No tags")), - ).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"); + it('warns about invalid optional directory', () => { + const skillDir = join(testDir, 'file-as-dir-skill'); mkdirSync(skillDir); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: file-as-dir-skill description: A skill with scripts as file --- # Skill`, ); - writeFileSync( - join(skillDir, "scripts"), - "not a directory", - ); + 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); + expect(result.warnings.some((w) => w.includes('scripts'))).toBe(true); }); }); - describe("optional directories", () => { - it("accepts valid optional directories", () => { - const skillDir = join(testDir, "dirs-skill"); + 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")); + mkdirSync(join(skillDir, 'scripts')); + mkdirSync(join(skillDir, 'references')); + mkdirSync(join(skillDir, 'assets')); writeFileSync( - join(skillDir, "SKILL.md"), + join(skillDir, 'SKILL.md'), `--- name: dirs-skill description: A skill with all optional dirs @@ -388,108 +332,102 @@ description: A skill with all optional dirs const result = validateSkillDirectory(skillDir); expect(result.valid).toBe(true); - expect( - result.warnings.filter((w) => - w.includes("not a directory"), - ), - ).toHaveLength(0); + expect(result.warnings.filter((w) => w.includes('not a directory'))).toHaveLength(0); }); }); }); -describe("formatValidationResult", () => { - it("formats valid result correctly", () => { +describe('formatValidationResult', () => { + it('formats valid result correctly', () => { const result = { valid: true, - name: "test-skill", - path: "/path/to/skill", + name: 'test-skill', + path: '/path/to/skill', frontmatter: { - name: "test-skill", - description: "A test skill description", + 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:"); + 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", () => { + it('formats invalid result correctly', () => { const result = { valid: false, name: null, - path: "/path/to/skill", + path: '/path/to/skill', frontmatter: null, - errors: ["SKILL.md not found"], + 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"); + expect(output).toContain('\u2717 Invalid: /path/to/skill'); + expect(output).toContain('Errors:'); + expect(output).toContain('SKILL.md not found'); }); - it("formats warnings correctly", () => { + it('formats warnings correctly', () => { const result = { valid: true, - name: "test-skill", - path: "/path/to/skill", + name: 'test-skill', + path: '/path/to/skill', frontmatter: { - name: "test-skill", - description: "A test skill", + name: 'test-skill', + description: 'A test skill', }, errors: [], - warnings: ["No metadata field"], + warnings: ['No metadata field'], }; const output = formatValidationResult(result); - expect(output).toContain("Warnings:"); - expect(output).toContain("No metadata field"); + expect(output).toContain('Warnings:'); + expect(output).toContain('No metadata field'); }); - it("formats optional fields", () => { + it('formats optional fields', () => { const result = { valid: true, - name: "test-skill", - path: "/path/to/skill", + name: 'test-skill', + path: '/path/to/skill', frontmatter: { - name: "test-skill", - description: "A test skill", - license: "MIT", - compatibility: "Claude Code", - "allowed-tools": "Read Grep", + 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"); + expect(output).toContain('license: MIT'); + expect(output).toContain('compatibility: Claude Code'); + expect(output).toContain('allowed-tools: Read Grep'); }); - it("formats metadata correctly", () => { + it('formats metadata correctly', () => { const result = { valid: true, - name: "test-skill", - path: "/path/to/skill", + name: 'test-skill', + path: '/path/to/skill', frontmatter: { - name: "test-skill", - description: "A test skill", + 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" }, + version: '1.0.0', + category: 'development' as const, + tags: ['test', 'validation'], + triggers: ['test trigger'], + author: { name: 'Test Author' }, }, }, errors: [], @@ -497,22 +435,22 @@ describe("formatValidationResult", () => { }; 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"); + 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); + it('truncates long descriptions', () => { + const longDescription = 'A'.repeat(100); const result = { valid: true, - name: "test-skill", - path: "/path/to/skill", + name: 'test-skill', + path: '/path/to/skill', frontmatter: { - name: "test-skill", + name: 'test-skill', description: longDescription, }, errors: [], @@ -520,7 +458,7 @@ describe("formatValidationResult", () => { }; const output = formatValidationResult(result); - expect(output).toContain("..."); - expect(output).toContain("(100 chars)"); + expect(output).toContain('...'); + expect(output).toContain('(100 chars)'); }); }); diff --git a/packages/cli/tests/version.test.ts b/packages/cli/tests/version.test.ts index 77880bd..ece5822 100644 --- a/packages/cli/tests/version.test.ts +++ b/packages/cli/tests/version.test.ts @@ -1,18 +1,16 @@ -import { describe, it, expect } from "vitest"; -import { getVersion } from "../src/utils/version.js"; +import { describe, it, expect } from 'vitest'; +import { getVersion } from '../src/utils/version.js'; -describe("getVersion", () => { - it("should return a valid version string", () => { +describe('getVersion', () => { + it('should return a valid version string', () => { const version = getVersion(); expect(version).toBeTruthy(); - expect(typeof version).toBe("string"); + 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, - ); + expect(version === 'unknown' || semverRegex.test(version)).toBe(true); }); }); diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index f63db83..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(), }); // ============================================================================= diff --git a/packages/schemas/src/skill.ts b/packages/schemas/src/skill.ts index ee5d0bc..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,9 +200,7 @@ 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; diff --git a/packages/sdk-typescript/src/cache.ts b/packages/sdk-typescript/src/cache.ts index fafe7ec..432e771 100644 --- a/packages/sdk-typescript/src/cache.ts +++ b/packages/sdk-typescript/src/cache.ts @@ -230,10 +230,7 @@ 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, - options?: { force?: boolean }, - ): Promise { + async checkForUpdate(packageName: string, options?: { force?: boolean }): Promise { const cachedMeta = this.getBundleMetadata(packageName); if (!cachedMeta) return null; diff --git a/packages/sdk-typescript/src/errors.ts b/packages/sdk-typescript/src/errors.ts index 1917344..dbe774a 100644 --- a/packages/sdk-typescript/src/errors.ts +++ b/packages/sdk-typescript/src/errors.ts @@ -113,7 +113,12 @@ export class MpakInvalidBundleError extends MpakError { export class MpakConfigError extends MpakError { constructor( public readonly packageName: string, - public readonly missingFields: Array<{ key: string; title: string; description?: 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/index.ts b/packages/sdk-typescript/src/index.ts index 8ba2e23..c3bb979 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -19,7 +19,12 @@ // Facade — primary entry point export { Mpak } from './mpakSDK.js'; -export type { MpakOptions, PrepareServerSpec, PrepareServerOptions, ServerCommand } from './mpakSDK.js'; +export type { + MpakOptions, + PrepareServerSpec, + PrepareServerOptions, + ServerCommand, +} from './mpakSDK.js'; // Components (standalone use) export { MpakConfigManager } from './config-manager.js'; diff --git a/packages/sdk-typescript/src/mpakSDK.ts b/packages/sdk-typescript/src/mpakSDK.ts index 75e5279..9c2ef15 100644 --- a/packages/sdk-typescript/src/mpakSDK.ts +++ b/packages/sdk-typescript/src/mpakSDK.ts @@ -7,10 +7,14 @@ import { MpakBundleCache } from './cache.js'; import { MpakClient } from './client.js'; import { MpakConfigManager } from './config-manager.js'; import { MpakCacheCorruptedError, MpakConfigError, MpakInvalidBundleError } from './errors.js'; -import { extractZip, hashBundlePath, localBundleNeedsExtract, readJsonFromFile } from './helpers.js'; +import { + extractZip, + hashBundlePath, + localBundleNeedsExtract, + readJsonFromFile, +} from './helpers.js'; import type { MpakClientConfig } from './types.js'; - /** * Options for the {@link Mpak} facade. * @@ -35,9 +39,7 @@ export interface MpakOptions { * - `{ 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 }; +export type PrepareServerSpec = { name: string; version?: string } | { local: string }; /** * Options for {@link Mpak.prepareServer}. @@ -141,7 +143,10 @@ export class Mpak { * @throws {MpakConfigError} If required user config values are missing. * @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download. */ - async prepareServer(spec: PrepareServerSpec, options?: PrepareServerOptions): Promise { + async prepareServer( + spec: PrepareServerSpec, + options?: PrepareServerOptions, + ): Promise { let cacheDir: string; let name: string; let version: string; @@ -150,18 +155,18 @@ export class Mpak { 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)); + ({ 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, - cacheDir, - userConfigValues, - ); + const { command, args, env } = this.resolveCommand(manifest, cacheDir, userConfigValues); // Set MPAK_WORKSPACE env['MPAK_WORKSPACE'] = options?.workspaceDir ?? join(process.cwd(), '.mpak'); @@ -199,7 +204,12 @@ export class Mpak { ); } - return { cacheDir: loadResult.cacheDir, name: packageName, version: loadResult.version, manifest }; + return { + cacheDir: loadResult.cacheDir, + name: packageName, + version: loadResult.version, + manifest, + }; } /** diff --git a/packages/sdk-typescript/tests/helpers.test.ts b/packages/sdk-typescript/tests/helpers.test.ts index b0aff3d..0aa67dc 100644 --- a/packages/sdk-typescript/tests/helpers.test.ts +++ b/packages/sdk-typescript/tests/helpers.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -140,7 +148,10 @@ describe('localBundleNeedsExtract', () => { // 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 })); + 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'); diff --git a/packages/sdk-typescript/tests/mpak.integration.test.ts b/packages/sdk-typescript/tests/mpak.integration.test.ts index 1292439..1149115 100644 --- a/packages/sdk-typescript/tests/mpak.integration.test.ts +++ b/packages/sdk-typescript/tests/mpak.integration.test.ts @@ -143,9 +143,12 @@ describe('Mpak facade integration', () => { }); it('prepareServer respects workspaceDir option', async () => { - const result = await sdk.prepareServer({ name: 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'); }); diff --git a/packages/sdk-typescript/tests/mpak.test.ts b/packages/sdk-typescript/tests/mpak.test.ts index 7cab049..b8915c1 100644 --- a/packages/sdk-typescript/tests/mpak.test.ts +++ b/packages/sdk-typescript/tests/mpak.test.ts @@ -348,7 +348,9 @@ describe('Mpak facade', () => { it('throws MpakCacheCorruptedError when manifest is null', async () => { const { sdk } = setupSdk(null); - await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow(MpakCacheCorruptedError); + 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', ); @@ -357,9 +359,12 @@ describe('Mpak facade', () => { it('sets MPAK_WORKSPACE from workspaceDir option', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer({ name: '@scope/echo' }, { - workspaceDir: '/custom/workspace', - }); + const result = await sdk.prepareServer( + { name: '@scope/echo' }, + { + workspaceDir: '/custom/workspace', + }, + ); expect(result.env['MPAK_WORKSPACE']).toBe('/custom/workspace'); }); @@ -375,9 +380,12 @@ describe('Mpak facade', () => { it('caller env overrides MPAK_WORKSPACE default', async () => { const { sdk } = setupSdk(); - const result = await sdk.prepareServer({ name: '@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'); }); @@ -395,9 +403,12 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(manifestWithEnv); - const result = await sdk.prepareServer({ name: '@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'); @@ -526,7 +537,12 @@ describe('Mpak facade', () => { 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 }, + { + key: 'endpoint', + title: 'Endpoint URL', + description: 'The API base URL', + sensitive: false, + }, ]); } }); @@ -565,8 +581,12 @@ describe('Mpak facade', () => { }; const { sdk } = setupSdk(badManifest); - await expect(sdk.prepareServer({ name: '@scope/echo' })).rejects.toThrow(MpakCacheCorruptedError); - await expect(sdk.prepareServer({ name: '@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', + ); }); }); @@ -601,9 +621,13 @@ describe('Mpak facade', () => { 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', - }); + execFileSync( + 'zip', + ['-j', mcpbPath, join(srcDir, 'manifest.json'), join(srcDir, 'index.js')], + { + stdio: 'pipe', + }, + ); return mcpbPath; } @@ -693,7 +717,9 @@ describe('Mpak facade', () => { 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'); + await expect(sdk.prepareServer({ local: mcpbPath })).rejects.toThrow( + 'File failed validation', + ); }); it('resolves a python server from local bundle', async () => { @@ -719,9 +745,12 @@ describe('Mpak facade', () => { const sdk = new Mpak({ mpakHome: testDir }); const mcpbPath = createMcpbBundle(testDir, nodeManifest); - const result = await sdk.prepareServer({ local: mcpbPath }, { - workspaceDir: '/custom/workspace', - }); + const result = await sdk.prepareServer( + { local: mcpbPath }, + { + workspaceDir: '/custom/workspace', + }, + ); expect(result.env['MPAK_WORKSPACE']).toBe('/custom/workspace'); });