From ffa9e390be9c8abae56018ec36f906723a3061e6 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Sun, 20 Sep 2026 19:37:42 +0100 Subject: [PATCH 1/3] fix: preserve CLI contracts and discover trusted native executables --- .github/workflows/ci.yml | 24 ++++++ lib/__tests__/cli-contract.test.js | 115 ++++++++++++++++++++++++++ lib/__tests__/cli-discovery.test.js | 72 ++++++++++++++++ lib/__tests__/cli.test.js | 27 ++---- lib/__tests__/integration.test.js | 7 +- lib/__tests__/server-contract.test.js | 62 ++++++++++++++ lib/__tests__/tools/add.test.js | 7 +- lib/__tests__/tools/install.test.js | 7 +- lib/__tests__/tools/search.test.js | 4 +- lib/cli.js | 105 ++++++++++++++++------- lib/constants.js | 2 +- lib/format.js | 8 ++ lib/server.js | 4 +- lib/tools/add.js | 52 +++--------- lib/tools/audit.js | 37 +-------- lib/tools/install.js | 29 +------ lib/tools/search.js | 29 ++++--- 17 files changed, 419 insertions(+), 172 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 lib/__tests__/cli-contract.test.js create mode 100644 lib/__tests__/cli-discovery.test.js create mode 100644 lib/__tests__/server-contract.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea8cf6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm test + - run: npm run lint diff --git a/lib/__tests__/cli-contract.test.js b/lib/__tests__/cli-contract.test.js new file mode 100644 index 0000000..64f0c28 --- /dev/null +++ b/lib/__tests__/cli-contract.test.js @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest" + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), + execFile: vi.fn(), +})) + +import { execFile } from "node:child_process" +import { runCli } from "../cli.js" +import { add } from "../tools/add.js" +import { audit } from "../tools/audit.js" +import { install } from "../tools/install.js" + +beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv("LPM_CLI_PATH", "/trusted/lpm") +}) +afterEach(() => vi.unstubAllEnvs()) + +function respond(data, error = null) { + execFile.mockImplementation((_command, _args, _options, callback) => { + callback(error, JSON.stringify(data), "") + }) +} + +it("audits the requested project directory", async () => { + respond({ success: true, scanned: 1, packages: [] }) + await audit({ path: "/projects/selected" }, { getToken: async () => "token" }) + expect(execFile.mock.calls[0][2].cwd).toBe("/projects/selected") +}) + +it("keeps a nonzero process result even when the report completed successfully", async () => { + respond({ success: true, scanned: 1, total_issues: 1 }, new Error("exit 1")) + const result = await runCli(["audit", "--json"]) + expect(result.success).toBe(false) + expect(result.data.total_issues).toBe(1) +}) + +it.each([ + "token rejected", + { message: "token rejected", code: "AUTH" }, +])("preserves current structured error messages: %j", async error => { + respond({ success: false, error, error_code: "auth_required" }) + expect((await runCli(["install", "--json"])).error).toBe("token rejected") +}) + +it.each([ + [ + add, + { name: "owner.source" }, + { + success: true, + install_path: "src/ui", + files_copied: 2, + files: [], + dependencies_installed: 1, + }, + ], + [ + install, + { name: "owner.library" }, + { success: true, installed: 1, total: 3, security_summary: { blocked: 1 } }, + ], + [ + audit, + {}, + { + success: true, + scanned: 3, + packages_with_issues: 1, + vulnerabilities: [{ id: "GHSA-fixture" }], + packages: [], + }, + ], +])("preserves the complete current CLI report for %s", async (tool, args, data) => { + respond(data) + const result = await tool(args, { getToken: async () => "token" }) + expect(JSON.parse(result.content[0].text)).toEqual(data) +}) + +it.each([ + add, + install, + audit, +])("lets the CLI decide whether authentication is required for %s", async tool => { + respond({ success: true, packages: [] }) + const result = await tool( + { name: "owner.free" }, + { getToken: async () => null }, + ) + expect(result.isError).toBeUndefined() + expect(execFile).toHaveBeenCalled() +}) + +it("keeps findings in a failed audit tool response", async () => { + const report = { + success: true, + scanned: 1, + vulnerabilities: [{ id: "GHSA-fixture" }], + packages: [], + } + respond(report, new Error("exit 1")) + const result = await audit({}, { getToken: async () => "token" }) + expect(result.isError).toBe(true) + expect(JSON.parse(result.content[0].text)).toEqual(report) +}) + +it("rejects a relative CLI override before changing to an audit project", async () => { + vi.stubEnv("LPM_CLI_PATH", "./bin/lpm") + respond({ success: true }) + const result = await audit({ path: "/projects/untrusted" }) + expect(result.isError).toBe(true) + expect(execFile).not.toHaveBeenCalled() +}) diff --git a/lib/__tests__/cli-discovery.test.js b/lib/__tests__/cli-discovery.test.js new file mode 100644 index 0000000..6fa347d --- /dev/null +++ b/lib/__tests__/cli-discovery.test.js @@ -0,0 +1,72 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { afterEach, beforeEach, expect, it, vi } from "vitest" + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + execFile: vi.fn(), +})) + +import { execFileSync } from "node:child_process" +import { resolveCli } from "../cli.js" + +let root +beforeEach(() => { + root = realpathSync(mkdtempSync(path.join(tmpdir(), "lpm-discovery-"))) + vi.stubEnv("LPM_CLI_PATH", "") + vi.stubEnv("PATH", root) + vi.resetAllMocks() +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it("finds a Windows native executable directly without a workspace locator", () => { + const executable = path.join(root, "lpm.exe") + writeFileSync(executable, "fixture", { mode: 0o755 }) + execFileSync.mockReturnValue(path.join(process.cwd(), "lpm.exe")) + expect(resolveCli("win32")).toBe(executable) + expect(execFileSync).not.toHaveBeenCalled() +}) + +it("finds the native target of a global npm Windows installation", () => { + const packageRoot = path.join(root, "node_modules", "@lpm-registry") + const wrapper = path.join(packageRoot, "cli") + const native = path.join(packageRoot, "cli-win32-x64") + mkdirSync(path.join(wrapper, "bin"), { recursive: true }) + mkdirSync(native, { recursive: true }) + writeFileSync(path.join(root, "lpm.cmd"), "@echo off\r\n") + writeFileSync( + path.join(wrapper, "package.json"), + JSON.stringify({ name: "@lpm-registry/cli", bin: { lpm: "bin/lpm" } }), + ) + writeFileSync(path.join(wrapper, "bin", "lpm"), "#!/usr/bin/env node\n") + writeFileSync( + path.join(native, "package.json"), + JSON.stringify({ name: "@lpm-registry/cli-win32-x64" }), + ) + const executable = path.join(native, "lpm.exe") + writeFileSync(executable, "fixture", { mode: 0o755 }) + expect(resolveCli("win32")).toBe(executable) +}) + +it("ignores relative and project-local PATH entries", () => { + vi.spyOn(process, "cwd").mockReturnValue(root) + const local = path.join(root, "node_modules", ".bin") + mkdirSync(local, { recursive: true }) + writeFileSync(path.join(root, "lpm.exe"), "fixture", { mode: 0o755 }) + writeFileSync(path.join(local, "lpm.exe"), "fixture", { mode: 0o755 }) + vi.stubEnv("PATH", ["", ".", root, local].join(path.delimiter)) + execFileSync.mockReturnValue(path.join(root, "lpm.exe")) + expect(resolveCli("win32")).toBeNull() + expect(execFileSync).not.toHaveBeenCalled() +}) diff --git a/lib/__tests__/cli.test.js b/lib/__tests__/cli.test.js index 214938f..fd3d1a4 100644 --- a/lib/__tests__/cli.test.js +++ b/lib/__tests__/cli.test.js @@ -1,17 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" vi.mock("node:child_process", () => ({ - execSync: vi.fn(), + execFileSync: vi.fn(), execFile: vi.fn(), })) -import { execFile, execSync } from "node:child_process" +import { execFile } from "node:child_process" import { resolveCli, runCli } from "../cli.js" describe("resolveCli", () => { beforeEach(() => { vi.resetAllMocks() delete process.env.LPM_CLI_PATH + vi.unstubAllEnvs() }) it("returns LPM_CLI_PATH env var when set", () => { @@ -19,23 +20,10 @@ describe("resolveCli", () => { expect(resolveCli()).toBe("/custom/path/lpm") }) - it("falls back to which lpm", () => { - execSync.mockReturnValueOnce("/usr/local/bin/lpm\n") - expect(resolveCli()).toBe("/usr/local/bin/lpm") - }) - it("returns null when CLI not found", () => { - execSync.mockImplementation(() => { - throw new Error("not found") - }) + vi.stubEnv("PATH", "") expect(resolveCli()).toBeNull() - }) - - it("prefers LPM_CLI_PATH over which", () => { - process.env.LPM_CLI_PATH = "/override/lpm" - execSync.mockReturnValueOnce("/usr/local/bin/lpm\n") - expect(resolveCli()).toBe("/override/lpm") - expect(execSync).not.toHaveBeenCalled() + vi.unstubAllEnvs() }) }) @@ -47,13 +35,12 @@ describe("runCli", () => { afterEach(() => { delete process.env.LPM_CLI_PATH + vi.unstubAllEnvs() }) it("returns error when CLI not found", async () => { delete process.env.LPM_CLI_PATH - execSync.mockImplementation(() => { - throw new Error("not found") - }) + vi.stubEnv("PATH", "") const result = await runCli(["add", "test.pkg"]) diff --git a/lib/__tests__/integration.test.js b/lib/__tests__/integration.test.js index e0bd8d7..673a390 100644 --- a/lib/__tests__/integration.test.js +++ b/lib/__tests__/integration.test.js @@ -323,7 +323,12 @@ describe("Integration: AI workflow", () => { expect(treeData.tree).toContain("Sources/Network/Client.swift") }) - it("unauthenticated user gets helpful errors", async () => { + it("unauthenticated user gets helpful errors from protected operations", async () => { + runCli.mockResolvedValue({ + success: false, + data: null, + error: "Authentication required by registry", + }) const ctx = createContext({ getToken: vi.fn().mockResolvedValue(null), }) diff --git a/lib/__tests__/server-contract.test.js b/lib/__tests__/server-contract.test.js new file mode 100644 index 0000000..b28f788 --- /dev/null +++ b/lib/__tests__/server-contract.test.js @@ -0,0 +1,62 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { afterEach, expect, it, vi } from "vitest" + +vi.mock("../api.js", () => ({ searchGet: vi.fn() })) +vi.mock("../auth.js", async importOriginal => ({ + ...(await importOriginal()), + getToken: async () => null, +})) + +import { searchGet } from "../api.js" + +afterEach(() => vi.unstubAllEnvs()) + +import { createServer } from "../server.js" + +it("offers Free as a registry distribution filter", async () => { + const server = createServer() + const client = new Client({ name: "test", version: "1.0.0" }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + try { + await server.connect(serverTransport) + await client.connect(clientTransport) + const { tools } = await client.listTools() + const search = tools.find(tool => tool.name === "lpm_search") + expect(search.inputSchema.properties.distribution.enum).toContain("free") + } finally { + await client.close() + await server.close() + } +}) + +it.each([ + { distribution: "free" }, + { ecosystem: "swift" }, +])("browses with a structured filter alone: %j", async filter => { + vi.stubEnv("LPM_TOKEN", "") + searchGet.mockResolvedValue({ ok: true, data: { packages: [] } }) + const server = createServer() + const client = new Client({ name: "test", version: "1.0.0" }) + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair() + try { + await server.connect(serverTransport) + await client.connect(clientTransport) + const result = await client.callTool({ + name: "lpm_search", + arguments: filter, + }) + expect(result.isError).not.toBe(true) + const [key, value] = Object.entries(filter)[0] + expect(searchGet).toHaveBeenCalledWith( + expect.stringContaining(`${key}=${value}`), + null, + expect.any(String), + ) + } finally { + await client.close() + await server.close() + } +}) diff --git a/lib/__tests__/tools/add.test.js b/lib/__tests__/tools/add.test.js index 0b12118..b54720f 100644 --- a/lib/__tests__/tools/add.test.js +++ b/lib/__tests__/tools/add.test.js @@ -25,7 +25,12 @@ describe("add tool", () => { expect(result.content[0].text).toContain("Invalid") }) - it("returns error when not authenticated", async () => { + it("reports authentication errors from the CLI", async () => { + runCli.mockResolvedValueOnce({ + success: false, + data: null, + error: "Authentication required by registry", + }) const ctx = createContext({ getToken: vi.fn().mockResolvedValue(null) }) const result = await add({ name: "alice.button" }, ctx) expect(result.isError).toBe(true) diff --git a/lib/__tests__/tools/install.test.js b/lib/__tests__/tools/install.test.js index 681a403..44cc010 100644 --- a/lib/__tests__/tools/install.test.js +++ b/lib/__tests__/tools/install.test.js @@ -25,7 +25,12 @@ describe("install tool", () => { expect(result.content[0].text).toContain("Invalid") }) - it("returns error when not authenticated", async () => { + it("reports authentication errors from the CLI", async () => { + runCli.mockResolvedValueOnce({ + success: false, + data: null, + error: "Authentication required by registry", + }) const ctx = createContext({ getToken: vi.fn().mockResolvedValue(null) }) const result = await install({ name: "alice.utils" }, ctx) expect(result.isError).toBe(true) diff --git a/lib/__tests__/tools/search.test.js b/lib/__tests__/tools/search.test.js index 88ccb43..81c0417 100644 --- a/lib/__tests__/tools/search.test.js +++ b/lib/__tests__/tools/search.test.js @@ -44,11 +44,11 @@ const EXPLORE_PACKAGE = { describe("search — validation", () => { beforeEach(() => vi.clearAllMocks()) - it("returns error when neither query nor category provided", async () => { + it("returns error when neither query nor structured filter provided", async () => { const result = await search({}, makeContext()) expect(result.isError).toBe(true) expect(result.content[0].text).toContain("query") - expect(result.content[0].text).toContain("category") + expect(result.content[0].text).toContain("structured filter") }) it("returns error for single-character query", async () => { diff --git a/lib/cli.js b/lib/cli.js index 1968e2a..6ed4cdd 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,41 +1,77 @@ -import { execFile, execSync } from "node:child_process" +import { execFile } from "node:child_process" +import { accessSync, constants, realpathSync, statSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" const CLI_TIMEOUT_MS = 60_000 -/** - * Resolve the path to the `lpm` CLI binary. - * - * Resolution order: - * 1. LPM_CLI_PATH env var (explicit override) - * 2. `which lpm` (global install) - * 3. null (caller should handle fallback) - * - * @returns {string|null} - */ -export function resolveCli() { - // 1. Explicit override +/** Resolve an explicit CLI path or a global installation outside the caller's project. */ +export function resolveCli(platform = process.platform) { const envPath = process.env.LPM_CLI_PATH - if (envPath) return envPath + if (envPath) return path.isAbsolute(envPath) ? envPath : null - // 2. Global install + const workspace = realpathSync(process.cwd()) + for (const directory of (process.env.PATH || "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue + const executable = globalExecutable( + path.join(directory, platform === "win32" ? "lpm.exe" : "lpm"), + workspace, + platform, + ) + if (executable) return executable + if (platform !== "win32") continue + + // npm exposes a .cmd shim. Resolve its native dependency without a shell. + try { + const wrapper = path.join( + directory, + "node_modules", + "@lpm-registry", + "cli", + "package.json", + ) + if (!globalExecutable(wrapper, workspace, platform)) continue + const require = createRequire(wrapper) + const nativePackage = require.resolve( + "@lpm-registry/cli-win32-x64/package.json", + ) + const native = globalExecutable( + path.join(path.dirname(nativePackage), "lpm.exe"), + workspace, + platform, + ) + if (native) return native + } catch { + // Continue to the next PATH directory if optional native dependencies are absent. + } + } + return null +} + +function globalExecutable(candidate, workspace, platform) { try { - const result = execSync("which lpm", { - encoding: "utf-8", - timeout: 5_000, - }).trim() - if (result) return result + const resolved = realpathSync(candidate) + const relative = path.relative(workspace, resolved) + if ( + relative === "" || + (!relative.startsWith(`..${path.sep}`) && + relative !== ".." && + !path.isAbsolute(relative)) + ) + return null + if (!statSync(resolved).isFile()) return null + accessSync(resolved, platform === "win32" ? constants.F_OK : constants.X_OK) + return resolved } catch { - // not found + return null } - - return null } /** * Run an LPM CLI command and return parsed JSON output. * * @param {string[]} args - CLI arguments (e.g., ['add', '@lpm.dev/owner.pkg', '--json']) - * @param {{ timeout?: number }} options + * @param {{ timeout?: number, cwd?: string }} options * @returns {Promise<{ success: boolean, data: object|null, error: string|null }>} */ export async function runCli(args, options = {}) { @@ -58,6 +94,7 @@ export async function runCli(args, options = {}) { args, { timeout, + cwd: options.cwd, maxBuffer: 10 * 1024 * 1024, env: { ...process.env }, }, @@ -74,13 +111,23 @@ export async function runCli(args, options = {}) { // Try to parse JSON from stdout try { const data = JSON.parse(stdout) + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error("CLI returned an invalid result") + } + const success = !err && data.success !== false + const reason = + typeof data.error === "string" ? data.error : data.error?.message resolve({ - success: data.success !== false, + success, data, - error: - data.success === false - ? data.errors?.[0] || "Command failed" - : null, + error: success + ? null + : reason || + data.errors?.[0] || + (err?.killed + ? `CLI command timed out after ${timeout / 1000}s` + : err?.message) || + "Command failed", }) } catch { // CLI produced output but not valid JSON diff --git a/lib/constants.js b/lib/constants.js index c0f93f2..c32959e 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -31,5 +31,5 @@ export const ERROR_MESSAGES = { timeout: "Request timed out. Try again later.", invalidName: "Invalid package name format. Expected: owner.package-name or @lpm.dev/owner.package-name", - searchNoParams: 'At least one of "query" or "category" is required.', + searchNoParams: "A search query or structured filter is required.", } diff --git a/lib/format.js b/lib/format.js index a29812a..f4bdf33 100644 --- a/lib/format.js +++ b/lib/format.js @@ -65,3 +65,11 @@ export function parseName(input) { return { owner, name } } + +/** Preserve CLI reports and mark failed process results as MCP tool errors. */ +export function cliResponse(result) { + if (!result.data) return errorResponse(result.error || "Command failed") + const response = jsonResponse(result.data) + if (!result.success) response.isError = true + return response +} diff --git a/lib/server.js b/lib/server.js index a00c4b3..ee2b3f6 100644 --- a/lib/server.js +++ b/lib/server.js @@ -173,7 +173,7 @@ export function createServer() { server.tool( "lpm_audit", - "Run a security audit on the project's LPM dependencies. Returns behavioral tags (eval, childProcess, shell, dynamicRequire), AI security findings, quality scores, and lifecycle scripts. Requires LPM CLI installed and authentication.", + "Run a security audit on the project's LPM dependencies. Returns behavioral tags (eval, childProcess, shell, dynamicRequire), AI security findings, quality scores, and lifecycle scripts. Requires LPM CLI installed; registry access follows CLI authentication and policy.", { path: z .string() @@ -308,7 +308,7 @@ If you must use this tool: fetch directory paths (e.g., "src") to get multiple f 'Filter by package ecosystem (e.g., "swift" for iOS/macOS packages)', ), distribution: z - .enum(["marketplace", "pool", "private"]) + .enum(["free", "marketplace", "pool", "private"]) .optional() .describe("Filter by distribution mode"), packageType: z diff --git a/lib/tools/add.js b/lib/tools/add.js index 83e8c73..a5fbe9a 100644 --- a/lib/tools/add.js +++ b/lib/tools/add.js @@ -1,17 +1,22 @@ import { runCli } from "../cli.js" -import { errorResponse, parseName, textResponse } from "../format.js" +import { cliResponse, errorResponse, parseName } from "../format.js" /** * Add an LPM package to the user's project via the CLI. * Extracts source files into the project for customization. * * @param {{ name: string, version?: string, path?: string, alias?: string, target?: string, force?: boolean, installDeps?: boolean, config?: Record }} params - * @param {{ getToken: () => Promise }} ctx */ -export async function add( - { name, version, path, alias, target, force, installDeps, config }, - ctx, -) { +export async function add({ + name, + version, + path, + alias, + target, + force, + installDeps, + config, +}) { let owner, pkgName try { const parsed = parseName(name) @@ -21,13 +26,6 @@ export async function add( return errorResponse(err.message) } - const token = await ctx.getToken() - if (!token) { - return errorResponse( - "Authentication required. Set LPM_TOKEN environment variable or run `lpm login`.", - ) - } - // Build package reference with optional config params as URL query string let pkgRef = `@lpm.dev/${owner}.${pkgName}` if (version) pkgRef += `@${version}` @@ -49,31 +47,5 @@ export async function add( const result = await runCli(args) - if (!result.success) { - return errorResponse(result.error || "Failed to add package.") - } - - // Format the result for the AI - const data = result.data - const summary = { - success: true, - package: data.package, - installPath: data.installPath, - alias: data.alias || null, - files: (data.files || []).map(f => ({ - dest: f.dest, - action: f.action, - })), - dependencies: data.dependencies || { npm: [], lpm: [] }, - } - - if (data.config && Object.keys(data.config).length > 0) { - summary.config = data.config - } - - if (data.warnings?.length > 0) { - summary.warnings = data.warnings - } - - return textResponse(JSON.stringify(summary, null, 2)) + return cliResponse(result) } diff --git a/lib/tools/audit.js b/lib/tools/audit.js index 5473100..5db33f3 100644 --- a/lib/tools/audit.js +++ b/lib/tools/audit.js @@ -1,21 +1,13 @@ import { runCli } from "../cli.js" -import { errorResponse, textResponse } from "../format.js" +import { cliResponse } from "../format.js" /** * Run a security audit on the project's LPM dependencies. * Returns behavioral tags, AI security findings, quality scores, and lifecycle scripts. * * @param {{ path?: string }} params - * @param {{ getToken: () => Promise }} ctx */ -export async function audit({ path }, ctx) { - const token = await ctx.getToken() - if (!token) { - return errorResponse( - "Authentication required. Set LPM_TOKEN environment variable or run `lpm login`.", - ) - } - +export async function audit({ path }) { const args = ["audit", "--json"] const result = await runCli(args, { @@ -23,28 +15,5 @@ export async function audit({ path }, ctx) { ...(path && { cwd: path }), }) - if (!result.success) { - return errorResponse(result.error || "Failed to run audit.") - } - - const data = result.data - - // Format for AI consumption - const summary = { - success: true, - totalPackages: data.totalPackages || 0, - packagesWithIssues: data.packagesWithIssues || 0, - packages: (data.packages || []).map(pkg => ({ - name: pkg.name, - version: pkg.version, - qualityScore: pkg.qualityScore, - issues: pkg.issues || [], - })), - } - - if (data.summary) { - summary.summary = data.summary - } - - return textResponse(JSON.stringify(summary, null, 2)) + return cliResponse(result) } diff --git a/lib/tools/install.js b/lib/tools/install.js index e495812..16f8caf 100644 --- a/lib/tools/install.js +++ b/lib/tools/install.js @@ -1,14 +1,13 @@ import { runCli } from "../cli.js" -import { errorResponse, parseName, textResponse } from "../format.js" +import { cliResponse, errorResponse, parseName } from "../format.js" /** * Install an LPM package as a dependency via the CLI. * Installs to node_modules like npm install. * * @param {{ name: string, version?: string }} params - * @param {{ getToken: () => Promise }} ctx */ -export async function install({ name, version }, ctx) { +export async function install({ name, version }) { let owner, pkgName try { const parsed = parseName(name) @@ -18,13 +17,6 @@ export async function install({ name, version }, ctx) { return errorResponse(err.message) } - const token = await ctx.getToken() - if (!token) { - return errorResponse( - "Authentication required. Set LPM_TOKEN environment variable or run `lpm login`.", - ) - } - let pkgRef = `@lpm.dev/${owner}.${pkgName}` if (version) pkgRef += `@${version}` @@ -32,20 +24,5 @@ export async function install({ name, version }, ctx) { const result = await runCli(args) - if (!result.success) { - return errorResponse(result.error || "Failed to install package.") - } - - const data = result.data - const summary = { - success: true, - packages: data.packages || [{ name: pkgRef }], - npmOutput: data.npmOutput || "", - } - - if (data.warnings?.length > 0) { - summary.warnings = data.warnings - } - - return textResponse(JSON.stringify(summary, null, 2)) + return cliResponse(result) } diff --git a/lib/tools/search.js b/lib/tools/search.js index 4cc1664..c0ce4d7 100644 --- a/lib/tools/search.js +++ b/lib/tools/search.js @@ -10,10 +10,10 @@ import { errorResponse, textResponse } from "../format.js" * - Explore path (/packages/explore): when any structured filter is active * * @param {object} params - * @param {string} [params.query] - Search text (required unless category provided) + * @param {string} [params.query] - Search text (optional with a structured filter) * @param {string} [params.category] - Category slug filter * @param {string} [params.ecosystem] - Ecosystem filter (js, swift, xcframework) - * @param {string} [params.distribution] - Distribution mode filter (marketplace, pool, private) + * @param {string} [params.distribution] - Distribution mode filter (free, marketplace, pool, private) * @param {string} [params.packageType] - Package type filter * @param {string} [params.sort] - Sort order (newest, popular, name) * @param {boolean} [params.hasTypes] - TypeScript types filter @@ -36,18 +36,6 @@ export async function search(params, ctx) { minNodeVersion, } = params - // Validation: at least query or category required - if (!query?.trim() && !category) { - return errorResponse(ERROR_MESSAGES.searchNoParams) - } - if (query && query.trim().length < 2) { - return errorResponse("Search query must be at least 2 characters.") - } - - const token = await ctx.getToken() - const baseUrl = ctx.getBaseUrl() - - // Determine which path to use const hasStructuredFilters = category || distribution || @@ -56,7 +44,18 @@ export async function search(params, ctx) { hasTypes || moduleType || license || - minNodeVersion + minNodeVersion || + (!query?.trim() && params.ecosystem) + + if (!query?.trim() && !hasStructuredFilters) { + return errorResponse(ERROR_MESSAGES.searchNoParams) + } + if (query && query.trim().length < 2) { + return errorResponse("Search query must be at least 2 characters.") + } + + const token = await ctx.getToken() + const baseUrl = ctx.getBaseUrl() // Build deterministic cache key from all params const cacheKey = buildCacheKey(params) From 25bd35e46004dff5b9ed54fb0532b55153c8f62d Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Sun, 20 Sep 2026 19:40:48 +0100 Subject: [PATCH 2/3] ci: keep source line endings stable on Windows --- .gitattributes | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index dfe0770..6313b56 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text=auto eol=lf From bf5e93eb8cc509efed1eaaf949cd70af8a303bac Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Sun, 20 Sep 2026 19:53:32 +0100 Subject: [PATCH 3/3] fix: require fully qualified Windows CLI overrides --- lib/__tests__/cli-contract.test.js | 3 ++- lib/__tests__/cli-discovery.test.js | 5 +++++ lib/__tests__/cli.test.js | 9 +++++---- lib/cli.js | 8 +++++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/cli-contract.test.js b/lib/__tests__/cli-contract.test.js index 64f0c28..26bbeab 100644 --- a/lib/__tests__/cli-contract.test.js +++ b/lib/__tests__/cli-contract.test.js @@ -1,3 +1,4 @@ +import path from "node:path" import { afterEach, beforeEach, expect, it, vi } from "vitest" vi.mock("node:child_process", () => ({ @@ -14,7 +15,7 @@ import { install } from "../tools/install.js" beforeEach(() => { vi.resetAllMocks() - vi.stubEnv("LPM_CLI_PATH", "/trusted/lpm") + vi.stubEnv("LPM_CLI_PATH", path.resolve("/trusted/lpm")) }) afterEach(() => vi.unstubAllEnvs()) diff --git a/lib/__tests__/cli-discovery.test.js b/lib/__tests__/cli-discovery.test.js index 6fa347d..12ba977 100644 --- a/lib/__tests__/cli-discovery.test.js +++ b/lib/__tests__/cli-discovery.test.js @@ -70,3 +70,8 @@ it("ignores relative and project-local PATH entries", () => { expect(resolveCli("win32")).toBeNull() expect(execFileSync).not.toHaveBeenCalled() }) + +it("rejects Windows overrides that omit the drive or UNC share", () => { + vi.stubEnv("LPM_CLI_PATH", "/tools/lpm.exe") + expect(resolveCli("win32")).toBeNull() +}) diff --git a/lib/__tests__/cli.test.js b/lib/__tests__/cli.test.js index fd3d1a4..ed1d60a 100644 --- a/lib/__tests__/cli.test.js +++ b/lib/__tests__/cli.test.js @@ -1,3 +1,4 @@ +import path from "node:path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" vi.mock("node:child_process", () => ({ @@ -16,8 +17,8 @@ describe("resolveCli", () => { }) it("returns LPM_CLI_PATH env var when set", () => { - process.env.LPM_CLI_PATH = "/custom/path/lpm" - expect(resolveCli()).toBe("/custom/path/lpm") + process.env.LPM_CLI_PATH = path.resolve("/custom/path/lpm") + expect(resolveCli()).toBe(path.resolve("/custom/path/lpm")) }) it("returns null when CLI not found", () => { @@ -30,7 +31,7 @@ describe("resolveCli", () => { describe("runCli", () => { beforeEach(() => { vi.resetAllMocks() - process.env.LPM_CLI_PATH = "/usr/local/bin/lpm" + process.env.LPM_CLI_PATH = path.resolve("/usr/local/bin/lpm") }) afterEach(() => { @@ -121,7 +122,7 @@ describe("runCli", () => { await runCli(["add", "@lpm.dev/test.pkg", "--json", "--yes"]) expect(execFile).toHaveBeenCalledWith( - "/usr/local/bin/lpm", + path.resolve("/usr/local/bin/lpm"), ["add", "@lpm.dev/test.pkg", "--json", "--yes"], expect.objectContaining({ timeout: 60_000 }), expect.any(Function), diff --git a/lib/cli.js b/lib/cli.js index 6ed4cdd..ba5772e 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -8,7 +8,13 @@ const CLI_TIMEOUT_MS = 60_000 /** Resolve an explicit CLI path or a global installation outside the caller's project. */ export function resolveCli(platform = process.platform) { const envPath = process.env.LPM_CLI_PATH - if (envPath) return path.isAbsolute(envPath) ? envPath : null + if (envPath) { + const syntax = platform === "win32" ? path.win32 : path.posix + const qualified = + syntax.isAbsolute(envPath) && + (platform !== "win32" || syntax.parse(envPath).root.length > 1) + return qualified ? envPath : null + } const workspace = realpathSync(process.cwd()) for (const directory of (process.env.PATH || "").split(path.delimiter)) {