diff --git a/Source/manifest.ts b/Source/manifest.ts index e1c8fa00..9737c856 100644 --- a/Source/manifest.ts +++ b/Source/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/Source/nls.ts b/Source/nls.ts index 33b0e1f2..eed6b44b 100644 --- a/Source/nls.ts +++ b/Source/nls.ts @@ -1,4 +1,4 @@ -import { Manifest } from "./manifest"; +import { ManifestPackage } from './manifest'; export interface ITranslations { [key: string]: string; @@ -27,10 +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/Source/publish.ts b/Source/publish.ts index d11e9941..d6723ed2 100644 --- a/Source/publish.ts +++ b/Source/publish.ts @@ -1,36 +1,19 @@ -import * as fs from "fs"; -import { promisify } from "util"; -import * as semver from "semver"; -import { - ExtensionQueryFlags, - PublishedExtension, -} from "azure-devops-node-api/interfaces/GalleryInterfaces"; -import { - pack, - readManifest, - versionBump, - prepublish, - signPackage, - createSignatureArchive, -} from "./package"; -import * as tmp from "tmp"; -import { IVerifyPatOptions, getPublisher } from "./store"; -import { - getGalleryAPI, - read, - getPublishedUrl, - log, - getHubUrl, - patchOptionsWithManifest, -} from "./util"; -import { Manifest } from "./manifest"; -import { readVSIXPackage } from "./zip"; -import { validatePublisher } from "./validation"; -import { GalleryApi } from "azure-devops-node-api/GalleryApi"; -import FormData from "form-data"; -import { basename } from "path"; -import { IterableBackoff, handleWhen, retry } from "cockatiel"; -import { getAzureCredentialAccessToken } from "./auth"; +import * as fs from 'fs'; +import { promisify } from 'util'; +import * as semver from 'semver'; +import { ExtensionQueryFlags, PublishedExtension } from 'azure-devops-node-api/interfaces/GalleryInterfaces'; +import { pack, readManifest, versionBump, prepublish, signPackage, createSignatureArchive } from './package'; +import * as tmp from 'tmp'; +import { IVerifyPatOptions, getPublisher } from './store'; +import { getGalleryAPI, read, getPublishedUrl, log, getHubUrl, patchOptionsWithManifest } from './util'; +import { ManifestPackage, ManifestPublish } from './manifest'; +import { readVSIXPackage } from './zip'; +import { validatePublisher } from './validation'; +import { GalleryApi } from 'azure-devops-node-api/GalleryApi'; +import FormData from 'form-data'; +import { basename } from 'path'; +import { IterableBackoff, handleWhen, retry } from 'cockatiel'; +import { getAzureCredentialAccessToken } from './auth'; const tmpName = promisify(tmp.tmpName); @@ -154,7 +137,7 @@ export async function publish(options: IPublishOptions = {}): Promise { } } - validateMarketplaceRequirements(vsix.manifest, options); + const manifestValidated = validateManifestForPublishing(vsix.manifest, options); let sigzipPath: string | undefined; if ( @@ -175,10 +158,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(); @@ -186,7 +166,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); @@ -194,33 +174,17 @@ export async function publish(options: IPublishOptions = {}): Promise { if (options.targets) { for (const target of options.targets) { const packagePath = await tmpName(); - const packageResult = await pack({ - ...options, - target, - packagePath, - }); - const sigzipPath = options.signTool - ? await signPackage(packagePath, options.signTool) - : undefined; - await _publish( - packagePath, - sigzipPath, - packageResult.manifest, - { ...options, target }, - ); + 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, manifestValidated, { ...options, target }); } } else { const packagePath = await tmpName(); const packageResult = await pack({ ...options, packagePath }); - const sigzipPath = options.signTool - ? await signPackage(packagePath, options.signTool) - : undefined; - await _publish( - packagePath, - sigzipPath, - packageResult.manifest, - options, - ); + const manifestValidated = validateManifestForPublishing(packageResult.manifest, options); + const sigzipPath = options.signTool ? await signPackage(packagePath, options.signTool) : undefined; + await _publish(packagePath, sigzipPath, manifestValidated, options); } } } @@ -235,12 +199,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); @@ -351,15 +310,8 @@ async function _publish( log.done(`Published ${description}.`); } -async function _publishSignedPackage( - api: GalleryApi, - packageName: string, - packageStream: fs.ReadStream, - sigzipName: string, - sigzipStream: fs.ReadStream, - manifest: Manifest, -) { - const extensionType = "Visual Studio Code"; +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"; form.setBoundary("0f411892-ef48-488f-89d3-4f0546e84723"); @@ -401,7 +353,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; } @@ -424,17 +376,8 @@ export async function unpublish(options: IUnpublishOptions = {}): Promise { log.done(`Deleted extension: ${fullName}!`); } -function validateMarketplaceRequirements( - manifest: Manifest, - options: IInternalPublishOptions, -) { - validatePublisher(manifest.publisher); - - if ( - manifest.enableProposedApi && - !options.allowAllProposedApis && - !options.noVerify - ) { +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", ); @@ -458,6 +401,8 @@ function validateMarketplaceRequirements( `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( diff --git a/Source/store.ts b/Source/store.ts index c9a18de0..f3e6ae00 100644 --- a/Source/store.ts +++ b/Source/store.ts @@ -149,8 +149,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/Source/util.ts b/Source/util.ts index 9f4f6d73..b7ec8b66 100644 --- a/Source/util.ts +++ b/Source/util.ts @@ -1,13 +1,13 @@ -import { promisify } from "util"; -import * as fs from "fs"; -import _read from "read"; -import { WebApi, getBasicHandler } from "azure-devops-node-api/WebApi"; -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 { EOL } from "os"; +import { promisify } from 'util'; +import * as fs from 'fs'; +import _read from 'read'; +import { WebApi, getBasicHandler } from 'azure-devops-node-api/WebApi'; +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 { ManifestPackage } from './manifest'; +import { EOL } from 'os'; const __read = promisify<_read.Options, string>(_read); export function read( @@ -201,10 +201,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/Source/validation.ts b/Source/validation.ts index f76f301d..616afe00 100644 --- a/Source/validation.ts +++ b/Source/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`); } @@ -49,6 +55,8 @@ export function validateEngineCompatibility(version: string): void { `Invalid vscode engine compatibility version '${version}'`, ); } + + return version; } /** diff --git a/src/package.ts b/src/package.ts new file mode 100644 index 00000000..83de1a3b --- /dev/null +++ b/src/package.ts @@ -0,0 +1,2038 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import * as cp from 'child_process'; +import * as yazl from 'yazl'; +import { ExtensionKind, ManifestPackage, UnverifiedManifest } from './manifest'; +import { ITranslations, patchNLS } from './nls'; +import * as util from './util'; +import { glob } from 'glob'; +import minimatch from 'minimatch'; +import markdownit from 'markdown-it'; +import * as cheerio from 'cheerio'; +import * as url from 'url'; +import mime from 'mime'; +import * as semver from 'semver'; +import urljoin from 'url-join'; +import chalk from 'chalk'; +import { + validateExtensionName, + validateVersion, + validateEngineCompatibility, + validateVSCodeTypesCompatibility, + validatePublisher, +} from './validation'; +import { detectYarn, getDependencies } from './npm'; +import * as GitHost from 'hosted-git-info'; +import parseSemver from 'parse-semver'; +import * as jsonc from 'jsonc-parser'; +import * as vsceSign from '@vscode/vsce-sign'; + +const MinimatchOptions: minimatch.IOptions = { dot: true }; + +export interface IInMemoryFile { + path: string; + mode?: number; + readonly contents: Buffer | string; +} + +export interface ILocalFile { + path: string; + mode?: number; + readonly localPath: string; +} + +export type IFile = IInMemoryFile | ILocalFile; + +function isInMemoryFile(file: IFile): file is IInMemoryFile { + return !!(file as IInMemoryFile).contents; +} + +export function read(file: IFile): Promise { + if (isInMemoryFile(file)) { + return Promise.resolve(file.contents).then(b => (typeof b === 'string' ? b : b.toString('utf8'))); + } else { + return fs.promises.readFile(file.localPath, 'utf8'); + } +} + +export interface IPackage { + manifest: ManifestPackage; + packagePath: string; +} + +export interface IPackageResult extends IPackage { + files: IFile[]; +} + +export interface IAsset { + type: string; + path: string; +} + +/** + * Options for the `createVSIX` function. + * @public + */ +export interface IPackageOptions { + /** + * The destination of the packaged the VSIX. + * + * Defaults to `NAME-VERSION.vsix`. + */ + readonly packagePath?: string; + readonly version?: string; + + /** + * Optional target the extension should run on. + * + * https://code.visualstudio.com/api/working-with-extensions/publishing-extension#platformspecific-extensions + */ + readonly target?: string; + + /** + * Ignore all files inside folders named as other targets. Only relevant when + * `target` is set. For example, if `target` is `linux-x64` and there are + * folders named `win32-x64`, `darwin-arm64` or `web`, the files inside + * those folders will be ignored. + * + * @default false + */ + readonly ignoreOtherTargetFolders?: boolean; + + readonly commitMessage?: string; + readonly gitTagVersion?: boolean; + readonly updatePackageJson?: boolean; + + /** + * The location of the extension in the file system. + * + * Defaults to `process.cwd()`. + */ + readonly cwd?: string; + + readonly readmePath?: string; + readonly changelogPath?: string; + + /** + * GitHub branch used to publish the package. Used to automatically infer + * the base content and images URI. + */ + readonly githubBranch?: string; + + /** + * GitLab branch used to publish the package. Used to automatically infer + * the base content and images URI. + */ + readonly gitlabBranch?: string; + + readonly rewriteRelativeLinks?: boolean; + /** + * The base URL for links detected in Markdown files. + */ + readonly baseContentUrl?: string; + + /** + * The base URL for images detected in Markdown files. + */ + readonly baseImagesUrl?: string; + + /** + * Should use Yarn instead of NPM. + */ + readonly useYarn?: boolean; + readonly dependencyEntryPoints?: string[]; + readonly ignoreFile?: string; + readonly gitHubIssueLinking?: boolean; + readonly gitLabIssueLinking?: boolean; + readonly dependencies?: boolean; + + /** + * Mark this package as a pre-release + */ + readonly preRelease?: boolean; + readonly allowStarActivation?: boolean; + readonly allowMissingRepository?: boolean; + readonly allowUnusedFilesPattern?: boolean; + readonly skipLicense?: boolean; + + readonly signTool?: string; +} + +export interface IProcessor { + onFile(file: IFile): Promise; + onEnd(): Promise; + assets: IAsset[]; + tags: string[]; + vsix: any; +} + +export interface VSIX { + id: string; + displayName: string; + version: string; + publisher?: string; + target?: string; + engine: string; + description: string; + categories: string; + flags: string; + icon?: string; + license?: string; + assets: IAsset[]; + tags: string; + links: { + repository?: string; + bugs?: string; + homepage?: string; + github?: string; + }; + galleryBanner: NonNullable; + badges?: ManifestPackage['badges']; + githubMarkdown: boolean; + enableMarketplaceQnA?: boolean; + customerQnALink?: ManifestPackage['qna']; + extensionDependencies: string; + extensionPack: string; + extensionKind: string; + localizedLanguages: string; + enabledApiProposals: string; + preRelease: boolean; + sponsorLink: string; + pricing: string; + executesCode: boolean; +} + +export class BaseProcessor implements IProcessor { + constructor(protected manifest: ManifestPackage) { } + assets: IAsset[] = []; + tags: string[] = []; + vsix: VSIX = Object.create(null); + async onFile(file: IFile): Promise { + return file; + } + async onEnd() { + // noop + } +} + +// https://github.com/npm/cli/blob/latest/lib/utils/hosted-git-info-from-manifest.js +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: ManifestPackage, gitHost?: GitHost | null): string | undefined { + if (gitHost) { + return gitHost.https(); + } + + let url: string | undefined = undefined; + + if (manifest.repository) { + if (typeof manifest.repository === 'string') { + url = manifest.repository; + } else if ( + typeof manifest.repository === 'object' && + manifest.repository.url && + typeof manifest.repository.url === 'string' + ) { + url = manifest.repository.url; + } + } + + return url; +} + +// https://github.com/npm/cli/blob/latest/lib/bugs.js +function getBugsUrl(manifest: ManifestPackage, gitHost: GitHost | undefined): string | undefined { + if (manifest.bugs) { + if (typeof manifest.bugs === 'string') { + return manifest.bugs; + } + if (typeof manifest.bugs === 'object' && manifest.bugs.url) { + return manifest.bugs.url; + } + if (typeof manifest.bugs === 'object' && manifest.bugs.email) { + return `mailto:${manifest.bugs.email}`; + } + } + + if (gitHost) { + return gitHost.bugs(); + } + + return undefined; +} + +// https://github.com/npm/cli/blob/latest/lib/docs.js +function getHomepageUrl(manifest: ManifestPackage, gitHost: GitHost | undefined): string | undefined { + if (manifest.homepage) { + return manifest.homepage; + } + + if (gitHost) { + return gitHost.docs(); + } + + return undefined; +} + +// Contributed by Mozilla developer authors +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +function toExtensionTags(extensions: string[]): string[] { + return extensions + .map(s => s.replace(/\W/g, '')) + .filter(s => !!s) + .map(s => `__ext_${s}`); +} + +function toLanguagePackTags(translations: { id: string }[], languageId: string): string[] { + return (translations ?? []) + .map(({ id }) => [`__lp_${id}`, `__lp-${languageId}_${id}`]) + .reduce((r, t) => [...r, ...t], []); +} + +/* This list is also maintained by the Marketplace team. + * Remember to reach out to them when adding new domains. + */ +const TrustedSVGSources = [ + 'api.bintray.com', + 'api.travis-ci.com', + 'api.travis-ci.org', + 'app.fossa.io', + 'badge.buildkite.com', + 'badge.fury.io', + 'badge.waffle.io', + 'badgen.net', + 'badges.frapsoft.com', + 'badges.gitter.im', + 'badges.greenkeeper.io', + 'cdn.travis-ci.com', + 'cdn.travis-ci.org', + 'ci.appveyor.com', + 'circleci.com', + 'cla.opensource.microsoft.com', + 'codacy.com', + 'codeclimate.com', + 'codecov.io', + 'coveralls.io', + 'david-dm.org', + 'deepscan.io', + 'dev.azure.com', + 'docs.rs', + 'flat.badgen.net', + 'gemnasium.com', + 'githost.io', + 'gitlab.com', + 'godoc.org', + 'goreportcard.com', + 'img.shields.io', + 'isitmaintained.com', + 'marketplace.visualstudio.com', + 'nodesecurity.io', + 'opencollective.com', + 'snyk.io', + 'travis-ci.com', + 'travis-ci.org', + 'visualstudio.com', + 'vsmarketplacebadges.dev', + 'www.bithound.io', + 'www.versioneye.com', +]; + +function isGitHubRepository(repository: string | undefined): boolean { + return /^https:\/\/github\.com\/|^git@github\.com:/.test(repository ?? ''); +} + +function isGitLabRepository(repository: string | undefined): boolean { + return /^https:\/\/gitlab\.com\/|^git@gitlab\.com:/.test(repository ?? ''); +} + +function isGitHubBadge(href: string): boolean { + return /^https:\/\/github\.com\/[^/]+\/[^/]+\/(actions\/)?workflows\/.*badge\.svg/.test(href || ''); +} + +function isHostTrusted(url: url.URL): boolean { + return (url.host && TrustedSVGSources.indexOf(url.host.toLowerCase()) > -1) || isGitHubBadge(url.href); +} + +export interface IVersionBumpOptions { + readonly cwd?: string; + readonly version?: string; + readonly commitMessage?: string; + readonly gitTagVersion?: boolean; + readonly updatePackageJson?: boolean; +} + +export async function versionBump(options: IVersionBumpOptions): Promise { + if (!options.version) { + return; + } + + if (!(options.updatePackageJson ?? true)) { + return; + } + + const cwd = options.cwd ?? process.cwd(); + const manifest = await readManifest(cwd); + + if (manifest.version === options.version) { + return; + } + + switch (options.version) { + case 'major': + case 'minor': + case 'patch': + break; + case 'premajor': + case 'preminor': + case 'prepatch': + case 'prerelease': + case 'from-git': + return Promise.reject(`Not supported: ${options.version}`); + default: + if (!semver.valid(options.version)) { + return Promise.reject(`Invalid version ${options.version}`); + } + } + + + // call `npm version` to do our dirty work + const args = ['version', options.version]; + + const isWindows = process.platform === 'win32'; + + const commitMessage = isWindows ? sanitizeCommitMessage(options.commitMessage) : options.commitMessage; + if (commitMessage) { + args.push('-m', commitMessage); + } + + if (!(options.gitTagVersion ?? true)) { + args.push('--no-git-tag-version'); + } + + const { stdout, stderr } = await promisify(cp.execFile)(isWindows ? 'npm.cmd' : 'npm', args, { cwd, shell: isWindows /* https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2 */ }); + if (!process.env['VSCE_TESTS']) { + process.stdout.write(stdout); + process.stderr.write(stderr); + } +} + +function sanitizeCommitMessage(message?: string): string | undefined { + if (!message) { + return undefined; + } + + // Remove any unsafe characters found by the unsafeRegex + // Check for characters that might escape quotes or introduce shell commands. + // Don't allow: ', ", `, $, \ (except for \n which is allowed) + const sanitizedMessage = message.replace(/(?=1.61', { includePrerelease: true })) { + throw new Error( + `Platform specific extension is supported by VS Code >=1.61. Current 'engines.vscode' is '${manifest.engines.vscode}'.` + ); + } + if (!Targets.has(target)) { + throw new Error(`'${target}' is not a valid VS Code target. Valid targets: ${[...Targets].join(', ')}`); + } + } + + 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}'.` + ); + } + } + } + + this.vsix = { + ...this.vsix, + id: manifest.name, + displayName: manifest.displayName ?? manifest.name, + version: options.version && !(options.updatePackageJson ?? true) ? options.version : manifest.version, + publisher: manifest.publisher, + target, + engine: manifest.engines.vscode, + description: manifest.description ?? '', + pricing: manifest.pricing ?? 'Free', + categories: (manifest.categories ?? []).join(','), + flags: flags.join(' '), + links: { + repository, + bugs: getBugsUrl(manifest, gitHost), + homepage: getHomepageUrl(manifest, gitHost), + }, + galleryBanner: manifest.galleryBanner ?? {}, + badges: manifest.badges, + githubMarkdown: manifest.markdown !== 'standard', + enableMarketplaceQnA, + customerQnALink, + extensionDependencies: [...new Set(manifest.extensionDependencies ?? [])].join(','), + extensionPack: [...new Set(manifest.extensionPack ?? [])].join(','), + extensionKind: extensionKind.join(','), + localizedLanguages: + manifest.contributes && manifest.contributes.localizations + ? manifest.contributes.localizations + .map(loc => loc.localizedLanguageName ?? loc.languageName ?? loc.languageId) + .join(',') + : '', + enabledApiProposals: manifest.enabledApiProposals ? manifest.enabledApiProposals.join(',') : '', + preRelease: !!this.options.preRelease, + executesCode: !!(manifest.main ?? manifest.browser), + sponsorLink: manifest.sponsor?.url || '', + }; + + if (isGitHub) { + this.vsix.links.github = repository; + } + } + + async onFile(file: IFile): Promise { + const path = util.normalize(file.path); + + if (!/^extension\/package.json$/i.test(path)) { + return Promise.resolve(file); + } + + if (this.options.version && !(this.options.updatePackageJson ?? true)) { + const contents = await read(file); + const packageJson = JSON.parse(contents); + packageJson.version = this.options.version; + file = { ...file, contents: JSON.stringify(packageJson, undefined, 2) }; + } + + // Ensure that package.json is writable as VS Code needs to + // store metadata in the extracted file. + return { ...file, mode: 0o100644 }; + } + + async onEnd(): Promise { + if (typeof this.manifest.extensionKind === 'string') { + util.log.warn(`The 'extensionKind' property should be of type 'string[]'. Learn more at: https://aka.ms/vscode/api/incorrect-execution-location`); + } + + if (this.manifest.publisher === 'vscode-samples') { + throw new Error("It's not allowed to use the 'vscode-samples' publisher. Learn more at: https://code.visualstudio.com/api/working-with-extensions/publishing-extension."); + } + + if (!this.options.allowMissingRepository && !this.manifest.repository) { + util.log.warn(`A 'repository' field is missing from the 'package.json' manifest file.\nUse --allow-missing-repository to bypass.`); + + if (!/^y$/i.test(await util.read('Do you want to continue? [y/N] '))) { + throw new Error('Aborted'); + } + } + + if (!this.options.allowStarActivation && this.manifest.activationEvents?.some(e => e === '*')) { + let message = ''; + message += `Using '*' activation is usually a bad idea as it impacts performance.\n`; + message += `More info: https://code.visualstudio.com/api/references/activation-events#Start-up\n`; + message += `Use --allow-star-activation to bypass.`; + util.log.warn(message); + + if (!/^y$/i.test(await util.read('Do you want to continue? [y/N] '))) { + throw new Error('Aborted'); + } + } + } +} + +export class TagsProcessor extends BaseProcessor { + private static Keywords: Record = { + git: ['git'], + npm: ['node'], + spell: ['markdown'], + bootstrap: ['bootstrap'], + lint: ['linters'], + linting: ['linters'], + react: ['javascript'], + js: ['javascript'], + node: ['javascript', 'node'], + 'c++': ['c++'], + Cplusplus: ['c++'], + xml: ['xml'], + angular: ['javascript'], + jquery: ['javascript'], + php: ['php'], + python: ['python'], + latex: ['latex'], + ruby: ['ruby'], + java: ['java'], + erlang: ['erlang'], + sql: ['sql'], + nodejs: ['node'], + 'c#': ['c#'], + css: ['css'], + javascript: ['javascript'], + ftp: ['ftp'], + haskell: ['haskell'], + unity: ['unity'], + terminal: ['terminal'], + powershell: ['powershell'], + laravel: ['laravel'], + meteor: ['meteor'], + emmet: ['emmet'], + eslint: ['linters'], + tfs: ['tfs'], + rust: ['rust'], + }; + + async onEnd(): Promise { + const keywords = this.manifest.keywords ?? []; + const contributes = this.manifest.contributes; + const activationEvents = this.manifest.activationEvents ?? []; + const doesContribute = (...properties: string[]) => { + let obj = contributes; + for (const property of properties) { + if (!obj) { + return false; + } + obj = obj[property]; + } + return obj && obj.length > 0; + }; + + const colorThemes = doesContribute('themes') ? ['theme', 'color-theme'] : []; + const iconThemes = doesContribute('iconThemes') ? ['theme', 'icon-theme'] : []; + const productIconThemes = doesContribute('productIconThemes') ? ['theme', 'product-icon-theme'] : []; + const snippets = doesContribute('snippets') ? ['snippet'] : []; + const keybindings = doesContribute('keybindings') ? ['keybindings'] : []; + const debuggers = doesContribute('debuggers') ? ['debuggers'] : []; + const json = doesContribute('jsonValidation') ? ['json'] : []; + const remoteMenu = doesContribute('menus', 'statusBar/remoteIndicator') ? ['remote-menu'] : []; + + const localizationContributions = ((contributes && contributes['localizations']) ?? []).reduce( + (r, l) => [...r, `lp-${l.languageId}`, ...toLanguagePackTags(l.translations, l.languageId)], + [] + ); + + const languageContributions = ((contributes && contributes['languages']) ?? []).reduce( + (r, l) => [...r, l.id, ...(l.aliases ?? []), ...toExtensionTags(l.extensions ?? [])], + [] + ); + + const languageActivations = activationEvents + .map(e => /^onLanguage:(.*)$/.exec(e)) + .filter(util.nonnull) + .map(r => r[1]); + + const grammars = ((contributes && contributes['grammars']) ?? []).map(g => g.language); + + const description = this.manifest.description || ''; + const descriptionKeywords = Object.keys(TagsProcessor.Keywords).reduce( + (r, k) => + r.concat( + new RegExp('\\b(?:' + escapeRegExp(k) + ')(?!\\w)', 'gi').test(description) ? TagsProcessor.Keywords[k] : [] + ), + [] + ); + + const webExtensionTags = isWebKind(this.manifest) ? ['__web_extension'] : []; + const sponsorTags = this.manifest.sponsor?.url ? ['__sponsor_extension'] : []; + + const tags = new Set([ + ...keywords, + ...colorThemes, + ...iconThemes, + ...productIconThemes, + ...snippets, + ...keybindings, + ...debuggers, + ...json, + ...remoteMenu, + ...localizationContributions, + ...languageContributions, + ...languageActivations, + ...grammars, + ...descriptionKeywords, + ...webExtensionTags, + ...sponsorTags, + ]); + + this.tags = [...tags].filter(tag => !!tag); + } +} + +export abstract class MarkdownProcessor extends BaseProcessor { + + private regexp: RegExp; + private baseContentUrl: string | undefined; + private baseImagesUrl: string | undefined; + private rewriteRelativeLinks: boolean; + private isGitHub: boolean; + private isGitLab: boolean; + private repositoryUrl: string | undefined; + private gitHubIssueLinking: boolean; + private gitLabIssueLinking: boolean; + + protected filesProcessed: number = 0; + + constructor( + manifest: ManifestPackage, + private name: string, + filePath: string, + private assetType: string, + protected options: IPackageOptions = {} + ) { + super(manifest); + + this.regexp = new RegExp(`^${util.filePathToVsixPath(filePath)}$`, 'i'); + + const guess = this.guessBaseUrls(options.githubBranch || options.gitlabBranch); + this.baseContentUrl = options.baseContentUrl || (guess && guess.content); + this.baseImagesUrl = options.baseImagesUrl || options.baseContentUrl || (guess && guess.images); + this.rewriteRelativeLinks = options.rewriteRelativeLinks ?? true; + this.repositoryUrl = guess && guess.repository; + this.isGitHub = isGitHubRepository(this.repositoryUrl); + this.isGitLab = isGitLabRepository(this.repositoryUrl); + this.gitHubIssueLinking = typeof options.gitHubIssueLinking === 'boolean' ? options.gitHubIssueLinking : true; + this.gitLabIssueLinking = typeof options.gitLabIssueLinking === 'boolean' ? options.gitLabIssueLinking : true; + } + + async onFile(file: IFile): Promise { + const filePath = util.normalize(file.path); + + if (!this.regexp.test(filePath)) { + return Promise.resolve(file); + } + this.filesProcessed++; + + this.assets.push({ type: this.assetType, path: filePath }); + + let contents = await read(file); + + if (/This is the README for your extension /.test(contents)) { + throw new Error(`It seems the README.md still contains template text. Make sure to edit the README.md file before you package or publish your extension.`); + } + + if (this.rewriteRelativeLinks) { + const markdownPathRegex = /(!?)\[([^\]\[]*|!\[[^\]\[]*]\([^\)]+\))\]\(([^\)]+)\)/g; + const urlReplace = (_: string, isImage: string, title: string, link: string) => { + if (/^mailto:/i.test(link)) { + return `${isImage}[${title}](${link})`; + } + + const isLinkRelative = !/^\w+:\/\//.test(link) && link[0] !== '#'; + + if (!this.baseContentUrl && !this.baseImagesUrl) { + const asset = isImage ? 'image' : 'link'; + + if (isLinkRelative) { + throw new Error( + `Couldn't detect the repository where this extension is published. The ${asset} '${link}' will be broken in ${this.name}. GitHub/GitLab repositories will be automatically detected. Otherwise, please provide the repository URL in package.json or use the --baseContentUrl and --baseImagesUrl options.` + ); + } + } + + title = title.replace(markdownPathRegex, urlReplace); + const prefix = isImage ? this.baseImagesUrl : this.baseContentUrl; + + if (!prefix || !isLinkRelative) { + return `${isImage}[${title}](${link})`; + } + + return `${isImage}[${title}](${urljoin(prefix, path.posix.normalize(link))})`; + }; + + // Replace Markdown links with urls + contents = contents.replace(markdownPathRegex, urlReplace); + + // Replace links with urls + contents = contents.replace(/<(?:img|video)[^>]+src=["']([/.\w\s#-]+)['"][^>]*>/gm, (all, link) => { + const isLinkRelative = !/^\w+:\/\//.test(link) && link[0] !== '#' && !link.startsWith('data:'); + + if (!this.baseImagesUrl && isLinkRelative) { + throw new Error( + `Couldn't detect the repository where this extension is published. The image will be broken in ${this.name}. GitHub/GitLab repositories will be automatically detected. Otherwise, please provide the repository URL in package.json or use the --baseContentUrl and --baseImagesUrl options.` + ); + } + const prefix = this.baseImagesUrl; + + if (!prefix || !isLinkRelative) { + return all; + } + + return all.replace(link, urljoin(prefix, path.posix.normalize(link))); + }); + + if ((this.gitHubIssueLinking && this.isGitHub) || (this.gitLabIssueLinking && this.isGitLab)) { + const markdownIssueRegex = /(\s|\n)([\w\d_-]+\/[\w\d_-]+)?#(\d+)\b/g; + const issueReplace = ( + all: string, + prefix: string, + ownerAndRepositoryName: string, + issueNumber: string + ): string => { + let result = all; + let owner: string | undefined; + let repositoryName: string | undefined; + + if (ownerAndRepositoryName) { + [owner, repositoryName] = ownerAndRepositoryName.split('/', 2); + } + + if (owner && repositoryName && issueNumber) { + // Issue in external repository + const issueUrl = this.isGitHub + ? urljoin('https://github.com', owner, repositoryName, 'issues', issueNumber) + : urljoin('https://gitlab.com', owner, repositoryName, '-', 'issues', issueNumber); + result = prefix + `[${owner}/${repositoryName}#${issueNumber}](${issueUrl})`; + } else if (!owner && !repositoryName && issueNumber && this.repositoryUrl) { + // Issue in own repository + result = + prefix + + `[#${issueNumber}](${this.isGitHub + ? urljoin(this.repositoryUrl, 'issues', issueNumber) + : urljoin(this.repositoryUrl, '-', 'issues', issueNumber) + })`; + } + + return result; + }; + // Replace Markdown issue references with urls + contents = contents.replace(markdownIssueRegex, issueReplace); + } + } + + const html = markdownit({ html: true }).render(contents); + const $ = cheerio.load(html); + + if (this.rewriteRelativeLinks) { + $('img').each((_, img) => { + const rawSrc = $(img).attr('src'); + + if (!rawSrc) { + throw new Error(`Images in ${this.name} must have a source.`); + } + + const src = decodeURI(rawSrc); + let srcUrl: url.URL + + try { + srcUrl = new url.URL(src); + } catch (err) { + throw new Error(`Invalid image source in ${this.name}: ${src}`); + } + + if (/^data:$/i.test(srcUrl.protocol) && /^image$/i.test(srcUrl.host) && /\/svg/i.test(srcUrl.pathname)) { + throw new Error(`SVG data URLs are not allowed in ${this.name}: ${src}`); + } + + if (!/^https:$/i.test(srcUrl.protocol)) { + throw new Error(`Images in ${this.name} must come from an HTTPS source: ${src}`); + } + + if (/\.svg$/i.test(srcUrl.pathname) && !isHostTrusted(srcUrl)) { + throw new Error( + `SVGs are restricted in ${this.name}; please use other file image formats, such as PNG: ${src}` + ); + } + }); + } + + $('svg').each(() => { + throw new Error(`SVG tags are not allowed in ${this.name}.`); + }); + + return { + path: file.path, + contents: Buffer.from(contents, 'utf8'), + }; + } + + // GitHub heuristics + private guessBaseUrls( + githostBranch: string | undefined + ): { content: string; images: string; repository: string } | undefined { + let repository = null; + + if (typeof this.manifest.repository === 'string') { + repository = this.manifest.repository; + } else if (this.manifest.repository && typeof this.manifest.repository['url'] === 'string') { + repository = this.manifest.repository['url']; + } + + if (!repository) { + return undefined; + } + + const gitHubRegex = /(?github(\.com\/|:))(?(?:[^/]+)\/(?:[^/]+))(\/|$)/; + const gitLabRegex = /(?gitlab(\.com\/|:))(?(?:[^/]+)(\/(?:[^/]+))+)(\/|$)/; + const match = ((gitHubRegex.exec(repository) || gitLabRegex.exec(repository)) as unknown) as { + groups: Record; + }; + + if (!match) { + return undefined; + } + + const project = match.groups.project.replace(/\.git$/i, ''); + const branchName = githostBranch ? githostBranch : 'HEAD'; + + if (/^github/.test(match.groups.domain)) { + return { + content: `https://github.com/${project}/blob/${branchName}`, + images: `https://github.com/${project}/raw/${branchName}`, + repository: `https://github.com/${project}`, + }; + } else if (/^gitlab/.test(match.groups.domain)) { + return { + content: `https://gitlab.com/${project}/-/blob/${branchName}`, + images: `https://gitlab.com/${project}/-/raw/${branchName}`, + repository: `https://gitlab.com/${project}`, + }; + } + + return undefined; + } +} + +export class ReadmeProcessor extends MarkdownProcessor { + constructor(manifest: ManifestPackage, options: IPackageOptions = {}) { + super( + manifest, + 'README.md', + options.readmePath ?? 'readme.md', + 'Microsoft.VisualStudio.Services.Content.Details', + options + ); + } + + override async onEnd(): Promise { + if (this.options.readmePath && this.filesProcessed === 0) { + util.log.error(`The provided readme file (${this.options.readmePath}) could not be found.`); + process.exit(1); + } + } +} + +export class ChangelogProcessor extends MarkdownProcessor { + constructor(manifest: ManifestPackage, options: IPackageOptions = {}) { + super( + manifest, + 'CHANGELOG.md', + options.changelogPath ?? 'changelog.md', + 'Microsoft.VisualStudio.Services.Content.Changelog', + options + ); + } + + override async onEnd(): Promise { + if (this.options.changelogPath && this.filesProcessed === 0) { + util.log.error(`The provided changelog file (${this.options.changelogPath}) could not be found.`); + process.exit(1); + } + } +} + +export class LicenseProcessor extends BaseProcessor { + private didFindLicense = false; + private expectedLicenseName: string; + filter: (name: string) => boolean; + + constructor(manifest: ManifestPackage, private readonly options: IPackageOptions = {}) { + super(manifest); + + const match = /^SEE LICENSE IN (.*)$/.exec(manifest.license || ''); + + if (!match || !match[1]) { + this.expectedLicenseName = 'LICENSE, LICENSE.md, or LICENSE.txt'; + this.filter = name => /^extension\/licen[cs]e(\.(md|txt))?$/i.test(name); + } else { + this.expectedLicenseName = match[1]; + const regexp = new RegExp(`^${util.filePathToVsixPath(match[1])}$`); + this.filter = regexp.test.bind(regexp); + } + + delete this.vsix.license; + } + + onFile(file: IFile): Promise { + if (!this.didFindLicense) { + let normalizedPath = util.normalize(file.path); + + if (this.filter(normalizedPath)) { + if (!path.extname(normalizedPath)) { + file.path += '.txt'; + normalizedPath += '.txt'; + } + + this.assets.push({ type: 'Microsoft.VisualStudio.Services.Content.License', path: normalizedPath }); + this.vsix.license = normalizedPath; + this.didFindLicense = true; + } + } + + return Promise.resolve(file); + } + + async onEnd(): Promise { + if (!this.didFindLicense && !this.options.skipLicense) { + util.log.warn(`${this.expectedLicenseName} not found`); + + if (!/^y$/i.test(await util.read('Do you want to continue? [y/N] '))) { + throw new Error('Aborted'); + } + } + } +} + +class LaunchEntryPointProcessor extends BaseProcessor { + private entryPoints: Set = new Set(); + + constructor(manifest: ManifestPackage) { + super(manifest); + if (manifest.main) { + this.entryPoints.add(util.normalize(path.join('extension', this.appendJSExt(manifest.main)))); + } + if (manifest.browser) { + this.entryPoints.add(util.normalize(path.join('extension', this.appendJSExt(manifest.browser)))); + } + } + + appendJSExt(filePath: string): string { + if (filePath.endsWith('.js') || filePath.endsWith('.cjs')) { + return filePath; + } + return filePath + '.js'; + } + + onFile(file: IFile): Promise { + this.entryPoints.delete(util.normalize(file.path)); + return Promise.resolve(file); + } + + async onEnd(): Promise { + if (this.entryPoints.size > 0) { + const files: string = [...this.entryPoints].join(',\n '); + throw new Error( + `Extension entrypoint(s) missing. Make sure these files exist and aren't ignored by '.vscodeignore':\n ${files}` + ); + } + } +} + +class IconProcessor extends BaseProcessor { + private icon: string | undefined; + private didFindIcon = false; + + constructor(manifest: ManifestPackage) { + super(manifest); + + this.icon = manifest.icon && path.posix.normalize(util.filePathToVsixPath(manifest.icon)); + delete this.vsix.icon; + } + + onFile(file: IFile): Promise { + const normalizedPath = util.normalize(file.path); + if (normalizedPath === this.icon) { + this.didFindIcon = true; + this.assets.push({ type: 'Microsoft.VisualStudio.Services.Icons.Default', path: normalizedPath }); + this.vsix.icon = this.icon; + } + return Promise.resolve(file); + } + + async onEnd(): Promise { + if (this.icon && !this.didFindIcon) { + return Promise.reject(new Error(`The specified icon '${this.icon}' wasn't found in the extension.`)); + } + } +} + +const ValidExtensionKinds = new Set(['ui', 'workspace']); + +export function isWebKind(manifest: ManifestPackage): boolean { + const extensionKind = getExtensionKind(manifest); + return extensionKind.some(kind => kind === 'web'); +} + +const extensionPointExtensionKindsMap = new Map(); +extensionPointExtensionKindsMap.set('jsonValidation', ['workspace', 'web']); +extensionPointExtensionKindsMap.set('localizations', ['ui', 'workspace']); +extensionPointExtensionKindsMap.set('debuggers', ['workspace']); +extensionPointExtensionKindsMap.set('terminal', ['workspace']); +extensionPointExtensionKindsMap.set('typescriptServerPlugins', ['workspace']); +extensionPointExtensionKindsMap.set('markdown.previewStyles', ['workspace', 'web']); +extensionPointExtensionKindsMap.set('markdown.previewScripts', ['workspace', 'web']); +extensionPointExtensionKindsMap.set('markdown.markdownItPlugins', ['workspace', 'web']); +extensionPointExtensionKindsMap.set('html.customData', ['workspace', 'web']); +extensionPointExtensionKindsMap.set('css.customData', ['workspace', 'web']); + +function getExtensionKind(manifest: ManifestPackage): ExtensionKind[] { + const deduced = deduceExtensionKinds(manifest); + + // check the manifest + if (manifest.extensionKind) { + const result: ExtensionKind[] = Array.isArray(manifest.extensionKind) + ? manifest.extensionKind + : manifest.extensionKind === 'ui' + ? ['ui', 'workspace'] + : [manifest.extensionKind]; + + // Add web kind if the extension can run as web extension + if (deduced.includes('web') && !result.includes('web')) { + result.push('web'); + } + + return result; + } + + return deduced; +} + +function deduceExtensionKinds(manifest: ManifestPackage): ExtensionKind[] { + // Not an UI extension if it has main + if (manifest.main) { + if (manifest.browser) { + return ['workspace', 'web']; + } + return ['workspace']; + } + + if (manifest.browser) { + return ['web']; + } + + let result: ExtensionKind[] = ['ui', 'workspace', 'web']; + + const isNonEmptyArray = (obj: any) => Array.isArray(obj) && obj.length > 0; + // Extension pack defaults to workspace,web extensionKind + if (isNonEmptyArray(manifest.extensionPack) || isNonEmptyArray(manifest.extensionDependencies)) { + result = ['workspace', 'web']; + } + + if (manifest.contributes) { + for (const contribution of Object.keys(manifest.contributes)) { + const supportedExtensionKinds = extensionPointExtensionKindsMap.get(contribution); + if (supportedExtensionKinds) { + result = result.filter(extensionKind => supportedExtensionKinds.indexOf(extensionKind) !== -1); + } + } + } + + return result; +} + +export class NLSProcessor extends BaseProcessor { + private translations: { [path: string]: string } = Object.create(null); + + constructor(manifest: ManifestPackage) { + super(manifest); + + if ( + !manifest.contributes || + !manifest.contributes.localizations || + manifest.contributes.localizations.length === 0 + ) { + return; + } + + const localizations = manifest.contributes.localizations; + const translations: { [languageId: string]: string } = Object.create(null); + + // take last reference in the manifest for any given language + for (const localization of localizations) { + for (const translation of localization.translations) { + if (translation.id === 'vscode' && !!translation.path) { + const translationPath = util.normalize(translation.path.replace(/^\.[\/\\]/, '')); + translations[localization.languageId.toUpperCase()] = util.filePathToVsixPath(translationPath); + } + } + } + + // invert the map for later easier retrieval + for (const languageId of Object.keys(translations)) { + this.translations[translations[languageId]] = languageId; + } + } + + onFile(file: IFile): Promise { + const normalizedPath = util.normalize(file.path); + const language = this.translations[normalizedPath]; + + if (language) { + this.assets.push({ type: `Microsoft.VisualStudio.Code.Translation.${language}`, path: normalizedPath }); + } + + return Promise.resolve(file); + } +} + +export class ValidationProcessor extends BaseProcessor { + private files = new Map(); + private duplicates = new Set(); + + async onFile(file: IFile): Promise { + const lower = file.path.toLowerCase(); + const existing = this.files.get(lower); + + if (existing) { + this.duplicates.add(lower); + existing.push(file.path); + } else { + this.files.set(lower, [file.path]); + } + + return file; + } + + async onEnd() { + if (this.duplicates.size === 0) { + return; + } + + const messages = [ + `The following files have the same case insensitive path, which isn't supported by the VSIX format:`, + ]; + + for (const lower of this.duplicates) { + for (const filePath of this.files.get(lower)!) { + messages.push(` - ${filePath}`); + } + } + + throw new Error(messages.join('\n')); + } +} + +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.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; + const hasOtherImplicitActivationEvents = + manifest.contributes?.commands || + manifest.contributes?.authentication || + manifest.contributes?.customEditors || + manifest.contributes?.views; + const hasImplicitActivationEvents = hasImplicitLanguageActivationEvents || hasOtherImplicitActivationEvents; + + const hasMain = !!manifest.main; + const hasBrowser = !!manifest.browser; + + let parsedEngineVersion: string; + try { + const engineSemver = parseSemver(`vscode@${engines.vscode}`); + parsedEngineVersion = engineSemver.version; + } catch (err) { + throw new Error('Failed to parse semver of engines.vscode'); + } + + if ( + hasActivationEvents || + ((engines.vscode === '*' || semver.satisfies(parsedEngineVersion, '>=1.74', { includePrerelease: true })) && + hasImplicitActivationEvents) + ) { + if (!hasMain && !hasBrowser && (hasActivationEvents || !hasImplicitLanguageActivationEvents)) { + throw new Error( + "Manifest needs either a 'main' or 'browser' property, given it has a 'activationEvents' property." + ); + } + } else if (hasMain) { + throw new Error("Manifest needs the 'activationEvents' property, given it has a 'main' property."); + } else if (hasBrowser) { + throw new Error("Manifest needs the 'activationEvents' property, given it has a 'browser' property."); + } + + if (manifest.devDependencies && manifest.devDependencies['@types/vscode']) { + validateVSCodeTypesCompatibility(engines.vscode, manifest.devDependencies['@types/vscode']); + } + + if (/\.svg$/i.test(manifest.icon || '')) { + throw new Error(`SVGs can't be used as icons: ${manifest.icon}`); + } + + (manifest.badges ?? []).forEach(badge => { + const decodedUrl = decodeURI(badge.url); + let srcUrl: url.URL; + + try { + srcUrl = new url.URL(decodedUrl); + } catch (err) { + throw new Error(`Badge URL is invalid: ${badge.url}`); + } + + if (!/^https:$/i.test(srcUrl.protocol)) { + throw new Error(`Badge URLs must come from an HTTPS source: ${badge.url}`); + } + + if (/\.svg$/i.test(srcUrl.pathname) && !isHostTrusted(srcUrl)) { + throw new Error(`Badge SVGs are restricted. Please use other file image formats, such as PNG: ${badge.url}`); + } + }); + + Object.keys(manifest.dependencies || {}).forEach(dep => { + if (dep === 'vscode') { + throw new Error( + `You should not depend on 'vscode' in your 'dependencies'. Did you mean to add it to 'devDependencies'?` + ); + } + }); + + if (manifest.extensionKind) { + const extensionKinds = Array.isArray(manifest.extensionKind) ? manifest.extensionKind : [manifest.extensionKind]; + + for (const kind of extensionKinds) { + if (!ValidExtensionKinds.has(kind)) { + throw new Error( + `Manifest contains invalid value '${kind}' in the 'extensionKind' property. Allowed values are ${[ + ...ValidExtensionKinds, + ] + .map(k => `'${k}'`) + .join(', ')}.` + ); + } + } + } + + if (manifest.sponsor) { + let isValidSponsorUrl = true; + try { + const sponsorUrl = new url.URL(manifest.sponsor.url); + isValidSponsorUrl = /^(https|http):$/i.test(sponsorUrl.protocol); + } catch (error) { + isValidSponsorUrl = false; + } + if (!isValidSponsorUrl) { + throw new Error( + `Manifest contains invalid value '${manifest.sponsor.url}' in the 'sponsor' property. It must be a valid URL with a HTTP or HTTPS protocol.` + ); + } + } + + return { + ...manifest, + name, + version, + engines, + publisher, + }; +} + +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 => { + try { + return Promise.resolve(JSON.parse(manifestStr)); + } catch (e) { + console.error(`Error parsing 'package.json' manifest file: not a valid JSON file.`); + throw e; + } + }) + .then(validateManifestForPackaging); + + if (!nls) { + return manifest; + } + + const manifestNLS = fs.promises + .readFile(manifestNLSPath, 'utf8') + .catch(err => (err.code !== 'ENOENT' ? Promise.reject(err) : Promise.resolve(undefined))) + .then(raw => { + if (!raw) { + return Promise.resolve(undefined); + } + + try { + return Promise.resolve(jsonc.parse(raw)); + } catch (e) { + console.error(`Error parsing JSON manifest translations file: ${manifestNLSPath}`); + throw e; + } + }); + + return Promise.all([manifest, manifestNLS]).then(([manifest, translations]) => { + if (!translations) { + return manifest; + } + return patchNLS(manifest, translations); + }); +} + +const escapeChars = new Map([ + ["'", '''], + ['"', '"'], + ['<', '<'], + ['>', '>'], + ['&', '&'], +]); + +function escape(value: any): string { + return String(value).replace(/(['"<>&])/g, (_, char) => escapeChars.get(char)!); +} + +export async function toVsixManifest(vsix: VSIX): Promise { + return ` + + + + ${escape(vsix.displayName)} + ${escape(vsix.description)} + ${escape(vsix.tags)} + ${escape(vsix.categories)} + ${escape(vsix.flags)} + ${!vsix.badges + ? '' + : `${vsix.badges + .map( + badge => + `` + ) + .join('\n')}` + } + + + + + + + + ${vsix.preRelease ? `` : ''} + ${vsix.executesCode ? `` : ''} + ${vsix.sponsorLink + ? `` + : '' + } + ${!vsix.links.repository + ? '' + : ` + + ${vsix.links.github + ? `` + : `` + }` + } + ${vsix.links.bugs + ? `` + : '' + } + ${vsix.links.homepage + ? `` + : '' + } + ${vsix.galleryBanner.color + ? `` + : '' + } + ${vsix.galleryBanner.theme + ? `` + : '' + } + + + + ${vsix.enableMarketplaceQnA !== undefined + ? `` + : '' + } + ${vsix.customerQnALink !== undefined + ? `` + : '' + } + + ${vsix.license ? `${escape(vsix.license)}` : ''} + ${vsix.icon ? `${escape(vsix.icon)}` : ''} + + + + + + + + ${vsix.assets + .map(asset => ``) + .join('\n')} + + `; +} + +const defaultMimetypes = new Map([ + ['.json', 'application/json'], + ['.vsixmanifest', 'text/xml'], +]); + +export async function toContentTypes(files: IFile[]): Promise { + const mimetypes = new Map(defaultMimetypes); + + for (const file of files) { + const ext = path.extname(file.path).toLowerCase(); + + if (ext) { + mimetypes.set(ext, mime.lookup(ext)); + } + } + + const contentTypes: string[] = []; + for (const [extension, contentType] of mimetypes) { + contentTypes.push(``); + } + + return ` +${contentTypes.join('')} +`; +} + +const defaultIgnore = [ + '.vscodeignore', + 'package-lock.json', + 'npm-debug.log', + 'yarn.lock', + 'yarn-error.log', + 'npm-shrinkwrap.json', + '.editorconfig', + '.npmrc', + '.yarnrc', + '.gitattributes', + '*.todo', + 'tslint.yaml', + '.eslintrc*', + '.babelrc*', + '.prettierrc*', + '.cz-config.js', + '.commitlintrc*', + 'webpack.config.js', + 'ISSUE_TEMPLATE.md', + 'CONTRIBUTING.md', + 'PULL_REQUEST_TEMPLATE.md', + 'CODE_OF_CONDUCT.md', + '.github', + '.travis.yml', + 'appveyor.yml', + '**/.git', + '**/.git/**', + '**/*.vsix', + '**/.DS_Store', + '**/*.vsixmanifest', + '**/.vscode-test/**', + '**/.vscode-test-web/**', +]; + +async function collectAllFiles( + cwd: string, + dependencies: 'npm' | 'yarn' | 'none' | undefined, + dependencyEntryPoints?: string[] +): Promise { + const deps = await getDependencies(cwd, dependencies, dependencyEntryPoints); + const promises = deps.map(dep => + glob('**', { cwd: dep, nodir: true, dot: true, ignore: 'node_modules/**' }).then(files => + files.map(f => path.relative(cwd, path.join(dep, f))).map(f => f.replace(/\\/g, '/')) + ) + ); + + return Promise.all(promises).then(util.flatten); +} + +function getDependenciesOption(options: IPackageOptions): 'npm' | 'yarn' | 'none' | undefined { + if (options.dependencies === false) { + return 'none'; + } + + switch (options.useYarn) { + case true: + return 'yarn'; + case false: + return 'npm'; + default: + return undefined; + } +} + +function collectFiles( + cwd: string, + dependencies: 'npm' | 'yarn' | 'none' | undefined, + dependencyEntryPoints?: string[], + ignoreFile?: string, + manifestFileIncludes?: string[], + readmePath?: string, +): Promise { + readmePath = readmePath ?? 'README.md'; + const notIgnored = ['!package.json', `!${readmePath}`]; + + return collectAllFiles(cwd, dependencies, dependencyEntryPoints).then(files => { + files = files.filter(f => !/\r$/m.test(f)); + + return ( + fs.promises + .readFile(ignoreFile ? ignoreFile : path.join(cwd, '.vscodeignore'), 'utf8') + .catch(err => + err.code !== 'ENOENT' ? + Promise.reject(err) : + ignoreFile ? + Promise.reject(err) : + // No .vscodeignore file exists + manifestFileIncludes ? + // include all files in manifestFileIncludes and ignore the rest + Promise.resolve(manifestFileIncludes.map(file => `!${file}`).concat(['**']).join('\n\r')) : + // "files" property not used in package.json + Promise.resolve('') + ) + + // Parse raw ignore by splitting output into lines and filtering out empty lines and comments + .then(rawIgnore => + rawIgnore + .split(/[\n\r]/) + .map(s => s.trim()) + .filter(s => !!s) + .filter(i => !/^\s*#/.test(i)) + ) + + // Add '/**' to possible folder names + .then(ignore => [ + ...ignore, + ...ignore.filter(i => !/(^|\/)[^/]*\*[^/]*$/.test(i)).map(i => (/\/$/.test(i) ? `${i}**` : `${i}/**`)), + ]) + + // Combine with default ignore list + .then(ignore => [...defaultIgnore, ...ignore, ...notIgnored]) + + // Split into ignore and negate list + .then(ignore => + ignore.reduce<[string[], string[]]>( + (r, e) => (!/^\s*!/.test(e) ? [[...r[0], e], r[1]] : [r[0], [...r[1], e]]), + [[], []] + ) + ) + .then(r => ({ ignore: r[0], negate: r[1] })) + + // Filter out files + .then(({ ignore, negate }) => + files.filter( + f => + !ignore.some(i => minimatch(f, i, MinimatchOptions)) || + negate.some(i => minimatch(f, i.substr(1), MinimatchOptions)) + ) + ) + ); + }); +} + +export function processFiles(processors: IProcessor[], files: IFile[]): Promise { + const processedFiles = files.map(file => util.chain(file, processors, (file, processor) => processor.onFile(file))); + + return Promise.all(processedFiles).then(files => { + return util.sequence(processors.map(p => () => p.onEnd())).then(() => { + const assets = processors.reduce((r, p) => [...r, ...p.assets], []); + const tags = [ + ...processors.reduce>((r, p) => { + for (const tag of p.tags) { + if (tag) { + r.add(tag); + } + } + return r; + }, new Set()), + ].join(','); + const vsix = processors.reduce((r, p) => ({ ...r, ...p.vsix }), { assets, tags } as VSIX); + + return Promise.all([toVsixManifest(vsix), toContentTypes(files)]).then(result => { + return [ + { path: 'extension.vsixmanifest', contents: Buffer.from(result[0], 'utf8') }, + { path: '[Content_Types].xml', contents: Buffer.from(result[1], 'utf8') }, + ...files, + ]; + }); + }); + }); +} + +export function createDefaultProcessors(manifest: ManifestPackage, options: IPackageOptions = {}): IProcessor[] { + return [ + new ManifestProcessor(manifest, options), + new TagsProcessor(manifest), + new ReadmeProcessor(manifest, options), + new ChangelogProcessor(manifest, options), + new LaunchEntryPointProcessor(manifest), + new LicenseProcessor(manifest, options), + new IconProcessor(manifest), + new NLSProcessor(manifest), + new ValidationProcessor(manifest), + ]; +} + +export function collect(manifest: ManifestPackage, options: IPackageOptions = {}): Promise { + const cwd = options.cwd || process.cwd(); + const packagedDependencies = options.dependencyEntryPoints || undefined; + const ignoreFile = options.ignoreFile || undefined; + const processors = createDefaultProcessors(manifest, options); + + return collectFiles(cwd, getDependenciesOption(options), packagedDependencies, ignoreFile, manifest.files, options.readmePath).then(fileNames => { + const files = fileNames.map(f => ({ path: util.filePathToVsixPath(f), localPath: path.join(cwd, f) })); + + return processFiles(processors, files); + }); +} + +function writeVsix(files: IFile[], packagePath: string): Promise { + return fs.promises + .unlink(packagePath) + .catch(err => (err.code !== 'ENOENT' ? Promise.reject(err) : Promise.resolve(null))) + .then( + () => + new Promise((c, e) => { + const zip = new yazl.ZipFile(); + files.forEach(f => + isInMemoryFile(f) + ? zip.addBuffer(typeof f.contents === 'string' ? Buffer.from(f.contents, 'utf8') : f.contents, f.path, { + mode: f.mode, + }) + : zip.addFile(f.localPath, f.path, { mode: f.mode }) + ); + zip.end(); + + const zipStream = fs.createWriteStream(packagePath); + zip.outputStream.pipe(zipStream); + + zip.outputStream.once('error', e); + zipStream.once('error', e); + zipStream.once('finish', () => c()); + }) + ); +} + +function getDefaultPackageName(manifest: ManifestPackage, options: IPackageOptions): string { + let version = manifest.version; + + if (options.version && !(options.updatePackageJson ?? true)) { + version = options.version; + } + + if (options.target) { + return `${manifest.name}-${options.target}-${version}.vsix`; + } + + return `${manifest.name}-${version}.vsix`; +} + +export async function prepublish(cwd: string, manifest: ManifestPackage, useYarn?: boolean): Promise { + if (!manifest.scripts || !manifest.scripts['vscode:prepublish']) { + return; + } + + if (useYarn === undefined) { + useYarn = await detectYarn(cwd); + } + + console.log(`Executing prepublish script '${useYarn ? 'yarn' : 'npm'} run vscode:prepublish'...`); + + await new Promise((c, e) => { + const tool = useYarn ? 'yarn' : 'npm'; + const child = cp.spawn(tool, ['run', 'vscode:prepublish'], { cwd, shell: true, stdio: 'inherit' }); + child.on('exit', code => (code === 0 ? c() : e(`${tool} failed with exit code ${code}`))); + child.on('error', e); + }); +} + +async function getPackagePath(cwd: string, manifest: ManifestPackage, options: IPackageOptions = {}): Promise { + if (!options.packagePath) { + return path.join(cwd, getDefaultPackageName(manifest, options)); + } + + try { + const _stat = await fs.promises.stat(options.packagePath); + + if (_stat.isDirectory()) { + return path.join(options.packagePath, getDefaultPackageName(manifest, options)); + } else { + return options.packagePath; + } + } catch { + return options.packagePath; + } +} + +export async function pack(options: IPackageOptions = {}): Promise { + const cwd = options.cwd || process.cwd(); + const manifest = await readManifest(cwd); + const files = await collect(manifest, options); + + await printAndValidatePackagedFiles(files, cwd, manifest, options); + + if (options.version && !(options.updatePackageJson ?? true)) { + manifest.version = options.version; + } + + const packagePath = await getPackagePath(cwd, manifest, options); + await writeVsix(files, path.resolve(packagePath)); + + return { manifest, packagePath, files }; +} + +export async function signPackage(packageFile: string, signTool: string): Promise { + const packageFolder = path.dirname(packageFile); + const packageName = path.basename(packageFile, '.vsix'); + const manifestFile = path.join(packageFolder, `${packageName}.signature.manifest`); + const signatureFile = path.join(packageFolder, `${packageName}.signature.p7s`); + const signatureZip = path.join(packageFolder, `${packageName}.signature.zip`); + + await generateManifest(packageFile, manifestFile); + + // Sign the manifest file to generate the signature file + cp.execSync(`${signTool} "${manifestFile}" "${signatureFile}"`, { stdio: 'inherit' }); + + return createSignatureArchive(manifestFile, signatureFile, signatureZip); +} + +// Generate the signature manifest file +export function generateManifest(packageFile: string, outputFile?: string): Promise { + if (!outputFile) { + const packageFolder = path.dirname(packageFile); + const packageName = path.basename(packageFile, '.vsix'); + outputFile = path.join(packageFolder, `${packageName}.manifest`); + } + return vsceSign.generateManifest(packageFile, outputFile); +} + +// Create a signature zip file containing the manifest and signature file +export async function createSignatureArchive(manifestFile: string, signatureFile: string, outputFile?: string): Promise { + return vsceSign.zip(manifestFile, signatureFile, outputFile) +} + +export async function packageCommand(options: IPackageOptions = {}): Promise { + const cwd = options.cwd || process.cwd(); + const manifest = await readManifest(cwd); + util.patchOptionsWithManifest(options, manifest); + + await prepublish(cwd, manifest, options.useYarn); + await versionBump(options); + + const { packagePath, files } = await pack(options); + + if (options.signTool) { + await signPackage(packagePath, options.signTool); + } + + const stats = await fs.promises.stat(packagePath); + const packageSize = util.bytesToString(stats.size); + util.log.done(`Packaged: ${packagePath} ` + chalk.bold(`(${files.length} files, ${packageSize})`)); +} + +export interface IListFilesOptions { + readonly cwd?: string; + readonly manifest?: ManifestPackage; + readonly useYarn?: boolean; + readonly packagedDependencies?: string[]; + readonly ignoreFile?: string; + readonly dependencies?: boolean; + readonly prepublish?: boolean; + readonly readmePath?: string; +} + +/** + * Lists the files included in the extension's package. + */ +export async function listFiles(options: IListFilesOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const manifest = options.manifest ?? await readManifest(cwd); + + if (options.prepublish) { + await prepublish(cwd, manifest, options.useYarn); + } + + return await collectFiles(cwd, getDependenciesOption(options), options.packagedDependencies, options.ignoreFile, manifest.files, options.readmePath); +} + +interface ILSOptions { + readonly tree?: boolean; + readonly useYarn?: boolean; + readonly packagedDependencies?: string[]; + readonly ignoreFile?: string; + readonly dependencies?: boolean; + readonly readmePath?: string; +} + +/** + * Lists the files included in the extension's package. + */ +export async function ls(options: ILSOptions = {}): Promise { + const cwd = process.cwd(); + const manifest = await readManifest(cwd); + + const files = await listFiles({ ...options, cwd, manifest }); + + if (options.tree) { + const printableFileStructure = await util.generateFileStructureTree( + getDefaultPackageName(manifest, options), + files.map(f => ({ origin: f, tree: f })) + ); + console.log(printableFileStructure.join('\n')); + } else { + console.log(files.join('\n')); + } +} + +/** + * 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: 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) { + let message = ''; + message += `This extension consists of ${chalk.bold(String(files.length))} files, out of which ${chalk.bold(String(jsFiles.length))} are JavaScript files. `; + message += `For performance reasons, you should bundle your extension: ${chalk.underline('https://aka.ms/vscode-bundle-extension')}. `; + message += `You should also exclude unnecessary files by adding them to your .vscodeignore: ${chalk.underline('https://aka.ms/vscode-vscodeignore')}.\n`; + util.log.warn(message); + } + + // Warn if the extension does not have a .vscodeignore file or a files property in package.json + const hasIgnoreFile = fs.existsSync(options.ignoreFile ?? path.join(cwd, '.vscodeignore')); + if (!hasIgnoreFile && !manifest.files) { + let message = ''; + message += `Neither a ${chalk.bold('.vscodeignore')} file nor a ${chalk.bold('"files"')} property in package.json was found. `; + message += `To ensure only necessary files are included in your extension, `; + message += `add a .vscodeignore file or specify the "files" property in package.json. More info: ${chalk.underline('https://aka.ms/vscode-vscodeignore')}\n`; + util.log.warn(message); + } + // Throw an error if the extension uses both a .vscodeignore file and the files property in package.json + else if (hasIgnoreFile && manifest.files !== undefined && manifest.files.length > 0) { + let message = ''; + message += `Both a ${chalk.bold('.vscodeignore')} file and a ${chalk.bold('"files"')} property in package.json were found. `; + message += `VSCE does not support combining both strategies. `; + message += `Either remove the ${chalk.bold('.vscodeignore')} file or the ${chalk.bold('"files"')} property in package.json.`; + util.log.error(message); + process.exit(1); + } + // Throw an error if the extension uses the files property in package.json and + // the package does not include at least one file for each include pattern + else if (manifest.files !== undefined && manifest.files.length > 0 && !options.allowUnusedFilesPattern) { + const originalFilePaths = files.map(f => util.vsixPathToFilePath(f.path)); + const unusedIncludePatterns = manifest.files.filter(includePattern => !originalFilePaths.some(filePath => minimatch(filePath, includePattern, MinimatchOptions))); + if (unusedIncludePatterns.length > 0) { + let message = ''; + message += `The following include patterns in the ${chalk.bold('"files"')} property in package.json do not match any files packaged in the extension:\n`; + message += unusedIncludePatterns.map(p => ` - ${p}`).join('\n'); + message += '\nRemove any include pattern which is not needed.\n'; + message += `\n=> Run ${chalk.bold('vsce ls --tree')} to see all included files.\n`; + message += `=> Use ${chalk.bold('--allow-unused-files-patterns')} to skip this check`; + util.log.error(message); + process.exit(1); + } + } + + // Print the files included in the package + const printableFileStructure = await util.generateFileStructureTree( + getDefaultPackageName(manifest, options), + files.map(f => ({ + // File path relative to the extension root + origin: util.vsixPathToFilePath(f.path), + // File path in the VSIX + tree: f.path + })), + 35 // Print up to 35 files/folders + ); + + let message = ''; + message += chalk.bold.blue(`Files included in the VSIX:\n`); + message += printableFileStructure.join('\n'); + + // If not all files have been printed, mention how all files can be printed + if (files.length + 1 > printableFileStructure.length) { + message += `\n\n=> Run ${chalk.bold('vsce ls --tree')} to see all included files.`; + } + + message += '\n'; + util.log.info(message); +} diff --git a/src/test/package.test.ts b/src/test/package.test.ts new file mode 100644 index 00000000..e3d1ef6e --- /dev/null +++ b/src/test/package.test.ts @@ -0,0 +1,3081 @@ +import { + readManifest, + collect, + toContentTypes, + ReadmeProcessor, + read, + processFiles, + createDefaultProcessors, + toVsixManifest, + IFile, + validateManifestForPackaging, + IPackageOptions, + ManifestProcessor, + versionBump, + VSIX, + LicenseProcessor, +} from '../package'; +import { ManifestPackage } from '../manifest'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as assert from 'assert'; +import * as tmp from 'tmp'; +import { spawnSync } from 'child_process'; +import { XMLManifest, parseXmlManifest, parseContentTypes } from '../xml'; +import { flatten, log } from '../util'; +import { validatePublisher } from '../validation'; +import * as jsonc from 'jsonc-parser'; + +// don't warn in tests +console.warn = () => null; + +// accept read in tests +process.env['VSCE_TESTS'] = 'true'; + +async function throws(fn: () => Promise): Promise { + let didThrow = false; + + try { + await fn(); + } catch (err: any) { + didThrow = true; + } + + if (!didThrow) { + throw new Error('Assertion failed'); + } +} + +const fixture = (name: string) => path.join(path.dirname(path.dirname(__dirname)), 'src', 'test', 'fixtures', name); + +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)); + const tags = flatten(processors.map(p => p.tags)).join(','); + const vsix = processors.reduce((r, p) => ({ ...r, ...p.vsix }), { assets, tags } as VSIX); + + return toVsixManifest(vsix); + }); +} + +async function toXMLManifest(manifest: ManifestPackage, files: IFile[] = []): Promise { + const raw = await _toVsixManifest(manifest, files); + return parseXmlManifest(raw); +} + +function assertProperty(manifest: XMLManifest, name: string, value: string): void { + const property = manifest.PackageManifest.Metadata[0].Properties[0].Property.filter(p => p.$.Id === name); + assert.strictEqual(property.length, 1, `Property '${name}' should exist`); + + const enableMarketplaceQnA = property[0].$.Value; + assert.strictEqual(enableMarketplaceQnA, value, `Property '${name}' should have value '${value}'`); +} + +function assertMissingProperty(manifest: XMLManifest, name: string): void { + const property = manifest.PackageManifest.Metadata[0].Properties[0].Property.filter(p => p.$.Id === name); + assert.strictEqual(property.length, 0, `Property '${name}' should not exist`); +} + +function createManifest(extra: Partial = {}): ManifestPackage { + return { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: { vscode: '*' }, + ...extra, + }; +} + +describe('collect', function () { + this.timeout(60000); + + it('should catch all files', () => { + const cwd = fixture('uuid'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd })) + .then(files => { + assert.strictEqual(files.length, 3); + }); + }); + + it('should ignore .git/**', () => { + const cwd = fixture('uuid'); + + if (!fs.existsSync(path.join(cwd, '.git'))) { + fs.mkdirSync(path.join(cwd, '.git')); + } + + if (!fs.existsSync(path.join(cwd, '.git', 'hello'))) { + fs.writeFileSync(path.join(cwd, '.git', 'hello'), 'world'); + } + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd })) + .then(files => { + assert.strictEqual(files.length, 3); + }); + }); + + it('should ignore content of .vscodeignore', async () => { + const cwd = fixture('vscodeignore'); + const manifest = await readManifest(cwd); + const files = await collect(manifest, { cwd }); + const names = files.map(f => f.path).sort(); + + assert.deepStrictEqual(names, [ + '[Content_Types].xml', + 'extension.vsixmanifest', + 'extension/foo/bar/hello.txt', + 'extension/package.json', + ]); + }); + + it('should include content of manifest.files', async () => { + const cwd = fixture('manifestFiles'); + const manifest = await readManifest(cwd); + const files = await collect(manifest, { cwd }); + const names = files.map(f => f.path).sort(); + + assert.deepStrictEqual(names, [ + '[Content_Types].xml', + 'extension.vsixmanifest', + 'extension/foo/bar/hello.txt', + 'extension/foo2/bar2/include.me', + 'extension/foo3/bar3/hello.txt', + 'extension/package.json', + ]); + }); + + it('should ignore devDependencies', () => { + const cwd = fixture('devDependencies'); + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd })) + .then(files => { + // ..extension.vsixmanifest + // [Content_Types].xml + // extension/package.json + // extension/node_modules/real/dependency.js + // extension/node_modules/real/package.json + // extension/node_modules/real2/dependency.js + // extension/node_modules/real2/package.json + // extension/node_modules/real_sub/dependency.js + // extension/node_modules/real_sub/package.json + // extension/node_modules/real/node_modules/real_sub/dependency.js + // extension/node_modules/real/node_modules/real_sub/package.json + assert.strictEqual(files.length, 11); + assert.ok(files.some(f => /real\/dependency\.js/.test(f.path))); + assert.ok(!files.some(f => /fake\/dependency\.js/.test(f.path))); + }); + }); + + it('should ignore **/.vsixmanifest', () => { + const cwd = fixture('vsixmanifest'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd })) + .then(files => { + assert.strictEqual(files.filter(f => /\.vsixmanifest$/.test(f.path)).length, 1); + }); + }); + + it('should honor dependencyEntryPoints', () => { + const cwd = fixture('packagedDependencies'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd, useYarn: true, dependencyEntryPoints: ['isexe'] })) + .then(files => { + let seenWhich: boolean = false; + let seenIsexe: boolean = false; + for (const file of files) { + seenWhich = file.path.indexOf('/node_modules/which/') >= 0; + seenIsexe = file.path.indexOf('/node_modules/isexe/') >= 0; + } + assert.strictEqual(seenWhich, false); + assert.strictEqual(seenIsexe, true); + }); + }); + + it('should detect yarn', () => { + const cwd = fixture('packagedDependencies'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd, dependencyEntryPoints: ['isexe'] })) + .then(files => { + let seenWhich: boolean = false; + let seenIsexe: boolean = false; + for (const file of files) { + seenWhich = file.path.indexOf('/node_modules/which/') >= 0; + seenIsexe = file.path.indexOf('/node_modules/isexe/') >= 0; + } + assert.strictEqual(seenWhich, false); + assert.strictEqual(seenIsexe, true); + }); + }); + + it('should include all node_modules when dependencyEntryPoints is not defined', () => { + const cwd = fixture('packagedDependencies'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd, useYarn: true })) + .then(files => { + let seenWhich: boolean = false; + let seenIsexe: boolean = false; + for (const file of files) { + seenWhich = file.path.indexOf('/node_modules/which/') >= 0; + seenIsexe = file.path.indexOf('/node_modules/isexe/') >= 0; + } + assert.strictEqual(seenWhich, true); + assert.strictEqual(seenIsexe, true); + }); + }); + + it('should skip all node_modules when dependencyEntryPoints is []', () => { + const cwd = fixture('packagedDependencies'); + + return readManifest(cwd) + .then(manifest => collect(manifest, { cwd, useYarn: true, dependencyEntryPoints: [] })) + .then(files => { + files.forEach(file => assert.ok(file.path.indexOf('/node_modules/which/') < 0, file.path)); + }); + }); + + it('should skip all dependencies when using --no-dependencies', async () => { + const cwd = fixture('devDependencies'); + const manifest = await readManifest(cwd); + const files = await collect(manifest, { cwd, dependencies: false }); + + assert.strictEqual(files.length, 3); + + for (const file of files) { + assert.ok(!/\bnode_modules\b/i.test(file.path)); + } + }); + + it('should handle relative icon paths', async function () { + const cwd = fixture('icon'); + const manifest = await readManifest(cwd); + await collect(manifest, { cwd }); + }); +}); + +describe('readManifest', () => { + it('should patch NLS', async function () { + const cwd = fixture('nls'); + const raw = JSON.parse(await fs.promises.readFile(path.join(cwd, 'package.json'), 'utf8')); + const translations = jsonc.parse(await fs.promises.readFile(path.join(cwd, 'package.nls.json'), 'utf8')); + const manifest = await readManifest(cwd); + + assert.strictEqual(manifest.name, raw.name); + assert.strictEqual(manifest.description, translations['extension.description']); + assert.strictEqual(manifest.contributes!.debuggers[0].label, translations['node.label']); + }); + + it('should not patch NLS if required', async function () { + const cwd = fixture('nls'); + const raw = JSON.parse(await fs.promises.readFile(path.join(cwd, 'package.json'), 'utf8')); + const translations = jsonc.parse(await fs.promises.readFile(path.join(cwd, 'package.nls.json'), 'utf8')); + const manifest = await readManifest(cwd, false); + + assert.strictEqual(manifest.name, raw.name); + assert.notStrictEqual(manifest.description, translations['extension.description']); + assert.notStrictEqual(manifest.contributes!.debuggers[0].label, translations['node.label']); + }); +}); + +describe('validateManifest', () => { + it('should catch missing fields', () => { + assert.ok(validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: '0.10.1' } })); + assert.throws(() => { + validateManifestForPackaging({ publisher: 'demo', name: null!, version: '1.0.0', engines: { vscode: '0.10.1' } }); + }); + assert.throws(() => { + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: null!, engines: { vscode: '0.10.1' } }); + }); + assert.throws(() => { + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0', engines: { vscode: '0.10.1' } }); + }); + assert.throws(() => { + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: null! }); + }); + assert.throws(() => { + validateManifestForPackaging({ publisher: 'demo', name: 'demo', version: '1.0.0', engines: { vscode: null } as any }); + }); + validatePublisher('demo'); + 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(validateManifestForPackaging(createManifest({ icon: 'icon.png' }))); + assert.throws(() => { + validateManifestForPackaging(createManifest({ icon: 'icon.svg' })); + }); + }); + + it('should prevent badges from non HTTPS sources', () => { + assert.throws(() => { + validateManifestForPackaging( + createManifest({ badges: [{ url: 'relative.png', href: 'http://badgeurl', description: 'this is a badge' }] }) + ); + }); + assert.throws(() => { + validateManifestForPackaging( + createManifest({ badges: [{ url: 'relative.svg', href: 'http://badgeurl', description: 'this is a badge' }] }) + ); + }); + assert.throws(() => { + validateManifestForPackaging( + createManifest({ + badges: [{ url: 'http://badgeurl.png', href: 'http://badgeurl', description: 'this is a badge' }], + }) + ); + }); + }); + + it('should allow non SVG badges', () => { + assert.ok( + validateManifestForPackaging( + createManifest({ + badges: [{ url: 'https://host/badge.png', href: 'http://badgeurl', description: 'this is a badge' }], + }) + ) + ); + }); + + it('should allow SVG badges from trusted sources', () => { + assert.ok( + validateManifestForPackaging( + createManifest({ + badges: [{ url: 'https://gemnasium.com/foo.svg', href: 'http://badgeurl', description: 'this is a badge' }], + }) + ) + ); + }); + + it('should prevent SVG badges from non trusted sources', () => { + assert.throws(() => { + assert.ok( + validateManifestForPackaging( + createManifest({ + badges: [{ url: 'https://github.com/foo.svg', href: 'http://badgeurl', description: 'this is a badge' }], + }) + ) + ); + }); + assert.throws(() => { + assert.ok( + validateManifestForPackaging( + createManifest({ + badges: [ + { + url: 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/410.sv%67', + href: 'http://badgeurl', + description: 'this is a badge', + }, + ], + }) + ) + ); + }); + }); + + it('should validate activationEvents against main and browser', () => { + 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(() => 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(() => 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(() => validateManifestForPackaging(createManifest({ pricing: 'Paid' }))); + validateManifestForPackaging(createManifest({ pricing: 'Trial' })); + validateManifestForPackaging(createManifest({ pricing: 'Free' })); + validateManifestForPackaging(createManifest()); + }); + + it('should allow implicit activation events', () => { + validateManifestForPackaging( + createManifest({ + engines: { vscode: '>=1.74.0' }, + main: 'main.js', + contributes: { + commands: [ + { + command: 'extension.helloWorld', + title: 'Hello World', + }, + ], + }, + }) + ); + + validateManifestForPackaging( + createManifest({ + engines: { vscode: '*' }, + main: 'main.js', + contributes: { + commands: [ + { + command: 'extension.helloWorld', + title: 'Hello World', + }, + ], + }, + }) + ); + + validateManifestForPackaging( + createManifest({ + engines: { vscode: '>=1.74.0' }, + contributes: { + languages: [ + { + id: 'typescript', + } + ] + } + }) + ); + + assert.throws(() => + validateManifestForPackaging( + createManifest({ + engines: { vscode: '>=1.73.3' }, + main: 'main.js', + }) + ) + ); + + assert.throws(() => + validateManifestForPackaging( + createManifest({ + engines: { vscode: '>=1.73.3' }, + activationEvents: ['*'], + }) + ) + ); + + assert.throws(() => + validateManifestForPackaging( + createManifest({ + engines: { vscode: '>=1.73.3' }, + main: 'main.js', + contributes: { + commands: [ + { + command: 'extension.helloWorld', + title: 'Hello World', + }, + ], + }, + }) + ) + ); + }); +}); + +describe('toVsixManifest', () => { + it('should produce a good xml', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + assert.ok(result); + assert.ok(result.PackageManifest); + assert.ok(result.PackageManifest.$); + assert.strictEqual(result.PackageManifest.$.Version, '2.0.0'); + assert.strictEqual(result.PackageManifest.$.xmlns, 'http://schemas.microsoft.com/developer/vsx-schema/2011'); + assert.strictEqual( + result.PackageManifest.$['xmlns:d'], + 'http://schemas.microsoft.com/developer/vsx-schema-design/2011' + ); + assert.ok(result.PackageManifest.Metadata); + assert.strictEqual(result.PackageManifest.Metadata.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].Description[0]._, 'test extension'); + assert.strictEqual(result.PackageManifest.Metadata[0].DisplayName[0], 'test'); + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Id, 'test'); + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Version, '0.0.1'); + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Publisher, 'mocha'); + assert.deepEqual(result.PackageManifest.Metadata[0].Tags, ['__web_extension']); + assert.deepEqual(result.PackageManifest.Metadata[0].GalleryFlags, ['Public']); + assert.strictEqual(result.PackageManifest.Installation.length, 1); + assert.strictEqual(result.PackageManifest.Installation[0].InstallationTarget.length, 1); + assert.strictEqual( + result.PackageManifest.Installation[0].InstallationTarget[0].$.Id, + 'Microsoft.VisualStudio.Code' + ); + assert.deepEqual(result.PackageManifest.Dependencies, ['']); + assert.strictEqual(result.PackageManifest.Assets.length, 1); + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 1); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[0].$.Type, 'Microsoft.VisualStudio.Code.Manifest'); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[0].$.Path, 'extension/package.json'); + }); + }); + + it('should escape special characters', () => { + const specialCharacters = '\'"<>&`'; + + const name = `name${specialCharacters}`; + const publisher = `publisher${specialCharacters}`; + const version = `version${specialCharacters}`; + const description = `description${specialCharacters}`; + + const manifest = { + name, + publisher, + version, + description, + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Version, version); + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Publisher, publisher); + assert.strictEqual(result.PackageManifest.Metadata[0].DisplayName[0], name); + assert.strictEqual(result.PackageManifest.Metadata[0].Description[0]._, description); + }); + }); + + it('should treat README.md as asset', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/readme.md', contents: Buffer.from('') }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.Details' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/readme.md'); + }); + }); + + it('should handle readmePath', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/foo/readme-foo.md', contents: Buffer.from('') }]; + + return _toVsixManifest(manifest, files, { readmePath: 'foo/readme-foo.md' }) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.Details' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/foo/readme-foo.md'); + }); + }); + + it('should treat CHANGELOG.md as asset', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/changelog.md', contents: Buffer.from('') }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.Changelog' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/changelog.md'); + }); + }); + + it('should handle changelogPath', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/foo/changelog-foo.md', contents: Buffer.from('') }]; + + return _toVsixManifest(manifest, files, { changelogPath: 'foo/changelog-foo.md' }) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.Changelog' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/foo/changelog-foo.md'); + }); + }); + + it('should respect display name', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + displayName: 'Test Extension', + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Metadata[0].Identity[0].$.Id, 'test'); + assert.strictEqual(result.PackageManifest.Metadata[0].DisplayName[0], 'Test Extension'); + }); + }); + + it('should treat any license file as asset', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + license: 'SEE LICENSE IN thelicense.md', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/thelicense.md', contents: '' }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.License' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/thelicense.md'); + }); + }); + + it('should add a license metadata tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + license: 'SEE LICENSE IN thelicense.md', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/thelicense.md', contents: '' }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok(result.PackageManifest.Metadata[0].License); + assert.strictEqual(result.PackageManifest.Metadata[0].License.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].License[0], 'extension/thelicense.md'); + }); + }); + + it('should automatically detect license files', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/LICENSE.md', contents: '' }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok(result.PackageManifest.Metadata[0].License); + assert.strictEqual(result.PackageManifest.Metadata[0].License.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].License[0], 'extension/LICENSE.md'); + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.License' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/LICENSE.md'); + }); + }); + + it('should automatically detect misspelled license files', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [{ path: 'extension/LICENCE.md', contents: '' }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok(result.PackageManifest.Metadata[0].License); + assert.strictEqual(result.PackageManifest.Metadata[0].License.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].License[0], 'extension/LICENCE.md'); + assert.strictEqual(result.PackageManifest.Assets[0].Asset.length, 2); + assert.strictEqual( + result.PackageManifest.Assets[0].Asset[1].$.Type, + 'Microsoft.VisualStudio.Services.Content.License' + ); + assert.strictEqual(result.PackageManifest.Assets[0].Asset[1].$.Path, 'extension/LICENCE.md'); + }); + }); + + it('should add an icon metadata tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + icon: 'fake.png', + license: 'SEE LICENSE IN thelicense.md', + }; + + const files = [ + { path: 'extension/fake.png', contents: '' }, + { path: 'extension/thelicense.md', contents: '' }, + ]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok(result.PackageManifest.Metadata[0].Icon); + assert.strictEqual(result.PackageManifest.Metadata[0].Icon.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].Icon[0], 'extension/fake.png'); + assert.strictEqual(result.PackageManifest.Metadata[0].License[0], 'extension/thelicense.md'); + }); + }); + + it('should add an icon asset', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + icon: 'fake.png', + }; + + const files = [{ path: 'extension/fake.png', contents: '' }]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok( + result.PackageManifest.Assets[0].Asset.some( + d => d.$.Type === 'Microsoft.VisualStudio.Services.Icons.Default' && d.$.Path === 'extension/fake.png' + ) + ); + }); + }); + + it('should add asset with win path', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + icon: 'fake.png', + license: 'SEE LICENSE IN thelicense.md', + }; + + const files = [ + { path: 'extension\\fake.png', contents: '' }, + { path: 'extension\\thelicense.md', contents: '' }, + ]; + + return _toVsixManifest(manifest, files) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.ok(result.PackageManifest.Metadata[0].Icon); + assert.strictEqual(result.PackageManifest.Metadata[0].Icon.length, 1); + assert.strictEqual(result.PackageManifest.Metadata[0].Icon[0], 'extension/fake.png'); + assert.strictEqual(result.PackageManifest.Metadata[0].License[0], 'extension/thelicense.md'); + }); + }); + + it('should understand gallery color and theme', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + galleryBanner: { + color: '#5c2d91', + theme: 'dark', + }, + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property.map(p => p.$); + assert.ok( + properties.some(p => p.Id === 'Microsoft.VisualStudio.Services.Branding.Color' && p.Value === '#5c2d91') + ); + assert.ok( + properties.some(p => p.Id === 'Microsoft.VisualStudio.Services.Branding.Theme' && p.Value === 'dark') + ); + }); + }); + + it('should understand all link types', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: { + type: 'git', + url: 'https://server.com/Microsoft/vscode-spell-check.git', + }, + bugs: { + url: 'https://server.com/Microsoft/vscode-spell-check/issues', + }, + homepage: 'https://server.com/Microsoft/vscode-spell-check', + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property.map(p => p.$); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Source' && + p.Value === 'https://server.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Getstarted' && + p.Value === 'https://server.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Repository' && + p.Value === 'https://server.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Support' && + p.Value === 'https://server.com/Microsoft/vscode-spell-check/issues' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Learn' && + p.Value === 'https://server.com/Microsoft/vscode-spell-check' + ) + ); + }); + }); + + it('should detect github repositories', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: { + type: 'git', + url: 'https://github.com/Microsoft/vscode-spell-check.git', + }, + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property.map(p => p.$); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.GitHub' && + p.Value === 'https://github.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok(properties.every(p => p.Id !== 'Microsoft.VisualStudio.Services.Links.Repository')); + }); + }); + + it('should detect short gitlab repositories', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'gitlab:Microsoft/vscode-spell-check', + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property.map(p => p.$); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Repository' && + p.Value === 'https://gitlab.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Support' && + p.Value === 'https://gitlab.com/Microsoft/vscode-spell-check/issues' + ) + ); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.Learn' && + p.Value === 'https://gitlab.com/Microsoft/vscode-spell-check#readme' + ) + ); + }); + }); + + it('should detect short github repositories', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'Microsoft/vscode-spell-check', + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property.map(p => p.$); + assert.ok( + properties.some( + p => + p.Id === 'Microsoft.VisualStudio.Services.Links.GitHub' && + p.Value === 'https://github.com/Microsoft/vscode-spell-check.git' + ) + ); + assert.ok(properties.every(p => p.Id !== 'Microsoft.VisualStudio.Services.Links.Repository')); + }); + }); + + it('should understand categories', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + categories: ['hello', 'world'], + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const categories = result.PackageManifest.Metadata[0].Categories[0].split(','); + assert.ok(categories.some(c => c === 'hello')); + assert.ok(categories.some(c => c === 'world')); + }); + }); + + it('should respect preview flag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + preview: true, + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + assert.deepEqual(result.PackageManifest.Metadata[0].GalleryFlags, ['Public Preview']); + }); + }); + + it('should automatically add theme tag for color themes', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + themes: [{ label: 'monokai', uiTheme: 'vs', path: 'monokai.tmTheme' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'theme')); + }); + }); + + it('should not automatically add theme tag when themes are empty', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + themes: [], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => assert.deepEqual(result.PackageManifest.Metadata[0].Tags[0], '__web_extension')); + }); + + it('should automatically add color-theme tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + themes: [{ label: 'monokai', uiTheme: 'vs', path: 'monokai.tmTheme' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'color-theme')); + }); + }); + + it('should automatically add theme tag for icon themes', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + iconThemes: [{ id: 'fakeicons', label: 'fakeicons', path: 'fake.icons' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'theme')); + }); + }); + + it('should automatically add icon-theme tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + iconThemes: [{ id: 'fakeicons', label: 'fakeicons', path: 'fake.icons' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'icon-theme')); + }); + }); + + it('should automatically add product-icon-theme tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + productIconThemes: [{ id: 'fakeicons', label: 'fakeicons', path: 'fake.icons' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'product-icon-theme')); + }); + }); + + it('should automatically add remote-menu tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + menus: { + 'statusBar/remoteIndicator': [ + { + command: 'remote-wsl.newWindow', + }, + ], + }, + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'remote-menu')); + }); + }); + + it('should automatically add language tag with activationEvent', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + activationEvents: ['onLanguage:go'], + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => assert.deepEqual(result.PackageManifest.Metadata[0].Tags[0], 'go,__web_extension')); + }); + + it('should automatically add language tag with language contribution', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + languages: [{ id: 'go' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => assert.deepEqual(result.PackageManifest.Metadata[0].Tags[0], 'go,__web_extension')); + }); + + it('should automatically add snippets tag', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + snippets: [{ language: 'go', path: 'gosnippets.json' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => assert.deepEqual(result.PackageManifest.Metadata[0].Tags[0], 'snippet,__web_extension')); + }); + + it('should remove duplicate tags', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + keywords: ['theme', 'theme'], + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => assert.deepEqual(result.PackageManifest.Metadata[0].Tags[0], 'theme,__web_extension')); + }); + + it('should detect keybindings', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + keybindings: [{ command: 'hello', key: 'ctrl+f1' }], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'keybindings')); + }); + }); + + it('should detect debuggers', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + debuggers: [ + { + type: 'node', + label: 'Node Debug', + program: './out/node/nodeDebug.js', + runtime: 'node', + enableBreakpointsFor: { languageIds: ['javascript', 'javascriptreact'] }, + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'debuggers')); + }); + }); + + it('should detect json validation rules', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + jsonValidation: [ + { + fileMatch: '.jshintrc', + url: 'http://json.schemastore.org/jshintrc', + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'json')); + }); + }); + + it('should detect keywords in description', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + description: 'This C++ extension likes combines ftp with javascript', + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok( + tags.some(tag => tag === 'c++'), + 'detect c++' + ); + assert.ok( + tags.some(tag => tag === 'ftp'), + 'detect ftp' + ); + assert.ok( + tags.some(tag => tag === 'javascript'), + 'detect javascript' + ); + assert.ok(!tags.includes('java'), "don't detect java"); + }); + }); + + it('should detect language grammars', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + grammars: [ + { + language: 'shellscript', + scopeName: 'source.shell', + path: './syntaxes/Shell-Unix-Bash.tmLanguage', + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'shellscript')); + }); + }); + + it('should detect language aliases', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + languages: [ + { + id: 'go', + aliases: ['golang', 'google-go'], + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'go')); + assert.ok(tags.some(tag => tag === 'golang')); + assert.ok(tags.some(tag => tag === 'google-go')); + }); + }); + + it('should detect localization contributions', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + localizations: [ + { + languageId: 'de', + translations: [ + { id: 'vscode', path: 'fake.json' }, + { id: 'vscode.go', path: 'what.json' }, + ], + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === 'lp-de')); + assert.ok(tags.some(tag => tag === '__lp_vscode')); + assert.ok(tags.some(tag => tag === '__lp-de_vscode')); + assert.ok(tags.some(tag => tag === '__lp_vscode.go')); + assert.ok(tags.some(tag => tag === '__lp-de_vscode.go')); + }); + }); + + it('should expose localization contributions as assets', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + localizations: [ + { + languageId: 'de', + languageName: 'German', + translations: [ + { id: 'vscode', path: 'de.json' }, + { id: 'vscode.go', path: 'what.json' }, + ], + }, + { + languageId: 'pt', + languageName: 'Portuguese', + localizedLanguageName: 'Português', + translations: [{ id: 'vscode', path: './translations/pt.json' }], + }, + ], + }, + }; + + const files = [ + { path: 'extension/de.json', contents: Buffer.from('') }, + { path: 'extension/translations/pt.json', contents: Buffer.from('') }, + ]; + + return _toVsixManifest(manifest, files) + .then(parseXmlManifest) + .then(result => { + const assets = result.PackageManifest.Assets[0].Asset; + assert.ok( + assets.some( + asset => + asset.$.Type === 'Microsoft.VisualStudio.Code.Translation.DE' && asset.$.Path === 'extension/de.json' + ) + ); + assert.ok( + assets.some( + asset => + asset.$.Type === 'Microsoft.VisualStudio.Code.Translation.PT' && + asset.$.Path === 'extension/translations/pt.json' + ) + ); + + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const localizedLangProp = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.LocalizedLanguages'); + assert.strictEqual(localizedLangProp.length, 1); + + const localizedLangs = localizedLangProp[0].$.Value.split(','); + assert.strictEqual(localizedLangs.length, 2); + assert.strictEqual(localizedLangs[0], 'German'); + assert.strictEqual(localizedLangs[1], 'Português'); + }); + }); + + it('should detect language extensions', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + languages: [ + { + id: 'go', + extensions: ['go', 'golang'], + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === '__ext_go')); + assert.ok(tags.some(tag => tag === '__ext_golang')); + }); + }); + + it('should detect and sanitize language extensions', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + languages: [ + { + id: 'go', + extensions: ['.go'], + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + assert.ok(tags.some(tag => tag === '__ext_go')); + }); + }); + + it('should understand badges', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + badges: [ + { url: 'http://badgeurl.png', href: 'http://badgeurl', description: 'this is a badge' }, + { url: 'http://anotherbadgeurl.png', href: 'http://anotherbadgeurl', description: 'this is another badge' }, + ], + }; + + return _toVsixManifest(manifest, []) + .then(xml => parseXmlManifest(xml)) + .then(result => { + const badges = result.PackageManifest.Metadata[0].Badges[0].Badge; + assert.strictEqual(badges.length, 2); + assert.strictEqual(badges[0].$.Link, 'http://badgeurl'); + assert.strictEqual(badges[0].$.ImgUri, 'http://badgeurl.png'); + assert.strictEqual(badges[0].$.Description, 'this is a badge'); + assert.strictEqual(badges[1].$.Link, 'http://anotherbadgeurl'); + assert.strictEqual(badges[1].$.ImgUri, 'http://anotherbadgeurl.png'); + assert.strictEqual(badges[1].$.Description, 'this is another badge'); + }); + }); + + it('should not have empty keywords #114', () => { + const manifest: ManifestPackage = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + contributes: { + grammars: [ + { + language: 'javascript', + scopeName: 'source.js.jsx', + path: './syntaxes/Babel Language.json', + }, + { + language: 'regex', + scopeName: 'source.regexp.babel', + path: './syntaxes/Babel Regex.json', + }, + ], + }, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const tags = result.PackageManifest.Metadata[0].Tags[0].split(',') as string[]; + tags.forEach(tag => assert.ok(tag, `Found empty tag '${tag}'.`)); + }); + }); + + it('should use engine as a version property', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: { vscode: '^1.0.0' } as any, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const engineProperties = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.Engine'); + assert.strictEqual(engineProperties.length, 1); + + const engine = engineProperties[0].$.Value; + assert.strictEqual(engine, '^1.0.0'); + }); + }); + + it('should use github markdown by default', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + assert.ok( + properties.some( + p => p.$.Id === 'Microsoft.VisualStudio.Services.GitHubFlavoredMarkdown' && p.$.Value === 'true' + ) + ); + }); + }); + + it('should understand the markdown property', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + markdown: 'standard' as 'standard', + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + assert.ok( + properties.some( + p => p.$.Id === 'Microsoft.VisualStudio.Services.GitHubFlavoredMarkdown' && p.$.Value === 'false' + ) + ); + }); + }); + + it('should ignore unknown markdown properties', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + markdown: 'wow' as any, + engines: Object.create(null), + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + assert.ok( + properties.some( + p => p.$.Id === 'Microsoft.VisualStudio.Services.GitHubFlavoredMarkdown' && p.$.Value === 'true' + ) + ); + }); + }); + + it('should add extension dependencies property', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + extensionDependencies: ['foo.bar', 'foo.bar', 'monkey.hello'], + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const dependenciesProp = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.ExtensionDependencies'); + assert.strictEqual(dependenciesProp.length, 1); + + const dependencies = dependenciesProp[0].$.Value.split(','); + assert.strictEqual(dependencies.length, 2); + assert.ok(dependencies.some(d => d === 'foo.bar')); + assert.ok(dependencies.some(d => d === 'monkey.hello')); + }); + }); + + it('should error with files with same case insensitive name', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const files = [ + { path: 'extension/file.txt', contents: '' }, + { path: 'extension/FILE.txt', contents: '' }, + ]; + + try { + await _toVsixManifest(manifest, files); + } catch (err: any) { + assert.ok(/have the same case insensitive path/i.test(err.message)); + return; + } + + throw new Error('Should not reach here'); + }); + + it('should automatically add web tag for web extensions', async () => { + const manifest = createManifest({ browser: 'browser.js' }); + const files = [{ path: 'extension/browser.js', contents: Buffer.from('') }]; + + const vsixManifest = await _toVsixManifest(manifest, files); + const result = await parseXmlManifest(vsixManifest); + + assert.strictEqual(result.PackageManifest.Metadata[0].Tags[0], '__web_extension'); + }); + + it('should expose extension kind properties when provided', async () => { + const manifest = createManifest({ + extensionKind: ['ui', 'workspace', 'web'], + }); + const files = [{ path: 'extension/main.js', contents: Buffer.from('') }]; + + const vsixManifest = await _toVsixManifest(manifest, files); + const result = await parseXmlManifest(vsixManifest); + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const extensionKindProps = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.ExtensionKind'); + assert.strictEqual(extensionKindProps[0].$.Value, ['ui', 'workspace', 'web'].join(',')); + }); + + it('should expose extension kind properties when derived', async () => { + const manifest = createManifest({ + main: 'main.js', + }); + const files = [{ path: 'extension/main.js', contents: Buffer.from('') }]; + + const vsixManifest = await _toVsixManifest(manifest, files); + const result = await parseXmlManifest(vsixManifest); + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const extensionKindProps = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.ExtensionKind'); + assert.strictEqual(extensionKindProps[0].$.Value, 'workspace'); + }); + + it('should not have target platform by default', async () => { + const manifest = createManifest(); + const raw = await _toVsixManifest(manifest, []); + const dom = await parseXmlManifest(raw); + + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Id, 'test'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Version, '0.0.1'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Publisher, 'mocha'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.TargetPlatform, undefined); + }); + + it('should set the right target platform by default', async () => { + const manifest = createManifest(); + const raw = await _toVsixManifest(manifest, [], { target: 'win32-x64' }); + const dom = await parseXmlManifest(raw); + + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Id, 'test'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Version, '0.0.1'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Publisher, 'mocha'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.TargetPlatform, 'win32-x64'); + }); + + it('should set the target platform when engine is set to insider', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.62.0-insider' } }); + const raw = await _toVsixManifest(manifest, [], { target: 'win32-x64' }); + const dom = await parseXmlManifest(raw); + + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Id, 'test'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Version, '0.0.1'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.Publisher, 'mocha'); + assert.strictEqual(dom.PackageManifest.Metadata[0].Identity[0].$.TargetPlatform, 'win32-x64'); + }); + + it('should fail when target is invalid', async () => { + const manifest = createManifest(); + + try { + await _toVsixManifest(manifest, [], { target: 'what' }); + } catch (err: any) { + return assert.ok(/is not a valid VS Code target/i.test(err.message)); + } + + throw new Error('Should not reach here'); + }); + + it('should throw when using an invalid target platform', async () => { + const manifest = createManifest(); + + try { + await _toVsixManifest(manifest, [], { target: 'linux-ia32' }); + } catch (err: any) { + return assert.ok(/not a valid VS Code target/.test(err.message)); + } + + throw new Error('Should not reach here'); + }); + + it('should throw when targeting an old VS Code version with platform specific', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.60.0' } }); + + try { + await _toVsixManifest(manifest, [], { target: 'linux-ia32' }); + } catch (err: any) { + return assert.ok(/>=1.61/.test(err.message)); + } + + throw new Error('Should not reach here'); + }); + + it('should add prerelease property when --pre-release flag is passed', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.63.0' } }); + + const raw = await _toVsixManifest(manifest, [], { preRelease: true }); + const xmlManifest = await parseXmlManifest(raw); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Code.PreRelease', 'true'); + }); + + it('should add executes code property when main is passed', async () => { + const manifest = createManifest({ main: 'main.js' }); + const files = [{ path: 'extension/main.js', contents: Buffer.from('') }]; + + const raw = await _toVsixManifest(manifest, files); + const xmlManifest = await parseXmlManifest(raw); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Code.ExecutesCode', 'true'); + }); + + it('should add executes code property when browser is passed', async () => { + const manifest = createManifest({ browser: 'browser.js' }); + const files = [{ path: 'extension/browser.js', contents: Buffer.from('') }]; + + const raw = await _toVsixManifest(manifest, files); + const xmlManifest = await parseXmlManifest(raw); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Code.ExecutesCode', 'true'); + }); + + it('should not add executes code property when neither main nor browser is passed', async () => { + const manifest = createManifest(); + + const raw = await _toVsixManifest(manifest, []); + const xmlManifest = await parseXmlManifest(raw); + + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Code.ExecutesCode'); + }); + + it('should add sponsor link property', () => { + const sponsor = { url: 'https://foo.bar' }; + const manifest: ManifestPackage = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + sponsor, + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const sponsorLinkProp = properties.find(p => p.$.Id === 'Microsoft.VisualStudio.Code.SponsorLink'); + assert.strictEqual(sponsorLinkProp?.$.Value, sponsor.url); + }); + }); + + it('should automatically add sponsor tag for extension with sponsor link', async () => { + const manifest = createManifest({ sponsor: { url: 'https://foo.bar' } }); + const vsixManifest = await _toVsixManifest(manifest, []); + const result = await parseXmlManifest(vsixManifest); + + assert.ok(result.PackageManifest.Metadata[0].Tags[0].split(',').includes('__sponsor_extension')); + }); + + it('should add prerelease property when --pre-release flag is passed when engine property is for insiders', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.64.0-insider' } }); + + const raw = await _toVsixManifest(manifest, [], { preRelease: true }); + const xmlManifest = await parseXmlManifest(raw); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Code.PreRelease', 'true'); + }); + + it('should not add prerelease property when --pre-release flag is not passed', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.64.0' } }); + + const raw = await _toVsixManifest(manifest, []); + const xmlManifest = await parseXmlManifest(raw); + + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Code.PreRelease'); + }); + + it('should throw when targeting an old VS Code version with --pre-release', async () => { + const manifest = createManifest({ engines: { vscode: '>=1.62.0' } }); + + try { + await _toVsixManifest(manifest, [], { preRelease: true }); + } catch (err: any) { + return assert.ok(/>=1.63/.test(err.message)); + } + + throw new Error('Should not reach here'); + }); + + it('should identify trial version of an extension', async () => { + const manifest = createManifest({ pricing: 'Trial' }); + var raw = await _toVsixManifest(manifest, []); + const xmlManifest = await parseXmlManifest(raw); + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Services.Content.Pricing', 'Trial'); + }); + + it('should expose enabledApiProposals as properties', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + enabledApiProposals: [ + 'foo', + 'bar@2' + ], + }; + + return _toVsixManifest(manifest, []) + .then(parseXmlManifest) + .then(result => { + const properties = result.PackageManifest.Metadata[0].Properties[0].Property; + const enabledApiProposalsProp = properties.filter(p => p.$.Id === 'Microsoft.VisualStudio.Code.EnabledApiProposals'); + assert.strictEqual(enabledApiProposalsProp.length, 1); + + const enabledApiProposals = enabledApiProposalsProp[0].$.Value.split(','); + assert.strictEqual(enabledApiProposals.length, 2); + assert.strictEqual(enabledApiProposals[0], 'foo'); + assert.strictEqual(enabledApiProposals[1], 'bar@2'); + }); + }); +}); + +describe('qna', () => { + it('should use marketplace qna by default', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + }); + + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA'); + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink'); + }); + + it('should not use marketplace in a github repo, without specifying it', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }); + + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA'); + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink'); + }); + + it('should use marketplace in a github repo, when specifying it', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + qna: 'marketplace', + }); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA', 'true'); + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink'); + }); + + it('should handle qna=marketplace', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + qna: 'marketplace', + }); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA', 'true'); + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink'); + }); + + it('should handle qna=false', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + qna: false, + }); + + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA', 'false'); + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink'); + }); + + it('should handle custom qna', async () => { + const xmlManifest = await toXMLManifest({ + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + qna: 'http://myqna', + }); + + assertMissingProperty(xmlManifest, 'Microsoft.VisualStudio.Services.EnableMarketplaceQnA'); + assertProperty(xmlManifest, 'Microsoft.VisualStudio.Services.CustomerQnALink', 'http://myqna'); + }); +}); + +describe('toContentTypes', () => { + it('should produce a good xml', () => { + return toContentTypes([]) + .then(xml => parseContentTypes(xml)) + .then(result => { + assert.ok(result); + assert.ok(result.Types); + assert.ok(result.Types.Default); + assert.strictEqual(result.Types.Default.length, 2); + assert.ok(result.Types.Default.some(d => d.$.Extension === '.vsixmanifest' && d.$.ContentType === 'text/xml')); + assert.ok(result.Types.Default.some(d => d.$.Extension === '.json' && d.$.ContentType === 'application/json')); + }); + }); + + it('should include extra extensions', () => { + const files = [ + { path: 'hello.txt', contents: '' }, + { path: 'hello.png', contents: '' }, + { path: 'hello.md', contents: '' }, + { path: 'hello', contents: '' }, + ]; + + return toContentTypes(files) + .then(xml => parseContentTypes(xml)) + .then(result => { + assert.ok(result.Types.Default, 'there are content types'); + assert.ok( + result.Types.Default.some(d => d.$.Extension === '.txt' && d.$.ContentType === 'text/plain'), + 'there are txt' + ); + assert.ok( + result.Types.Default.some(d => d.$.Extension === '.png' && d.$.ContentType === 'image/png'), + 'there are png' + ); + assert.ok( + result.Types.Default.some(d => d.$.Extension === '.md' && /^text\/(x-)?markdown$/.test(d.$.ContentType)), + 'there are md' + ); + assert.ok(!result.Types.Default.some(d => d.$.Extension === '')); + }); + }); +}); + +describe('LaunchEntryPointProcessor', () => { + it('should detect when declared entrypoint is not in package', async () => { + const manifest = createManifest({ main: 'main.js' }); + const files = [{ path: 'extension/browser.js', contents: Buffer.from('') }]; + + let didErr = false; + + try { + await _toVsixManifest(manifest, files); + } catch (err: any) { + const message = err.message; + didErr = message.includes('entrypoint(s) missing') && message.includes('main.js'); + } + + assert.ok(didErr); + }); + + it('should work even if .js extension is not used', async () => { + const manifest = createManifest({ main: 'out/src/extension' }); + const files = [{ path: 'extension/out/src/extension.js', contents: Buffer.from('') }]; + await _toVsixManifest(manifest, files); + }); + + it('should accept manifest if no entrypoints defined', async () => { + const manifest = createManifest({}); + const files = [{ path: 'extension/something.js', contents: Buffer.from('') }]; + await _toVsixManifest(manifest, files); + }); +}); +describe('ManifestProcessor', () => { + it('should ensure that package.json is writable', async () => { + const root = fixture('uuid'); + const manifest = JSON.parse(await fs.promises.readFile(path.join(root, 'package.json'), 'utf8')); + const processor = new ManifestProcessor(manifest); + const packageJson = { + path: 'extension/package.json', + localPath: path.join(root, 'package.json'), + }; + + const outPackageJson = await processor.onFile(packageJson); + assert.ok(outPackageJson.mode); + assert.ok(outPackageJson.mode & 0o200); + }); + + it('should bump package.json version in-memory when using --no-update-package-json', async () => { + const root = fixture('uuid'); + + let manifest = JSON.parse(await fs.promises.readFile(path.join(root, 'package.json'), 'utf8')); + assert.deepStrictEqual(manifest.version, '1.0.0'); + + const processor = new ManifestProcessor(manifest, { version: '1.1.1', updatePackageJson: false }); + const packageJson = { + path: 'extension/package.json', + localPath: path.join(root, 'package.json'), + }; + + manifest = JSON.parse(await read(await processor.onFile(packageJson))); + assert.deepStrictEqual(manifest.version, '1.1.1'); + assert.deepStrictEqual(processor.vsix.version, '1.1.1'); + + manifest = JSON.parse(await fs.promises.readFile(path.join(root, 'package.json'), 'utf8')); + assert.deepStrictEqual(manifest.version, '1.0.0'); + }); + + it('should not bump package.json version in-memory when not using --no-update-package-json', async () => { + const root = fixture('uuid'); + + let manifest = JSON.parse(await fs.promises.readFile(path.join(root, 'package.json'), 'utf8')); + assert.deepStrictEqual(manifest.version, '1.0.0'); + + const processor = new ManifestProcessor(manifest, { version: '1.1.1' }); + const packageJson = { + path: 'extension/package.json', + localPath: path.join(root, 'package.json'), + }; + + manifest = JSON.parse(await read(await processor.onFile(packageJson))); + assert.deepStrictEqual(manifest.version, '1.0.0'); + assert.deepStrictEqual(processor.vsix.version, '1.0.0'); + + manifest = JSON.parse(await fs.promises.readFile(path.join(root, 'package.json'), 'utf8')); + assert.deepStrictEqual(manifest.version, '1.0.0'); + }); +}); + +describe('MarkdownProcessor', () => { + it('should throw when no baseContentUrl is provided', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + let didThrow = false; + + try { + await processor.onFile(readme); + } catch (err: any) { + didThrow = true; + } + + assert.ok(didThrow); + }); + + it('should take baseContentUrl', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + baseContentUrl: 'https://github.com/username/repository/blob/master', + baseImagesUrl: 'https://github.com/username/repository/raw/master', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a github repo', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should replace relative links with GitHub URLs while respecting githubBranch', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + githubBranch: 'main', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.branch.main.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should override image URLs with baseImagesUrl while also respecting githubBranch', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + githubBranch: 'main', + // Override image relative links to point to different base URL + baseImagesUrl: 'https://github.com/base', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises + .readFile(path.join(root, 'readme.branch.override.images.expected.md'), 'utf8') + .then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should override githubBranch setting with baseContentUrl', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + githubBranch: 'main', + baseContentUrl: 'https://github.com/base', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises + .readFile(path.join(root, 'readme.branch.override.content.expected.md'), 'utf8') + .then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a github repo (.git)', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a github repo (short format)', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'github:username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a gitlab repo', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a gitlab repo (.git)', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should infer baseContentUrl if its a gitlab repo (short format)', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'gitlab:username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.default.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should replace relative links with GitLab URLs while respecting gitlabBranch', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + gitlabBranch: 'main', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.branch.main.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should override image URLs with baseImagesUrl while also respecting gitlabBranch', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + gitlabBranch: 'main', + // Override image relative links to point to different base URL + baseImagesUrl: 'https://gitlab.com/base', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises + .readFile(path.join(root, 'readme.gitlab.branch.override.images.expected.md'), 'utf8') + .then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should override gitlabBranch setting with baseContentUrl', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { + gitlabBranch: 'main', + baseContentUrl: 'https://gitlab.com/base', + }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises + .readFile(path.join(root, 'readme.gitlab.branch.override.content.expected.md'), 'utf8') + .then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should replace img urls with baseImagesUrl', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository.git', + }; + + const options = { + baseImagesUrl: 'https://github.com/username/repository/path/to', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, options); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.images.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should replace issue links with urls if its a github repo.', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.github.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.github.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should not replace issue links with urls if its a github repo but issue link expansion is disabled.', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://github.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { gitHubIssueLinking: false }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.github.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.github.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should not replace issue links with urls if its not a github repo.', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://some-other-provider.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.github.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.github.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should replace issue links with urls if its a gitlab repo.', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, {}); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.gitlab.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.expected.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should not replace issue links with urls if its a gitlab repo but issue link expansion is disabled.', () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + description: 'test extension', + engines: Object.create(null), + repository: 'https://gitlab.com/username/repository.git', + }; + + const root = fixture('readme'); + const processor = new ReadmeProcessor(manifest, { gitLabIssueLinking: false }); + const readme = { + path: 'extension/readme.md', + localPath: path.join(root, 'readme.gitlab.md'), + }; + + return processor + .onFile(readme) + .then(file => read(file)) + .then(actual => { + return fs.promises.readFile(path.join(root, 'readme.gitlab.md'), 'utf8').then(expected => { + assert.strictEqual(actual, expected); + }); + }); + }); + + it('should prevent non-HTTPS images', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](http://foo.png)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should prevent non-HTTPS img tags', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should prevent SVGs from not trusted sources', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](https://foo/hello.svg)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should allow SVGs from trusted sources', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](https://badges.gitter.im/hello.svg)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + const file = await processor.onFile(readme); + assert.ok(file); + }); + + it('should allow SVG from GitHub actions in image tag (old url format)', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](https://github.com/fakeuser/fakerepo/workflows/fakeworkflowname/badge.svg)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + const file = await processor.onFile(readme); + assert.ok(file); + }); + + it('should allow SVG from GitHub actions in image tag', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](https://github.com/fakeuser/fakerepo/actions/workflows/fakeworkflowname/badge.svg)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + const file = await processor.onFile(readme); + assert.ok(file); + }); + + it('should prevent SVG from a GitHub repo in image tag', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `![title](https://github.com/eviluser/evilrepo/blob/master/malicious.svg)`; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should prevent SVGs from not trusted sources in img tags', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should allow SVGs from trusted sources in img tags', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + const file = await processor.onFile(readme); + assert.ok(file); + }); + + it('should prevent SVG tags', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should prevent SVG data urls in img tags', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); + + it('should allow img tags spanning across lines, issue #904', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = ``; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + const file = await processor.onFile(readme); + assert.ok(file); + }); + + it('should catch an unchanged README.md', async () => { + const manifest = { + name: 'test', + publisher: 'mocha', + version: '0.0.1', + engines: Object.create(null), + repository: 'https://github.com/username/repository', + }; + const contents = `This is the README for your extension `; + const processor = new ReadmeProcessor(manifest, {}); + const readme = { path: 'extension/readme.md', contents }; + + await throws(() => processor.onFile(readme)); + }); +}); + +describe('LicenseProcessor', () => { + it('should fail if license file not specified', async () => { + const originalUtilWarn = log.warn; + const logs: string[] = []; + + log.warn = (message) => { + logs.push(message); + }; + + const message = 'LICENSE, LICENSE.md, or LICENSE.txt not found'; + + const processor = new LicenseProcessor(createManifest(), {}); + await processor.onEnd(); + + log.warn = originalUtilWarn; + + assert.strictEqual(logs.length, 1); + assert.strictEqual(logs[0], message); + }); + + it('should pass if no license specified and --skip-license flag is passed', async () => { + const originalUtilWarn = log.warn; + const logs: string[] = []; + + log.warn = (message) => { + logs.push(message); + }; + + const processor = new LicenseProcessor(createManifest(), { skipLicense: true }); + await processor.onEnd(); + + log.warn = originalUtilWarn; + + assert.strictEqual(logs.length, 0); + }); +}); + +describe('version', function () { + this.timeout(5000); + + let dir: tmp.DirResult; + const fixtureFolder = fixture('vsixmanifest'); + let cwd: string; + + const git = (args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf-8', shell: true }); + + beforeEach(() => { + dir = tmp.dirSync({ unsafeCleanup: true }); + cwd = dir.name; + fs.copyFileSync(path.join(fixtureFolder, 'package.json'), path.join(cwd, 'package.json')); + git(['init']); + git(['config', '--local', 'user.name', 'Sample Name']); + git(['config', '--local', 'user.email', 'sample@email.com']); + }); + + afterEach(() => { + dir.removeCallback(); + }); + + it('should bump patch version', async () => { + await versionBump({ cwd, version: 'patch' }); + + const newManifest = await readManifest(cwd); + + assert.strictEqual(newManifest.version, '1.0.1'); + }); + + it('should bump minor version', async () => { + await versionBump({ cwd, version: 'minor' }); + + const newManifest = await readManifest(cwd); + + assert.strictEqual(newManifest.version, '1.1.0'); + }); + + it('should bump major version', async () => { + await versionBump({ cwd, version: 'major' }); + + const newManifest = await readManifest(cwd); + + assert.strictEqual(newManifest.version, '2.0.0'); + }); + + it('should set custom version', async () => { + await versionBump({ cwd, version: '1.1.1' }); + + const newManifest = await readManifest(cwd); + + assert.strictEqual(newManifest.version, '1.1.1'); + }); + + it('should fail with invalid version', async () => { + await assert.rejects(versionBump({ cwd, version: 'a1.a.2' })); + await assert.rejects(versionBump({ cwd, version: 'prepatch' })); + await assert.rejects(versionBump({ cwd, version: 'preminor' })); + await assert.rejects(versionBump({ cwd, version: 'premajor' })); + await assert.rejects(versionBump({ cwd, version: 'prerelease' })); + await assert.rejects(versionBump({ cwd, version: 'from-git' })); + }); + + it('should create git tag and commit', async () => { + await versionBump({ cwd, version: '1.1.1' }); + + assert.strictEqual(git(['rev-parse', 'v1.1.1']).status, 0); + assert.strictEqual(git(['rev-parse', 'HEAD']).status, 0); + }); + + it('should use custom commit message', async () => { + const commitMessage = 'test commit message'; + await versionBump({ cwd, version: '1.1.1', commitMessage }); + + assert.deepStrictEqual(git(['show', '-s', '--format=%B', 'HEAD']).stdout, `${commitMessage}\n\n`); + }); + + it('should not create git tag and commit', async () => { + await versionBump({ cwd, version: '1.1.1', gitTagVersion: false }); + + assert.notDeepStrictEqual(git(['rev-parse', 'v1.1.1']).status, 0); + assert.notDeepStrictEqual(git(['rev-parse', 'HEAD']).status, 0); + }); + + it('should not write to package.json with --no-update-package-json', async () => { + await versionBump({ cwd, version: '1.1.1', updatePackageJson: false }); + const newManifest = await readManifest(cwd); + assert.strictEqual(newManifest.version, '1.0.0'); + }); +}); diff --git a/src/zip.ts b/src/zip.ts new file mode 100644 index 00000000..cb68d9b5 --- /dev/null +++ b/src/zip.ts @@ -0,0 +1,76 @@ +import { Entry, open, ZipFile } from 'yauzl'; +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) => { + const buffers: Buffer[] = []; + stream.on('data', buffer => buffers.push(buffer)); + stream.once('error', e); + stream.once('end', () => c(Buffer.concat(buffers))); + }); +} + +export async function readZip(packagePath: string, filter: (name: string) => boolean): Promise> { + const zipfile = await new Promise((c, e) => + open(packagePath, { lazyEntries: true }, (err, zipfile) => (err ? e(err) : c(zipfile!))) + ); + + return await new Promise((c, e) => { + const result = new Map(); + + zipfile.once('close', () => c(result)); + + zipfile.readEntry(); + zipfile.on('entry', (entry: Entry) => { + const name = entry.fileName.toLowerCase(); + + if (filter(name)) { + zipfile.openReadStream(entry, (err, stream) => { + if (err) { + zipfile.close(); + return e(err); + } + + bufferStream(stream!).then(buffer => { + result.set(name, buffer); + zipfile.readEntry(); + }); + }); + } else { + zipfile.readEntry(); + } + }); + }); +} + +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')); + + if (!rawManifest) { + throw new Error('Manifest not found'); + } + + const rawXmlManifest = map.get('extension.vsixmanifest'); + + if (!rawXmlManifest) { + 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: manifestValidated, + xmlManifest: await parseXmlManifest(rawXmlManifest.toString('utf8')), + }; +}