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)) 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/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..bf10fc67a 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -123,18 +123,48 @@ 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 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. + */ + getVersionReferences( + namespace: string, + extension: string, + target: string | undefined, + size: number, + offset: number + ): Promise { + try { + 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) + })); + } catch (err) { + return rejectError(err); + } + } + download(file: string, url: URL): Promise { return new Promise((resolve, reject) => { const stream = fs.createWriteStream(file); @@ -293,8 +323,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 +386,25 @@ export interface Badge { description: string; } +export interface ExtensionReplacement { + url: string; + displayName?: string; +} + +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; + versions?: VersionReference[]; +} + 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..b45e17340 --- /dev/null +++ b/cli/src/show.ts @@ -0,0 +1,347 @@ +/****************************************************************************** + * 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, VersionReference } 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; + 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. + */ +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` or `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; + } + + const allVersions = options.allVersions === true; + const versions = await getVersions(registry, namespace, name, options.target, allVersions); + printSummary(extension, versions, allVersions); +} + +/** + * 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 }; + } + 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 }; +} + +/** + * 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, + target: string | undefined, + allVersions: boolean +): Promise { + const references: VersionReference[] = []; + let offset = 0; + try { + for (;;) { + const page = await registry.getVersionReferences(namespace, name, target, 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 []; + } + + const byVersion = new Map(); + 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); + } + } + + 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. + * + * 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.valid(a.version); + const right = semver.valid(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.targetPlatforms.join(', ')]); + + console.log(); + console.log('Version History:'); + 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)`); + } +} + +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(extension.reviewCount, 'review')}`; +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no'; +} 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 new file mode 100644 index 000000000..69ef67434 --- /dev/null +++ b/cli/test/unit/show.spec.ts @@ -0,0 +1,350 @@ +/****************************************************************************** + * 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[]; + versionRequests: 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 version-reference listing. */ +async function startRegistryStub( + metadata: { status?: number; body?: unknown } = {}, + versions: { status?: number; body?: unknown } = {} +): Promise { + const metadataRequests: 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 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 ?? (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, + versionRequests, + 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 }, + versions?: { status?: number; body?: unknown } + ): Promise { + const stub = await startRegistryStub(metadata, versions); + 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` or `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, + 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.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('1.1.0'); + }); + + // 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, + versions: [ + { version: '1.9.0' }, + { version: '1.10.0' }, + { 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')); + }); + + // 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 } }); + + await show({ extensionId: 'redhat.java', registryUrl: registry.url }); + + expect(output()).toContain('... and 3 more'); + }); + + it('lists every version with --all-versions', async () => { + 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 }); + + const printed = output(); + expect(printed).not.toContain('more (pass --all-versions'); + 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(); + + 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'); + }); + + // 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(); + + 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.versionRequests).toHaveLength(0); + }); + + // 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 listing 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:'); + }); +}); 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); });