diff --git a/src/manifest.ts b/src/manifest.ts index 058f6392..cb2d8234 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -64,14 +64,14 @@ export interface Contributions { export type ExtensionKind = 'ui' | 'workspace' | 'web'; -export interface Manifest { +export interface ManifestPackage { // mandatory (npm) name: string; version: string; - engines: { [name: string]: string }; + engines: { vscode: string;[name: string]: string }; // vscode - publisher: string; + publisher?: string; icon?: string; contributes?: Contributions; activationEvents?: string[]; @@ -125,3 +125,13 @@ export interface Manifest { // preferGlobal // publishConfig } + +export interface ManifestPublish extends ManifestPackage { + publisher: string; +} + +type RecursivePartial = { + [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P]; +}; + +export type UnverifiedManifest = RecursivePartial; diff --git a/src/nls.ts b/src/nls.ts index 9f358753..7e8dc874 100644 --- a/src/nls.ts +++ b/src/nls.ts @@ -1,4 +1,4 @@ -import { Manifest } from './manifest'; +import { ManifestPackage } from './manifest'; export interface ITranslations { [key: string]: string; @@ -27,7 +27,7 @@ function createPatcher(translations: ITranslations): (value: T) => T { }; } -export function patchNLS(manifest: Manifest, translations: ITranslations): Manifest { +export function patchNLS(manifest: ManifestPackage, translations: ITranslations): ManifestPackage { const patcher = createPatcher(translations); return JSON.parse(JSON.stringify(manifest, (_, value: any) => patcher(value))); } diff --git a/src/package.ts b/src/package.ts index 9849e6a3..83de1a3b 100644 --- a/src/package.ts +++ b/src/package.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { promisify } from 'util'; import * as cp from 'child_process'; import * as yazl from 'yazl'; -import { ExtensionKind, Manifest } from './manifest'; +import { ExtensionKind, ManifestPackage, UnverifiedManifest } from './manifest'; import { ITranslations, patchNLS } from './nls'; import * as util from './util'; import { glob } from 'glob'; @@ -57,7 +57,7 @@ export function read(file: IFile): Promise { } export interface IPackage { - manifest: Manifest; + manifest: ManifestPackage; packagePath: string; } @@ -171,7 +171,7 @@ export interface VSIX { id: string; displayName: string; version: string; - publisher: string; + publisher?: string; target?: string; engine: string; description: string; @@ -187,11 +187,11 @@ export interface VSIX { homepage?: string; github?: string; }; - galleryBanner: NonNullable; - badges?: Manifest['badges']; + galleryBanner: NonNullable; + badges?: ManifestPackage['badges']; githubMarkdown: boolean; enableMarketplaceQnA?: boolean; - customerQnALink?: Manifest['qna']; + customerQnALink?: ManifestPackage['qna']; extensionDependencies: string; extensionPack: string; extensionKind: string; @@ -204,7 +204,7 @@ export interface VSIX { } export class BaseProcessor implements IProcessor { - constructor(protected manifest: Manifest) { } + constructor(protected manifest: ManifestPackage) { } assets: IAsset[] = []; tags: string[] = []; vsix: VSIX = Object.create(null); @@ -217,13 +217,13 @@ export class BaseProcessor implements IProcessor { } // https://github.com/npm/cli/blob/latest/lib/utils/hosted-git-info-from-manifest.js -function getGitHost(manifest: Manifest): GitHost | undefined { +function getGitHost(manifest: ManifestPackage): GitHost | undefined { const url = getRepositoryUrl(manifest); return url ? GitHost.fromUrl(url, { noGitPlus: true }) : undefined; } // https://github.com/npm/cli/blob/latest/lib/repo.js -function getRepositoryUrl(manifest: Manifest, gitHost?: GitHost | null): string | undefined { +function getRepositoryUrl(manifest: ManifestPackage, gitHost?: GitHost | null): string | undefined { if (gitHost) { return gitHost.https(); } @@ -246,7 +246,7 @@ function getRepositoryUrl(manifest: Manifest, gitHost?: GitHost | null): string } // https://github.com/npm/cli/blob/latest/lib/bugs.js -function getBugsUrl(manifest: Manifest, gitHost: GitHost | undefined): string | undefined { +function getBugsUrl(manifest: ManifestPackage, gitHost: GitHost | undefined): string | undefined { if (manifest.bugs) { if (typeof manifest.bugs === 'string') { return manifest.bugs; @@ -267,7 +267,7 @@ function getBugsUrl(manifest: Manifest, gitHost: GitHost | undefined): string | } // https://github.com/npm/cli/blob/latest/lib/docs.js -function getHomepageUrl(manifest: Manifest, gitHost: GitHost | undefined): string | undefined { +function getHomepageUrl(manifest: ManifestPackage, gitHost: GitHost | undefined): string | undefined { if (manifest.homepage) { return manifest.homepage; } @@ -457,7 +457,7 @@ export const Targets = new Set([ ]); export class ManifestProcessor extends BaseProcessor { - constructor(manifest: Manifest, private readonly options: IPackageOptions = {}) { + constructor(manifest: ManifestPackage, private readonly options: IPackageOptions = {}) { super(manifest); const flags = ['Public']; @@ -489,7 +489,7 @@ export class ManifestProcessor extends BaseProcessor { let engineVersion: string; try { - const engineSemver = parseSemver(`vscode@${manifest.engines['vscode']}`); + const engineSemver = parseSemver(`vscode@${manifest.engines.vscode}`); engineVersion = engineSemver.version; } catch (err) { throw new Error('Failed to parse semver of engines.vscode'); @@ -498,7 +498,7 @@ export class ManifestProcessor extends BaseProcessor { if (target) { if (engineVersion !== 'latest' && !semver.satisfies(engineVersion, '>=1.61', { includePrerelease: true })) { throw new Error( - `Platform specific extension is supported by VS Code >=1.61. Current 'engines.vscode' is '${manifest.engines['vscode']}'.` + `Platform specific extension is supported by VS Code >=1.61. Current 'engines.vscode' is '${manifest.engines.vscode}'.` ); } if (!Targets.has(target)) { @@ -509,7 +509,7 @@ export class ManifestProcessor extends BaseProcessor { if (preRelease) { if (engineVersion !== 'latest' && !semver.satisfies(engineVersion, '>=1.63', { includePrerelease: true })) { throw new Error( - `Pre-release versions are supported by VS Code >=1.63. Current 'engines.vscode' is '${manifest.engines['vscode']}'.` + `Pre-release versions are supported by VS Code >=1.63. Current 'engines.vscode' is '${manifest.engines.vscode}'.` ); } } @@ -522,7 +522,7 @@ export class ManifestProcessor extends BaseProcessor { version: options.version && !(options.updatePackageJson ?? true) ? options.version : manifest.version, publisher: manifest.publisher, target, - engine: manifest.engines['vscode'], + engine: manifest.engines.vscode, description: manifest.description ?? '', pricing: manifest.pricing ?? 'Free', categories: (manifest.categories ?? []).join(','), @@ -738,7 +738,7 @@ export abstract class MarkdownProcessor extends BaseProcessor { protected filesProcessed: number = 0; constructor( - manifest: Manifest, + manifest: ManifestPackage, private name: string, filePath: string, private assetType: string, @@ -958,7 +958,7 @@ export abstract class MarkdownProcessor extends BaseProcessor { } export class ReadmeProcessor extends MarkdownProcessor { - constructor(manifest: Manifest, options: IPackageOptions = {}) { + constructor(manifest: ManifestPackage, options: IPackageOptions = {}) { super( manifest, 'README.md', @@ -977,7 +977,7 @@ export class ReadmeProcessor extends MarkdownProcessor { } export class ChangelogProcessor extends MarkdownProcessor { - constructor(manifest: Manifest, options: IPackageOptions = {}) { + constructor(manifest: ManifestPackage, options: IPackageOptions = {}) { super( manifest, 'CHANGELOG.md', @@ -1000,7 +1000,7 @@ export class LicenseProcessor extends BaseProcessor { private expectedLicenseName: string; filter: (name: string) => boolean; - constructor(manifest: Manifest, private readonly options: IPackageOptions = {}) { + constructor(manifest: ManifestPackage, private readonly options: IPackageOptions = {}) { super(manifest); const match = /^SEE LICENSE IN (.*)$/.exec(manifest.license || ''); @@ -1050,7 +1050,7 @@ export class LicenseProcessor extends BaseProcessor { class LaunchEntryPointProcessor extends BaseProcessor { private entryPoints: Set = new Set(); - constructor(manifest: Manifest) { + constructor(manifest: ManifestPackage) { super(manifest); if (manifest.main) { this.entryPoints.add(util.normalize(path.join('extension', this.appendJSExt(manifest.main)))); @@ -1086,7 +1086,7 @@ class IconProcessor extends BaseProcessor { private icon: string | undefined; private didFindIcon = false; - constructor(manifest: Manifest) { + constructor(manifest: ManifestPackage) { super(manifest); this.icon = manifest.icon && path.posix.normalize(util.filePathToVsixPath(manifest.icon)); @@ -1112,7 +1112,7 @@ class IconProcessor extends BaseProcessor { const ValidExtensionKinds = new Set(['ui', 'workspace']); -export function isWebKind(manifest: Manifest): boolean { +export function isWebKind(manifest: ManifestPackage): boolean { const extensionKind = getExtensionKind(manifest); return extensionKind.some(kind => kind === 'web'); } @@ -1129,7 +1129,7 @@ extensionPointExtensionKindsMap.set('markdown.markdownItPlugins', ['workspace', extensionPointExtensionKindsMap.set('html.customData', ['workspace', 'web']); extensionPointExtensionKindsMap.set('css.customData', ['workspace', 'web']); -function getExtensionKind(manifest: Manifest): ExtensionKind[] { +function getExtensionKind(manifest: ManifestPackage): ExtensionKind[] { const deduced = deduceExtensionKinds(manifest); // check the manifest @@ -1151,7 +1151,7 @@ function getExtensionKind(manifest: Manifest): ExtensionKind[] { return deduced; } -function deduceExtensionKinds(manifest: Manifest): ExtensionKind[] { +function deduceExtensionKinds(manifest: ManifestPackage): ExtensionKind[] { // Not an UI extension if it has main if (manifest.main) { if (manifest.browser) { @@ -1187,7 +1187,7 @@ function deduceExtensionKinds(manifest: Manifest): ExtensionKind[] { export class NLSProcessor extends BaseProcessor { private translations: { [path: string]: string } = Object.create(null); - constructor(manifest: Manifest) { + constructor(manifest: ManifestPackage) { super(manifest); if ( @@ -1266,30 +1266,21 @@ export class ValidationProcessor extends BaseProcessor { } } -export function validateManifest(manifest: Manifest): Manifest { - validateExtensionName(manifest.name); - validatePublisher(manifest.publisher); - - if (!manifest.version) { - throw new Error('Manifest missing field: version'); - } - - if (manifest.pricing && !['Free', 'Trial'].includes(manifest.pricing)) { - throw new Error('Pricing can only be "Free" or "Trial"'); - } - - validateVersion(manifest.version); +export function validateManifestForPackaging(manifest: UnverifiedManifest): ManifestPackage { if (!manifest.engines) { throw new Error('Manifest missing field: engines'); } + const engines = { ...manifest.engines, vscode: validateEngineCompatibility(manifest.engines.vscode) }; + const name = validateExtensionName(manifest.name); + const version = validateVersion(manifest.version); + // allow users to package an extension without a publisher for testing reasons + const publisher = manifest.publisher ? validatePublisher(manifest.publisher) : undefined; - if (!manifest.engines['vscode']) { - throw new Error('Manifest missing field: engines.vscode'); - } - const engineVersion = manifest.engines['vscode']; - validateEngineCompatibility(engineVersion); + if (manifest.pricing && !['Free', 'Trial'].includes(manifest.pricing)) { + throw new Error('Pricing can only be "Free" or "Trial"'); + } const hasActivationEvents = !!manifest.activationEvents; const hasImplicitLanguageActivationEvents = manifest.contributes?.languages; @@ -1305,7 +1296,7 @@ export function validateManifest(manifest: Manifest): Manifest { let parsedEngineVersion: string; try { - const engineSemver = parseSemver(`vscode@${engineVersion}`); + const engineSemver = parseSemver(`vscode@${engines.vscode}`); parsedEngineVersion = engineSemver.version; } catch (err) { throw new Error('Failed to parse semver of engines.vscode'); @@ -1313,7 +1304,7 @@ export function validateManifest(manifest: Manifest): Manifest { if ( hasActivationEvents || - ((engineVersion === '*' || semver.satisfies(parsedEngineVersion, '>=1.74', { includePrerelease: true })) && + ((engines.vscode === '*' || semver.satisfies(parsedEngineVersion, '>=1.74', { includePrerelease: true })) && hasImplicitActivationEvents) ) { if (!hasMain && !hasBrowser && (hasActivationEvents || !hasImplicitLanguageActivationEvents)) { @@ -1328,7 +1319,7 @@ export function validateManifest(manifest: Manifest): Manifest { } if (manifest.devDependencies && manifest.devDependencies['@types/vscode']) { - validateVSCodeTypesCompatibility(manifest.engines['vscode'], manifest.devDependencies['@types/vscode']); + validateVSCodeTypesCompatibility(engines.vscode, manifest.devDependencies['@types/vscode']); } if (/\.svg$/i.test(manifest.icon || '')) { @@ -1393,17 +1384,23 @@ export function validateManifest(manifest: Manifest): Manifest { } } - return manifest; + return { + ...manifest, + name, + version, + engines, + publisher, + }; } -export function readManifest(cwd = process.cwd(), nls = true): Promise { +export function readManifest(cwd = process.cwd(), nls = true): Promise { const manifestPath = path.join(cwd, 'package.json'); const manifestNLSPath = path.join(cwd, 'package.nls.json'); const manifest = fs.promises .readFile(manifestPath, 'utf8') .catch(() => Promise.reject(`Extension manifest not found: ${manifestPath}`)) - .then(manifestStr => { + .then(manifestStr => { try { return Promise.resolve(JSON.parse(manifestStr)); } catch (e) { @@ -1411,7 +1408,7 @@ export function readManifest(cwd = process.cwd(), nls = true): Promise throw e; } }) - .then(validateManifest); + .then(validateManifestForPackaging); if (!nls) { return manifest; @@ -1741,7 +1738,7 @@ export function processFiles(processors: IProcessor[], files: IFile[]): Promise< }); } -export function createDefaultProcessors(manifest: Manifest, options: IPackageOptions = {}): IProcessor[] { +export function createDefaultProcessors(manifest: ManifestPackage, options: IPackageOptions = {}): IProcessor[] { return [ new ManifestProcessor(manifest, options), new TagsProcessor(manifest), @@ -1755,7 +1752,7 @@ export function createDefaultProcessors(manifest: Manifest, options: IPackageOpt ]; } -export function collect(manifest: Manifest, options: IPackageOptions = {}): Promise { +export function collect(manifest: ManifestPackage, options: IPackageOptions = {}): Promise { const cwd = options.cwd || process.cwd(); const packagedDependencies = options.dependencyEntryPoints || undefined; const ignoreFile = options.ignoreFile || undefined; @@ -1795,7 +1792,7 @@ function writeVsix(files: IFile[], packagePath: string): Promise { ); } -function getDefaultPackageName(manifest: Manifest, options: IPackageOptions): string { +function getDefaultPackageName(manifest: ManifestPackage, options: IPackageOptions): string { let version = manifest.version; if (options.version && !(options.updatePackageJson ?? true)) { @@ -1809,7 +1806,7 @@ function getDefaultPackageName(manifest: Manifest, options: IPackageOptions): st return `${manifest.name}-${version}.vsix`; } -export async function prepublish(cwd: string, manifest: Manifest, useYarn?: boolean): Promise { +export async function prepublish(cwd: string, manifest: ManifestPackage, useYarn?: boolean): Promise { if (!manifest.scripts || !manifest.scripts['vscode:prepublish']) { return; } @@ -1828,7 +1825,7 @@ export async function prepublish(cwd: string, manifest: Manifest, useYarn?: bool }); } -async function getPackagePath(cwd: string, manifest: Manifest, options: IPackageOptions = {}): Promise { +async function getPackagePath(cwd: string, manifest: ManifestPackage, options: IPackageOptions = {}): Promise { if (!options.packagePath) { return path.join(cwd, getDefaultPackageName(manifest, options)); } @@ -1914,7 +1911,7 @@ export async function packageCommand(options: IPackageOptions = {}): Promise { /** * Prints the packaged files of an extension. And ensures .vscodeignore and files property in package.json are used correctly. */ -export async function printAndValidatePackagedFiles(files: IFile[], cwd: string, manifest: Manifest, options: IPackageOptions): Promise { +export async function printAndValidatePackagedFiles(files: IFile[], cwd: string, manifest: ManifestPackage, options: IPackageOptions): Promise { // Warn if the extension contains a lot of files const jsFiles = files.filter(f => /\.js$/i.test(f.path)); if (files.length > 5000 || jsFiles.length > 100) { diff --git a/src/publish.ts b/src/publish.ts index 68da2818..853371ef 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -6,7 +6,7 @@ import { pack, readManifest, versionBump, prepublish, signPackage, createSignatu import * as tmp from 'tmp'; import { IVerifyPatOptions, getPublisher } from './store'; import { getGalleryAPI, read, getPublishedUrl, log, getHubUrl, patchOptionsWithManifest } from './util'; -import { Manifest } from './manifest'; +import { ManifestPackage, ManifestPublish } from './manifest'; import { readVSIXPackage } from './zip'; import { validatePublisher } from './validation'; import { GalleryApi } from 'azure-devops-node-api/GalleryApi'; @@ -126,7 +126,7 @@ export async function publish(options: IPublishOptions = {}): Promise { } } - validateMarketplaceRequirements(vsix.manifest, options); + const manifestValidated = validateManifestForPublishing(vsix.manifest, options); let sigzipPath: string | undefined; if (options.manifestPath?.[index] && options.signaturePath?.[index]) { @@ -141,7 +141,7 @@ export async function publish(options: IPublishOptions = {}): Promise { sigzipPath = await signPackage(packagePath, options.signTool); } - await _publish(packagePath, sigzipPath, vsix.manifest, { ...options, target }); + await _publish(packagePath, sigzipPath, manifestValidated, { ...options, target }); } } else { const cwd = options.cwd || process.cwd(); @@ -149,7 +149,7 @@ export async function publish(options: IPublishOptions = {}): Promise { patchOptionsWithManifest(options, manifest); // Validate marketplace requirements before prepublish to avoid unnecessary work - validateMarketplaceRequirements(manifest, options); + validateManifestForPublishing(manifest, options); await prepublish(cwd, manifest, options.useYarn); await versionBump(options); @@ -158,14 +158,16 @@ export async function publish(options: IPublishOptions = {}): Promise { for (const target of options.targets) { const packagePath = await tmpName(); const packageResult = await pack({ ...options, target, packagePath }); + const manifestValidated = validateManifestForPublishing(packageResult.manifest, options); const sigzipPath = options.signTool ? await signPackage(packagePath, options.signTool) : undefined; - await _publish(packagePath, sigzipPath, packageResult.manifest, { ...options, target }); + await _publish(packagePath, sigzipPath, manifestValidated, { ...options, target }); } } else { const packagePath = await tmpName(); const packageResult = await pack({ ...options, packagePath }); + const manifestValidated = validateManifestForPublishing(packageResult.manifest, options); const sigzipPath = options.signTool ? await signPackage(packagePath, options.signTool) : undefined; - await _publish(packagePath, sigzipPath, packageResult.manifest, options); + await _publish(packagePath, sigzipPath, manifestValidated, options); } } } @@ -180,7 +182,7 @@ export interface IInternalPublishOptions { readonly skipDuplicate?: boolean; } -async function _publish(packagePath: string, sigzipPath: string | undefined, manifest: Manifest, options: IInternalPublishOptions) { +async function _publish(packagePath: string, sigzipPath: string | undefined, manifest: ManifestPublish, options: IInternalPublishOptions) { const pat = await getPAT(manifest.publisher, options); const api = await getGalleryAPI(pat); const packageStream = fs.createReadStream(packagePath); @@ -265,7 +267,7 @@ async function _publish(packagePath: string, sigzipPath: string | undefined, man log.done(`Published ${description}.`); } -async function _publishSignedPackage(api: GalleryApi, packageName: string, packageStream: fs.ReadStream, sigzipName: string, sigzipStream: fs.ReadStream, manifest: Manifest) { +async function _publishSignedPackage(api: GalleryApi, packageName: string, packageStream: fs.ReadStream, sigzipName: string, sigzipStream: fs.ReadStream, manifest: ManifestPublish) { const extensionType = 'Visual Studio Code'; const form = new FormData(); const lineBreak = '\r\n'; @@ -299,7 +301,7 @@ export async function unpublish(options: IUnpublishOptions = {}): Promise { [publisher, name] = options.id.split('.'); } else { const manifest = await readManifest(options.cwd); - publisher = manifest.publisher; + publisher = validatePublisher(manifest.publisher); name = manifest.name; } @@ -320,9 +322,7 @@ export async function unpublish(options: IUnpublishOptions = {}): Promise { log.done(`Deleted extension: ${fullName}!`); } -function validateMarketplaceRequirements(manifest: Manifest, options: IInternalPublishOptions) { - validatePublisher(manifest.publisher); - +function validateManifestForPublishing(manifest: ManifestPackage, options: IInternalPublishOptions): ManifestPublish { if (manifest.enableProposedApi && !options.allowAllProposedApis && !options.noVerify) { throw new Error( "Extensions using proposed API (enableProposedApi: true) can't be published to the Marketplace. Use --allow-all-proposed-apis to bypass. https://code.visualstudio.com/api/advanced-topics/using-proposed-api" @@ -338,6 +338,8 @@ function validateMarketplaceRequirements(manifest: Manifest, options: IInternalP if (semver.prerelease(manifest.version)) { throw new Error(`The VS Marketplace doesn't support prerelease versions: '${manifest.version}'. Checkout our pre-release versioning recommendation here: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions`); } + + return { ...manifest, publisher: validatePublisher(manifest.publisher) }; } export async function getPAT(publisher: string, options: IPublishOptions | IUnpublishOptions | IVerifyPatOptions): Promise { diff --git a/src/store.ts b/src/store.ts index 7c5182e9..2f86d377 100644 --- a/src/store.ts +++ b/src/store.ts @@ -121,7 +121,7 @@ export interface IVerifyPatOptions { } export async function verifyPat(options: IVerifyPatOptions): Promise { - const publisherName = options.publisherName ?? (await readManifest()).publisher; + const publisherName = options.publisherName ?? validatePublisher((await readManifest()).publisher); const pat = await getPAT(publisherName, options); try { diff --git a/src/test/package.test.ts b/src/test/package.test.ts index 52d206a6..e3d1ef6e 100644 --- a/src/test/package.test.ts +++ b/src/test/package.test.ts @@ -8,14 +8,14 @@ import { createDefaultProcessors, toVsixManifest, IFile, - validateManifest, + validateManifestForPackaging, IPackageOptions, ManifestProcessor, versionBump, VSIX, LicenseProcessor, } from '../package'; -import { Manifest } from '../manifest'; +import { ManifestPackage } from '../manifest'; import * as path from 'path'; import * as fs from 'fs'; import * as assert from 'assert'; @@ -48,7 +48,7 @@ async function throws(fn: () => Promise): Promise { const fixture = (name: string) => path.join(path.dirname(path.dirname(__dirname)), 'src', 'test', 'fixtures', name); -function _toVsixManifest(manifest: Manifest, files: IFile[], options: IPackageOptions = {}): Promise { +function _toVsixManifest(manifest: ManifestPackage, files: IFile[], options: IPackageOptions = {}): Promise { const processors = createDefaultProcessors(manifest, options); return processFiles(processors, files).then(() => { const assets = flatten(processors.map(p => p.assets)); @@ -59,7 +59,7 @@ function _toVsixManifest(manifest: Manifest, files: IFile[], options: IPackageOp }); } -async function toXMLManifest(manifest: Manifest, files: IFile[] = []): Promise { +async function toXMLManifest(manifest: ManifestPackage, files: IFile[] = []): Promise { const raw = await _toVsixManifest(manifest, files); return parseXmlManifest(raw); } @@ -77,7 +77,7 @@ function assertMissingProperty(manifest: XMLManifest, name: string): void { assert.strictEqual(property.length, 0, `Property '${name}' should not exist`); } -function createManifest(extra: Partial = {}): Manifest { +function createManifest(extra: Partial = {}): ManifestPackage { return { name: 'test', publisher: 'mocha', @@ -287,46 +287,47 @@ describe('readManifest', () => { describe('validateManifest', () => { it('should catch missing fields', () => { - assert.ok(validateManifest({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: '0.10.1' } })); + assert.ok(validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: '0.10.1' } })); assert.throws(() => { - validateManifest({ publisher: 'demo', name: null!, version: '1.0.0', engines: { vscode: '0.10.1' } }); + validateManifestForPackaging({ publisher: 'demo', name: null!, version: '1.0.0', engines: { vscode: '0.10.1' } }); }); assert.throws(() => { - validateManifest({ publisher: 'demo', name: 'demo', version: null!, engines: { vscode: '0.10.1' } }); + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: null!, engines: { vscode: '0.10.1' } }); }); assert.throws(() => { - validateManifest({ publisher: 'demo', name: 'demo', version: '1.0', engines: { vscode: '0.10.1' } }); + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0', engines: { vscode: '0.10.1' } }); }); assert.throws(() => { - validateManifest({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: null! }); + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: null! }); }); assert.throws(() => { - validateManifest({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: null } as any }); + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: null } as any }); }); validatePublisher('demo'); - assert.throws(() => validatePublisher(undefined!)); + assert.throws(() => validatePublisher(undefined)); + assert.ok(validateManifestForPackaging({ publisher: undefined, name: 'demo', version: '1.0.0', engines: { vscode: '0.10.1' } })); }); it('should prevent SVG icons', () => { - assert.ok(validateManifest(createManifest({ icon: 'icon.png' }))); + assert.ok(validateManifestForPackaging(createManifest({ icon: 'icon.png' }))); assert.throws(() => { - validateManifest(createManifest({ icon: 'icon.svg' })); + validateManifestForPackaging(createManifest({ icon: 'icon.svg' })); }); }); it('should prevent badges from non HTTPS sources', () => { assert.throws(() => { - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'relative.png', href: 'http://badgeurl', description: 'this is a badge' }] }) ); }); assert.throws(() => { - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'relative.svg', href: 'http://badgeurl', description: 'this is a badge' }] }) ); }); assert.throws(() => { - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'http://badgeurl.png', href: 'http://badgeurl', description: 'this is a badge' }], }) @@ -336,7 +337,7 @@ describe('validateManifest', () => { it('should allow non SVG badges', () => { assert.ok( - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'https://host/badge.png', href: 'http://badgeurl', description: 'this is a badge' }], }) @@ -346,7 +347,7 @@ describe('validateManifest', () => { it('should allow SVG badges from trusted sources', () => { assert.ok( - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'https://gemnasium.com/foo.svg', href: 'http://badgeurl', description: 'this is a badge' }], }) @@ -357,7 +358,7 @@ describe('validateManifest', () => { it('should prevent SVG badges from non trusted sources', () => { assert.throws(() => { assert.ok( - validateManifest( + validateManifestForPackaging( createManifest({ badges: [{ url: 'https://github.com/foo.svg', href: 'http://badgeurl', description: 'this is a badge' }], }) @@ -366,7 +367,7 @@ describe('validateManifest', () => { }); assert.throws(() => { assert.ok( - validateManifest( + validateManifestForPackaging( createManifest({ badges: [ { @@ -382,46 +383,46 @@ describe('validateManifest', () => { }); it('should validate activationEvents against main and browser', () => { - assert.throws(() => validateManifest(createManifest({ activationEvents: ['any'] }))); - assert.throws(() => validateManifest(createManifest({ main: 'main.js' }))); - assert.throws(() => validateManifest(createManifest({ browser: 'browser.js' }))); - assert.throws(() => validateManifest(createManifest({ main: 'main.js', browser: 'browser.js' }))); - validateManifest(createManifest({ activationEvents: ['any'], main: 'main.js' })); - validateManifest(createManifest({ activationEvents: ['any'], browser: 'browser.js' })); - validateManifest(createManifest({ activationEvents: ['any'], main: 'main.js', browser: 'browser.js' })); + assert.throws(() => validateManifestForPackaging(createManifest({ activationEvents: ['any'] }))); + assert.throws(() => validateManifestForPackaging(createManifest({ main: 'main.js' }))); + assert.throws(() => validateManifestForPackaging(createManifest({ browser: 'browser.js' }))); + assert.throws(() => validateManifestForPackaging(createManifest({ main: 'main.js', browser: 'browser.js' }))); + validateManifestForPackaging(createManifest({ activationEvents: ['any'], main: 'main.js' })); + validateManifestForPackaging(createManifest({ activationEvents: ['any'], browser: 'browser.js' })); + validateManifestForPackaging(createManifest({ activationEvents: ['any'], main: 'main.js', browser: 'browser.js' })); }); it('should validate extensionKind', () => { - assert.throws(() => validateManifest(createManifest({ extensionKind: ['web'] }))); - assert.throws(() => validateManifest(createManifest({ extensionKind: 'web' }))); - assert.throws(() => validateManifest(createManifest({ extensionKind: ['workspace', 'ui', 'web'] }))); - assert.throws(() => validateManifest(createManifest({ extensionKind: ['workspace', 'web'] }))); - assert.throws(() => validateManifest(createManifest({ extensionKind: ['ui', 'web'] }))); - assert.throws(() => validateManifest(createManifest({ extensionKind: ['any'] }))); - validateManifest(createManifest({ extensionKind: 'ui' })); - validateManifest(createManifest({ extensionKind: ['ui'] })); - validateManifest(createManifest({ extensionKind: 'workspace' })); - validateManifest(createManifest({ extensionKind: ['workspace'] })); - validateManifest(createManifest({ extensionKind: ['ui', 'workspace'] })); - validateManifest(createManifest({ extensionKind: ['workspace', 'ui'] })); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: ['web'] }))); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: 'web' }))); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: ['workspace', 'ui', 'web'] }))); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: ['workspace', 'web'] }))); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: ['ui', 'web'] }))); + assert.throws(() => validateManifestForPackaging(createManifest({ extensionKind: ['any'] }))); + validateManifestForPackaging(createManifest({ extensionKind: 'ui' })); + validateManifestForPackaging(createManifest({ extensionKind: ['ui'] })); + validateManifestForPackaging(createManifest({ extensionKind: 'workspace' })); + validateManifestForPackaging(createManifest({ extensionKind: ['workspace'] })); + validateManifestForPackaging(createManifest({ extensionKind: ['ui', 'workspace'] })); + validateManifestForPackaging(createManifest({ extensionKind: ['workspace', 'ui'] })); }); it('should validate sponsor', () => { - assert.throws(() => validateManifest(createManifest({ sponsor: { url: 'hello' } }))); - assert.throws(() => validateManifest(createManifest({ sponsor: { url: 'www.foo.com' } }))); - validateManifest(createManifest({ sponsor: { url: 'https://foo.bar' } })); - validateManifest(createManifest({ sponsor: { url: 'http://www.foo.com' } })); + assert.throws(() => validateManifestForPackaging(createManifest({ sponsor: { url: 'hello' } }))); + assert.throws(() => validateManifestForPackaging(createManifest({ sponsor: { url: 'www.foo.com' } }))); + validateManifestForPackaging(createManifest({ sponsor: { url: 'https://foo.bar' } })); + validateManifestForPackaging(createManifest({ sponsor: { url: 'http://www.foo.com' } })); }); it('should validate pricing', () => { - assert.throws(() => validateManifest(createManifest({ pricing: 'Paid' }))); - validateManifest(createManifest({ pricing: 'Trial' })); - validateManifest(createManifest({ pricing: 'Free' })); - validateManifest(createManifest()); + assert.throws(() => validateManifestForPackaging(createManifest({ pricing: 'Paid' }))); + validateManifestForPackaging(createManifest({ pricing: 'Trial' })); + validateManifestForPackaging(createManifest({ pricing: 'Free' })); + validateManifestForPackaging(createManifest()); }); it('should allow implicit activation events', () => { - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '>=1.74.0' }, main: 'main.js', @@ -436,7 +437,7 @@ describe('validateManifest', () => { }) ); - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '*' }, main: 'main.js', @@ -451,7 +452,7 @@ describe('validateManifest', () => { }) ); - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '>=1.74.0' }, contributes: { @@ -465,7 +466,7 @@ describe('validateManifest', () => { ); assert.throws(() => - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '>=1.73.3' }, main: 'main.js', @@ -474,7 +475,7 @@ describe('validateManifest', () => { ); assert.throws(() => - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '>=1.73.3' }, activationEvents: ['*'], @@ -483,7 +484,7 @@ describe('validateManifest', () => { ); assert.throws(() => - validateManifest( + validateManifestForPackaging( createManifest({ engines: { vscode: '>=1.73.3' }, main: 'main.js', @@ -1571,7 +1572,7 @@ describe('toVsixManifest', () => { }); it('should not have empty keywords #114', () => { - const manifest: Manifest = { + const manifest: ManifestPackage = { name: 'test', publisher: 'mocha', version: '0.0.1', @@ -1879,7 +1880,7 @@ describe('toVsixManifest', () => { it('should add sponsor link property', () => { const sponsor = { url: 'https://foo.bar' }; - const manifest: Manifest = { + const manifest: ManifestPackage = { name: 'test', publisher: 'mocha', version: '0.0.1', diff --git a/src/util.ts b/src/util.ts index 7077d763..bccc7374 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,7 +6,7 @@ import { IGalleryApi, GalleryApi } from 'azure-devops-node-api/GalleryApi'; import chalk from 'chalk'; import { PublicGalleryAPI } from './publicgalleryapi'; import { ISecurityRolesApi } from 'azure-devops-node-api/SecurityRolesApi'; -import { Manifest } from './manifest'; +import { ManifestPackage } from './manifest'; import { EOL } from 'os'; const __read = promisify<_read.Options, string>(_read); @@ -171,7 +171,7 @@ export const log = { error: _log.bind(null, LogMessageType.ERROR) as LogFn, }; -export function patchOptionsWithManifest(options: any, manifest: Manifest): void { +export function patchOptionsWithManifest(options: any, manifest: ManifestPackage): void { if (!manifest.vsce) { return; } diff --git a/src/validation.ts b/src/validation.ts index 12e2a655..37f29b74 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -3,7 +3,7 @@ import parseSemver from 'parse-semver'; const nameRegex = /^[a-z0-9][a-z0-9\-]*$/i; -export function validatePublisher(publisher: string): void { +export function validatePublisher(publisher: string | undefined): string { if (!publisher) { throw new Error( `Missing publisher name. Learn more: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#publishing-extensions` @@ -15,9 +15,11 @@ export function validatePublisher(publisher: string): void { `Invalid publisher name '${publisher}'. Expected the identifier of a publisher, not its human-friendly name. Learn more: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#publishing-extensions` ); } + + return publisher; } -export function validateExtensionName(name: string): void { +export function validateExtensionName(name: string | undefined): string { if (!name) { throw new Error(`Missing extension name`); } @@ -25,9 +27,11 @@ export function validateExtensionName(name: string): void { if (!nameRegex.test(name)) { throw new Error(`Invalid extension name '${name}'`); } + + return name; } -export function validateVersion(version: string): void { +export function validateVersion(version: string | undefined): string { if (!version) { throw new Error(`Missing extension version`); } @@ -35,9 +39,11 @@ export function validateVersion(version: string): void { if (!semver.valid(version)) { throw new Error(`Invalid extension version '${version}'`); } + + return version; } -export function validateEngineCompatibility(version: string): void { +export function validateEngineCompatibility(version: string | undefined): string { if (!version) { throw new Error(`Missing vscode engine compatibility version`); } @@ -45,6 +51,8 @@ export function validateEngineCompatibility(version: string): void { if (!/^\*$|^(\^|>=)?((\d+)|x)\.((\d+)|x)\.((\d+)|x)(\-.*)?$/.test(version)) { throw new Error(`Invalid vscode engine compatibility version '${version}'`); } + + return version; } /** diff --git a/src/zip.ts b/src/zip.ts index cf7c1b08..cb68d9b5 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -1,8 +1,9 @@ import { Entry, open, ZipFile } from 'yauzl'; -import { Manifest } from './manifest'; +import { ManifestPackage, UnverifiedManifest } from './manifest'; import { parseXmlManifest, XMLManifest } from './xml'; import { Readable } from 'stream'; import { filePathToVsixPath } from './util'; +import { validateManifestForPackaging } from './package'; async function bufferStream(stream: Readable): Promise { return await new Promise((c, e) => { @@ -46,7 +47,7 @@ export async function readZip(packagePath: string, filter: (name: string) => boo }); } -export async function readVSIXPackage(packagePath: string): Promise<{ manifest: Manifest; xmlManifest: XMLManifest }> { +export async function readVSIXPackage(packagePath: string): Promise<{ manifest: ManifestPackage; xmlManifest: XMLManifest }> { const map = await readZip(packagePath, name => /^extension\/package\.json$|^extension\.vsixmanifest$/i.test(name)); const rawManifest = map.get(filePathToVsixPath('package.json')); @@ -60,8 +61,16 @@ export async function readVSIXPackage(packagePath: string): Promise<{ manifest: throw new Error('VSIX manifest not found'); } + const manifest = JSON.parse(rawManifest.toString('utf8')) as UnverifiedManifest; + let manifestValidated; + try { + manifestValidated = validateManifestForPackaging(manifest); + } catch (error) { + throw new Error(`Invalid extension VSIX manifest: ${error}`); + } + return { - manifest: JSON.parse(rawManifest.toString('utf8')), + manifest: manifestValidated, xmlManifest: await parseXmlManifest(rawXmlManifest.toString('utf8')), }; }