From 9b64588c24d6ab584fada525df4d80a7e6e323dc Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 3 Sep 2026 23:24:01 +0200 Subject: [PATCH 1/3] feat(cli): add search and list commands Completes the discovery half of the CLI. 'ovsx show' inspects an extension you can already name; until now there was no way to find out what a registry holds - 'get --metadata' needs an identifier you already have, which is the gap that made writing the deletion guide for #1620 awkward. 'ovsx search [text]' queries /api/-/search, the endpoint built for exactly this, and prints one row per result with the identifier, version, downloads, rating and a truncated description. It takes --category, --target, --sort-by, --sort-order, --size and --offset, and reports where the page sits in the result set so paging is discoverable rather than guesswork. The text is optional, since browsing a category on its own is a reasonable thing to want. Sort keys and orders are checked locally, because the registry answers a bad one with a bare 400 that says little. 'ovsx list ' prints the extensions in a namespace from /api/{namespace}, sorted by name so the output is stable enough to diff and pipe rather than depending on the response's key order. That is the question an operator of a private registry actually asks - "what is published here" - and it needs no token, so it works on a deployment with no login provider configured. Both take --json. Neither uses /api/v2/-/query. The search endpoint returns a summary shape built for result lists, and a namespace listing from /api/{namespace} is a single small response, where the same namespace via the query endpoint is 477KB because it repeats every extension-level field on each version/target-platform row. The table formatting show introduced moves to a shared table module, so the three commands align columns and format counts the same way rather than each carrying a copy. Refs #2149, #1620 Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/list-options.ts | 25 +++++ cli/src/list.ts | 48 +++++++++ cli/src/main.ts | 31 +++++- cli/src/registry.ts | 79 ++++++++++++++ cli/src/search-options.ts | 49 +++++++++ cli/src/search.ts | 95 +++++++++++++++++ cli/src/show.ts | 25 +---- cli/src/table.ts | 46 ++++++++ cli/src/util.ts | 12 +++ cli/test/unit/list.spec.ts | 134 ++++++++++++++++++++++++ cli/test/unit/search.spec.ts | 196 +++++++++++++++++++++++++++++++++++ 11 files changed, 717 insertions(+), 23 deletions(-) create mode 100644 cli/src/list-options.ts create mode 100644 cli/src/list.ts create mode 100644 cli/src/search-options.ts create mode 100644 cli/src/search.ts create mode 100644 cli/src/table.ts create mode 100644 cli/test/unit/list.spec.ts create mode 100644 cli/test/unit/search.spec.ts diff --git a/cli/src/list-options.ts b/cli/src/list-options.ts new file mode 100644 index 000000000..a3e5b9fc2 --- /dev/null +++ b/cli/src/list-options.ts @@ -0,0 +1,25 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { RegistryOptions } from './registry-options'; + +export interface ListOptions extends RegistryOptions { + /** + * Namespace whose extensions should be listed. + */ + namespace: string; + /** + * Print the raw namespace metadata as JSON instead of a list. + */ + json?: boolean; +} diff --git a/cli/src/list.ts b/cli/src/list.ts new file mode 100644 index 000000000..d015a8390 --- /dev/null +++ b/cli/src/list.ts @@ -0,0 +1,48 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { ListOptions } from './list-options'; +import { Registry } from './registry'; +import { formatCount } from './table'; +import { addEnvOptions } from './util'; + +/** + * Lists the extensions published in a namespace. + */ +export async function list(options: ListOptions): Promise { + addEnvOptions(options); + const registry = new Registry(options); + const namespace = await registry.getNamespace(options.namespace); + if (namespace.error) { + throw new Error(namespace.error); + } + + if (options.json) { + console.log(JSON.stringify(namespace, null, 4)); + return; + } + + // Sorted here rather than relying on the response's key order, so the output is stable and + // scriptable regardless of how the registry happens to serialise the map. + const names = Object.keys(namespace.extensions ?? {}).sort((a, b) => a.localeCompare(b)); + const verified = namespace.verified ? ' (verified)' : ''; + console.log(`${namespace.name}${verified} - ${formatCount(names.length, 'extension')}`); + if (names.length === 0) { + return; + } + + console.log(); + for (const name of names) { + console.log(` ${namespace.name}.${name}`); + } +} diff --git a/cli/src/main.ts b/cli/src/main.ts index 2a94e3c37..91640cf80 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -14,8 +14,10 @@ import { createNamespace } from './create-namespace'; import { verifyPat } from './verify-pat'; import { publish } from './publish'; import { unpublish } from './unpublish'; -import { handleError } from './util'; +import { handleError, parsePositiveInt } from './util'; import { getExtension } from './get'; +import { list } from './list'; +import { DEFAULT_SEARCH_SIZE, SORT_KEYS, SORT_ORDERS, search } from './search'; import { show } from './show'; import { verify } from './verify'; import { verifySignature } from './verify-signature'; @@ -117,6 +119,33 @@ module.exports = function (argv: string[]): void { }).catch(handleError(program.debug)); }); + const searchCmd = program.command('search [text]'); + searchCmd.description('Search the registry for extensions.') + .option('-c, --category ', 'Only return extensions in this category.') + .option('-t, --target ', 'Only return extensions built for this target architecture.') + .option( + '-s, --size ', + `Number of results to return (default ${DEFAULT_SEARCH_SIZE}).`, + parsePositiveInt + ) + .option('-o, --offset ', 'Index of the first result, for paging.', parsePositiveInt) + .option('--sort-by ', `Sort key: ${SORT_KEYS.join(', ')}.`) + .option('--sort-order ', `Sort order: ${SORT_ORDERS.join(', ')}.`) + .option('--json', 'Print the raw results as JSON.') + .action((text: string | undefined, { category, target, size, offset, sortBy, sortOrder, json }) => { + const { registryUrl } = program.opts(); + search({ text, category, target, size, offset, sortBy, sortOrder, json, registryUrl }) + .catch(handleError(program.debug)); + }); + + const listCmd = program.command('list '); + listCmd.description('List the extensions published in a namespace.') + .option('--json', 'Print the raw namespace metadata as JSON.') + .action((namespace: string, { json }) => { + const { registryUrl } = program.opts(); + list({ namespace, json, registryUrl }).catch(handleError(program.debug)); + }); + const showCmd = program.command('show '); showCmd.description('Show an extension\'s metadata.') .option('-t, --target ', 'Only report on the given target architecture.') diff --git a/cli/src/registry.ts b/cli/src/registry.ts index bf10fc67a..25f54228b 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -165,6 +165,46 @@ export class Registry { } } + /** + * Full-text search across the registry. Returns the purpose-built summary shape rather than + * whole extension records, so a page of results stays small. + */ + search(options: SearchQuery): Promise { + try { + const query: Record = { + size: String(options.size), + offset: String(options.offset) + }; + if (options.query) { + query.query = options.query; + } + if (options.category) { + query.category = options.category; + } + if (options.targetPlatform) { + query.targetPlatform = options.targetPlatform; + } + if (options.sortBy) { + query.sortBy = options.sortBy; + } + if (options.sortOrder) { + query.sortOrder = options.sortOrder; + } + return this.getJson(this.getUrl(['api', '-', 'search'], query)); + } catch (err) { + return rejectError(err); + } + } + + /** Returns a namespace and the extensions published in it. */ + getNamespace(namespace: string): Promise { + try { + return this.getJson(this.getUrl(['api', namespace])); + } catch (err) { + return rejectError(err); + } + } + download(file: string, url: URL): Promise { return new Promise((resolve, reject) => { const stream = fs.createWriteStream(file); @@ -405,6 +445,45 @@ export interface VersionReferences extends Response { versions?: VersionReference[]; } +export interface SearchQuery { + query?: string; + category?: string; + targetPlatform?: string; + sortBy?: string; + sortOrder?: string; + size: number; + offset: number; +} + +export interface SearchEntry { + url: string; + files: { [type: string]: string }; + name: string; + namespace: string; + version: string; + timestamp: string; + verified?: boolean; + averageRating?: number; + reviewCount?: number; + downloadCount: number; + displayName?: string; + description?: string; + deprecated?: boolean; +} + +export interface SearchResult extends Response { + offset: number; + totalSize: number; + extensions?: SearchEntry[]; +} + +export interface Namespace extends Response { + name: string; + verified?: boolean; + // key: extension name, value: url + extensions?: { [name: string]: string }; +} + export interface ExtensionReference { url: string; namespace: string; diff --git a/cli/src/search-options.ts b/cli/src/search-options.ts new file mode 100644 index 000000000..222ce2058 --- /dev/null +++ b/cli/src/search-options.ts @@ -0,0 +1,49 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { RegistryOptions } from './registry-options'; + +export interface SearchOptions extends RegistryOptions { + /** + * Text to search for. Omit to browse, which is what makes `--category` useful on its own. + */ + text?: string; + /** + * Restrict results to a category, e.g. `Programming Languages`. + */ + category?: string; + /** + * Restrict results to extensions published for a target platform. + */ + target?: string; + /** + * Sort key: `relevance`, `timestamp`, `rating` or `downloadCount`. + */ + sortBy?: string; + /** + * `asc` or `desc`. + */ + sortOrder?: string; + /** + * Number of results to return. + */ + size?: number; + /** + * Index of the first result, for paging through a large result set. + */ + offset?: number; + /** + * Print the raw results as JSON instead of a table. + */ + json?: boolean; +} diff --git a/cli/src/search.ts b/cli/src/search.ts new file mode 100644 index 000000000..68b4dae78 --- /dev/null +++ b/cli/src/search.ts @@ -0,0 +1,95 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { Registry, SearchEntry } from './registry'; +import { SearchOptions } from './search-options'; +import { formatNumber, printRows, truncate } from './table'; +import { addEnvOptions } from './util'; + +/** Sort keys the registry accepts, for an up-front check with a better message than a 400. */ +export const SORT_KEYS = ['relevance', 'timestamp', 'rating', 'downloadCount']; + +export const SORT_ORDERS = ['asc', 'desc']; + +export const DEFAULT_SEARCH_SIZE = 20; + +/** Keeps a row from wrapping on a normal terminal, where the other columns take about half of it. */ +const DESCRIPTION_WIDTH = 60; + +/** + * Searches the registry for extensions. + */ +export async function search(options: SearchOptions): Promise { + addEnvOptions(options); + if (options.sortBy && !SORT_KEYS.includes(options.sortBy)) { + throw new Error(`Sort key must be one of ${SORT_KEYS.join(', ')}.`); + } + if (options.sortOrder && !SORT_ORDERS.includes(options.sortOrder)) { + throw new Error(`Sort order must be one of ${SORT_ORDERS.join(', ')}.`); + } + + const size = options.size ?? DEFAULT_SEARCH_SIZE; + const offset = options.offset ?? 0; + const registry = new Registry(options); + const result = await registry.search({ + query: options.text, + category: options.category, + targetPlatform: options.target, + sortBy: options.sortBy, + sortOrder: options.sortOrder, + size, + offset + }); + if (result.error) { + throw new Error(result.error); + } + + if (options.json) { + console.log(JSON.stringify(result, null, 4)); + return; + } + + printResults(result.extensions ?? [], result.totalSize, offset); +} + +function printResults(entries: SearchEntry[], totalSize: number, offset: number): void { + if (entries.length === 0) { + console.log('No extensions found.'); + return; + } + + const rows = entries.map(entry => [ + `${entry.namespace}.${entry.name}`, + entry.version, + formatNumber(entry.downloadCount), + entry.averageRating !== undefined ? entry.averageRating.toFixed(1) : '-', + describe(entry) + ]); + printRows([['Extension', 'Version', 'Downloads', 'Rating', 'Description'], ...rows]); + + const last = offset + entries.length; + console.log(); + console.log(`Showing ${offset + 1}-${last} of ${formatNumber(totalSize)}.`); + if (last < totalSize) { + console.log(`Pass --offset ${last} for the next page.`); + } +} + +/** + * The description, prefixed with anything a reader should weigh before installing. Deprecation is + * the one flag worth spending row width on here; `show` reports the rest. + */ +function describe(entry: SearchEntry): string { + const description = truncate(entry.description ?? '', DESCRIPTION_WIDTH); + return entry.deprecated ? `(deprecated) ${description}`.trimEnd() : description; +} diff --git a/cli/src/show.ts b/cli/src/show.ts index b45e17340..144a4c89e 100644 --- a/cli/src/show.ts +++ b/cli/src/show.ts @@ -13,6 +13,7 @@ import * as semver from 'semver'; import { Extension, Registry, VersionReference } from './registry'; +import { formatCount, formatNumber, printRows } from './table'; import { ShowOptions } from './show-options'; import { addEnvOptions, matchExtensionId } from './util'; @@ -279,11 +280,11 @@ function registryInfo(extension: Extension): string[][] { } function statistics(extension: Extension): string[][] { - const rows: string[][] = [['Downloads', (extension.downloadCount ?? 0).toLocaleString('en-US')]]; + const rows: string[][] = [['Downloads', formatNumber(extension.downloadCount)]]; if (extension.averageRating !== undefined) { rows.push(['Average Rating', `${extension.averageRating.toFixed(1)}/5`]); } - rows.push(['Reviews', Number(extension.reviewCount ?? 0).toLocaleString('en-US')]); + rows.push(['Reviews', formatNumber(Number(extension.reviewCount ?? 0))]); return rows; } @@ -315,26 +316,6 @@ function printTable(title: string, rows: string[][]): void { printRows(rows); } -/** Pads every column but the last, so trailing empty cells don't leave ragged whitespace. */ -function printRows(rows: string[][]): void { - const widths: number[] = []; - for (const row of rows) { - row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length)); - } - for (const row of rows) { - const line = row - .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))) - .join(' ') - .trimEnd(); - console.log(` ${line}`); - } -} - -function formatCount(count: number | undefined, noun: string): string { - const value = count ?? 0; - return `${value.toLocaleString('en-US')} ${value === 1 ? noun : noun + 's'}`; -} - function formatRating(extension: Extension): string { if (extension.averageRating === undefined) { return 'no ratings'; diff --git a/cli/src/table.ts b/cli/src/table.ts new file mode 100644 index 000000000..c7168d4e0 --- /dev/null +++ b/cli/src/table.ts @@ -0,0 +1,46 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +/** + * Prints rows as an aligned table, indented by two spaces. Every column but the last is padded, + * so a trailing empty cell doesn't leave ragged whitespace behind. + */ +export function printRows(rows: string[][]): void { + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length)); + } + for (const row of rows) { + const line = row + .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))) + .join(' ') + .trimEnd(); + console.log(` ${line}`); + } +} + +/** Formats a count with thousands separators, and the noun pluralised to match. */ +export function formatCount(count: number | undefined, noun: string): string { + const value = count ?? 0; + return `${formatNumber(value)} ${value === 1 ? noun : noun + 's'}`; +} + +export function formatNumber(value: number | undefined): string { + return (value ?? 0).toLocaleString('en-US'); +} + +/** Shortens `text` to `max` characters, marking that it was cut. */ +export function truncate(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, ' ').trim(); + return collapsed.length <= max ? collapsed : `${collapsed.substring(0, max - 1)}…`; +} diff --git a/cli/src/util.ts b/cli/src/util.ts index f13ae38b9..177b5e738 100644 --- a/cli/src/util.ts +++ b/cli/src/util.ts @@ -37,6 +37,18 @@ function parseBooleanEnv(value?: string): boolean | undefined { return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); } +/** + * Parses a commander option that must be a non-negative whole number, so a typo is reported by the + * CLI rather than sent to the registry as a bad request. + */ +export function parsePositiveInt(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`Expected a non-negative whole number, got '${value}'.`); + } + return parsed; +} + export function matchExtensionId(id: string): RegExpExecArray | null { return /^([\w-]+)(?:\.|\/)([\w-]+)$/.exec(id); } diff --git a/cli/test/unit/list.spec.ts b/cli/test/unit/list.spec.ts new file mode 100644 index 000000000..793bf0979 --- /dev/null +++ b/cli/test/unit/list.spec.ts @@ -0,0 +1,134 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { list } from '../../src/list'; + +interface ListStub { + url: string; + requests: string[]; + close: () => Promise; +} + +async function startNamespaceStub(body?: unknown): Promise { + const requests: string[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + requests.push(url.pathname); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body ?? { + name: 'redhat', + verified: true, + extensions: { + java: 'https://example.test/api/redhat/java', + ansible: 'https://example.test/api/redhat/ansible' + } + })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise(resolve => server.close(() => resolve())) + }; +} + +describe('list', () => { + + const stubs: ListStub[] = []; + let log: ReturnType; + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(async () => { + log.mockRestore(); + await Promise.all(stubs.splice(0).map(stub => stub.close())); + }); + + async function givenRegistry(body?: unknown): Promise { + const stub = await startNamespaceStub(body); + stubs.push(stub); + return stub; + } + + function lines(): string[] { + return log.mock.calls.map(call => String(call[0] ?? '')); + } + + it('lists the namespace and its extensions', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + expect(registry.requests[0]).toBe('/api/redhat'); + const printed = lines().join('\n'); + expect(printed).toContain('redhat (verified) - 2 extensions'); + expect(printed).toContain('redhat.java'); + expect(printed).toContain('redhat.ansible'); + }); + + // The response is a JSON object, whose key order is not something to depend on for output that + // people diff and pipe. + it('sorts the extensions by name', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + const printed = lines().join('\n'); + expect(printed.indexOf('redhat.ansible')).toBeLessThan(printed.indexOf('redhat.java')); + }); + + it('pluralises a single extension', async () => { + const registry = await givenRegistry({ name: 'solo', extensions: { only: 'https://example.test' } }); + + await list({ namespace: 'solo', registryUrl: registry.url }); + + expect(lines()[0]).toBe('solo - 1 extension'); + }); + + it('handles an empty namespace', async () => { + const registry = await givenRegistry({ name: 'empty', extensions: {} }); + + await list({ namespace: 'empty', registryUrl: registry.url }); + + expect(lines()).toEqual(['empty - 0 extensions']); + }); + + it('omits the verified marker when the namespace is not verified', async () => { + const registry = await givenRegistry({ name: 'redhat', verified: false, extensions: {} }); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + expect(lines()[0]).not.toContain('verified'); + }); + + it('reports the error the registry returns', async () => { + const registry = await givenRegistry({ error: 'Namespace not found: nope' }); + + await expect(list({ namespace: 'nope', registryUrl: registry.url })) + .rejects.toThrow('Namespace not found: nope'); + }); + + it('prints raw JSON with --json', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', json: true, registryUrl: registry.url }); + + expect(JSON.parse(lines().join('\n'))).toMatchObject({ name: 'redhat' }); + }); +}); diff --git a/cli/test/unit/search.spec.ts b/cli/test/unit/search.spec.ts new file mode 100644 index 000000000..9f8ed49e5 --- /dev/null +++ b/cli/test/unit/search.spec.ts @@ -0,0 +1,196 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { search } from '../../src/search'; + +interface SearchStub { + url: string; + requests: { pathname: string; query: URLSearchParams }[]; + close: () => Promise; +} + +const entry = { + url: 'https://example.test/api/redhat/java', + files: {}, + namespace: 'redhat', + name: 'java', + version: '1.2.0', + timestamp: '2026-08-01T10:00:00Z', + downloadCount: 40086502, + averageRating: 4.75, + reviewCount: 16, + displayName: 'Language Support for Java', + description: 'Java Linting, Intellisense, formatting, refactoring and more', + deprecated: false +}; + +async function startSearchStub(status = 200, body?: unknown): Promise { + const requests: { pathname: string; query: URLSearchParams }[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + requests.push({ pathname: url.pathname, query: url.searchParams }); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body ?? { offset: 0, totalSize: 1, extensions: [entry] })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise(resolve => server.close(() => resolve())) + }; +} + +describe('search', () => { + + const stubs: SearchStub[] = []; + let log: ReturnType; + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(async () => { + log.mockRestore(); + await Promise.all(stubs.splice(0).map(stub => stub.close())); + }); + + async function givenRegistry(status?: number, body?: unknown): Promise { + const stub = await startSearchStub(status, body); + stubs.push(stub); + return stub; + } + + function output(): string { + return log.mock.calls.map(call => String(call[0] ?? '')).join('\n'); + } + + it('prints a result row per extension', async () => { + const registry = await givenRegistry(); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(registry.requests[0].pathname).toBe('/api/-/search'); + expect(registry.requests[0].query.get('query')).toBe('java'); + const printed = output(); + expect(printed).toContain('redhat.java'); + expect(printed).toContain('1.2.0'); + expect(printed).toContain('40,086,502'); + expect(printed).toContain('4.8'); + }); + + it('reports where the page sits in the result set', async () => { + const registry = await givenRegistry(200, { offset: 20, totalSize: 57, extensions: [entry] }); + + await search({ text: 'java', offset: 20, registryUrl: registry.url }); + + const printed = output(); + expect(printed).toContain('Showing 21-21 of 57.'); + expect(printed).toContain('Pass --offset 21 for the next page.'); + }); + + it('does not offer a next page on the last one', async () => { + const registry = await givenRegistry(200, { offset: 0, totalSize: 1, extensions: [entry] }); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(output()).not.toContain('--offset'); + }); + + it('says so when nothing matched', async () => { + const registry = await givenRegistry(200, { offset: 0, totalSize: 0, extensions: [] }); + + await search({ text: 'nothing-matches-this', registryUrl: registry.url }); + + expect(output()).toContain('No extensions found.'); + }); + + it('marks a deprecated result', async () => { + const registry = await givenRegistry(200, { + offset: 0, + totalSize: 1, + extensions: [{ ...entry, deprecated: true }] + }); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(output()).toContain('(deprecated)'); + }); + + it('passes the filters and paging through', async () => { + const registry = await givenRegistry(); + + await search({ + text: 'java', + category: 'Programming Languages', + target: 'linux-x64', + sortBy: 'downloadCount', + sortOrder: 'desc', + size: 5, + offset: 10, + registryUrl: registry.url + }); + + const query = registry.requests[0].query; + expect(query.get('category')).toBe('Programming Languages'); + expect(query.get('targetPlatform')).toBe('linux-x64'); + expect(query.get('sortBy')).toBe('downloadCount'); + expect(query.get('sortOrder')).toBe('desc'); + expect(query.get('size')).toBe('5'); + expect(query.get('offset')).toBe('10'); + }); + + // Browsing by category alone is a legitimate use, so the text is optional. + it('searches without any text', async () => { + const registry = await givenRegistry(); + + await search({ category: 'Snippets', registryUrl: registry.url }); + + expect(registry.requests[0].query.has('query')).toBe(false); + expect(registry.requests[0].query.get('category')).toBe('Snippets'); + }); + + // Caught locally rather than sent on, since the registry answers a bad key with a bare 400. + it('rejects an unknown sort key before making a request', async () => { + const registry = await givenRegistry(); + + await expect(search({ text: 'java', sortBy: 'downloads', registryUrl: registry.url })) + .rejects.toThrow('Sort key must be one of relevance, timestamp, rating, downloadCount.'); + expect(registry.requests).toHaveLength(0); + }); + + it('rejects an unknown sort order before making a request', async () => { + const registry = await givenRegistry(); + + await expect(search({ text: 'java', sortOrder: 'sideways', registryUrl: registry.url })) + .rejects.toThrow('Sort order must be one of asc, desc.'); + expect(registry.requests).toHaveLength(0); + }); + + it('reports the error the registry returns', async () => { + const registry = await givenRegistry(200, { error: 'Invalid category', offset: 0, totalSize: 0 }); + + await expect(search({ text: 'java', registryUrl: registry.url })).rejects.toThrow('Invalid category'); + }); + + it('prints raw JSON with --json', async () => { + const registry = await givenRegistry(); + + await search({ text: 'java', json: true, registryUrl: registry.url }); + + expect(JSON.parse(output())).toMatchObject({ totalSize: 1 }); + }); +}); From b0ba42e3700e2a52a06e317d95925142d2281011 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 13:47:17 +0200 Subject: [PATCH 2/3] fix(cli): tighten truncate's bounds and name the int parser for what it takes Two review points from Copilot on #2154. truncate returned an ellipsis for a max of 0, one character wider than the budget it was handed - the shape of bug that makes a table wrap. No caller can reach it today (the only call site passes a width of 60), but table.ts was extracted precisely to be shared, so it should hold at its boundaries. Covered by a spec of its own: the helpers had no direct tests, only whatever width the calling command happened to use. parsePositiveInt accepted 0, as its own docstring and error message said. Zero is the right answer for --offset, whose default it is, so the name was the wrong half; renamed to parseNonNegativeInt. Free to do now because the export is new in this PR and has no consumers outside it. Left alone: the suggestion to make SearchEntry.verified/deprecated, SearchResult.extensions and Namespace.verified required to match the server's @NotNull. Those classes are @JsonInclude(NON_NULL), so a null boxed Boolean is omitted rather than serialised, and the search endpoint returns its errors *as* a SearchResultJson with no extensions at all - which is why the type extends Response and why the server's own merge loop null-checks getExtensions() twice. Requiring them would be a claim nothing validates at runtime, and would make `result.extensions ?? []` look like dead code. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/main.ts | 6 ++--- cli/src/table.ts | 9 ++++++- cli/src/util.ts | 2 +- cli/test/unit/table.spec.ts | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 cli/test/unit/table.spec.ts diff --git a/cli/src/main.ts b/cli/src/main.ts index 91640cf80..98e5f7a6e 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -14,7 +14,7 @@ import { createNamespace } from './create-namespace'; import { verifyPat } from './verify-pat'; import { publish } from './publish'; import { unpublish } from './unpublish'; -import { handleError, parsePositiveInt } from './util'; +import { handleError, parseNonNegativeInt } from './util'; import { getExtension } from './get'; import { list } from './list'; import { DEFAULT_SEARCH_SIZE, SORT_KEYS, SORT_ORDERS, search } from './search'; @@ -126,9 +126,9 @@ module.exports = function (argv: string[]): void { .option( '-s, --size ', `Number of results to return (default ${DEFAULT_SEARCH_SIZE}).`, - parsePositiveInt + parseNonNegativeInt ) - .option('-o, --offset ', 'Index of the first result, for paging.', parsePositiveInt) + .option('-o, --offset ', 'Index of the first result, for paging.', parseNonNegativeInt) .option('--sort-by ', `Sort key: ${SORT_KEYS.join(', ')}.`) .option('--sort-order ', `Sort order: ${SORT_ORDERS.join(', ')}.`) .option('--json', 'Print the raw results as JSON.') diff --git a/cli/src/table.ts b/cli/src/table.ts index c7168d4e0..89acb98df 100644 --- a/cli/src/table.ts +++ b/cli/src/table.ts @@ -39,8 +39,15 @@ export function formatNumber(value: number | undefined): string { return (value ?? 0).toLocaleString('en-US'); } -/** Shortens `text` to `max` characters, marking that it was cut. */ +/** + * Shortens `text` to at most `max` characters, marking that it was cut. A `max` of zero or less + * leaves no room for the marker either, so it yields nothing rather than an ellipsis wider than the + * budget it was given. + */ export function truncate(text: string, max: number): string { + if (max <= 0) { + return ''; + } const collapsed = text.replace(/\s+/g, ' ').trim(); return collapsed.length <= max ? collapsed : `${collapsed.substring(0, max - 1)}…`; } diff --git a/cli/src/util.ts b/cli/src/util.ts index 177b5e738..e74126625 100644 --- a/cli/src/util.ts +++ b/cli/src/util.ts @@ -41,7 +41,7 @@ function parseBooleanEnv(value?: string): boolean | undefined { * Parses a commander option that must be a non-negative whole number, so a typo is reported by the * CLI rather than sent to the registry as a bad request. */ -export function parsePositiveInt(value: string): number { +export function parseNonNegativeInt(value: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0) { throw new Error(`Expected a non-negative whole number, got '${value}'.`); diff --git a/cli/test/unit/table.spec.ts b/cli/test/unit/table.spec.ts new file mode 100644 index 000000000..35485ad92 --- /dev/null +++ b/cli/test/unit/table.spec.ts @@ -0,0 +1,50 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { describe, expect, it } from 'vitest'; +import { truncate } from '../../src/table'; + +/** + * `table.ts` exists to be shared between commands, so its helpers are asserted at their boundaries + * here rather than only through whichever command happens to call them with a comfortable width. + */ +describe('truncate', () => { + + it('leaves text that fits alone', () => { + expect(truncate('short', 10)).toBe('short'); + }); + + it('collapses whitespace and trims', () => { + expect(truncate(' two words\n', 20)).toBe('two words'); + }); + + it('keeps the result within the given width, marker included', () => { + expect(truncate('abcdefghij', 5)).toBe('abcd…'); + expect(truncate('abcdefghij', 5)).toHaveLength(5); + }); + + it('spends its whole budget on the marker when only one character fits', () => { + expect(truncate('abcdefghij', 1)).toBe('…'); + }); + + // A zero budget leaves no room for the marker either. Returning the ellipsis anyway would be one + // character wider than asked for, which is how a truncating helper ends up wrapping a table. + it('yields nothing for a width of zero or less', () => { + expect(truncate('abcdefghij', 0)).toBe(''); + expect(truncate('abcdefghij', -1)).toBe(''); + }); + + it('yields nothing for text that is only whitespace', () => { + expect(truncate(' ', 10)).toBe(''); + }); +}); From f9d6b36f663dae4d2be5f12a162e16805f46ab20 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 13:47:17 +0200 Subject: [PATCH 3/3] docs(cli): add the changelog entries for search and list Co-Authored-By: Claude Opus 5 (1M context) --- cli/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 1e901ec48..af3f9d350 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,8 @@ This change log covers only the command line interface (CLI) of Open VSX. #### Added +- Add `search` command to search the registry for extensions, mirroring the web UI's search: `--category`, `--target`, `--sort-by` and `--sort-order` narrow the query, `--size` and `--offset` page through the results, and `--json` prints the registry's raw response ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154)) +- Add `list` command to print the extensions a namespace holds, sorted by name so the output stays stable across registries, with `--json` for the raw namespace metadata ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154)) - Add `show` command to print an extension's metadata, mirroring `vsce show`: identity, publisher, rating, notices and a version history listing each version's target platforms ([#2149](https://github.com/eclipse-openvsx/openvsx/issues/2149)). `namespace.extension@version` reports a single version, `--target` scopes the report to one target platform, `--all-versions` lists every published version instead of the most recent few, and `--json` prints the registry's raw metadata - Add `unpublish` command to delete an extension or some of its versions, mirroring `vsce unpublish` ([#1958](https://github.com/eclipse-openvsx/openvsx/issues/1958)); requires a registry running version 1.2.0 or later, which `unpublish` checks for before deleting - `publish` checks the packaged extension's size against the limit reported by the registry's `/api/version` endpoint before uploading, instead of failing only after the upload completes ([#1953](https://github.com/eclipse-openvsx/openvsx/issues/1953))