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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
# Auto detect text files and perform LF normalization
* text=auto
* text=auto eol=lf
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions lib/__tests__/cli-contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import path from "node:path"
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", path.resolve("/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()
})
77 changes: 77 additions & 0 deletions lib/__tests__/cli-discovery.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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()
})

it("rejects Windows overrides that omit the drive or UNC share", () => {
vi.stubEnv("LPM_CLI_PATH", "/tools/lpm.exe")
expect(resolveCli("win32")).toBeNull()
})
36 changes: 12 additions & 24 deletions lib/__tests__/cli.test.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,47 @@
import path from "node:path"
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", () => {
process.env.LPM_CLI_PATH = "/custom/path/lpm"
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")
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", () => {
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()
})
})

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(() => {
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"])

Expand Down Expand Up @@ -134,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),
Expand Down
7 changes: 6 additions & 1 deletion lib/__tests__/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
62 changes: 62 additions & 0 deletions lib/__tests__/server-contract.test.js
Original file line number Diff line number Diff line change
@@ -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()
}
})
7 changes: 6 additions & 1 deletion lib/__tests__/tools/add.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading