From 90011d83d25224be5ee2d46f887edd2c0576ff03 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 3 Sep 2026 23:10:40 +0200 Subject: [PATCH 1/5] feat(cli): add a show command Prints an extension's metadata, following 'vsce show' closely enough that the habit transfers: display name, publisher, downloads and rating, description, a version history table, categories, tags (the internal '__'-prefixed ones filtered out, as the web UI does), a "More Info" block and statistics. '--json' prints the raw metadata. Beyond parity, a Registry block reports what an Open VSX compatible registry knows and the Marketplace has no equivalent for: verified publisher, trusted publishing, pre-release status, extension kind, localized languages, dependencies and bundled extensions. Deprecation leads the output instead, naming the replacement extension where the registry supplies one, since that changes whether you should install the extension at all - as do a disputed namespace and an extension the registry won't serve for download. The version history needs per-version timestamps and pre-release flags, which the metadata response doesn't carry - 'allVersions' is version numbers and links only, and 'allTargetPlatformVersions' is populated on the user and admin endpoints rather than the public one. So it comes from '/api/v2/-/query?includeAllVersions=true', one request for every version, whose one-row-per-target-platform results are collapsed to a row per version. That query is best-effort: a registry that doesn't serve it still gets the rest of the output. Versions are sorted newest first here rather than trusting the query's order, which also means the table's cap drops the oldest versions rather than arbitrary ones. '--all-versions' lists them all, and the count left out is reported rather than truncating silently. Accepts 'namespace.extension@version' the way vsce does, including the 'latest' and 'pre-release' aliases, plus '--target' for a specific target platform. No token needed - both endpoints are public. Closes #2149 Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/main.ts | 11 ++ cli/src/registry.ts | 43 ++++- cli/src/show-options.ts | 34 ++++ cli/src/show.ts | 319 +++++++++++++++++++++++++++++++++++++ cli/test/unit/show.spec.ts | 273 +++++++++++++++++++++++++++++++ 5 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 cli/src/show-options.ts create mode 100644 cli/src/show.ts create mode 100644 cli/test/unit/show.spec.ts diff --git a/cli/src/main.ts b/cli/src/main.ts index 9cd0438ec..2a94e3c37 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -16,6 +16,7 @@ import { publish } from './publish'; import { unpublish } from './unpublish'; import { handleError } from './util'; import { getExtension } from './get'; +import { show } from './show'; import { verify } from './verify'; import { verifySignature } from './verify-signature'; import login from './login'; @@ -116,6 +117,16 @@ module.exports = function (argv: string[]): void { }).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.') + .option('--all-versions', 'List every published version instead of the most recent few.') + .option('--json', 'Print the raw metadata as JSON.') + .action((extensionId: string, { target, allVersions, json }) => { + const { registryUrl } = program.opts(); + show({ extensionId, target, allVersions, json, registryUrl }).catch(handleError(program.debug)); + }); + const getCmd = program.command('get '); getCmd.description('Download an extension or its metadata.') .option('-t, --target ', 'Target architecture') diff --git a/cli/src/registry.ts b/cli/src/registry.ts index 8968dde44..d1a95f060 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -123,18 +123,38 @@ export class Registry { } } - getMetadata(namespace: string, extension: string, target?: string): Promise { + getMetadata(namespace: string, extension: string, target?: string, version?: string): Promise { try { const segments = ['api', namespace, extension]; if (target) { segments.push(target); } + if (version) { + segments.push(version); + } return this.getJson(this.getUrl(segments)); } catch (err) { return rejectError(err); } } + /** + * Returns every published version of an extension, one entry per version and target platform. + * `allVersions` on the metadata response only carries version numbers and links, so this is + * what makes timestamps and pre-release flags available without a request per version. + */ + queryAllVersions(namespace: string, extension: string): Promise { + try { + return this.getJson(this.getUrl(['api', 'v2', '-', 'query'], { + namespaceName: namespace, + extensionName: extension, + includeAllVersions: 'true' + })); + } catch (err) { + return rejectError(err); + } + } + download(file: string, url: URL): Promise { return new Promise((resolve, reject) => { const stream = fs.createWriteStream(file); @@ -293,8 +313,18 @@ export interface Extension extends Response { versionAlias: string[]; timestamp: string; preview?: boolean; + preRelease?: boolean; displayName?: string; + namespaceDisplayName?: string; description?: string; + deprecated?: boolean; + replacement?: ExtensionReplacement; + downloadable?: boolean; + publishedWithTrustedPublishing?: boolean; + namespaceOwnershipConflict?: boolean; + extensionKind?: string[]; + localizedLanguages?: string[]; + sponsorLink?: string; // key: engine, value: version constraint engines?: { [engine: string]: string }; @@ -346,6 +376,17 @@ export interface Badge { description: string; } +export interface ExtensionReplacement { + url: string; + displayName?: string; +} + +export interface QueryResult extends Response { + offset: number; + totalSize: number; + extensions?: Extension[]; +} + export interface ExtensionReference { url: string; namespace: string; diff --git a/cli/src/show-options.ts b/cli/src/show-options.ts new file mode 100644 index 000000000..124880cfe --- /dev/null +++ b/cli/src/show-options.ts @@ -0,0 +1,34 @@ +/****************************************************************************** + * 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 ShowOptions extends RegistryOptions { + /** + * Identifier in the form `namespace.extension` or `namespace/extension`, optionally suffixed + * with `@` - an exact version, or one of the `latest` / `pre-release` aliases. + */ + extensionId: string; + /** + * Target platform to report on. Defaults to whichever the registry considers current. + */ + target?: string; + /** + * Print the raw metadata as JSON instead of a readable summary. + */ + json?: boolean; + /** + * List every published version rather than the most recent few. + */ + allVersions?: boolean; +} diff --git a/cli/src/show.ts b/cli/src/show.ts new file mode 100644 index 000000000..c28b23a9f --- /dev/null +++ b/cli/src/show.ts @@ -0,0 +1,319 @@ +/****************************************************************************** + * 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 semver from 'semver'; +import { Extension, Registry } from './registry'; +import { ShowOptions } from './show-options'; +import { addEnvOptions, matchExtensionId } from './util'; + +/** + * How many versions the history table prints unless `--all-versions` is given. Matches what + * `vsce show` lists; the remainder is reported as a count rather than dropped silently. + */ +const VERSION_HISTORY_SIZE = 6; + +/** + * Tags starting with `__` are internal bookkeeping (e.g. `__web_extension`) rather than anything + * the publisher wrote, and the web UI hides them too. + */ +const INTERNAL_TAG_PREFIX = '__'; + +/** One row of the version history table. */ +interface VersionSummary { + version: string; + timestamp?: string; + preRelease: boolean; + targetPlatforms: string[]; +} + +/** + * Prints an extension's metadata. + */ +export async function show(options: ShowOptions): Promise { + addEnvOptions(options); + const { id, version } = splitVersion(options.extensionId); + const match = matchExtensionId(id); + if (!match) { + throw new Error('The extension identifier must have the form `namespace.extension`.'); + } + + const [, namespace, name] = match; + const registry = new Registry(options); + const extension = await registry.getMetadata(namespace, name, options.target, version); + if (extension.error) { + throw new Error(extension.error); + } + + if (options.json) { + console.log(JSON.stringify(extension, null, 4)); + return; + } + + printSummary(extension, await getVersions(registry, namespace, name), options.allVersions === true); +} + +/** + * Splits a trailing `@` off the identifier, the way `vsce show` accepts it. Only the last + * `@` is considered, so it stays out of the way of the identifier itself. + */ +function splitVersion(extensionId: string): { id: string; version?: string } { + const at = extensionId.lastIndexOf('@'); + if (at <= 0) { + return { id: extensionId }; + } + return { id: extensionId.substring(0, at), version: extensionId.substring(at + 1) }; +} + +/** + * Collects the version history, collapsing the one-entry-per-target-platform rows the query + * returns into a single row per version. Best-effort: a registry that doesn't serve the v2 query + * endpoint still gets everything else, just without the history table. + */ +async function getVersions(registry: Registry, namespace: string, name: string): Promise { + let extensions: Extension[] | undefined; + try { + extensions = (await registry.queryAllVersions(namespace, name)).extensions; + } catch { + return []; + } + if (!extensions) { + return []; + } + + const byVersion = new Map(); + for (const extension of extensions) { + const summary = byVersion.get(extension.version); + if (summary) { + if (extension.targetPlatform && !summary.targetPlatforms.includes(extension.targetPlatform)) { + summary.targetPlatforms.push(extension.targetPlatform); + } + continue; + } + byVersion.set(extension.version, { + version: extension.version, + timestamp: extension.timestamp, + preRelease: extension.preRelease === true, + targetPlatforms: extension.targetPlatform ? [extension.targetPlatform] : [] + }); + } + + return [...byVersion.values()].sort(byNewestFirst); +} + +/** + * Newest version first. Anything that isn't valid semver (the registry accepts such versions from + * a mirror) sorts after everything that is, rather than being compared incomparably. + */ +function byNewestFirst(a: VersionSummary, b: VersionSummary): number { + const left = semver.coerce(a.version); + const right = semver.coerce(b.version); + if (left && right) { + return semver.rcompare(left, right); + } + if (left) { + return -1; + } + if (right) { + return 1; + } + return b.version.localeCompare(a.version); +} + +function printSummary(extension: Extension, versions: VersionSummary[], allVersions: boolean): void { + const publisher = extension.namespaceDisplayName || extension.namespace; + console.log(extension.displayName || extension.name); + console.log(` ${publisher}${extension.verified ? ' (verified publisher)' : ''}`); + console.log(` ${formatCount(extension.downloadCount, 'download')} ${formatRating(extension)}`); + if (extension.description) { + console.log(); + console.log(` ${extension.description}`); + } + + printNotices(extension); + printVersionHistory(versions, allVersions); + printList('Categories', extension.categories); + printList('Tags', extension.tags?.filter(tag => !tag.startsWith(INTERNAL_TAG_PREFIX))); + printTable('More Info', moreInfo(extension)); + printTable('Registry', registryInfo(extension)); + printTable('Statistics', statistics(extension)); +} + +/** + * Anything a consumer of this extension should see before its metadata: deprecation first, since + * it changes whether they should install it at all. + */ +function printNotices(extension: Extension): void { + const notices: string[] = []; + if (extension.deprecated) { + const replacement = extension.replacement; + notices.push( + replacement + ? `Deprecated - superseded by ${replacement.displayName ?? replacement.url}` + : 'Deprecated' + ); + } + if (extension.downloadable === false) { + notices.push('Not downloadable from this registry'); + } + if (extension.preview) { + notices.push('Preview'); + } + if (extension.namespaceOwnershipConflict) { + notices.push('The namespace ownership of this extension is disputed'); + } + + if (notices.length > 0) { + console.log(); + for (const notice of notices) { + console.log(` ! ${notice}`); + } + } +} + +function printVersionHistory(versions: VersionSummary[], allVersions: boolean): void { + if (versions.length === 0) { + return; + } + + const shown = allVersions ? versions : versions.slice(0, VERSION_HISTORY_SIZE); + const rows = shown.map(v => [ + v.version, + v.timestamp ?? '', + v.preRelease ? 'pre-release' : '', + v.targetPlatforms.join(', ') + ]); + + console.log(); + console.log('Version History:'); + printRows([['Version', 'Last Updated', '', 'Target Platforms'], ...rows]); + const remaining = versions.length - shown.length; + if (remaining > 0) { + console.log(` ... and ${remaining} more (pass --all-versions to list them)`); + } +} + +function moreInfo(extension: Extension): string[][] { + const rows: string[][] = [ + ['Unique Identifier', `${extension.namespace}.${extension.name}`], + ['Version', extension.version] + ]; + if (extension.versionAlias?.length > 0) { + rows.push(['Version Aliases', extension.versionAlias.join(', ')]); + } + if (extension.targetPlatform) { + rows.push(['Target Platform', extension.targetPlatform]); + } + rows.push(['Last Updated', extension.timestamp]); + rows.push(['Published By', extension.publishedBy?.loginName ?? '']); + addIfPresent(rows, 'License', extension.license); + addIfPresent(rows, 'Homepage', extension.homepage); + addIfPresent(rows, 'Repository', extension.repository); + addIfPresent(rows, 'Bugs', extension.bugs); + addIfPresent(rows, 'Q&A', extension.qna); + addIfPresent(rows, 'Sponsor', extension.sponsorLink); + if (extension.engines) { + rows.push(['Engines', Object.entries(extension.engines).map(([e, v]) => `${e} ${v}`).join(', ')]); + } + return rows; +} + +/** + * The parts an Open VSX compatible registry reports that the Marketplace has no equivalent for. + * Kept in its own block rather than mixed into "More Info" so it's obvious what is registry + * specific, and omitted entirely when none of it applies. + */ +function registryInfo(extension: Extension): string[][] { + const rows: string[][] = []; + if (extension.verified !== undefined) { + rows.push(['Verified Publisher', yesNo(extension.verified)]); + } + if (extension.publishedWithTrustedPublishing) { + rows.push(['Trusted Publishing', 'yes']); + } + if (extension.preRelease !== undefined) { + rows.push(['Pre-Release', yesNo(extension.preRelease)]); + } + addIfPresent(rows, 'Extension Kind', extension.extensionKind?.join(', ')); + addIfPresent(rows, 'Localized', extension.localizedLanguages?.join(', ')); + addIfPresent(rows, 'Dependencies', extension.dependencies?.map(referenceId).join(', ')); + addIfPresent(rows, 'Bundled Extensions', extension.bundledExtensions?.map(referenceId).join(', ')); + return rows; +} + +function statistics(extension: Extension): string[][] { + const rows: string[][] = [['Downloads', (extension.downloadCount ?? 0).toLocaleString('en-US')]]; + if (extension.averageRating !== undefined) { + rows.push(['Average Rating', `${extension.averageRating.toFixed(1)}/5`]); + } + rows.push(['Reviews', Number(extension.reviewCount ?? 0).toLocaleString('en-US')]); + return rows; +} + +function referenceId(reference: { namespace: string; extension: string }): string { + return `${reference.namespace}.${reference.extension}`; +} + +function addIfPresent(rows: string[][], label: string, value?: string): void { + if (value) { + rows.push([label, value]); + } +} + +function printList(title: string, values?: string[]): void { + if (!values || values.length === 0) { + return; + } + console.log(); + console.log(`${title}:`); + console.log(` ${values.join(', ')}`); +} + +function printTable(title: string, rows: string[][]): void { + if (rows.length === 0) { + return; + } + console.log(); + console.log(`${title}:`); + 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'; + } + return `${extension.averageRating.toFixed(1)}/5 from ${formatCount(Number(extension.reviewCount), 'review')}`; +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no'; +} diff --git a/cli/test/unit/show.spec.ts b/cli/test/unit/show.spec.ts new file mode 100644 index 000000000..0fc5a1275 --- /dev/null +++ b/cli/test/unit/show.spec.ts @@ -0,0 +1,273 @@ +/****************************************************************************** + * 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 { show } from '../../src/show'; + +interface ShowRequest { + pathname: string; + query: URLSearchParams; +} + +interface RegistryStub { + url: string; + metadataRequests: ShowRequest[]; + queryRequests: ShowRequest[]; + close: () => Promise; +} + +const extension = { + namespace: 'redhat', + name: 'java', + version: '1.2.0', + targetPlatform: 'universal', + displayName: 'Language Support for Java', + description: 'Java language support', + timestamp: '2026-08-01T10:00:00Z', + versionAlias: ['latest'], + downloadCount: 1234567, + averageRating: 4.25, + reviewCount: 12, + verified: true, + publishedBy: { loginName: 'redhat-bot' }, + categories: ['Programming Languages', 'Linters'], + tags: ['java', '__web_extension'], + license: 'EPL-2.0', + repository: 'https://github.com/redhat-developer/vscode-java', + engines: { vscode: '^1.90.0' }, + extensionKind: ['workspace'], + publishedWithTrustedPublishing: true, + preRelease: false +}; + +/** Stands in for `/api/{namespace}/{extension}` plus the v2 query used for version history. */ +async function startRegistryStub( + metadata: { status?: number; body?: unknown } = {}, + query: { status?: number; body?: unknown } = {} +): Promise { + const metadataRequests: ShowRequest[] = []; + const queryRequests: ShowRequest[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const request = { pathname: url.pathname, query: url.searchParams }; + const isQuery = url.pathname === '/api/v2/-/query'; + (isQuery ? queryRequests : metadataRequests).push(request); + const stub = isQuery ? query : metadata; + res.writeHead(stub.status ?? 200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(stub.body ?? (isQuery ? { offset: 0, totalSize: 1, extensions: [extension] } : extension))); + }); + 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}`, + metadataRequests, + queryRequests, + close: () => new Promise(resolve => server.close(() => resolve())) + }; +} + +describe('show', () => { + + const stubs: RegistryStub[] = []; + 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( + metadata?: { status?: number; body?: unknown }, + query?: { status?: number; body?: unknown } + ): Promise { + const stub = await startRegistryStub(metadata, query); + stubs.push(stub); + return stub; + } + + /** The printed output as one string, so assertions can be about content rather than layout. */ + function output(): string { + return log.mock.calls.map(call => String(call[0] ?? '')).join('\n'); + } + + it('rejects a malformed extension identifier', async () => { + const registry = await givenRegistry(); + await expect(show({ extensionId: 'not-an-id', registryUrl: registry.url })) + .rejects.toThrow('The extension identifier must have the form `namespace.extension`.'); + expect(registry.metadataRequests).toHaveLength(0); + }); + + it('reports the error the registry returns', async () => { + const registry = await givenRegistry({ body: { error: 'Extension not found: redhat.java' } }); + await expect(show({ extensionId: 'redhat.java', registryUrl: registry.url })) + .rejects.toThrow('Extension not found: redhat.java'); + }); + + it('prints the identity, publisher and statistics', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + expect(registry.metadataRequests[0].pathname).toBe('/api/redhat/java'); + const printed = output(); + expect(printed).toContain('Language Support for Java'); + expect(printed).toContain('redhat (verified publisher)'); + expect(printed).toContain('1,234,567 downloads'); + expect(printed).toContain('4.3/5 from 12 reviews'); + expect(printed).toContain('redhat.java'); + expect(printed).toContain('EPL-2.0'); + }); + + it('hides internal tags', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + expect(printed).toContain('java'); + expect(printed).not.toContain('__web_extension'); + }); + + it('reports the registry-specific metadata the Marketplace has no equivalent for', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + expect(printed).toContain('Verified Publisher'); + expect(printed).toContain('Trusted Publishing'); + expect(printed).toContain('Extension Kind'); + }); + + it('leads with a deprecation notice and names the replacement', async () => { + const registry = await givenRegistry({ + body: { + ...extension, + deprecated: true, + replacement: { url: 'https://open-vsx.org/extension/redhat/java-next', displayName: 'Java Next' } + } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + expect(output()).toContain('Deprecated - superseded by Java Next'); + }); + + it('collapses the per-target-platform rows into one row per version', async () => { + const registry = await givenRegistry({}, { + body: { + offset: 0, + totalSize: 3, + extensions: [ + { ...extension, version: '1.2.0', targetPlatform: 'linux-x64' }, + { ...extension, version: '1.2.0', targetPlatform: 'win32-x64' }, + { ...extension, version: '1.1.0', targetPlatform: 'universal', preRelease: true } + ] + } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + expect(registry.queryRequests[0].query.get('includeAllVersions')).toBe('true'); + const printed = output(); + expect(printed).toContain('Version History:'); + expect(printed).toContain('linux-x64, win32-x64'); + expect(printed).toContain('pre-release'); + }); + + // The query makes no ordering promise, so the sort has to happen here - and it has to, because + // the history cap would otherwise drop an arbitrary version rather than the oldest. + it('orders the version history newest first', async () => { + const registry = await givenRegistry({}, { + body: { + offset: 0, + totalSize: 3, + extensions: [ + { ...extension, version: '1.9.0' }, + { ...extension, version: '1.10.0' }, + { ...extension, version: '1.2.0' } + ] + } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + // 1.10.0 outranks 1.9.0 numerically, which a string sort would get wrong. + expect(printed.indexOf('1.10.0')).toBeLessThan(printed.indexOf('1.9.0')); + expect(printed.indexOf('1.9.0')).toBeLessThan(printed.indexOf('1.2.0')); + }); + + it('caps the version history and says how many were left out', async () => { + const versions = Array.from({ length: 9 }, (_, i) => ({ ...extension, version: `1.0.${i}` })); + const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, extensions: versions } }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + expect(output()).toContain('... and 3 more'); + }); + + it('lists every version with --all-versions', async () => { + const versions = Array.from({ length: 9 }, (_, i) => ({ ...extension, version: `1.0.${i}` })); + const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, extensions: versions } }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url, allVersions: true }); + + const printed = output(); + expect(printed).not.toContain('more (pass --all-versions'); + expect(printed).toContain('1.0.8'); + }); + + it('requests the version named after @ and the given target platform', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java@1.1.0', target: 'linux-x64', registryUrl: registry.url }); + + expect(registry.metadataRequests[0].pathname).toBe('/api/redhat/java/linux-x64/1.1.0'); + }); + + it('passes a version alias through as given', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java@pre-release', registryUrl: registry.url }); + + expect(registry.metadataRequests[0].pathname).toBe('/api/redhat/java/pre-release'); + }); + + it('prints raw JSON with --json, without querying the version history', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java', json: true, registryUrl: registry.url }); + + expect(JSON.parse(output())).toMatchObject({ namespace: 'redhat', name: 'java', version: '1.2.0' }); + expect(registry.queryRequests).toHaveLength(0); + }); + + // The v2 query is only there for the history table, so a registry too old to serve it - or one + // that errors on it - must still produce the rest of the output rather than failing outright. + it('still prints the summary when the version query fails', async () => { + const registry = await givenRegistry({}, { status: 404, body: { error: 'Not found' } }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + expect(printed).toContain('Language Support for Java'); + expect(printed).not.toContain('Version History:'); + }); +}); From 874081cb7827bdbb9ea4166674c2babe5829565f Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 3 Sep 2026 23:18:03 +0200 Subject: [PATCH 2/5] fix(cli): build the version history from version-references Review feedback: the history table was reading /api/v2/-/query?includeAllVersions=true, which is by far the most expensive way to get it. For redhat.java on open-vsx.org that response is 267KB against 11KB for the same information from /api/{namespace}/{extension}/version-references, because the query repeats every extension-level field - files, tags, description, publisher - on all 3817 version/target-platform rows. Worse, the query pages at 100 rows, so '--all-versions' silently listed at most 100 of those 3817 rather than all of them, which is not what the option claims. version-references returns version and target platform, newest first, with size/offset paging, so the listing is now complete: one page for the default table, paging to the end for '--all-versions'. totalSize counts version/target-platform pairs rather than versions, so paging runs off what has actually been returned. The cost is the two columns that endpoint doesn't carry: neither it nor /versions reports a timestamp or the pre-release flag, so the table is now Version and Target Platforms. Adding those two fields to VersionReferenceJson would restore them at no bandwidth cost, and looks like the right fix rather than making every client pay for the query endpoint - raised separately. Refs #2149 Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/registry.ts | 27 ++++++++----- cli/src/show.ts | 80 ++++++++++++++++++++++---------------- cli/test/unit/show.spec.ts | 74 ++++++++++++++++++++++------------- 3 files changed, 110 insertions(+), 71 deletions(-) diff --git a/cli/src/registry.ts b/cli/src/registry.ts index d1a95f060..921b4d222 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -139,16 +139,15 @@ export class Registry { } /** - * Returns every published version of an extension, one entry per version and target platform. - * `allVersions` on the metadata response only carries version numbers and links, so this is - * what makes timestamps and pre-release flags available without a request per version. + * Returns a page of an extension's published versions, newest first, one entry per version and + * target platform. `allVersions` on the metadata response carries version numbers and links + * only, so this is what makes the target platforms of each version available. */ - queryAllVersions(namespace: string, extension: string): Promise { + getVersionReferences(namespace: string, extension: string, size: number, offset: number): Promise { try { - return this.getJson(this.getUrl(['api', 'v2', '-', 'query'], { - namespaceName: namespace, - extensionName: extension, - includeAllVersions: 'true' + return this.getJson(this.getUrl(['api', namespace, extension, 'version-references'], { + size: String(size), + offset: String(offset) })); } catch (err) { return rejectError(err); @@ -381,10 +380,18 @@ export interface ExtensionReplacement { displayName?: string; } -export interface QueryResult extends Response { +export interface VersionReference { + url: string; + files: { [type: string]: string }; + version: string; + targetPlatform?: string; + engines?: { [engine: string]: string }; +} + +export interface VersionReferences extends Response { offset: number; totalSize: number; - extensions?: Extension[]; + versions?: VersionReference[]; } export interface ExtensionReference { diff --git a/cli/src/show.ts b/cli/src/show.ts index c28b23a9f..9c3aea350 100644 --- a/cli/src/show.ts +++ b/cli/src/show.ts @@ -12,7 +12,7 @@ *****************************************************************************/ import * as semver from 'semver'; -import { Extension, Registry } from './registry'; +import { Extension, Registry, VersionReference } from './registry'; import { ShowOptions } from './show-options'; import { addEnvOptions, matchExtensionId } from './util'; @@ -31,11 +31,16 @@ const INTERNAL_TAG_PREFIX = '__'; /** One row of the version history table. */ interface VersionSummary { version: string; - timestamp?: string; - preRelease: boolean; targetPlatforms: string[]; } +/** + * Page size for the version-reference listing. Versions come back newest first, and each version + * contributes one entry per target platform, so a page has to be comfortably larger than the + * number of versions shown for the default table to be filled from a single request. + */ +const VERSION_PAGE_SIZE = 100; + /** * Prints an extension's metadata. */ @@ -59,7 +64,8 @@ export async function show(options: ShowOptions): Promise { return; } - printSummary(extension, await getVersions(registry, namespace, name), options.allVersions === true); + const allVersions = options.allVersions === true; + printSummary(extension, await getVersions(registry, namespace, name, allVersions), allVersions); } /** @@ -75,36 +81,49 @@ function splitVersion(extensionId: string): { id: string; version?: string } { } /** - * Collects the version history, collapsing the one-entry-per-target-platform rows the query - * returns into a single row per version. Best-effort: a registry that doesn't serve the v2 query - * endpoint still gets everything else, just without the history table. + * Collects the version history, collapsing the one-entry-per-target-platform rows the registry + * returns into a single row per version. Fetches one page unless every version is wanted, in which + * case it pages to the end - `totalSize` counts version/target-platform pairs, not versions, so + * paging has to run on what has actually been returned rather than on a version count. + * + * Best-effort: a registry that doesn't serve this endpoint still gets everything else, just + * without the history table. */ -async function getVersions(registry: Registry, namespace: string, name: string): Promise { - let extensions: Extension[] | undefined; +async function getVersions( + registry: Registry, + namespace: string, + name: string, + allVersions: boolean +): Promise { + const references: VersionReference[] = []; + let offset = 0; try { - extensions = (await registry.queryAllVersions(namespace, name)).extensions; + for (;;) { + const page = await registry.getVersionReferences(namespace, name, VERSION_PAGE_SIZE, offset); + if (page.error) { + return []; + } + const versions = page.versions ?? []; + references.push(...versions); + offset += versions.length; + if (!allVersions || versions.length === 0 || offset >= page.totalSize) { + break; + } + } } catch { return []; } - if (!extensions) { - return []; - } const byVersion = new Map(); - for (const extension of extensions) { - const summary = byVersion.get(extension.version); - if (summary) { - if (extension.targetPlatform && !summary.targetPlatforms.includes(extension.targetPlatform)) { - summary.targetPlatforms.push(extension.targetPlatform); - } - continue; + for (const reference of references) { + let summary = byVersion.get(reference.version); + if (!summary) { + summary = { version: reference.version, targetPlatforms: [] }; + byVersion.set(reference.version, summary); + } + if (reference.targetPlatform && !summary.targetPlatforms.includes(reference.targetPlatform)) { + summary.targetPlatforms.push(reference.targetPlatform); } - byVersion.set(extension.version, { - version: extension.version, - timestamp: extension.timestamp, - preRelease: extension.preRelease === true, - targetPlatforms: extension.targetPlatform ? [extension.targetPlatform] : [] - }); } return [...byVersion.values()].sort(byNewestFirst); @@ -186,16 +205,11 @@ function printVersionHistory(versions: VersionSummary[], allVersions: boolean): } const shown = allVersions ? versions : versions.slice(0, VERSION_HISTORY_SIZE); - const rows = shown.map(v => [ - v.version, - v.timestamp ?? '', - v.preRelease ? 'pre-release' : '', - v.targetPlatforms.join(', ') - ]); + const rows = shown.map(v => [v.version, v.targetPlatforms.join(', ')]); console.log(); console.log('Version History:'); - printRows([['Version', 'Last Updated', '', 'Target Platforms'], ...rows]); + printRows([['Version', 'Target Platforms'], ...rows]); const remaining = versions.length - shown.length; if (remaining > 0) { console.log(` ... and ${remaining} more (pass --all-versions to list them)`); diff --git a/cli/test/unit/show.spec.ts b/cli/test/unit/show.spec.ts index 0fc5a1275..739d699f1 100644 --- a/cli/test/unit/show.spec.ts +++ b/cli/test/unit/show.spec.ts @@ -24,7 +24,7 @@ interface ShowRequest { interface RegistryStub { url: string; metadataRequests: ShowRequest[]; - queryRequests: ShowRequest[]; + versionRequests: ShowRequest[]; close: () => Promise; } @@ -52,28 +52,29 @@ const extension = { preRelease: false }; -/** Stands in for `/api/{namespace}/{extension}` plus the v2 query used for version history. */ +/** Stands in for `/api/{namespace}/{extension}` plus the version-reference listing. */ async function startRegistryStub( metadata: { status?: number; body?: unknown } = {}, - query: { status?: number; body?: unknown } = {} + versions: { status?: number; body?: unknown } = {} ): Promise { const metadataRequests: ShowRequest[] = []; - const queryRequests: ShowRequest[] = []; + const versionRequests: ShowRequest[] = []; + const defaultVersions = { offset: 0, totalSize: 1, versions: [{ version: '1.2.0', targetPlatform: 'universal' }] }; const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); const request = { pathname: url.pathname, query: url.searchParams }; - const isQuery = url.pathname === '/api/v2/-/query'; - (isQuery ? queryRequests : metadataRequests).push(request); - const stub = isQuery ? query : metadata; + const isVersions = url.pathname.endsWith('/version-references'); + (isVersions ? versionRequests : metadataRequests).push(request); + const stub = isVersions ? versions : metadata; res.writeHead(stub.status ?? 200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(stub.body ?? (isQuery ? { offset: 0, totalSize: 1, extensions: [extension] } : extension))); + res.end(JSON.stringify(stub.body ?? (isVersions ? defaultVersions : extension))); }); 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}`, metadataRequests, - queryRequests, + versionRequests, close: () => new Promise(resolve => server.close(() => resolve())) }; } @@ -94,9 +95,9 @@ describe('show', () => { async function givenRegistry( metadata?: { status?: number; body?: unknown }, - query?: { status?: number; body?: unknown } + versions?: { status?: number; body?: unknown } ): Promise { - const stub = await startRegistryStub(metadata, query); + const stub = await startRegistryStub(metadata, versions); stubs.push(stub); return stub; } @@ -174,21 +175,21 @@ describe('show', () => { body: { offset: 0, totalSize: 3, - extensions: [ - { ...extension, version: '1.2.0', targetPlatform: 'linux-x64' }, - { ...extension, version: '1.2.0', targetPlatform: 'win32-x64' }, - { ...extension, version: '1.1.0', targetPlatform: 'universal', preRelease: true } + versions: [ + { version: '1.2.0', targetPlatform: 'linux-x64' }, + { version: '1.2.0', targetPlatform: 'win32-x64' }, + { version: '1.1.0', targetPlatform: 'universal' } ] } }); await show({ extensionId: 'redhat.java', registryUrl: registry.url }); - expect(registry.queryRequests[0].query.get('includeAllVersions')).toBe('true'); + expect(registry.versionRequests[0].pathname).toBe('/api/redhat/java/version-references'); const printed = output(); expect(printed).toContain('Version History:'); expect(printed).toContain('linux-x64, win32-x64'); - expect(printed).toContain('pre-release'); + expect(printed).toContain('1.1.0'); }); // The query makes no ordering promise, so the sort has to happen here - and it has to, because @@ -198,10 +199,10 @@ describe('show', () => { body: { offset: 0, totalSize: 3, - extensions: [ - { ...extension, version: '1.9.0' }, - { ...extension, version: '1.10.0' }, - { ...extension, version: '1.2.0' } + versions: [ + { version: '1.9.0' }, + { version: '1.10.0' }, + { version: '1.2.0' } ] } }); @@ -215,8 +216,8 @@ describe('show', () => { }); it('caps the version history and says how many were left out', async () => { - const versions = Array.from({ length: 9 }, (_, i) => ({ ...extension, version: `1.0.${i}` })); - const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, extensions: versions } }); + const refs = Array.from({ length: 9 }, (_, i) => ({ version: `1.0.${i}` })); + const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, versions: refs } }); await show({ extensionId: 'redhat.java', registryUrl: registry.url }); @@ -224,8 +225,8 @@ describe('show', () => { }); it('lists every version with --all-versions', async () => { - const versions = Array.from({ length: 9 }, (_, i) => ({ ...extension, version: `1.0.${i}` })); - const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, extensions: versions } }); + const refs = Array.from({ length: 9 }, (_, i) => ({ version: `1.0.${i}` })); + const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, versions: refs } }); await show({ extensionId: 'redhat.java', registryUrl: registry.url, allVersions: true }); @@ -234,6 +235,23 @@ describe('show', () => { expect(printed).toContain('1.0.8'); }); + // totalSize counts version/target-platform pairs rather than versions, so paging has to run off + // what actually came back. Without this the listing stopped after the first page and quietly + // under-reported - the reason this doesn't use the query endpoint, which caps at 100 rows. + it('pages to the end with --all-versions', async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => ({ version: `1.0.${i}` })); + const registry = await givenRegistry({}, { + body: { offset: 0, totalSize: 150, versions: firstPage } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url, allVersions: true }); + + // The first page returned 100 of 150, so a second request must follow at the next offset. + expect(registry.versionRequests).toHaveLength(2); + expect(registry.versionRequests[0].query.get('offset')).toBe('0'); + expect(registry.versionRequests[1].query.get('offset')).toBe('100'); + }); + it('requests the version named after @ and the given target platform', async () => { const registry = await givenRegistry(); @@ -256,12 +274,12 @@ describe('show', () => { await show({ extensionId: 'redhat.java', json: true, registryUrl: registry.url }); expect(JSON.parse(output())).toMatchObject({ namespace: 'redhat', name: 'java', version: '1.2.0' }); - expect(registry.queryRequests).toHaveLength(0); + expect(registry.versionRequests).toHaveLength(0); }); - // The v2 query is only there for the history table, so a registry too old to serve it - or one + // The listing is only there for the history table, so a registry that doesn't serve it - or one // that errors on it - must still produce the rest of the output rather than failing outright. - it('still prints the summary when the version query fails', async () => { + it('still prints the summary when the version listing fails', async () => { const registry = await givenRegistry({}, { status: 404, body: { error: 'Not found' } }); await show({ extensionId: 'redhat.java', registryUrl: registry.url }); From aecc262cdbb034d3ab89888fef71b62e52ac8e85 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 3 Sep 2026 23:33:04 +0200 Subject: [PATCH 3/5] fix(cli): correct show's version ordering, target scoping and rating Review feedback on #2153, all four points valid. byNewestFirst used semver.coerce, which drops prerelease identifiers: 1.2.0-alpha.1 coerced to 1.2.0, so a prerelease compared equal to its release and the order was unstable. coerce also accepts input semver itself rejects - 'v1.2' becomes 1.2.0 - which defeated the string fallback and made the comment about non-semver sorting last untrue. semver.valid keeps prerelease identifiers, orders them below their release, and rejects what it should. --target is described as scoping the report, but only the metadata lookup honoured it; the version listing always asked for the unscoped path. The registry serves /api/{ns}/{ext}/{target}/version-references, so the target is passed through and the history table now matches the rest of the output. formatRating wrapped reviewCount in Number(), so a response without one printed 'NaN reviews'. formatCount already handles undefined, so the value goes through directly. The identifier error message named only the dotted form, though matchExtensionId accepts namespace/extension too and ShowOptions documents both. Reworded - and the same wording in get and unpublish is updated with it, since three commands validating identically should not report it differently. Each fix has a test, all three confirmed to fail with the bug put back. Refs #2149 Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/get.ts | 2 +- cli/src/registry.ts | 15 ++++++++-- cli/src/show.ts | 18 ++++++++---- cli/src/unpublish.ts | 2 +- cli/test/unit/show.spec.ts | 51 ++++++++++++++++++++++++++++++++- cli/test/unit/unpublish.spec.ts | 2 +- 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/cli/src/get.ts b/cli/src/get.ts index bd5a917d2..0bf719dee 100644 --- a/cli/src/get.ts +++ b/cli/src/get.ts @@ -23,7 +23,7 @@ export async function getExtension(options: GetOptions): Promise { const registry = new Registry(options); const match = matchExtensionId(options.extensionId); if (!match) { - throw new Error('The extension identifier must have the form `namespace.extension`.'); + throw new Error('The extension identifier must have the form `namespace.extension` or `namespace/extension`.'); } const extension = await registry.getMetadata(match[1], match[2], options.target); diff --git a/cli/src/registry.ts b/cli/src/registry.ts index 921b4d222..bf10fc67a 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -143,9 +143,20 @@ export class Registry { * target platform. `allVersions` on the metadata response carries version numbers and links * only, so this is what makes the target platforms of each version available. */ - getVersionReferences(namespace: string, extension: string, size: number, offset: number): Promise { + getVersionReferences( + namespace: string, + extension: string, + target: string | undefined, + size: number, + offset: number + ): Promise { try { - return this.getJson(this.getUrl(['api', namespace, extension, 'version-references'], { + const segments = ['api', namespace, extension]; + if (target) { + segments.push(target); + } + segments.push('version-references'); + return this.getJson(this.getUrl(segments, { size: String(size), offset: String(offset) })); diff --git a/cli/src/show.ts b/cli/src/show.ts index 9c3aea350..603433bc6 100644 --- a/cli/src/show.ts +++ b/cli/src/show.ts @@ -49,7 +49,7 @@ export async function show(options: ShowOptions): Promise { const { id, version } = splitVersion(options.extensionId); const match = matchExtensionId(id); if (!match) { - throw new Error('The extension identifier must have the form `namespace.extension`.'); + throw new Error('The extension identifier must have the form `namespace.extension` or `namespace/extension`.'); } const [, namespace, name] = match; @@ -65,7 +65,8 @@ export async function show(options: ShowOptions): Promise { } const allVersions = options.allVersions === true; - printSummary(extension, await getVersions(registry, namespace, name, allVersions), allVersions); + const versions = await getVersions(registry, namespace, name, options.target, allVersions); + printSummary(extension, versions, allVersions); } /** @@ -93,13 +94,14 @@ async function getVersions( registry: Registry, namespace: string, name: string, + target: string | undefined, allVersions: boolean ): Promise { const references: VersionReference[] = []; let offset = 0; try { for (;;) { - const page = await registry.getVersionReferences(namespace, name, VERSION_PAGE_SIZE, offset); + const page = await registry.getVersionReferences(namespace, name, target, VERSION_PAGE_SIZE, offset); if (page.error) { return []; } @@ -132,10 +134,14 @@ async function getVersions( /** * Newest version first. Anything that isn't valid semver (the registry accepts such versions from * a mirror) sorts after everything that is, rather than being compared incomparably. + * + * Deliberately `valid` and not `coerce`: coerce drops prerelease identifiers, so `1.2.0-alpha.1` + * and `1.2.0` would compare equal and order unstably, and it accepts inputs semver itself rejects + * (`v1.2` becomes `1.2.0`), which would defeat the fallback below. */ function byNewestFirst(a: VersionSummary, b: VersionSummary): number { - const left = semver.coerce(a.version); - const right = semver.coerce(b.version); + const left = semver.valid(a.version); + const right = semver.valid(b.version); if (left && right) { return semver.rcompare(left, right); } @@ -325,7 +331,7 @@ function formatRating(extension: Extension): string { if (extension.averageRating === undefined) { return 'no ratings'; } - return `${extension.averageRating.toFixed(1)}/5 from ${formatCount(Number(extension.reviewCount), 'review')}`; + return `${extension.averageRating.toFixed(1)}/5 from ${formatCount(extension.reviewCount, 'review')}`; } function yesNo(value: boolean): string { diff --git a/cli/src/unpublish.ts b/cli/src/unpublish.ts index 2ac32db26..20fd2e21e 100644 --- a/cli/src/unpublish.ts +++ b/cli/src/unpublish.ts @@ -41,7 +41,7 @@ export async function unpublish(options: UnpublishOptions = {}): Promise { const extensionId = options.extensionId ?? await readExtensionId(); const match = matchExtensionId(extensionId); if (!match) { - throw new Error('The extension identifier must have the form `namespace.extension`.'); + throw new Error('The extension identifier must have the form `namespace.extension` or `namespace/extension`.'); } const [, namespace, extension] = match; diff --git a/cli/test/unit/show.spec.ts b/cli/test/unit/show.spec.ts index 739d699f1..e9490f119 100644 --- a/cli/test/unit/show.spec.ts +++ b/cli/test/unit/show.spec.ts @@ -110,7 +110,7 @@ describe('show', () => { it('rejects a malformed extension identifier', async () => { const registry = await givenRegistry(); await expect(show({ extensionId: 'not-an-id', registryUrl: registry.url })) - .rejects.toThrow('The extension identifier must have the form `namespace.extension`.'); + .rejects.toThrow('The extension identifier must have the form `namespace.extension` or `namespace/extension`.'); expect(registry.metadataRequests).toHaveLength(0); }); @@ -215,6 +215,55 @@ describe('show', () => { expect(printed.indexOf('1.9.0')).toBeLessThan(printed.indexOf('1.2.0')); }); + // semver.coerce would drop the prerelease identifier, making 1.2.0-alpha.1 compare equal to + // 1.2.0 and the order unstable. semver.valid keeps it, so the release sorts ahead of its + // prereleases - and inputs semver rejects still fall through to the string comparison. + it('orders prereleases below their release', async () => { + const registry = await givenRegistry({}, { + body: { + offset: 0, + totalSize: 4, + versions: [ + { version: '1.2.0-alpha.1' }, + { version: '1.2.0' }, + { version: '1.2.0-beta.1' }, + { version: 'nightly' } + ] + } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + expect(printed.indexOf('1.2.0 ')).toBeLessThan(printed.indexOf('1.2.0-beta.1')); + expect(printed.indexOf('1.2.0-beta.1')).toBeLessThan(printed.indexOf('1.2.0-alpha.1')); + // Not valid semver, so it sorts after everything that is. + expect(printed.indexOf('1.2.0-alpha.1')).toBeLessThan(printed.indexOf('nightly')); + }); + + // --target says it scopes the report, so it has to scope the listing too, not just the + // metadata lookup. The registry serves /api/{ns}/{ext}/{target}/version-references for this. + it('scopes the version history to --target', async () => { + const registry = await givenRegistry(); + + await show({ extensionId: 'redhat.java', target: 'linux-x64', registryUrl: registry.url }); + + expect(registry.versionRequests[0].pathname).toBe('/api/redhat/java/linux-x64/version-references'); + }); + + // Number(undefined) is NaN, which printed "NaN reviews". + it('reports a rating with no review count without printing NaN', async () => { + const registry = await givenRegistry({ + body: { ...extension, averageRating: 4.25, reviewCount: undefined } + }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + const printed = output(); + expect(printed).not.toContain('NaN'); + expect(printed).toContain('0 reviews'); + }); + it('caps the version history and says how many were left out', async () => { const refs = Array.from({ length: 9 }, (_, i) => ({ version: `1.0.${i}` })); const registry = await givenRegistry({}, { body: { offset: 0, totalSize: 9, versions: refs } }); diff --git a/cli/test/unit/unpublish.spec.ts b/cli/test/unit/unpublish.spec.ts index a4611ad83..5016a37d3 100644 --- a/cli/test/unit/unpublish.spec.ts +++ b/cli/test/unit/unpublish.spec.ts @@ -105,7 +105,7 @@ describe('unpublish', () => { it('rejects a malformed extension identifier', async () => { const registry = await givenRegistry(); await expect(unpublish({ extensionId: 'not-an-id', pat: 'the.pat', force: true, registryUrl: registry.url })) - .rejects.toThrow('The extension identifier must have the form `namespace.extension`.'); + .rejects.toThrow('The extension identifier must have the form `namespace.extension` or `namespace/extension`.'); expect(registry.requests).toHaveLength(0); }); From 476daf654f6b6a8f7a8fe83ba0c33e126af152ca Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 12:27:33 +0200 Subject: [PATCH 4/5] fix(cli): reject an empty version after @ in show splitVersion read `namespace.extension@` as "no version given": the trailing `@` cleared the version to an empty string, and getMetadata appends the version segment only when it is truthy, so the request went out without one and show reported the latest version instead. Silent, and wrong in the case that actually produces this input - a shell variable that did not expand, as in `ovsx show ext@$VERSION` with VERSION unset. A script asking about one version and being told about another is worse than an error, so this is now an error. Reported by Copilot on #2153. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/show.ts | 10 +++++++++- cli/test/unit/show.spec.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cli/src/show.ts b/cli/src/show.ts index 603433bc6..b45e17340 100644 --- a/cli/src/show.ts +++ b/cli/src/show.ts @@ -72,13 +72,21 @@ export async function show(options: ShowOptions): Promise { /** * Splits a trailing `@` off the identifier, the way `vsce show` accepts it. Only the last * `@` is considered, so it stays out of the way of the identifier itself. + * + * An `@` with nothing after it is rejected rather than read as "no version given". It usually means a + * shell variable that did not expand - `ovsx show redhat.java@$VERSION` with `VERSION` unset - and + * reporting the latest version for that would answer a question the caller did not ask. */ function splitVersion(extensionId: string): { id: string; version?: string } { const at = extensionId.lastIndexOf('@'); if (at <= 0) { return { id: extensionId }; } - return { id: extensionId.substring(0, at), version: extensionId.substring(at + 1) }; + const version = extensionId.substring(at + 1); + if (version.length === 0) { + throw new Error('A version must follow `@`, as in `namespace.extension@1.2.3`.'); + } + return { id: extensionId.substring(0, at), version }; } /** diff --git a/cli/test/unit/show.spec.ts b/cli/test/unit/show.spec.ts index e9490f119..69ef67434 100644 --- a/cli/test/unit/show.spec.ts +++ b/cli/test/unit/show.spec.ts @@ -309,6 +309,16 @@ describe('show', () => { expect(registry.metadataRequests[0].pathname).toBe('/api/redhat/java/linux-x64/1.1.0'); }); + // A trailing `@` with nothing after it used to be read as "no version", so this silently reported + // the latest version - the wrong answer for `ovsx show ext@$VERSION` with the variable unset. + it('rejects an empty version after @', async () => { + const registry = await givenRegistry(); + + await expect(show({ extensionId: 'redhat.java@', registryUrl: registry.url })) + .rejects.toThrow('A version must follow `@`, as in `namespace.extension@1.2.3`.'); + expect(registry.metadataRequests).toHaveLength(0); + }); + it('passes a version alias through as given', async () => { const registry = await givenRegistry(); From 8776e7da2cd26018aaae0bbdeaaa32131efd9589 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 13:36:14 +0200 Subject: [PATCH 5/5] docs(cli): add the changelog entry for the show command Co-Authored-By: Claude Opus 5 (1M context) --- cli/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 8e1b06c89..1e901ec48 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ This change log covers only the command line interface (CLI) of Open VSX. #### Added +- 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)) - Add `verify` command to check a downloaded `.vsix` package's signature against the registry's public key, mirroring `vsce verify-signature` ([#993](https://github.com/eclipse-openvsx/openvsx/issues/993))