Skip to content

Commit

Permalink
Merge pull request #1012 from amvanbaren/feature/issue-990
Browse files Browse the repository at this point in the history
Add ovsx login equivalent to vsce login
  • Loading branch information
amvanbaren authored Oct 8, 2024
2 parents 1a5f05e + 7d4a0a8 commit 55eaf1a
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 42 deletions.
2 changes: 2 additions & 0 deletions .gitpod.dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM gitpod/workspace-postgres:latest

ENV NODE_VERSION=20.17.0

# the following env variable is solely here to invalidate the docker image. We want to rebuild the image from time to time to get the latest base image (which is cached).
ENV DOCKER_BUMP=2

Expand Down
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@
"is-ci": "^2.0.0",
"leven": "^3.1.0",
"semver": "^7.6.0",
"tmp": "^0.2.3"
"tmp": "^0.2.3",
"yauzl": "^3.1.3"
},
"devDependencies": {
"@types/follow-redirects": "^1.13.1",
"@types/is-ci": "^2.0.0",
"@types/node": "^20.14.8",
"@types/semver": "^7.5.8",
"@types/tmp": "^0.2.2",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"eslint": "^8.28.0",
Expand Down
7 changes: 3 additions & 4 deletions cli/src/create-namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
********************************************************************************/

import { Registry, RegistryOptions } from './registry';
import { addEnvOptions } from './util';
import { addEnvOptions, getPAT } from './util';

/**
* Creates a namespace (corresponds to `publisher` in package.json).
Expand All @@ -19,9 +19,8 @@ export async function createNamespace(options: CreateNamespaceOptions = {}): Pro
if (!options.name) {
throw new Error('The namespace name is mandatory.');
}
if (!options.pat) {
throw new Error("A personal access token must be given with the option '--pat'.");
}

options.pat = await getPAT(options.name, options, false);

const registry = new Registry(options);
const result = await registry.createNamespace(options.name, options.pat);
Expand Down
41 changes: 41 additions & 0 deletions cli/src/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/********************************************************************************
* Copyright (c) 2024 Precies. Software 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 { } from '@vscode/vsce';
import { addEnvOptions, getUserInput, requestPAT } from './util';
import { openDefaultStore } from './store';
import { RegistryOptions } from './registry';

export default async function login(options: LoginOptions) {
addEnvOptions(options);
if (!options.namespace) {
throw new Error('Missing namespace name.');
}

const store = await openDefaultStore();
let pat = store.get(options.namespace);
if (pat) {
console.log(`Namespace '${options.namespace}' is already known.`);
const answer = await getUserInput('Do you want to overwrite its PAT? [y/N] ');

if (!/^y$/i.test(answer)) {
throw new Error('Aborted.');
}
}

pat = await requestPAT(options.namespace, options);
await store.add(options.namespace, pat);
}

export interface LoginOptions extends RegistryOptions {
/**
* Name of the namespace.
*/
namespace?: string
}
15 changes: 15 additions & 0 deletions cli/src/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { openDefaultStore } from "./store";

export default async function logout(namespaceName: string) {
if (!namespaceName) {
throw new Error('Missing namespace name.');
}

const store = await openDefaultStore();
if (!store.get(namespaceName)) {
throw new Error(`Unknown namespace '${namespaceName}'.`);
}

await store.delete(namespaceName);
console.log(`\ud83d\ude80 ${namespaceName} removed from the list of known namespaces`);
}
17 changes: 16 additions & 1 deletion cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { verifyPat } from './verify-pat';
import { publish } from './publish';
import { handleError } from './util';
import { getExtension } from './get';
import login from './login';
import logout from './logout';

const pkg = require('../package.json');

Expand Down Expand Up @@ -76,7 +78,7 @@ module.exports = function (argv: string[]): void {

if (reasons.length > 0) {
const message = 'See the documentation for more information:\n'
+ 'https://github.com/eclipse/openvsx/wiki/Publishing-Extensions';
+ 'https://github.com/eclipse/openvsx/wiki/Publishing-Extensions';
const errorHandler = handleError(program.debug, message, false);
for (const reason of reasons) {
errorHandler(reason);
Expand All @@ -99,6 +101,19 @@ module.exports = function (argv: string[]): void {
.catch(handleError(program.debug));
});

const loginCmd = program.command('login <namespace>');
loginCmd.description('Adds a namespace to the list of known namespaces')
.action((namespace: string) => {
const { registryUrl, pat } = program.opts();
login({ namespace, registryUrl, pat }).catch(handleError(program.debug));
});

const logoutCmd = program.command('logout <namespace>');
logoutCmd.description('Removes a namespace from the list of known namespaces')
.action((namespace: string) => {
logout(namespace).catch(handleError(program.debug));
});

program
.command('*', '', { noHelp: true })
.action((cmd: commander.Command) => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/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, '18.0.0')) {
console.error('ovsx requires at least NodeJS version 18. Check your installed version with `node --version`.');
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`.');
process.exit(1);
}

Expand Down
32 changes: 17 additions & 15 deletions cli/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,29 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import { createVSIX, IPackageOptions } from '@vscode/vsce';
import { createTempFile, addEnvOptions } from './util';
import { createTempFile, addEnvOptions, getPAT } from './util';
import { Extension, Registry, RegistryOptions } from './registry';
import { checkLicense } from './check-license';
import { readVSIXPackage } from './zip';

/**
* Publishes an extension.
*/
export async function publish(options: PublishOptions = {}): Promise<PromiseSettledResult<void>[]> {
addEnvOptions(options);
const internalPublishOptions: InternalPublishOptions[] = [];
const packagePaths = options.packagePath || [undefined];
const targets = options.targets || [undefined];
for (const packagePath of packagePaths) {
for (const target of targets) {
internalPublishOptions.push({ ... options, packagePath: packagePath, target: target });
}
addEnvOptions(options);
const internalPublishOptions: InternalPublishOptions[] = [];
const packagePaths = options.packagePath || [undefined];
const targets = options.targets || [undefined];
for (const packagePath of packagePaths) {
for (const target of targets) {
internalPublishOptions.push({ ...options, packagePath: packagePath, target: target });
}
}

return Promise.allSettled(internalPublishOptions.map(publishOptions => doPublish(publishOptions)));
return Promise.allSettled(internalPublishOptions.map(publishOptions => doPublish(publishOptions)));
}

async function doPublish(options: InternalPublishOptions = {}): Promise<void> {
if (!options.pat) {
throw new Error("A personal access token must be given with the option '--pat'.");
}

// if the packagePath is a link to a vsix, don't need to package it
if (options.packagePath?.endsWith('.vsix')) {
options.extensionFile = options.packagePath;
Expand All @@ -48,6 +45,11 @@ async function doPublish(options: InternalPublishOptions = {}): Promise<void> {
console.warn("Ignoring option '--pre-release' for prepackaged extension.");
}

if (!options.pat) {
const namespace = (await readVSIXPackage(options.extensionFile!)).publisher;
options.pat = await getPAT(namespace, options);
}

let extension: Extension | undefined;
try {
extension = await registry.publish(options.extensionFile!, options.pat);
Expand Down Expand Up @@ -141,7 +143,7 @@ interface InternalPublishOptions extends PublishCommonOptions {
/**
* Whether to do dependency detection via npm or yarn
*/
dependencies?: boolean;
dependencies?: boolean;
}

async function packageExtension(options: InternalPublishOptions, registry: Registry): Promise<void> {
Expand Down
142 changes: 142 additions & 0 deletions cli/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';

interface StoreEntry {
name: string
value: string
}

export interface Store extends Iterable<StoreEntry> {
readonly size: number;
get(name: string): string | undefined;
add(name: string, value: string): Promise<void>;
delete(name: string): Promise<void>;
}

export class FileStore implements Store {
private static readonly DefaultPath = path.join(homedir(), '.ovsx');

static async open(path: string = FileStore.DefaultPath): Promise<FileStore> {
try {
const rawStore = await fs.promises.readFile(path, 'utf8');
return new FileStore(path, JSON.parse(rawStore).entries);
} catch (err: any) {
if (err.code === 'ENOENT') {
return new FileStore(path, []);
} else if (/SyntaxError/.test(err)) {
throw new Error(`Error parsing file store: ${path}.`);
}

throw err;
}
}

get size(): number {
return this.entries.length;
}

private constructor(readonly path: string, private entries: StoreEntry[]) { }

private async save(): Promise<void> {
await fs.promises.writeFile(this.path, JSON.stringify({ entries: this.entries }), { mode: '0600' });
}

async deleteStore(): Promise<void> {
try {
await fs.promises.unlink(this.path);
} catch {
// noop
}
}

get(name: string): string | undefined {
return this.entries.find(p => p.name === name)?.value;
}

async add(name: string, value: string): Promise<void> {
const newEntry: StoreEntry = { name, value };
this.entries = [...this.entries.filter(p => p.name !== name), newEntry];
await this.save();
}

async delete(name: string): Promise<void> {
this.entries = this.entries.filter(p => p.name !== name);
await this.save();
}

[Symbol.iterator]() {
return this.entries[Symbol.iterator]();
}
}

export class KeytarStore implements Store {
static async open(serviceName = 'ovsx'): Promise<KeytarStore> {
const keytar = await import('keytar');
const creds = await keytar.findCredentials(serviceName);

return new KeytarStore(
keytar,
serviceName,
creds.map(({ account, password }) => ({ name: account, value: password }))
);
}

get size(): number {
return this.entries.length;
}

private constructor(
private readonly keytar: typeof import('keytar'),
private readonly serviceName: string,
private entries: StoreEntry[]
) { }

get(name: string): string | undefined {
return this.entries.find(p => p.name === name)?.value;
}

async add(name: string, value: string): Promise<void> {
const newEntry: StoreEntry = { name, value };
this.entries = [...this.entries.filter(p => p.name !== name), newEntry];
await this.keytar.setPassword(this.serviceName, name, value);
}

async delete(name: string): Promise<void> {
this.entries = this.entries.filter(p => p.name !== name);
await this.keytar.deletePassword(this.serviceName, name);
}

[Symbol.iterator](): Iterator<StoreEntry, any, undefined> {
return this.entries[Symbol.iterator]();
}
}

export async function openDefaultStore(): Promise<Store> {
if (/^file$/i.test(process.env['VSCE_STORE'] ?? '')) {
return await FileStore.open();
}

let keytarStore: Store;
try {
keytarStore = await KeytarStore.open();
} catch (err) {
const store = await FileStore.open();
console.warn(`Failed to open credential store. Falling back to storing secrets clear-text in: ${store.path}.`);
return store;
}

const fileStore = await FileStore.open();

// migrate from file store
if (fileStore.size) {
for (const { name, value } of fileStore) {
await keytarStore.add(name, value);
}

await fileStore.deleteStore();
console.info(`Migrated ${fileStore.size} publishers to system credential manager. Deleted local store '${fileStore.path}'.`);
}

return keytarStore;
}
Loading

0 comments on commit 55eaf1a

Please sign in to comment.