diff --git a/package-lock.json b/package-lock.json index 0fe9543a..55ed5a4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1040,7 +1040,8 @@ "version": "16.18.96", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.96.tgz", "integrity": "sha512-84iSqGXoO+Ha16j8pRZ/L90vDMKX04QTYMTfYeE1WrjWaZXuchBehGUZEpNgx7JnmlrIHdnABmpjrQjhCnNldQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -4631,6 +4632,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5755,7 +5757,8 @@ "version": "16.18.96", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.96.tgz", "integrity": "sha512-84iSqGXoO+Ha16j8pRZ/L90vDMKX04QTYMTfYeE1WrjWaZXuchBehGUZEpNgx7JnmlrIHdnABmpjrQjhCnNldQ==", - "dev": true + "dev": true, + "peer": true }, "@types/normalize-package-data": { "version": "2.4.4", @@ -8275,7 +8278,8 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "2.1.0", diff --git a/src/main.ts b/src/main.ts index 68d14e3a..705e09d4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,10 @@ import { publish, unpublish } from './publish'; import { show } from './show'; import { search } from './search'; import { listPublishers, deletePublisher, loginPublisher, logoutPublisher, verifyPat } from './store'; -import { getLatestVersion } from './npm'; import { CancellationToken, log } from './util'; import * as semver from 'semver'; import { isatty } from 'tty'; +import { getPackageManager, Managers, PackageManagerLiteral } from './managers'; const pkg = require('../package.json'); @@ -35,13 +35,19 @@ See https://code.visualstudio.com/api/working-with-extensions/publishing-extensi process.exit(1); } -function main(task: Promise): void { +export interface IMainOptions { + readonly packageManager?: PackageManagerLiteral; + readonly useYarn?: boolean; +} + +function main(task: Promise, options: IMainOptions): void { let latestVersion: string | null = null; const token = new CancellationToken(); + const manager = getPackageManager(options.packageManager); if (isatty(1)) { - getLatestVersion(pkg.name, token) + manager.pkgRequestLatest(pkg.name, token) .then(version => (latestVersion = version)) .catch(_ => { /* noop */ @@ -50,7 +56,7 @@ function main(task: Promise): void { task.catch(fatal).then(() => { if (latestVersion && semver.gt(latestVersion, pkg.version)) { - log.warn(`The latest version of ${pkg.name} is ${latestVersion} and you have ${pkg.version}.\nUpdate it now: npm install -g ${pkg.name}`); + log.warn(`The latest version of ${pkg.name} is ${latestVersion} and you have ${pkg.version}.\nUpdate it now: ${manager.commandInstall(pkg.name, true)}`); } else { token.cancel(); } @@ -58,6 +64,7 @@ function main(task: Promise): void { } const ValidTargets = [...Targets].join(', '); +const ValidManagers = [...Managers].join(', '); module.exports = function (argv: string[]): void { const program = new Command(); @@ -68,6 +75,7 @@ module.exports = function (argv: string[]): void { .command('ls') .description('Lists all the files that will be published/packaged') .option('--tree', 'Prints the files in a tree format', false) + .option('--packageManager ', `Specify package manager to use. Valid managers: ${ValidManagers}`, undefined) .option('--yarn', 'Use yarn instead of npm (default inferred from presence of yarn.lock or .yarnrc)') .option('--no-yarn', 'Use npm instead of yarn (default inferred from absence of yarn.lock or .yarnrc)') .option( @@ -82,8 +90,8 @@ module.exports = function (argv: string[]): void { .option('--no-dependencies', 'Disable dependency detection via npm or yarn', undefined) .option('--readme-path ', 'Path to README file (defaults to README.md)') .option('--follow-symlinks', 'Recurse into symlinked directories instead of treating them as files') - .action(({ tree, yarn, packagedDependencies, ignoreFile, dependencies, readmePath, followSymlinks }) => - main(ls({ tree, useYarn: yarn, packagedDependencies, ignoreFile, dependencies, readmePath, followSymlinks })) + .action(({ tree, packageManager, yarn, packagedDependencies, ignoreFile, dependencies, readmePath, followSymlinks }) => + main(ls({ tree, packageManager, useYarn: yarn, packagedDependencies, ignoreFile, dependencies, readmePath, followSymlinks }), { packageManager, useYarn: yarn }) ); program @@ -112,6 +120,7 @@ module.exports = function (argv: string[]): void { .option('--no-rewrite-relative-links', 'Skip rewriting relative links.') .option('--baseContentUrl ', 'Prepend all relative links in README.md with the specified URL.') .option('--baseImagesUrl ', 'Prepend all relative image links in README.md with the specified URL.') + .option('--packageManager ', `Specify package manager to use. Valid managers: ${ValidManagers}`, undefined) .option('--yarn', 'Use yarn instead of npm (default inferred from presence of yarn.lock or .yarnrc)') .option('--no-yarn', 'Use npm instead of yarn (default inferred from absence of yarn.lock or .yarnrc)') .option('--ignoreFile ', 'Indicate alternative .vscodeignore') @@ -147,6 +156,7 @@ module.exports = function (argv: string[]): void { rewriteRelativeLinks, baseContentUrl, baseImagesUrl, + packageManager, yarn, ignoreFile, gitHubIssueLinking, @@ -180,6 +190,7 @@ module.exports = function (argv: string[]): void { rewriteRelativeLinks, baseContentUrl, baseImagesUrl, + packageManager, useYarn: yarn, ignoreFile, gitHubIssueLinking, @@ -195,7 +206,8 @@ module.exports = function (argv: string[]): void { skipLicense, signTool, followSymlinks, - }) + }), + { packageManager, useYarn: yarn } ) ); @@ -233,6 +245,7 @@ module.exports = function (argv: string[]): void { ) .option('--baseContentUrl ', 'Prepend all relative links in README.md with the specified URL.') .option('--baseImagesUrl ', 'Prepend all relative image links in README.md with the specified URL.') + .option('--packageManager ', `Specify package manager to use. Valid managers: ${ValidManagers}`, undefined) .option('--yarn', 'Use yarn instead of npm (default inferred from presence of yarn.lock or .yarnrc)') .option('--no-yarn', 'Use npm instead of yarn (default inferred from absence of yarn.lock or .yarnrc)') .option('--no-verify', 'Allow all proposed APIs (deprecated: use --allow-all-proposed-apis instead)') @@ -274,6 +287,7 @@ module.exports = function (argv: string[]): void { gitlabBranch, baseContentUrl, baseImagesUrl, + packageManager, yarn, verify, noVerify, @@ -314,6 +328,7 @@ module.exports = function (argv: string[]): void { gitlabBranch, baseContentUrl, baseImagesUrl, + packageManager, useYarn: yarn, noVerify: noVerify || !verify, allowProposedApis, @@ -331,7 +346,8 @@ module.exports = function (argv: string[]): void { skipLicense, signTool, followSymlinks - }) + }), + { packageManager, useYarn: yarn } ) ); @@ -341,14 +357,14 @@ module.exports = function (argv: string[]): void { .option('-p, --pat ', 'Personal Access Token') .option('--azure-credential', 'Use Microsoft Entra ID for authentication') .option('-f, --force', 'Skip confirmation prompt when unpublishing an extension') - .action((id, { pat, azureCredential, force }) => main(unpublish({ id, pat, azureCredential, force }))); + .action((id, { pat, azureCredential, force }) => main(unpublish({ id, pat, azureCredential, force }), { packageManager: 'npm', useYarn: false })); program .command('generate-manifest') .description('Generates the extension manifest from the provided VSIX package.') .requiredOption('-i, --packagePath ', 'Path to the VSIX package') .option('-o, --out ', 'Output the extension manifest to location (defaults to .manifest)') - .action(({ packagePath, out }) => main(generateManifest(packagePath, out))); + .action(({ packagePath, out }) => main(generateManifest(packagePath, out), { packageManager: 'npm', useYarn: false })); program .command('verify-signature') @@ -356,27 +372,27 @@ module.exports = function (argv: string[]): void { .requiredOption('-i, --packagePath ', 'Path to the VSIX package') .requiredOption('-m, --manifestPath ', 'Path to the Manifest file') .requiredOption('-s, --signaturePath ', 'Path to the Signature file') - .action(({ packagePath, manifestPath, signaturePath }) => main(verifySignature(packagePath, manifestPath, signaturePath))); + .action(({ packagePath, manifestPath, signaturePath }) => main(verifySignature(packagePath, manifestPath, signaturePath), { packageManager: 'npm', useYarn: false })); program .command('ls-publishers') .description('Lists all known publishers') - .action(() => main(listPublishers())); + .action(() => main(listPublishers(), { packageManager: 'npm', useYarn: false })); program .command('delete-publisher ') .description('Deletes a publisher from marketplace') - .action(publisher => main(deletePublisher(publisher))); + .action(publisher => main(deletePublisher(publisher), { packageManager: 'npm', useYarn: false })); program .command('login ') .description('Adds a publisher to the list of known publishers') - .action(name => main(loginPublisher(name))); + .action(name => main(loginPublisher(name), { packageManager: 'npm', useYarn: false })); program .command('logout ') .description('Removes a publisher from the list of known publishers') - .action(name => main(logoutPublisher(name))); + .action(name => main(logoutPublisher(name), { packageManager: 'npm', useYarn: false })); program .command('verify-pat [publisher]') @@ -387,13 +403,13 @@ module.exports = function (argv: string[]): void { process.env['VSCE_PAT'] ) .option('--azure-credential', 'Use Microsoft Entra ID for authentication') - .action((publisherName, { pat, azureCredential }) => main(verifyPat({ publisherName, pat, azureCredential }))); + .action((publisherName, { pat, azureCredential }) => main(verifyPat({ publisherName, pat, azureCredential }), { packageManager: 'npm', useYarn: false })); program .command('show ') .description(`Shows an extension's metadata`) .option('--json', 'Outputs data in json format', false) - .action((extensionid, { json }) => main(show(extensionid, json))); + .action((extensionid, { json }) => main(show(extensionid, json), { packageManager: 'npm', useYarn: false })); program .command('search ') @@ -401,7 +417,7 @@ module.exports = function (argv: string[]): void { .option('--json', 'Output results in json format', false) .option('--stats', 'Shows extensions rating and download count', false) .option('-p, --pagesize [value]', 'Number of results to return', '100') - .action((text, { json, pagesize, stats }) => main(search(text, json, parseInt(pagesize), stats))); + .action((text, { json, pagesize, stats }) => main(search(text, json, parseInt(pagesize), stats), { packageManager: 'npm', useYarn: false })); program.on('command:*', ([cmd]: string) => { if (cmd === 'create-publisher') { diff --git a/src/managers/exec.ts b/src/managers/exec.ts new file mode 100644 index 00000000..fd426a62 --- /dev/null +++ b/src/managers/exec.ts @@ -0,0 +1,41 @@ +import * as cp from "node:child_process"; +import { CancellationToken } from "../util"; + +interface IOptions { + cwd?: string; + stdio?: any; + customFds?: any; + env?: any; + timeout?: number; + maxBuffer?: number; + killSignal?: string; +} + +export function exec( + command: string, + options: IOptions = {}, + cancellationToken?: CancellationToken +): Promise<{ stdout: string; stderr: string }> { + return new Promise((c, e) => { + let disposeCancellationListener: Function | null = null; + + const child = cp.exec(command, { ...options, encoding: 'utf8' } as any, (err, stdout: string, stderr: string) => { + if (disposeCancellationListener) { + disposeCancellationListener(); + disposeCancellationListener = null; + } + + if (err) { + return e(err); + } + c({ stdout, stderr }); + }); + + if (cancellationToken) { + disposeCancellationListener = cancellationToken.subscribe((err: any) => { + child.kill(); + e(err); + }); + } + }); +} diff --git a/src/managers/index.ts b/src/managers/index.ts new file mode 100644 index 00000000..4df1a690 --- /dev/null +++ b/src/managers/index.ts @@ -0,0 +1,38 @@ +import { PackageManager } from "./manager"; +import { pmNone } from "./none"; +import { pmNPM } from "./npm"; +import { pmYarn } from "./yarn"; + +// Reminder: scr/api.ts (PackageManager enum). +const managers = ['none', 'npm', 'yarn'] as const +export const Managers = new Set(managers); +export type PackageManagerLiteral = typeof managers[number]; + +/** + * Get package manager by preference. + * @returns Package manager implementation. + */ +export function getPackageManager( + preference: PackageManagerLiteral = "npm", +): PackageManager { + const choice = { + "none": pmNone, + "npm": pmNPM, + "yarn": pmYarn, + } as Record + + return choice[preference] +} + +/** + * Throws only for strings that are not valid package managers. + * `undefiend` is allowed. + */ +export function assertPackageManager(packageManager: string | undefined): asserts packageManager is PackageManagerLiteral | undefined { + if (packageManager === undefined) { + return + } + if (!Managers.has(packageManager as PackageManagerLiteral)) { + throw new Error(`'${packageManager}' is not a supported package manager. Valid managers: ${[...Managers].join(', ')}`); + } +} \ No newline at end of file diff --git a/src/managers/manager.ts b/src/managers/manager.ts new file mode 100644 index 00000000..33f7f6bd --- /dev/null +++ b/src/managers/manager.ts @@ -0,0 +1,49 @@ +import { CancellationToken } from "../util"; + +/** + * Interface for package manager implementations. + * Using interface to force explicit implementations. + */ +export abstract class PackageManager { + /** + * The binary name of the package manager. + * @example 'yarn' | 'npm' | 'bun' + */ + abstract binaryName: string; + + /** + * Get the version of the package manager itself. + */ + abstract selfVersion(cancellationToken?: CancellationToken): Promise; + + /** + * Check if the package manager version and configs are compatible. + */ + abstract selfCheck(cancellationToken?: CancellationToken): Promise; + + /** + * Get the command to run a script. + */ + abstract commandRun(scriptName: string): string; + + /** + * Get the command to install a package. + */ + abstract commandInstall(packageName: string, global: boolean): string; + + /** + * Request the latest version of a package from the registry. + */ + abstract pkgRequestLatest(name: string, cancellationToken?: CancellationToken): Promise; + + /** + * Get the production dependencies of a package. + */ + abstract pkgProdDependencies(cwd: string, packagedDependencies?: string[]): Promise; + + /** + * Get the files of production dependencies of a package. + * Should use pkgProdDependencies first to get the dependencies. + */ + abstract pkgProdDependenciesFiles(cwd: string, deps: string[], followSymlinks?: boolean): Promise; +} diff --git a/src/managers/none.ts b/src/managers/none.ts new file mode 100644 index 00000000..1257d7ea --- /dev/null +++ b/src/managers/none.ts @@ -0,0 +1,11 @@ +import { PackageManagerNpm } from './npm'; + +class PackageManagerNone extends PackageManagerNpm { + binaryName = "" + + async pkgProdDependencies(cwd: string, _?: string[]): Promise { + return [cwd] + } +} + +export const pmNone = new PackageManagerNone() diff --git a/src/managers/npm.ts b/src/managers/npm.ts new file mode 100644 index 00000000..56c44168 --- /dev/null +++ b/src/managers/npm.ts @@ -0,0 +1,53 @@ +import * as path from 'path'; +import * as semver from 'semver'; +import { glob } from 'glob'; +import { exec } from "./exec"; +import { PackageManager } from "./manager"; +import type { CancellationToken } from "../util"; + +export class PackageManagerNpm extends PackageManager { + binaryName = 'npm' + + async selfVersion(cancellationToken?: CancellationToken): Promise { + const { stdout } = await exec('npm -v', {}, cancellationToken); + return stdout.trim(); + } + async selfCheck(cancellationToken?: CancellationToken): Promise { + const version = await this.selfVersion(cancellationToken); + if (semver.intersects(version, '< 6')) { + throw new Error(`npm@${version} doesn't work with vsce. Please update npm: ${this.commandInstall('npm', true)}`); + } + } + + commandRun(scriptName: string): string { + return `${this.binaryName} run ${scriptName}` + } + commandInstall(pkg: string, global: boolean): string { + let flag = (global ? '-g' : '') + flag &&= flag + " " + return `${this.binaryName} install ${flag}${pkg}` + } + + async pkgRequestLatest(name: string, cancellationToken?: CancellationToken): Promise { + await this.selfCheck(cancellationToken) + const { stdout } = await exec(`npm show ${name} version`, {}, cancellationToken) + return stdout.split(/[\r\n]/).filter(line => !!line)[0]; + } + async pkgProdDependencies(cwd: string, _?: string[]): Promise { + await this.selfCheck() + const { stdout } = await exec('npm list --production --parseable --depth=99999 --loglevel=error', { cwd, maxBuffer: 5000 * 1024 }) + return stdout.split(/[\r\n]/).filter(dir => path.isAbsolute(dir)) + } + async pkgProdDependenciesFiles(cwd: string, deps: string[], followSymlinks?: boolean): Promise { + const promises = deps.map(dep => + glob('**', { cwd: dep, nodir: true, follow: followSymlinks, dot: true, ignore: 'node_modules/**' }).then(files => + files.map(f => path.relative(cwd, path.join(dep, f))).map(f => f.replace(/\\/g, '/')) + ) + ); + + const files = (await Promise.all(promises)).flat() + return files; + } +} + +export const pmNPM = new PackageManagerNpm() diff --git a/src/managers/yarn.ts b/src/managers/yarn.ts new file mode 100644 index 00000000..94cee0ee --- /dev/null +++ b/src/managers/yarn.ts @@ -0,0 +1,165 @@ +import * as path from 'path'; +import * as cp from "child_process"; +import { type CancellationToken, nonnull } from '../util'; +import parseSemver from 'parse-semver'; +import * as semver from 'semver'; +import { exec } from './exec'; +import { pmNPM, PackageManagerNpm } from './npm'; + +export class PackageManagerYarn extends PackageManagerNpm { + binaryName = 'yarn' + + async selfVersion(cancellationToken?: CancellationToken): Promise { + const { stdout } = await exec('yarn -v', {}, cancellationToken); + return stdout.trim(); + } + async selfCheck(cancellationToken?: CancellationToken): Promise { + const version = await this.selfVersion(cancellationToken); + if (semver.intersects(version, '>= 2')) { + throw new Error(`yarn@${version} doesn't work with vsce. Please update yarn: ${pmNPM.commandInstall('yarn', true)}`); + } + } + + commandRun(scriptName: string): string { + return `${this.binaryName} run ${scriptName}`; + } + commandInstall(pkg: string, global: boolean): string { + let flag = (global ? 'global' : '') + flag &&= flag + " " + return `${this.binaryName} ${flag}add ${pkg}` + } + + async pkgRequestLatest(name: string, cancellationToken?: CancellationToken): Promise { + await this.selfCheck(cancellationToken) + const { stdout } = await exec(`yarn info ${name} version`, {}, cancellationToken) + return stdout.split(/[\r\n]/).filter(line => !!line)[1]; + } + async pkgProdDependencies(cwd: string, packagedDependencies?: string[]): Promise { + await this.selfCheck() + const result = new Set([cwd]); + + const deps = await getYarnProductionDependencies(cwd, packagedDependencies); + const flatten = (dep: YarnDependency) => { + result.add(dep.path); + dep.children.forEach(flatten); + }; + deps.forEach(flatten); + + const files = [...result] + return files; + } +} + +export const pmYarn = new PackageManagerYarn() + +interface YarnTreeNode { + name: string; + children: YarnTreeNode[]; +} + +export interface YarnDependency { + name: string; + path: string; + children: YarnDependency[]; +} + +async function getYarnProductionDependencies(cwd: string, packagedDependencies?: string[]): Promise { + const raw = await new Promise((c, e) => + cp.exec( + 'yarn list --prod --json', + { cwd, encoding: 'utf8', env: { DISABLE_V8_COMPILE_CACHE: "1", ...process.env }, maxBuffer: 5000 * 1024 }, + (err, stdout) => (err ? e(err) : c(stdout)) + ) + ); + const match = /^{"type":"tree".*$/m.exec(raw); + + if (!match || match.length !== 1) { + throw new Error('Could not parse result of `yarn list --json`'); + } + + const usingPackagedDependencies = Array.isArray(packagedDependencies); + const trees = JSON.parse(match[0]).data.trees as YarnTreeNode[]; + + let result = trees + .map(tree => asYarnDependency(path.join(cwd, 'node_modules'), tree, !usingPackagedDependencies)) + .filter(nonnull); + + if (usingPackagedDependencies) { + result = selectYarnDependencies(result, packagedDependencies!); + } + + return result; +} + +function asYarnDependency(prefix: string, tree: YarnTreeNode, prune: boolean): YarnDependency | null { + if (prune && /@[\^~]/.test(tree.name)) { + return null; + } + + let name: string; + + try { + const parseResult = parseSemver(tree.name); + name = parseResult.name; + } catch (err) { + name = tree.name.replace(/^([^@+])@.*$/, '$1'); + } + + const dependencyPath = path.join(prefix, name); + const children: YarnDependency[] = []; + + for (const child of tree.children || []) { + const dep = asYarnDependency(path.join(prefix, name, 'node_modules'), child, prune); + + if (dep) { + children.push(dep); + } + } + + return { name, path: dependencyPath, children }; +} + +function selectYarnDependencies(deps: YarnDependency[], packagedDependencies: string[]): YarnDependency[] { + const index = new (class { + private data: { [name: string]: YarnDependency } = Object.create(null); + constructor() { + for (const dep of deps) { + if (this.data[dep.name]) { + throw Error(`Dependency seen more than once: ${dep.name}`); + } + this.data[dep.name] = dep; + } + } + find(name: string): YarnDependency { + let result = this.data[name]; + if (!result) { + throw new Error(`Could not find dependency: ${name}`); + } + return result; + } + })(); + + const reached = new (class { + values: YarnDependency[] = []; + add(dep: YarnDependency): boolean { + if (this.values.indexOf(dep) < 0) { + this.values.push(dep); + return true; + } + return false; + } + })(); + + const visit = (name: string) => { + let dep = index.find(name); + if (!reached.add(dep)) { + // already seen -> done + return; + } + for (const child of dep.children) { + visit(child.name); + } + }; + packagedDependencies.forEach(visit); + return reached.values; +} diff --git a/src/npm.ts b/src/npm.ts deleted file mode 100644 index 6ea6de5d..00000000 --- a/src/npm.ts +++ /dev/null @@ -1,230 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import * as cp from 'child_process'; -import parseSemver from 'parse-semver'; -import { CancellationToken, log, nonnull } from './util'; - -const exists = (file: string) => - fs.promises.stat(file).then( - _ => true, - _ => false - ); - -interface IOptions { - cwd?: string; - stdio?: any; - customFds?: any; - env?: any; - timeout?: number; - maxBuffer?: number; - killSignal?: string; -} - -function parseStdout({ stdout }: { stdout: string }): string { - return stdout.split(/[\r\n]/).filter(line => !!line)[0]; -} - -function exec( - command: string, - options: IOptions = {}, - cancellationToken?: CancellationToken -): Promise<{ stdout: string; stderr: string }> { - return new Promise((c, e) => { - let disposeCancellationListener: Function | null = null; - - const child = cp.exec(command, { ...options, encoding: 'utf8' } as any, (err, stdout: string, stderr: string) => { - if (disposeCancellationListener) { - disposeCancellationListener(); - disposeCancellationListener = null; - } - - if (err) { - return e(err); - } - c({ stdout, stderr }); - }); - - if (cancellationToken) { - disposeCancellationListener = cancellationToken.subscribe((err: any) => { - child.kill(); - e(err); - }); - } - }); -} - -async function checkNPM(cancellationToken?: CancellationToken): Promise { - const { stdout } = await exec('npm -v', {}, cancellationToken); - const version = stdout.trim(); - - if (/^3\.7\.[0123]$/.test(version)) { - throw new Error(`npm@${version} doesn't work with vsce. Please update npm: npm install -g npm`); - } -} - -function getNpmDependencies(cwd: string): Promise { - return checkNPM() - .then(() => - exec('npm list --production --parseable --depth=99999 --loglevel=error', { cwd, maxBuffer: 5000 * 1024 }) - ) - .then(({ stdout }) => stdout.split(/[\r\n]/).filter(dir => path.isAbsolute(dir))); -} - -interface YarnTreeNode { - name: string; - children: YarnTreeNode[]; -} - -export interface YarnDependency { - name: string; - path: string; - children: YarnDependency[]; -} - -function asYarnDependency(prefix: string, tree: YarnTreeNode, prune: boolean): YarnDependency | null { - if (prune && /@[\^~]/.test(tree.name)) { - return null; - } - - let name: string; - - try { - const parseResult = parseSemver(tree.name); - name = parseResult.name; - } catch (err) { - name = tree.name.replace(/^([^@+])@.*$/, '$1'); - } - - const dependencyPath = path.join(prefix, name); - const children: YarnDependency[] = []; - - for (const child of tree.children || []) { - const dep = asYarnDependency(path.join(prefix, name, 'node_modules'), child, prune); - - if (dep) { - children.push(dep); - } - } - - return { name, path: dependencyPath, children }; -} - -function selectYarnDependencies(deps: YarnDependency[], packagedDependencies: string[]): YarnDependency[] { - const index = new (class { - private data: { [name: string]: YarnDependency } = Object.create(null); - constructor() { - for (const dep of deps) { - if (this.data[dep.name]) { - throw Error(`Dependency seen more than once: ${dep.name}`); - } - this.data[dep.name] = dep; - } - } - find(name: string): YarnDependency { - let result = this.data[name]; - if (!result) { - throw new Error(`Could not find dependency: ${name}`); - } - return result; - } - })(); - - const reached = new (class { - values: YarnDependency[] = []; - add(dep: YarnDependency): boolean { - if (this.values.indexOf(dep) < 0) { - this.values.push(dep); - return true; - } - return false; - } - })(); - - const visit = (name: string) => { - let dep = index.find(name); - if (!reached.add(dep)) { - // already seen -> done - return; - } - for (const child of dep.children) { - visit(child.name); - } - }; - packagedDependencies.forEach(visit); - return reached.values; -} - -async function getYarnProductionDependencies(cwd: string, packagedDependencies?: string[]): Promise { - const raw = await new Promise((c, e) => - cp.exec( - 'yarn list --prod --json', - { cwd, encoding: 'utf8', env: { DISABLE_V8_COMPILE_CACHE: "1", ...process.env }, maxBuffer: 5000 * 1024 }, - (err, stdout) => (err ? e(err) : c(stdout)) - ) - ); - const match = /^{"type":"tree".*$/m.exec(raw); - - if (!match || match.length !== 1) { - throw new Error('Could not parse result of `yarn list --json`'); - } - - const usingPackagedDependencies = Array.isArray(packagedDependencies); - const trees = JSON.parse(match[0]).data.trees as YarnTreeNode[]; - - let result = trees - .map(tree => asYarnDependency(path.join(cwd, 'node_modules'), tree, !usingPackagedDependencies)) - .filter(nonnull); - - if (usingPackagedDependencies) { - result = selectYarnDependencies(result, packagedDependencies!); - } - - return result; -} - -async function getYarnDependencies(cwd: string, packagedDependencies?: string[]): Promise { - const result = new Set([cwd]); - - const deps = await getYarnProductionDependencies(cwd, packagedDependencies); - const flatten = (dep: YarnDependency) => { - result.add(dep.path); - dep.children.forEach(flatten); - }; - deps.forEach(flatten); - - return [...result]; -} - -export async function detectYarn(cwd: string): Promise { - for (const name of ['yarn.lock', '.yarnrc', '.yarnrc.yaml', '.pnp.cjs', '.yarn']) { - if (await exists(path.join(cwd, name))) { - if (!process.env['VSCE_TESTS']) { - log.info( - `Detected presence of ${name}. Using 'yarn' instead of 'npm' (to override this pass '--no-yarn' on the command line).` - ); - } - return true; - } - } - return false; -} - -export async function getDependencies( - cwd: string, - dependencies: 'npm' | 'yarn' | 'none' | undefined, - packagedDependencies?: string[] -): Promise { - if (dependencies === 'none') { - return [cwd]; - } else if (dependencies === 'yarn' || (dependencies === undefined && (await detectYarn(cwd)))) { - return await getYarnDependencies(cwd, packagedDependencies); - } else { - return await getNpmDependencies(cwd); - } -} - -export function getLatestVersion(name: string, cancellationToken?: CancellationToken): Promise { - return checkNPM(cancellationToken) - .then(() => exec(`npm show ${name} version`, {}, cancellationToken)) - .then(parseStdout); -} diff --git a/src/package.ts b/src/package.ts index 94e0ce0f..a767f58d 100644 --- a/src/package.ts +++ b/src/package.ts @@ -6,7 +6,6 @@ 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'; @@ -23,12 +22,12 @@ import { validatePublisher, validateExtensionDependencies, } 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'; import { getRuleNameFromRuleId, lintFiles, lintText, prettyPrintLintResult } from './secretLint'; +import { getPackageManager, assertPackageManager, PackageManagerLiteral } from './managers'; const MinimatchOptions: minimatch.IOptions = { dot: true }; @@ -146,6 +145,11 @@ export interface IPackageOptions { */ readonly baseImagesUrl?: string; + /** + * The package manager to use. + */ + readonly packageManager?: PackageManagerLiteral; + /** * Should use Yarn instead of NPM. */ @@ -1665,40 +1669,21 @@ const defaultIgnore = [ '**/.vscode-test-web/**', ]; -async function collectAllFiles( - cwd: string, - dependencies: 'npm' | 'yarn' | 'none' | undefined, - dependencyEntryPoints?: string[], - followSymlinks: boolean = true -): Promise { - const deps = await getDependencies(cwd, dependencies, dependencyEntryPoints); - const promises = deps.map(dep => - glob('**', { cwd: dep, nodir: true, follow: followSymlinks, 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 { +function getDependenciesOption(options: IPackageOptions): PackageManagerLiteral | undefined { if (options.dependencies === false) { return 'none'; } - switch (options.useYarn) { - case true: - return 'yarn'; - case false: - return 'npm'; - default: - return undefined; + if (options.useYarn === undefined) { + return options.packageManager } + + return options.useYarn ? 'yarn' : 'npm' } -function collectFiles( +async function collectFiles( cwd: string, - dependencies: 'npm' | 'yarn' | 'none' | undefined, + dependencies: PackageManagerLiteral | undefined, dependencyEntryPoints?: string[], ignoreFile?: string, manifestFileIncludes?: string[], @@ -1708,91 +1693,85 @@ function collectFiles( readmePath = readmePath ?? 'README.md'; const notIgnored = ['!package.json', `!${readmePath}`]; - return collectAllFiles(cwd, dependencies, dependencyEntryPoints, followSymlinks).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)) - ) - ) - ); - }); + const manager = getPackageManager(dependencies) + + const files = (await manager.pkgProdDependenciesFiles( + cwd, + await manager.pkgProdDependencies(cwd, dependencyEntryPoints), + followSymlinks + )) + .filter(f => !/\r$/m.test(f)); + return await ( + 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_1 => !!s_1) + .filter(i => !/^\s*#/.test(i)) + ) + + // Add '/**' to possible folder names + .then(ignore => [ + ...ignore, + ...ignore.filter(i_1 => !/(^|\/)[^/]*\*[^/]*$/.test(i_1)).map(i_2 => (/\/$/.test(i_2) ? `${i_2}**` : `${i_2}/**`)), + ]) + + // Combine with default ignore list + .then(ignore_1 => [...defaultIgnore, ...ignore_1, ...notIgnored]) + + // Split into ignore and negate list + .then(ignore_2 => ignore_2.reduce<[string[], string[]]>( + (r, e) => (!/^\s*!/.test(e) ? [[...r[0], e], r[1]] : [r[0], [...r[1], e]]), + [[], []] + ) + ) + .then(r_1 => ({ ignore: r_1[0], negate: r_1[1] })) + + // Filter out files + .then(({ ignore: ignore_3, negate }) => files.filter( + f_1 => !ignore_3.some(i_3 => minimatch(f_1, i_3, MinimatchOptions)) || + negate.some(i_4 => minimatch(f_1, i_4.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))); +export async function processFiles(processors: IProcessor[], files: IFile[]): Promise { + const processFiles = 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, - ]; - }); - }); - }); + const processedFiles = await Promise.all(processFiles); + await util.sequence(processors.map(p => () => p.onEnd())); + const assets = processors.reduce((r, p_1) => [...r, ...p_1.assets], []); + const tags = [ + ...processors.reduce>((r_1, p_2) => { + for (const tag of p_2.tags) { + if (tag) { + r_1.add(tag); + } + } + return r_1; + }, new Set()), + ].join(','); + const vsix = processors.reduce((r_2, p_3) => ({ ...r_2, ...p_3.vsix }), { assets, tags } as VSIX); + const result_1 = await Promise.all([toVsixManifest(vsix), toContentTypes(processedFiles)]); + return [ + { path: 'extension.vsixmanifest', contents: Buffer.from(result_1[0], 'utf8') }, + { path: '[Content_Types].xml', contents: Buffer.from(result_1[1], 'utf8') }, + ...processedFiles, + ]; } export function createDefaultProcessors(manifest: ManifestPackage, options: IPackageOptions = {}): IProcessor[] { @@ -1871,24 +1850,39 @@ function getDefaultPackageName(manifest: ManifestPackage, options: IPackageOptio return `${manifest.name}-${version}.vsix`; } +async function detectYarn(cwd: string): Promise { + for (const name of ['yarn.lock', '.yarnrc', '.yarnrc.yaml', '.pnp.cjs', '.yarn']) { + const exists = await fs.promises.stat(path.join(cwd, name)).then(_ => true, _ => false) + if (!exists) { + continue; + } + if (!process.env['VSCE_TESTS']) { + util.log.info( + `Detected presence of ${name}. Using 'yarn' instead of 'npm' (to override this pass '--no-yarn' on the command line).` + ); + } + return true; + } + return false; +} + 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); - } + useYarn ??= await detectYarn(cwd); - const tool = useYarn ? 'yarn' : 'npm'; - const prepublish = `${tool} run vscode:prepublish`; + const tool = useYarn === undefined ? undefined : (useYarn ? 'yarn' : 'npm'); + const manager = getPackageManager(tool) + const prepublish = manager.commandRun("vscode:prepublish"); console.log(`Executing prepublish script '${prepublish}'...`); await new Promise((c, e) => { // Use string command to avoid Node.js DEP0190 warning (args + shell: true is deprecated). const child = cp.spawn(prepublish, { cwd, shell: true, stdio: 'inherit' }); - child.on('exit', code => (code === 0 ? c() : e(`${tool} failed with exit code ${code}`))); + child.on('exit', code => (code === 0 ? c() : e(`${manager.binaryName} failed with exit code ${code}`))); child.on('error', e); }); } @@ -1979,6 +1973,8 @@ export async function createSignatureArchive(manifestFile: string, signatureFile } export async function packageCommand(options: IPackageOptions = {}): Promise { + assertPackageManager(options.packageManager) + const cwd = options.cwd || process.cwd(); const manifest = await readManifest(cwd); util.patchOptionsWithManifest(options, manifest); @@ -2025,6 +2021,7 @@ export async function listFiles(options: IListFilesOptions = {}): Promise { + assertPackageManager(options.packageManager) + const cwd = process.cwd(); const manifest = await readManifest(cwd); diff --git a/src/publish.ts b/src/publish.ts index 7475afcb..c415026a 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -14,6 +14,7 @@ import FormData from 'form-data'; import { basename } from 'path'; import { IterableBackoff, handleWhen, retry } from 'cockatiel'; import { getAzureCredentialAccessToken } from './auth'; +import { assertPackageManager, PackageManagerLiteral } from './managers'; const tmpName = promisify(tmp.tmpName); @@ -51,6 +52,11 @@ export interface IPublishOptions { */ readonly baseImagesUrl?: string; + /** + * The package manager to use. + */ + readonly packageManager?: PackageManagerLiteral; + /** * Should use Yarn instead of NPM. */ @@ -92,6 +98,8 @@ export interface IPublishOptions { } export async function publish(options: IPublishOptions = {}): Promise { + assertPackageManager(options.packageManager) + if (options.packagePath) { if (options.version) { throw new Error(`Both options not supported simultaneously: 'packagePath' and 'version'.`); diff --git a/src/test/package.test.ts b/src/test/package.test.ts index e250da37..72ba091c 100644 --- a/src/test/package.test.ts +++ b/src/test/package.test.ts @@ -24,7 +24,7 @@ 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 { log } from '../util'; import { validatePublisher } from '../validation'; import * as jsonc from 'jsonc-parser'; @@ -53,8 +53,8 @@ const fixture = (name: string) => path.join(path.dirname(path.dirname(__dirname) 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 assets = (processors.map(p => p.assets)).flat(); + const tags = (processors.map(p => p.tags)).flat().join(','); const vsix = processors.reduce((r, p) => ({ ...r, ...p.vsix }), { assets, tags } as VSIX); return toVsixManifest(vsix); diff --git a/src/util.ts b/src/util.ts index b18a4777..54641d4b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -67,10 +67,6 @@ export function chain(initial: T, processors: P[], process: (a: T, b: P) = return chain2(initial, processors, process); } -export function flatten(arr: T[][]): T[] { - return ([] as T[]).concat.apply([], arr) as T[]; -} - export function nonnull(arg: T | null | undefined): arg is T { return !!arg; }