Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
affcad3
feat(npm): add versions command, test suite, and expanded README
sreeramakhil Aug 21, 2026
69faaaa
fix(npm): sort versions by full ISO timestamp before formatting
sreeramakhil Aug 21, 2026
5a2f02a
fix(npm): filter versions by body.versions to exclude time-only ghost…
sreeramakhil Aug 21, 2026
9722a44
Merge pull request #1 from sreeramakhil/feat/npm-plugin-versions-and-…
sreeramakhil Aug 21, 2026
c267318
feat(omnisearch): add Reddit as a 7th research source
sreeramakhil Aug 22, 2026
7934049
fix(omnisearch): guard against invalid created_utc in redditSearch
sreeramakhil Aug 22, 2026
9c33fd2
fix(omnisearch): drop null/data-less Reddit children before normaliza…
sreeramakhil Aug 22, 2026
89073d7
fix(omnisearch): reject array-shaped child.data in redditSearch
sreeramakhil Aug 22, 2026
8c93f2f
fix(omnisearch): bound Reddit requests with a 10-second AbortSignal t…
sreeramakhil Aug 22, 2026
5c143a2
Merge pull request #2 from sreeramakhil/feat/omnisearch-reddit-source
sreeramakhil Aug 22, 2026
eef96ea
fix(npm): address maintainer review feedback for npm versions command
sreeramakhil Aug 24, 2026
ad51874
feat(omnisearch): implement Universal Package Search (omnisearch pack…
sreeramakhil Aug 24, 2026
b38a832
merge: resolve conflicts and merge upstream/main into feat/npm-plugin…
sreeramakhil Aug 24, 2026
f601157
Merge remote-tracking branch 'origin/main' into feat/npm-plugin-versi…
sreeramakhil Aug 25, 2026
b19a190
fix(omnisearch,npm): add fetch timeouts and resolve closure initializ…
sreeramakhil Aug 25, 2026
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
88 changes: 88 additions & 0 deletions plugin-command-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17213,6 +17213,13 @@
"default": 10,
"required": false,
"help": "Maximum versions to return (1-50)"
},
{
"name": "prereleases",
"type": "boolean",
"default": false,
"required": false,
"help": "Include prerelease and build versions (e.g. alpha, beta, rc, canary)"
}
],
"columns": [
Expand Down Expand Up @@ -17641,6 +17648,50 @@
"modulePath": "plugins/omnisearch/lobsters.js",
"sourceFile": "plugins/omnisearch/lobsters.js"
},
{
"site": "omnisearch",
"name": "packages",
"description": "Search across 6 major package registries simultaneously (npm, Crates.io, NuGet, RubyGems, Packagist, Maven Central)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Library name or search keyword"
},
{
"name": "limit",
"type": "int",
"default": 10,
"required": false,
"help": "Maximum total results to return"
},
{
"name": "registries",
"type": "str",
"default": "npm,crates,nuget,rubygems,packagist,maven",
"required": false,
"help": "Comma-separated registries to query (default: all)"
}
],
"columns": [
"registry",
"name",
"version",
"description",
"url"
],
"tags": [
"search"
],
"type": "js",
"modulePath": "plugins/omnisearch/packages.js",
"sourceFile": "plugins/omnisearch/packages.js"
},
{
"site": "omnisearch",
"name": "research",
Expand Down Expand Up @@ -20486,6 +20537,43 @@
"sourceFile": "plugins/reddit/comment.js",
"navigateBefore": "https://reddit.com"
},
{
"site": "reddit",
"name": "draft-comment",
"description": "Draft a comment on a Reddit post without submitting it",
"access": "write",
"example": "webcmd reddit draft-comment <post-id-or-url> <text> --window foreground",
"domain": "reddit.com",
"strategy": "ui",
"browser": true,
"args": [
{
"name": "post-id",
"type": "string",
"required": true,
"positional": true,
"help": "Post ID (e.g. 1abc123), t3 fullname, or Reddit post URL"
},
{
"name": "text",
"type": "string",
"required": true,
"positional": true,
"help": "Comment text to leave in the composer"
}
],
"columns": [
"status",
"message",
"url"
],
"type": "js",
"modulePath": "plugins/reddit/draft-comment.js",
"sourceFile": "plugins/reddit/draft-comment.js",
"navigateBefore": false,
"siteSession": "persistent",
"freshPage": true
},
{
"site": "reddit",
"name": "frontpage",
Expand Down
51 changes: 49 additions & 2 deletions plugins/npm/test/npm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ const SEARCH_PAYLOAD = {
// Helpers
// ---------------------------------------------------------------------------
function fakeRequest(payload, { ok = true, status = 200 } = {}) {
const req = async (url, _opts) => {
const req = async (url, opts) => {
req.calls.push(String(url));
req.opts.push(opts);
return { ok, status, json: async () => payload };
};
req.calls = [];
req.opts = [];
return req;
}

Expand Down Expand Up @@ -269,13 +271,45 @@ test('npm versions sorts correctly when two versions share the same date', async
});
});

test('npm versions filters out prereleases by default and includes them with flag', async () => {
const prereleasePayload = {
name: 'exlib',
'dist-tags': { latest: '2.1.0' },
versions: {
'2.0.0': { description: 'v2.0.0' },
'2.1.0': { description: 'v2.1.0' },
'2.2.0-beta.0': { description: 'v2.2.0-beta.0' },
},
time: {
created: '2025-01-01T00:00:00.000Z',
modified: '2026-07-01T00:00:00.000Z',
'2.0.0': '2025-03-10T08:00:00.000Z',
'2.1.0': '2026-06-15T12:00:00.000Z',
'2.2.0-beta.0': '2026-07-01T00:00:00.000Z',
},
};
await withFetch(prereleasePayload, async () => {
// By default, prerelease (2.2.0-beta.0) is filtered out
const defaultRows = await versionsNpm({ name: 'exlib', limit: 10 });
assert.equal(defaultRows.length, 2);
assert.equal(defaultRows[0].version, '2.1.0');
assert.equal(defaultRows[1].version, '2.0.0');

// With prereleases: true flag, prereleases are returned
const allRows = await versionsNpm({ name: 'exlib', limit: 10, prereleases: true });
assert.equal(allRows.length, 3);
assert.equal(allRows[0].version, '2.2.0-beta.0');
assert.equal(allRows[1].version, '2.1.0');
assert.equal(allRows[2].version, '2.0.0');
});
});

test('npm versions rejects out-of-range limit', async () => {
await assert.rejects(
() => versionsNpm({ name: 'exlib', limit: 51 }),
/50/,
);
});

// ---------------------------------------------------------------------------
// npm downloads
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -339,3 +373,16 @@ test('all npm commands are browser-free', () => {
assert.equal(cmd.browser, false, `${name} should not require a browser`);
}
});

test('npm commands pass a 10-second timeout AbortSignal to fetch requests', async () => {
const req = fakeRequest(REGISTRY_PAYLOAD);
const original = globalThis.fetch;
globalThis.fetch = req;
try {
await versionsNpm({ name: 'exlib' });
assert.equal(req.opts.length, 1);
assert.ok(req.opts[0].signal instanceof AbortSignal);
} finally {
globalThis.fetch = original;
}
});
5 changes: 4 additions & 1 deletion plugins/npm/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit'
export async function npmFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
resp = await fetch(url, {
headers: { 'user-agent': UA, accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
}
catch (err) {
throw new CommandExecutionError(
Expand Down
4 changes: 3 additions & 1 deletion plugins/npm/versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export async function versionsNpm(args) {
// 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')
.filter(([version]) => !version.includes('-'))
// filter out prereleases by default (they contain a hyphen, e.g. 19.0.0-rc.0)
.filter(([version]) => args.prereleases || !version.includes('-'))
// 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 ?? '')))
Expand Down Expand Up @@ -51,6 +52,7 @@ cli({
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)' },
{ name: 'prereleases', type: 'boolean', default: false, help: 'Include prerelease and build versions (e.g. alpha, beta, rc, canary)' },
],
columns: ['version', 'publishedAt', 'isLatest', 'url'],
func: (args) => versionsNpm(args),
Expand Down
Loading
Loading