diff --git a/plugins/npm/README.md b/plugins/npm/README.md index 43e11f87..ad541345 100644 --- a/plugins/npm/README.md +++ b/plugins/npm/README.md @@ -1,6 +1,7 @@ # webcmd-plugin-npm -Webcmd commands for npm. +Inspect public npm package metadata, download stats, version history, and +search results. No login or API key is required. ## Install @@ -12,6 +13,32 @@ webcmd plugin install github:agentrhq/webcmd/npm | Command | Description | | --- | --- | -| `webcmd npm downloads` | Daily download counts for an npm package over a window | -| `webcmd npm package` | Single npm package metadata (latest version, license, homepage, repository). Use `npm downloads` for stats. | -| `webcmd npm search` | Search the public npm registry by keyword | +| `webcmd npm package ` | Latest metadata: version, license, homepage, repository, maintainers | +| `webcmd npm versions ` | Published version history, newest first | +| `webcmd npm downloads ` | Daily download counts over a time window | +| `webcmd npm search ` | Search the public registry by keyword | + +## Examples + +```bash +# Package metadata +webcmd npm package react +webcmd npm package @vercel/og + +# Version history +webcmd npm versions typescript +webcmd npm versions react --limit 5 + +# Download stats (defaults to last week, one row per day) +webcmd npm downloads express +webcmd npm downloads express --period last-month +webcmd npm downloads express --period last-year +webcmd npm downloads express --period 2026-01-01:2026-06-30 + +# Search +webcmd npm search "graphql client" +webcmd npm search vite --limit 5 +``` + +Use this plugin when an agent needs deterministic package metadata before +installing, upgrading, or comparing JavaScript tools. diff --git a/plugins/npm/test/npm.test.js b/plugins/npm/test/npm.test.js new file mode 100644 index 00000000..7697b4ae --- /dev/null +++ b/plugins/npm/test/npm.test.js @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterAll, test } from 'vitest'; +import { fileURLToPath } from 'node:url'; + +const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pluginRoot, '..', '..'); +const peerScopeDir = path.join(pluginRoot, 'node_modules', '@agentrhq'); +const peerLink = path.join(peerScopeDir, 'webcmd'); + +let createdPeerLink = false; +if (!fs.existsSync(peerLink)) { + fs.mkdirSync(peerScopeDir, { recursive: true }); + // On Windows, directory junctions don't require elevated privileges. + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(repoRoot, peerLink, linkType); + createdPeerLink = true; +} + +afterAll(() => { + if (!createdPeerLink) return; + fs.rmSync(peerLink, { force: true, recursive: true }); + for (const dir of [peerScopeDir, path.dirname(peerScopeDir)]) { + try { fs.rmdirSync(dir); } catch { /* leave unrelated local state alone */ } + } +}); + +const { getRegistry } = await import('@agentrhq/webcmd/registry'); +const [{ versionsNpm }] = await Promise.all([ + import('../versions.js'), + import('../package.js'), + import('../downloads.js'), + import('../search.js'), +]); + +// --------------------------------------------------------------------------- +// Shared fixture — a minimal registry payload for a fictional package "exlib" +// --------------------------------------------------------------------------- +const REGISTRY_PAYLOAD = { + name: 'exlib', + description: 'An example library', + 'dist-tags': { latest: '2.1.0' }, + versions: { + '2.1.0': { + description: 'An example library', + license: 'MIT', + homepage: 'https://exlib.dev', + repository: { type: 'git', url: 'git+https://github.com/example/exlib.git' }, + bugs: { url: 'https://github.com/example/exlib/issues' }, + keywords: ['example', 'lib'], + }, + '2.0.0': { + description: 'An example library', + license: 'MIT', + }, + }, + maintainers: [{ name: 'alice', email: 'alice@example.com' }], + time: { + created: '2024-01-01T00:00:00.000Z', + modified: '2026-06-15T12:00:00.000Z', + '2.0.0': '2025-03-10T08:00:00.000Z', + '2.1.0': '2026-06-15T12:00:00.000Z', + }, +}; + +const DOWNLOADS_PAYLOAD = { + package: 'exlib', + downloads: [ + { day: '2026-06-09', downloads: 1200 }, + { day: '2026-06-10', downloads: 1350 }, + { day: '2026-06-11', downloads: 980 }, + ], +}; + +const SEARCH_PAYLOAD = { + objects: [ + { + package: { + name: 'exlib', + version: '2.1.0', + description: 'An example library', + license: 'MIT', + publisher: { username: 'alice' }, + links: { npm: 'https://www.npmjs.com/package/exlib' }, + }, + downloads: { weekly: 50000 }, + dependents: 120, + updated: '2026-06-15T12:00:00.000Z', + }, + ], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function fakeRequest(payload, { ok = true, status = 200 } = {}) { + const req = async (url, _opts) => { + req.calls.push(String(url)); + return { ok, status, json: async () => payload }; + }; + req.calls = []; + return req; +} + +function withFetch(payload, fn, { ok = true, status = 200 } = {}) { + const original = globalThis.fetch; + globalThis.fetch = fakeRequest(payload, { ok, status }); + return fn().finally(() => { globalThis.fetch = original; }); +} + +// --------------------------------------------------------------------------- +// npm package +// --------------------------------------------------------------------------- +test('npm package returns latest metadata', () => + withFetch(REGISTRY_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/package').func({ name: 'exlib' }); + assert.equal(rows.length, 1); + const [row] = rows; + assert.equal(row.name, 'exlib'); + assert.equal(row.latestVersion, '2.1.0'); + assert.equal(row.description, 'An example library'); + assert.equal(row.license, 'MIT'); + assert.equal(row.homepage, 'https://exlib.dev'); + assert.equal(row.repository, 'https://github.com/example/exlib'); + assert.equal(row.bugs, 'https://github.com/example/exlib/issues'); + assert.equal(row.maintainers, 'alice'); + assert.equal(row.keywords, 'example, lib'); + assert.equal(row.created, '2024-01-01'); + assert.equal(row.modified, '2026-06-15'); + assert.equal(row.url, 'https://www.npmjs.com/package/exlib'); + }), +); + +test('npm package hits the correct registry URL', () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const original = globalThis.fetch; + globalThis.fetch = req; + return getRegistry().get('npm/package').func({ name: 'exlib' }) + .then(() => { + assert.ok(req.calls[0].startsWith('https://registry.npmjs.org/')); + }) + .finally(() => { globalThis.fetch = original; }); +}); + +test('npm package rejects invalid package names', async () => { + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: '' }), + /required/, + ); + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: '../etc/passwd' }), + /valid/, + ); +}); + +test('npm package throws EmptyResultError on 404', () => + withFetch({}, async () => { + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: 'no-such-pkg-xyz' }), + (err) => err.code === 'EMPTY_RESULT', + ); + }, { ok: false, status: 404 }), +); + +// --------------------------------------------------------------------------- +// npm versions +// --------------------------------------------------------------------------- +test('npm versions returns rows newest first', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 10 }, req); + assert.equal(rows.length, 2); + assert.equal(rows[0].version, '2.1.0'); + assert.equal(rows[0].publishedAt, '2026-06-15'); + assert.equal(rows[0].isLatest, true); + assert.ok(rows[0].url.includes('2.1.0')); + assert.equal(rows[1].version, '2.0.0'); + assert.equal(rows[1].isLatest, false); +}); + +test('npm versions strips created/modified bookkeeping keys', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 50 }, req); + assert.ok(rows.every((r) => r.version !== 'created' && r.version !== 'modified')); +}); + +test('npm versions respects --limit', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 1 }, req); + assert.equal(rows.length, 1); + assert.equal(rows[0].version, '2.1.0'); +}); + +test('npm versions sorts correctly when two versions share the same date', async () => { + // Regression: sort must use the full ISO timestamp, not the truncated + // date-only string, so same-day releases still come out newest-first. + const sameDayPayload = { + name: 'exlib', + 'dist-tags': { latest: '2.1.1' }, + versions: { + '2.1.0': { description: 'v2.1.0' }, + '2.1.1': { description: 'v2.1.1' }, + // '0.0.1-ghost' intentionally absent — time-only entry below must be excluded + }, + time: { + created: '2026-06-15T08:00:00.000Z', + modified: '2026-06-15T14:00:00.000Z', + '2.1.0': '2026-06-15T08:00:00.000Z', // earlier on same day + '2.1.1': '2026-06-15T14:00:00.000Z', // later on same day + '0.0.1-ghost': '2026-06-15T06:00:00.000Z', // time-only, no body.versions entry + }, + }; + const req = fakeRequest(sameDayPayload); + const rows = await versionsNpm({ name: 'exlib', limit: 10 }, req); + // ghost entry must be excluded + assert.equal(rows.length, 2); + // 2.1.1 published at 14:00 must come before 2.1.0 published at 08:00 + assert.equal(rows[0].version, '2.1.1'); + assert.equal(rows[1].version, '2.1.0'); + // Both format to the same date string + assert.equal(rows[0].publishedAt, '2026-06-15'); + assert.equal(rows[1].publishedAt, '2026-06-15'); + // ghost must not appear at all + assert.ok(rows.every((r) => r.version !== '0.0.1-ghost')); +}); + +test('npm versions rejects out-of-range limit', async () => { + await assert.rejects( + () => versionsNpm({ name: 'exlib', limit: 51 }, fakeRequest(REGISTRY_PAYLOAD)), + /50/, + ); +}); + +// --------------------------------------------------------------------------- +// npm downloads +// --------------------------------------------------------------------------- +test('npm downloads returns one row per day', () => + withFetch(DOWNLOADS_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/downloads').func({ name: 'exlib', period: 'last-week' }); + assert.equal(rows.length, 3); + assert.equal(rows[0].rank, 1); + assert.equal(rows[0].package, 'exlib'); + assert.equal(rows[0].day, '2026-06-09'); + assert.equal(rows[0].downloads, 1200); + }), +); + +test('npm downloads rejects invalid period', async () => { + await assert.rejects( + () => getRegistry().get('npm/downloads').func({ name: 'exlib', period: 'bad-period' }), + /invalid/, + ); +}); + +test('npm downloads rejects date range where start is after end', async () => { + await assert.rejects( + () => getRegistry().get('npm/downloads').func({ name: 'exlib', period: '2026-06-15:2026-01-01' }), + /after end/, + ); +}); + +// --------------------------------------------------------------------------- +// npm search +// --------------------------------------------------------------------------- +test('npm search returns ranked results', () => + withFetch(SEARCH_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/search').func({ query: 'exlib', limit: 20 }); + assert.equal(rows.length, 1); + const [row] = rows; + assert.equal(row.rank, 1); + assert.equal(row.name, 'exlib'); + assert.equal(row.version, '2.1.0'); + assert.equal(row.weeklyDownloads, 50000); + assert.equal(row.dependents, 120); + assert.equal(row.url, 'https://www.npmjs.com/package/exlib'); + }), +); + +test('npm search rejects empty query', async () => { + await assert.rejects( + () => getRegistry().get('npm/search').func({ query: '', limit: 20 }), + /empty/, + ); +}); + +// --------------------------------------------------------------------------- +// All registered commands are browser: false +// --------------------------------------------------------------------------- +test('all npm commands are browser-free', () => { + const registry = getRegistry(); + for (const name of ['npm/package', 'npm/downloads', 'npm/search', 'npm/versions']) { + const cmd = registry.get(name); + assert.ok(cmd, `command ${name} not registered`); + assert.equal(cmd.browser, false, `${name} should not require a browser`); + } +}); diff --git a/plugins/npm/utils.js b/plugins/npm/utils.js index fd2aaaed..f041cd87 100644 --- a/plugins/npm/utils.js +++ b/plugins/npm/utils.js @@ -42,10 +42,10 @@ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit' return n; } -export async function npmFetch(url, label) { +export async function npmFetch(url, label, request = fetch) { let resp; try { - resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + resp = await request(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); } catch (err) { throw new CommandExecutionError( diff --git a/plugins/npm/versions.js b/plugins/npm/versions.js new file mode 100644 index 00000000..2eee020f --- /dev/null +++ b/plugins/npm/versions.js @@ -0,0 +1,56 @@ +// npm versions — list published versions for a package, newest first. +// +// Hits `https://registry.npmjs.org/` and projects `time` entries so +// agents can answer "when was X released?" or "what's the latest stable?". +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { NPM_REGISTRY, npmFetch, requireBoundedInt, requirePackageName } from './utils.js'; + +export async function versionsNpm(args, request = fetch) { + const name = requirePackageName(args.name); + const limit = requireBoundedInt(args.limit ?? 10, 10, 50); + const url = `${NPM_REGISTRY}/${name.split('/').map(encodeURIComponent).join('/')}`; + const body = await npmFetch(url, `npm versions ${name}`, request); + + const timeMap = body?.time && typeof body.time === 'object' ? body.time : {}; + const versionsMap = body?.versions && typeof body.versions === 'object' ? body.versions : {}; + const latest = body?.['dist-tags']?.latest ?? ''; + + const rows = Object.entries(timeMap) + // skip internal bookkeeping keys that npm puts in time + .filter(([version]) => version !== 'created' && version !== 'modified') + // only keep versions that actually exist in body.versions — time-only + // keys (e.g. unpublished entries) have no real release and must be omitted + .filter(([version, publishedAt]) => version in versionsMap && typeof publishedAt === 'string') + // sort on the raw full ISO timestamp BEFORE formatting so that two + // versions published on the same calendar date still sort correctly + .sort(([, left], [, right]) => String(right ?? '').localeCompare(String(left ?? ''))) + .slice(0, limit) + .map(([version, publishedAt]) => ({ + version, + publishedAt: String(publishedAt ?? '').slice(0, 10), + isLatest: version === latest, + url: `https://www.npmjs.com/package/${name}/v/${version}`, + })); + + if (!rows.length) { + throw new EmptyResultError('npm versions', `npm registry has no version history for "${name}".`); + } + return rows; +} + +cli({ + site: 'npm', + name: 'versions', + access: 'read', + description: 'List published versions of an npm package, newest first', + domain: 'registry.npmjs.org', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'name', positional: true, required: true, help: 'npm package name (e.g. "react", "@vercel/og")' }, + { name: 'limit', type: 'int', default: 10, help: 'Maximum versions to return (1-50)' }, + ], + columns: ['version', 'publishedAt', 'isLatest', 'url'], + func: (args) => versionsNpm(args), +});