Skip to content
Draft
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
6 changes: 6 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ This change log covers only the command line interface (CLI) of Open VSX.

### [next] (unreleased)

#### Breaking Changes

- The minimum version of Node.js required is now `22`, as `20` reached its end of life

#### Added

- Add an encrypted filestore as fallback to the system keychain if it cant be accessed ([#1950](https://github.com/eclipse/openvsx/pull/1950))
- Add `--allow-missing-repository` option to the `publish` command, passed on to `vsce` to package an extension whose `package.json` has no `repository` field without asking for confirmation ([#1735](https://github.com/eclipse-openvsx/openvsx/issues/1735))
- Add `publishVSIX` and `createVSIX` to the programmatic API, a `log` option to redirect or silence the output and an `interactive` option to control prompting ([#1591](https://github.com/eclipse-openvsx/openvsx/issues/1591))

#### Changed

- Replace `keytar` with `cross-keychain` to store credentials in the system keychain ([#1950](https://github.com/eclipse/openvsx/pull/1950))
- Mask token input when using `login` command ([#1966](https://github.com/eclipse-openvsx/openvsx/pull/1966)
- `publish` resolves with the published extensions instead of `void`, and no longer copies the environment into the options object of the caller ([#1591](https://github.com/eclipse-openvsx/openvsx/issues/1591))

#### Dependencies

Expand Down
36 changes: 36 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,39 @@ The `logout` command lets you remove a stored access token.
the name must correspond to the `publisher` of your extension.

By default `ovsx` stores access tokens in the operating system's credential manager (via [`cross-keychain`](https://www.npmjs.com/package/cross-keychain)), falling back to storing them as plaintext in the `~/.ovsx` file if the credential manager can't be used. You can also set the environment variable `OVSX_STORE=file` to force plaintext storage; this is strongly discouraged, as it leaves your tokens readable by anyone with access to your home directory.

### Programmatic API

Every command is available as a function, so `ovsx` can be scripted instead of shelled out to. This is
useful for publishing to Open VSX and the Visual Studio Marketplace from a single script:

```ts
import { createVSIX } from '@vscode/vsce';
import { publishVSIX } from 'ovsx';

const vsix = 'my-extension-1.0.0.vsix';
await createVSIX({ packagePath: vsix });
await publishVSIX(vsix, { pat: process.env.OVSX_PAT });
```

`publishVSIX` publishes packages that exist already and resolves with the metadata of the published
extensions, rejecting as soon as one of them fails. `createVSIX` packages an extension — delegating to
`vsce` and validating the license if the target registry requires one — and resolves with the path of
the package it wrote.

Unlike the command line interface, these two functions never ask for input: a missing access token is
an error rather than a prompt. Pass `interactive: true` to opt back into prompting.

Progress messages go to the console by default. Pass a `log` implementation to capture or silence
them (note that this covers the output of `ovsx` itself; `vsce` reports the progress of packaging on
its own):

```ts
import { publishVSIX, silentLogger } from 'ovsx';

await publishVSIX(vsix, { pat: process.env.OVSX_PAT, log: silentLogger });
```

The lower level building blocks are exported as well: `publish` (packages and publishes, reporting the
outcome of every package and target separately), `getExtension`, `createNamespace`, `verifyPat` and the
`Registry` class that wraps the registry API.
4 changes: 2 additions & 2 deletions cli/bin/ovsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

const semver = require('semver');

if (semver.lt(process.versions.node, '20.0.0')) {
console.error('ovsx requires at least NodeJS version 20. Check your installed version with `node --version`.');
if (semver.lt(process.versions.node, '22.0.0')) {
console.error('ovsx requires at least NodeJS version 22. Check your installed version with `node --version`.');
process.exit(1);
}

Expand Down
10 changes: 6 additions & 4 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"types": "lib/index",
"bin": "bin/ovsx",
"engines": {
"node": ">= 20"
"node": ">= 22"
},
"dependencies": {
"@inquirer/prompts": "^7.10.1",
Expand All @@ -51,7 +51,7 @@
"@stylistic/eslint-plugin": "^2.11.0",
"@types/follow-redirects": "^1.13.1",
"@types/is-ci": "^2.0.0",
"@types/node": "^20.14.8",
"@types/node": "^22.10.2",
"@types/semver": "^7.5.8",
"@types/tmp": "^0.2.2",
"@types/yauzl-promise": "^4",
Expand All @@ -60,14 +60,16 @@
"eslint": "^9.15.0",
"limiter": "^2.1.0",
"rimraf": "^6.0.1",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vitest": "^4.1.10"
},
"scripts": {
"clean": "rimraf lib",
"prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
"build": "tsc -p ./tsconfig.json && yarn run lint",
"watch": "tsc -w -p ./tsconfig.json",
"lint": "eslint -c ./configs/eslintrc.mjs src",
"test": "vitest run",
"lint": "eslint -c ./configs/eslintrc.mjs src test",
"prepare": "yarn run clean && yarn run prebuild && yarn run build",
"publish:next": "yarn npm publish --tag next",
"publish:latest": "yarn npm publish --tag latest",
Expand Down
118 changes: 118 additions & 0 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/********************************************************************************
* Copyright (c) 2026 Eclipse Foundation and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/

import { createVSIX as createVsceVSIX, IPackageOptions } from '@vscode/vsce';
import { checkLicense } from './check-license';
import { publishPackage } from './publish-package';
import { PublishCommonOptions } from './publish-options';
import { Extension, Registry } from './registry';
import { addEnvOptions, readManifest, validateManifest } from './util';

/**
* Options of {@link publishVSIX}.
*/
export type PublishVSIXOptions = Omit<PublishCommonOptions, 'extensionFile'>;

/**
* Options of {@link createVSIX}.
*/
export interface CreateVSIXOptions extends Omit<PublishCommonOptions, 'extensionFile'> {
/**
* The location of the extension to package. Defaults to the current working directory.
*/
packagePath?: string;
/**
* Where to write the package. Defaults to `NAME-VERSION.vsix` in {@link packagePath}.
*/
outputPath?: string;
/**
* Target architecture the package is built for.
*/
target?: string;
/**
* Whether to detect dependencies via npm or yarn.
*/
dependencies?: boolean;
}

/**
* Publishes extensions that are packaged already.
*
* Unlike {@link publish}, this rejects as soon as one of the packages cannot be published, and it
* never asks the user for input unless {@link PublishVSIXOptions.interactive} says so: a missing
* access token is an error rather than a prompt.
*
* @param packagePath path of the `.vsix` file to publish, or several of them
* @returns the published extensions, excluding the ones skipped as duplicates
*/
export async function publishVSIX(
packagePath: string | string[],
options: PublishVSIXOptions = {}
): Promise<Extension[]> {
const packagePaths = typeof packagePath === 'string' ? [packagePath] : packagePath;
// Work on a copy: the environment must not leak into the options object of the caller.
const resolvedOptions = { interactive: false, ...options };
addEnvOptions(resolvedOptions);

const published: Extension[] = [];
// Sequentially and failing fast: a caller of this API wants the first error, not a summary of
// everything that went wrong.
for (const extensionFile of packagePaths) {
const extension = await publishPackage(extensionFile, resolvedOptions);
if (extension) {
published.push(extension);
}
}

return published;
}

/**
* Packages an extension without publishing it, using `vsce` under the hood.
*
* Note that this validates the license of the extension when the target registry requires one, so
* the resulting package can be published to that registry. It never asks the user for input unless
* {@link CreateVSIXOptions.interactive} says so.
*
* @returns the path of the packaged extension
*/
export async function createVSIX(options: CreateVSIXOptions = {}): Promise<string> {
options = { interactive: false, ...options };
const manifest = await readManifest(options.packagePath);
validateManifest(manifest);

if (new Registry(options).requiresLicense) {
await checkLicense(options.packagePath ?? '.', options);
}

// vsce resolves a missing package path relative to its own cwd, so compute it here to be able to
// report back where the package ended up.
const outputPath = options.outputPath ?? defaultPackagePath(manifest.name, manifest.version, options);
const packageOptions: IPackageOptions = {
packagePath: outputPath,
target: options.target,
cwd: options.packagePath,
baseContentUrl: options.baseContentUrl,
baseImagesUrl: options.baseImagesUrl,
useYarn: options.yarn,
dependencies: options.dependencies,
preRelease: options.preRelease,
allowMissingRepository: options.allowMissingRepository,
version: options.packageVersion
};
await createVsceVSIX(packageOptions);

return outputPath;
}

function defaultPackagePath(name: string, version: string, options: CreateVSIXOptions): string {
const fileName = `${name}-${options.packageVersion ?? version}.vsix`;
return options.packagePath ? `${options.packagePath.replace(/[\\/]+$/, '')}/${fileName}` : fileName;
}
27 changes: 16 additions & 11 deletions cli/src/check-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { input, select } from '@inquirer/prompts';
import {
readManifest, writeManifest, Manifest, writeFile, validateManifest, promisify
} from './util';
import { Logger, consoleLogger } from './logger';
import { RegistryOptions } from './registry-options';

async function addLicense(packagePath: string, manifest: Manifest): Promise<void> {
console.log('Extension ' + manifest.publisher + '.' + manifest.name + ' has no license. All Open VSX '
async function addLicense(packagePath: string, manifest: Manifest, log: Logger): Promise<void> {
log.log('Extension ' + manifest.publisher + '.' + manifest.name + ' has no license. All Open VSX '
+ 'Registry Content Offerings must be licensed. You may choose to publish this extension under '
+ 'the MIT License (https://opensource.org/licenses/MIT). Please note you are responsible to '
+ 'ensure that you have the necessary rights to permit this extension to be made available under '
Expand All @@ -36,14 +38,14 @@ async function addLicense(packagePath: string, manifest: Manifest): Promise<void
});
switch (answer) {
case 'yes':
await useMITLicense(manifest, packagePath);
await useMITLicense(manifest, log, packagePath);
break;
case 'help':
console.log('If you select "yes" your extension will be published under the MIT License. '
log.log('If you select "yes" your extension will be published under the MIT License. '
+ 'You must enter the Copyright Year and Copyright Holder information. This information '
+ 'along with the text of the MIT License will be written to a LICENSE file and '
+ 'packaged with the uploaded extension.\n');
console.log(MIT_LICENSE_TEXT);
log.log(MIT_LICENSE_TEXT);
break;
case 'no':
throw new Error('This extension cannot be accepted because it has no license.');
Expand All @@ -70,15 +72,18 @@ export async function isLicenseOk(packagePath: string, manifest?: Manifest): Pro
return false;
}

export async function checkLicense(packagePath: string): Promise<void> {
export async function checkLicense(packagePath: string, options: RegistryOptions = {}): Promise<void> {
const manifest = await readManifest(packagePath);
if (!await isLicenseOk(packagePath, manifest) && !isCI) {
await addLicense(packagePath, manifest);
// Adding a license needs the user to accept it, so this is skipped whenever there is nobody to
// ask: the registry rejects the extension in that case, just like it does on CI today.
const canAskUser = !isCI && options.interactive !== false;
if (!await isLicenseOk(packagePath, manifest) && canAskUser) {
await addLicense(packagePath, manifest, options.log ?? consoleLogger);
}
}

async function useMITLicense(manifest: Manifest, packagePath?: string) {
console.log('Please enter a value for Copyright Year and Copyright Holder.\n'
async function useMITLicense(manifest: Manifest, log: Logger, packagePath?: string) {
log.log('Please enter a value for Copyright Year and Copyright Holder.\n'
+ 'Example: "Copyright 2020 John Doe"\n');
const copyright = await input({
message: 'Copyright',
Expand All @@ -88,7 +93,7 @@ async function useMITLicense(manifest: Manifest, packagePath?: string) {
await writeManifest(manifest, packagePath);
const license = MIT_LICENSE_TEXT.replace('<YEAR> <COPYRIGHT HOLDER>', copyright);
await writeFile('LICENSE', license, packagePath);
console.log('LICENSE file has been written. Please commit it to the source repository.');
log.log('LICENSE file has been written. Please commit it to the source repository.');
}

const LICENSE_FILE_NAMES = ['license.md', 'license', 'license.txt', 'licence.md', 'licence', 'licence.txt'];
Expand Down
5 changes: 4 additions & 1 deletion cli/src/create-namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import { CreateNamespaceOptions } from './create-namespace-options';
import { getPAT } from './pat';
import { Registry } from './registry';
import { addEnvOptions } from './util';
import { consoleLogger } from './logger';

/**
* Creates a namespace (corresponds to `publisher` in package.json).
*/
export async function createNamespace(options: CreateNamespaceOptions = {}): Promise<void> {
// Work on a copy: the environment must not leak into the options object of the caller.
options = { ...options };
addEnvOptions(options);
if (!options.name) {
throw new Error('The namespace name is mandatory.');
Expand All @@ -29,5 +32,5 @@ export async function createNamespace(options: CreateNamespaceOptions = {}): Pro
if (result.error) {
throw new Error(result.error);
}
console.log(`\ud83d\ude80 Created namespace ${options.name}`);
(options.log ?? consoleLogger).log(`\ud83d\ude80 Created namespace ${options.name}`);
}
16 changes: 10 additions & 6 deletions cli/src/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ import * as path from 'path';
import * as semver from 'semver';
import { Registry, Extension } from "./registry";
import { promisify, matchExtensionId, optionalStat, makeDirs, addEnvOptions, rejectError } from './util';
import { Logger, consoleLogger } from './logger';
import { GetOptions } from './get-options';

/**
* Downloads an extension or its metadata.
*/
export async function getExtension(options: GetOptions): Promise<void> {
// Work on a copy: the environment must not leak into the options object of the caller.
options = { ...options };
addEnvOptions(options);
const log = options.log ?? consoleLogger;
const registry = new Registry(options);
const match = matchExtensionId(options.extensionId);
if (!match) {
Expand All @@ -37,9 +41,9 @@ export async function getExtension(options: GetOptions): Promise<void> {
}

if (options.metadata) {
await printMetadata(registry, matchingVersion, options.output);
await printMetadata(registry, matchingVersion, log, options.output);
} else {
await download(registry, matchingVersion, options.output);
await download(registry, matchingVersion, log, options.output);
}
}

Expand All @@ -63,10 +67,10 @@ function isAlias(extension: Extension, version: string): boolean {
return extension.versionAlias.includes(version);
}

async function printMetadata(registry: Registry, extension: Extension, output?: string): Promise<void> {
async function printMetadata(registry: Registry, extension: Extension, log: Logger, output?: string): Promise<void> {
const metadata = JSON.stringify(extension, null, 4);
if (!output) {
console.log(metadata);
log.log(metadata);
return;
}
let filePath: string | undefined;
Expand All @@ -81,7 +85,7 @@ async function printMetadata(registry: Registry, extension: Extension, output?:
await promisify(fs.writeFile)(filePath, metadata);
}

async function download(registry: Registry, extension: Extension, output?: string): Promise<void> {
async function download(registry: Registry, extension: Extension, log: Logger, output?: string): Promise<void> {
const downloadUrl = extension.files.download;
if (!downloadUrl) {
throw new Error(`Extension ${extension.namespace}.${extension.name} does not provide a download URL.`);
Expand All @@ -101,6 +105,6 @@ async function download(registry: Registry, extension: Extension, output?: strin
}
await makeDirs(path.dirname(filePath));
const target = extension.targetPlatform !== 'universal' ? '@' + extension.targetPlatform : '';
console.log(`Downloading ${extension.namespace}.${extension.name}-${extension.version}${target} to ${filePath}`);
log.log(`Downloading ${extension.namespace}.${extension.name}-${extension.version}${target} to ${filePath}`);
await registry.download(filePath, new URL(downloadUrl));
}
Loading