Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This change log covers only the command line interface (CLI) of Open VSX.

#### Added

- Add `search` command to search the registry for extensions, mirroring the web UI's search: `--category`, `--target`, `--sort-by` and `--sort-order` narrow the query, `--size` and `--offset` page through the results, and `--json` prints the registry's raw response ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154))
- Add `list` command to print the extensions a namespace holds, sorted by name so the output stays stable across registries, with `--json` for the raw namespace metadata ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154))
- Add `show` command to print an extension's metadata, mirroring `vsce show`: identity, publisher, rating, notices and a version history listing each version's target platforms ([#2149](https://github.com/eclipse-openvsx/openvsx/issues/2149)). `namespace.extension@version` reports a single version, `--target` scopes the report to one target platform, `--all-versions` lists every published version instead of the most recent few, and `--json` prints the registry's raw metadata
- Add `unpublish` command to delete an extension or some of its versions, mirroring `vsce unpublish` ([#1958](https://github.com/eclipse-openvsx/openvsx/issues/1958)); requires a registry running version 1.2.0 or later, which `unpublish` checks for before deleting
- `publish` checks the packaged extension's size against the limit reported by the registry's `/api/version` endpoint before uploading, instead of failing only after the upload completes ([#1953](https://github.com/eclipse-openvsx/openvsx/issues/1953))
Expand Down
25 changes: 25 additions & 0 deletions cli/src/list-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

import { RegistryOptions } from './registry-options';

export interface ListOptions extends RegistryOptions {
/**
* Namespace whose extensions should be listed.
*/
namespace: string;
/**
* Print the raw namespace metadata as JSON instead of a list.
*/
json?: boolean;
}
48 changes: 48 additions & 0 deletions cli/src/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

import { ListOptions } from './list-options';
import { Registry } from './registry';
import { formatCount } from './table';
import { addEnvOptions } from './util';

/**
* Lists the extensions published in a namespace.
*/
export async function list(options: ListOptions): Promise<void> {
addEnvOptions(options);
const registry = new Registry(options);
const namespace = await registry.getNamespace(options.namespace);
if (namespace.error) {
throw new Error(namespace.error);
}

if (options.json) {
console.log(JSON.stringify(namespace, null, 4));
return;
}

// Sorted here rather than relying on the response's key order, so the output is stable and
// scriptable regardless of how the registry happens to serialise the map.
const names = Object.keys(namespace.extensions ?? {}).sort((a, b) => a.localeCompare(b));
const verified = namespace.verified ? ' (verified)' : '';
console.log(`${namespace.name}${verified} - ${formatCount(names.length, 'extension')}`);
if (names.length === 0) {
return;
}

console.log();
for (const name of names) {
console.log(` ${namespace.name}.${name}`);
}
}
31 changes: 30 additions & 1 deletion cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { createNamespace } from './create-namespace';
import { verifyPat } from './verify-pat';
import { publish } from './publish';
import { unpublish } from './unpublish';
import { handleError } from './util';
import { handleError, parseNonNegativeInt } from './util';
import { getExtension } from './get';
import { list } from './list';
import { DEFAULT_SEARCH_SIZE, SORT_KEYS, SORT_ORDERS, search } from './search';
import { show } from './show';
import { verify } from './verify';
import { verifySignature } from './verify-signature';
Expand Down Expand Up @@ -117,6 +119,33 @@ module.exports = function (argv: string[]): void {
}).catch(handleError(program.debug));
});

const searchCmd = program.command('search [text]');
searchCmd.description('Search the registry for extensions.')
.option('-c, --category <category>', 'Only return extensions in this category.')
.option('-t, --target <target>', 'Only return extensions built for this target architecture.')
.option(
'-s, --size <size>',
`Number of results to return (default ${DEFAULT_SEARCH_SIZE}).`,
parseNonNegativeInt
)
.option('-o, --offset <offset>', 'Index of the first result, for paging.', parseNonNegativeInt)
.option('--sort-by <key>', `Sort key: ${SORT_KEYS.join(', ')}.`)
.option('--sort-order <order>', `Sort order: ${SORT_ORDERS.join(', ')}.`)
.option('--json', 'Print the raw results as JSON.')
.action((text: string | undefined, { category, target, size, offset, sortBy, sortOrder, json }) => {
const { registryUrl } = program.opts();
search({ text, category, target, size, offset, sortBy, sortOrder, json, registryUrl })
.catch(handleError(program.debug));
});

const listCmd = program.command('list <namespace>');
listCmd.description('List the extensions published in a namespace.')
.option('--json', 'Print the raw namespace metadata as JSON.')
.action((namespace: string, { json }) => {
const { registryUrl } = program.opts();
list({ namespace, json, registryUrl }).catch(handleError(program.debug));
});

const showCmd = program.command('show <namespace.extension[@version]>');
showCmd.description('Show an extension\'s metadata.')
.option('-t, --target <target>', 'Only report on the given target architecture.')
Expand Down
79 changes: 79 additions & 0 deletions cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,46 @@ export class Registry {
}
}

/**
* Full-text search across the registry. Returns the purpose-built summary shape rather than
* whole extension records, so a page of results stays small.
*/
search(options: SearchQuery): Promise<SearchResult> {
try {
const query: Record<string, string> = {
size: String(options.size),
offset: String(options.offset)
};
if (options.query) {
query.query = options.query;
}
if (options.category) {
query.category = options.category;
}
if (options.targetPlatform) {
query.targetPlatform = options.targetPlatform;
}
if (options.sortBy) {
query.sortBy = options.sortBy;
}
if (options.sortOrder) {
query.sortOrder = options.sortOrder;
}
return this.getJson(this.getUrl(['api', '-', 'search'], query));
} catch (err) {
return rejectError(err);
}
}

/** Returns a namespace and the extensions published in it. */
getNamespace(namespace: string): Promise<Namespace> {
try {
return this.getJson(this.getUrl(['api', namespace]));
} catch (err) {
return rejectError(err);
}
}

download(file: string, url: URL): Promise<void> {
return new Promise((resolve, reject) => {
const stream = fs.createWriteStream(file);
Expand Down Expand Up @@ -405,6 +445,45 @@ export interface VersionReferences extends Response {
versions?: VersionReference[];
}

export interface SearchQuery {
query?: string;
category?: string;
targetPlatform?: string;
sortBy?: string;
sortOrder?: string;
size: number;
offset: number;
}

export interface SearchEntry {
url: string;
files: { [type: string]: string };
name: string;
namespace: string;
version: string;
timestamp: string;
verified?: boolean;
Comment thread
netomi marked this conversation as resolved.
averageRating?: number;
reviewCount?: number;
downloadCount: number;
displayName?: string;
description?: string;
deprecated?: boolean;
}

export interface SearchResult extends Response {
offset: number;
totalSize: number;
extensions?: SearchEntry[];
}

export interface Namespace extends Response {
name: string;
verified?: boolean;
// key: extension name, value: url
extensions?: { [name: string]: string };
}

export interface ExtensionReference {
url: string;
namespace: string;
Expand Down
49 changes: 49 additions & 0 deletions cli/src/search-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

import { RegistryOptions } from './registry-options';

export interface SearchOptions extends RegistryOptions {
/**
* Text to search for. Omit to browse, which is what makes `--category` useful on its own.
*/
text?: string;
/**
* Restrict results to a category, e.g. `Programming Languages`.
*/
category?: string;
/**
* Restrict results to extensions published for a target platform.
*/
target?: string;
/**
* Sort key: `relevance`, `timestamp`, `rating` or `downloadCount`.
*/
sortBy?: string;
/**
* `asc` or `desc`.
*/
sortOrder?: string;
/**
* Number of results to return.
*/
size?: number;
/**
* Index of the first result, for paging through a large result set.
*/
offset?: number;
/**
* Print the raw results as JSON instead of a table.
*/
json?: boolean;
}
95 changes: 95 additions & 0 deletions cli/src/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

import { Registry, SearchEntry } from './registry';
import { SearchOptions } from './search-options';
import { formatNumber, printRows, truncate } from './table';
import { addEnvOptions } from './util';

/** Sort keys the registry accepts, for an up-front check with a better message than a 400. */
export const SORT_KEYS = ['relevance', 'timestamp', 'rating', 'downloadCount'];

export const SORT_ORDERS = ['asc', 'desc'];

export const DEFAULT_SEARCH_SIZE = 20;

/** Keeps a row from wrapping on a normal terminal, where the other columns take about half of it. */
const DESCRIPTION_WIDTH = 60;

/**
* Searches the registry for extensions.
*/
export async function search(options: SearchOptions): Promise<void> {
addEnvOptions(options);
if (options.sortBy && !SORT_KEYS.includes(options.sortBy)) {
throw new Error(`Sort key must be one of ${SORT_KEYS.join(', ')}.`);
}
if (options.sortOrder && !SORT_ORDERS.includes(options.sortOrder)) {
throw new Error(`Sort order must be one of ${SORT_ORDERS.join(', ')}.`);
}

const size = options.size ?? DEFAULT_SEARCH_SIZE;
const offset = options.offset ?? 0;
const registry = new Registry(options);
const result = await registry.search({
query: options.text,
category: options.category,
targetPlatform: options.target,
sortBy: options.sortBy,
sortOrder: options.sortOrder,
size,
offset
});
if (result.error) {
throw new Error(result.error);
}

if (options.json) {
console.log(JSON.stringify(result, null, 4));
return;
}

printResults(result.extensions ?? [], result.totalSize, offset);
}

function printResults(entries: SearchEntry[], totalSize: number, offset: number): void {
if (entries.length === 0) {
console.log('No extensions found.');
return;
}

const rows = entries.map(entry => [
`${entry.namespace}.${entry.name}`,
entry.version,
formatNumber(entry.downloadCount),
entry.averageRating !== undefined ? entry.averageRating.toFixed(1) : '-',
describe(entry)
]);
printRows([['Extension', 'Version', 'Downloads', 'Rating', 'Description'], ...rows]);

const last = offset + entries.length;
console.log();
console.log(`Showing ${offset + 1}-${last} of ${formatNumber(totalSize)}.`);
if (last < totalSize) {
console.log(`Pass --offset ${last} for the next page.`);
}
}

/**
* The description, prefixed with anything a reader should weigh before installing. Deprecation is
* the one flag worth spending row width on here; `show` reports the rest.
*/
function describe(entry: SearchEntry): string {
const description = truncate(entry.description ?? '', DESCRIPTION_WIDTH);
return entry.deprecated ? `(deprecated) ${description}`.trimEnd() : description;
}
Loading