From 6c637cf0685fdb98edbf460766029aebc2857e85 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 19 Mar 2024 17:38:48 -0700 Subject: [PATCH] fix(laverna): fix cycle in type declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This basically just moves the types from `index.js` to `types.js` and renames `laverna.js` to `index.js`. This could be more elegant if the sources were TS or even ESM. 😄 --- packages/laverna/src/cli.js | 2 +- packages/laverna/src/defaults.js | 6 +- packages/laverna/src/index.js | 539 +++++++++++++++++++------- packages/laverna/src/laverna.js | 418 -------------------- packages/laverna/src/types.js | 153 ++++++++ packages/laverna/test/laverna.spec.js | 22 +- 6 files changed, 570 insertions(+), 570 deletions(-) delete mode 100644 packages/laverna/src/laverna.js create mode 100644 packages/laverna/src/types.js diff --git a/packages/laverna/src/cli.js b/packages/laverna/src/cli.js index 9dbfe20658..39379a921d 100755 --- a/packages/laverna/src/cli.js +++ b/packages/laverna/src/cli.js @@ -5,7 +5,7 @@ const { bold, magenta, gray, italic, cyan, underline } = require('kleur') const util = require('node:util') -const { Laverna } = require('./laverna') +const { Laverna } = require('./index') const { ERR } = require('./log-symbols') const { name, bugs, version, description } = require('../package.json') diff --git a/packages/laverna/src/defaults.js b/packages/laverna/src/defaults.js index 1574de59ec..525a40c741 100644 --- a/packages/laverna/src/defaults.js +++ b/packages/laverna/src/defaults.js @@ -30,7 +30,7 @@ const DEFAULT_GLOB_OPTS = Object.freeze( cwd: DEFAULT_ROOT, withFileTypes: true, ignore: { - ignored: /** @param {import('.').GlobDirent} p */ (p) => + ignored: /** @param {import('./types').GlobDirent} p */ (p) => !p.parent || !p.isDirectory(), }, }) @@ -41,7 +41,7 @@ exports.DEFAULT_GLOB_OPTS = DEFAULT_GLOB_OPTS * Default capabilities for Laverna */ const DEFAULT_CAPS = Object.freeze( - /** @type {import('.').AllLavernaCapabilities} */ ({ + /** @type {import('./types').AllLavernaCapabilities} */ ({ fs: Fs, glob: Glob.glob, execFile: execFileAsync, @@ -57,7 +57,7 @@ exports.DEFAULT_CAPS = DEFAULT_CAPS * Default options for Laverna */ const DEFAULT_OPTS = Object.freeze( - /** @type {import('.').AllLavernaOptions} */ ({ + /** @type {import('./types').AllLavernaOptions} */ ({ dryRun: false, yes: false, root: DEFAULT_ROOT, diff --git a/packages/laverna/src/index.js b/packages/laverna/src/index.js index 295bfd34b8..850f5ea002 100644 --- a/packages/laverna/src/index.js +++ b/packages/laverna/src/index.js @@ -1,153 +1,418 @@ -/** - * `Dirent`-like object returned by `glob`; adds a `fullpath()` method and - * `parent` prop - * - * @defaultValue `Path` from `path-scurry`, which is resolved by `glob()` if the - * @typedef {import('fs').Dirent & { - * fullpath: () => string - * parent?: GlobDirent - * }} GlobDirent - */ +const path = require('node:path') +const { INFO, ERR, WARN, OK } = require('./log-symbols') +const { yellow, bold, gray } = require('kleur') +const { + DEFAULT_SPAWN_OPTS, + DEFAULT_GLOB_OPTS, + DEFAULT_CAPS, + DEFAULT_OPTS, +} = require('./defaults') /** - * Function used to spawn `npm publish`. - * - * Returned `EventEmitter` _must_ emit `exit` and _may_ emit `error`. - * - * @defaultValue `childProcess.spawn` - * @typedef {( - * cmd: string, - * args: string[], - * opts: import('node:child_process').SpawnOptions - * ) => import('node:events').EventEmitter} SpawnFn + * @param {unknown} arr + * @returns {arr is string[]} */ +function isStringArray(arr) { + return Array.isArray(arr) && arr.every((v) => typeof v === 'string') +} /** - * Function used to execute commands and retrieve the `stdout` and `stderr` from - * execution. - * - * @typedef {( - * cmd: string, - * args?: string[], - * opts?: { cwd?: string; shell?: boolean } - * ) => Promise<{ stdout: string; stderr: string }>} ExecFileFn + * Main class */ +exports.Laverna = class Laverna { + /** + * Initializes options & capabilities + * + * @param {import('./types').LavernaOptions} [opts] + * @param {import('./types').LavernaCapabilities} [caps] + */ + constructor(opts = {}, caps = {}) { + this.opts = { ...DEFAULT_OPTS, ...opts } + this.caps = { ...DEFAULT_CAPS, ...caps } + this.getVersions = this.caps.getVersionsFactory + ? this.caps.getVersionsFactory(this.opts, this.caps) + : this.defaultGetVersions + this.invokePublish = this.caps.invokePublishFactory + ? this.caps.invokePublishFactory(this.opts, this.caps) + : this.defaultInvokePublish + } -/** - * Globbing function. - * - * Pretty tightly-bound to `glob`, unfortunately. - * - * @defaultValue `Glob.glob` - * @typedef {( - * pattern: string | string[], - * opts: import('glob').GlobOptionsWithFileTypesTrue - * ) => Promise} GlobFn - */ + /** + * Make a path relative from root (with leading `.`). + * + * Intended for use with logs and exceptions only. + * + * @private + * @param {string} somePath + * @returns {string} + */ + relPath(somePath) { + const fromRoot = path.relative(this.opts.root, somePath) + return fromRoot ? `.${path.sep}${fromRoot}` : '.' + } -/** - * Function used to parse a JSON string - * - * @defaultValue `JSON.parse` - * @typedef {(json: string) => any} ParseJsonFn - */ -/** - * A loosey-gooesy `fs.promises` implementation - * - * @typedef {{ - * [K in keyof Pick< - * typeof import('node:fs/promises'), - * 'lstat' | 'readdir' | 'readlink' | 'realpath' | 'readFile' - * >]: (...args: any[]) => Promise - * }} MinimalFsPromises - */ + /** + * Invoke `npm publish` + * + * @type {import('./types').InvokePublish} + */ + async defaultInvokePublish(pkgs) { + const { dryRun, root: cwd } = this.opts + const { spawn, console } = this.caps -/** - * Bare minimum `Console` implementation for our purposes - * - * @typedef MinimalConsole - * @property {Console['error']} error - */ + await new Promise((resolve, reject) => { + const args = ['publish', ...pkgs.map((name) => `--workspace=${name}`)] + if (dryRun) { + args.push('--dry-run') + } -/** - * Bare minimum `fs` implementation for our purposes - * - * @typedef MinimalFs - * @property {MinimalFsPromises} promises - */ + const relativeCwd = this.relPath(cwd) + console.error( + `${INFO} Running command in ${relativeCwd}:\nnpm ${args.join(' ')}` + ) -/** - * Options for {@link Laverna}, merged with {@link DEFAULT_OPTS the defaults}. - * - * @typedef AllLavernaOptions - * @property {boolean} dryRun - Whether to publish in dry-run mode - * @property {string} root - Workspace root - * @property {string[]} newPkg - New packages to publish - * @property {boolean} yes - Skip confirmation prompt - */ -/** - * Options for controlling Laverna's behavior. - * - * @typedef {Partial} LavernaOptions - */ -/** - * Capabilities for {@link Laverna} merged with {@link DEFAULT_CAPS the defaults}. - * - * @typedef AllLavernaCapabilities - * @property {MinimalFs} fs - Bare minimum `fs` implementation - * @property {GlobFn} glob - Function to glob for files - * @property {ExecFileFn} execFile - Function to execute a command - * @property {SpawnFn} spawn - Function to spawn a process - * @property {ParseJsonFn} parseJson - Function to parse JSON - * @property {MinimalConsole} console - Console to use for logging - * @property {GetVersionsFactory} [getVersionsFactory] - Factory for - * {@link GetVersions} - * @property {InvokePublishFactory} [invokePublishFactory] - Factory for - * {@link InvokePublish} - */ -/** - * Capabilities for {@link Laverna}, allowing the user to override functionality. - * - * @typedef {Partial} LavernaCapabilities - */ + spawn('npm', args, { ...DEFAULT_SPAWN_OPTS, cwd }) + .once('error', reject) + .once('exit', (code) => { + if (code === 0) { + resolve(void 0) + } else { + reject(new Error(`npm publish exited with code ${code}`)) + } + }) + }) + } -/** - * Factory for a {@link GetVersions} function. - * - * @callback GetVersionsFactory - * @param {LavernaOptions} opts - * @param {LavernaCapabilities} caps - * @returns {GetVersions} - */ + /** + * Prompts user to confirm before proceeding + * + * @returns {Promise} + */ + async confirm() { + if (this.opts.yes) { + throw new Error('Attempted to confirm with yes=true; this is a bug') + } + const rl = require('node:readline/promises').createInterface( + process.stdin, + process.stderr + ) + try { + const answer = await rl.question(`${yellow('Proceed?')} (y/N) `) + return answer?.toLowerCase() === 'y' + } finally { + rl.close() + } + } -/** - * Function which resolves a list of the known versions of a package - * - * @callback GetVersions - * @param {string} pkgName - * @param {string} cwd - * @returns {Promise} - * @this {Laverna} - */ + /** + * Default implementation of a {@link GetVersions} function. + * + * @type {import('./types').GetVersions} + */ + async defaultGetVersions(name, cwd) { + const { execFile, parseJson, console } = this.caps + const { newPkg } = this.opts -/** - * Factory for a {@link InvokePublish} function. - * - * @callback InvokePublishFactory - * @param {LavernaOptions} opts - * @param {LavernaCapabilities} caps - * @returns {InvokePublish} - */ + /** @type {string | undefined} */ + let versionContents + try { + versionContents = await execFile( + 'npm', + ['view', name, 'versions', '--json'], + { + cwd, + } + ).then(({ stdout }) => stdout.trim()) + } catch (e) { + if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') { + throw new Error(`Could not find npm: ${e}`) + } + if (newPkg.includes(name)) { + console.error( + `${INFO} Package ${Laverna.pkgToString(name)} confirmed as new` + ) + } else { + // it doesn't appear that the type of a rejection from a + // promisify'd `exec` is surfaced in the node typings + // anywhere. + const err = /** + * @type {import('node:child_process').ExecFileException & { + * stdout: string + * }} + */ (e) -/** - * Function which publishes a list of packages. - * - * Packages are expected to be workspaces in the current project. - * - * @callback InvokePublish - * @param {string[]} pkgs - * @returns {Promise} - * @this {Laverna} - */ + // when called with `--json`, you get a JSON error. + // this could also be handled in a catch() chained to the `exec` promise + if ('stdout' in err) { + /** + * @type {{ + * error: { + * code: string + * summary: string + * detail: string + * } + * }} + * @todo See if this type is defined anywhere in npm + */ + const errJson = parseJson(err.stdout) + + throw new Error( + `Querying for package ${Laverna.pkgToString( + name + )} failed (${errJson.error.summary.trim()}). Missing --newPkg=${name}?` + ) + } + throw err + } + } + + if (versionContents !== undefined) { + /** @type {unknown} */ + let json + try { + json = parseJson(versionContents) + } catch (err) { + console.error( + `${ERR} Failed to parse output from \`npm view\` for ${Laverna.pkgToString( + name + )} as JSON: ${versionContents}` + ) + throw err + } + + if (typeof json === 'string') { + json = [json] + } + if (!isStringArray(json)) { + throw new TypeError( + `Output from \`npm view\` for ${Laverna.pkgToString( + name + )} was not a JSON array of strings: ${versionContents}` + ) + } + + return json + } + return [] + } + + /** + * Inspects all workspaces and publishes any that have not yet been published + * + * @returns {Promise} + */ + async publishWorkspaces() { + const { dryRun, root: cwd } = this.opts + const { fs: _fs, glob, parseJson, console } = this.caps + + if (dryRun) { + console.error('🚨 DRY RUN 🚨 DRY RUN 🚨 DRY RUN 🚨') + } + + const { promises: fs } = _fs + + /** @type {string | Buffer} */ + let rootPkgJsonContents + const rootPkgJsonPath = path.resolve(cwd, 'package.json') + try { + rootPkgJsonContents = await fs.readFile(rootPkgJsonPath) + } catch (err) { + throw new Error( + `Could not read package.json in workspace root ${cwd}: ${err}` + ) + } + const relativePkgJsonPath = this.relPath(rootPkgJsonPath) + + /** @type {string[] | undefined} */ + let workspaces + try { + ;({ workspaces } = parseJson(rootPkgJsonContents.toString('utf-8'))) + } catch (err) { + console.error(`${ERR} Failed to parse ${relativePkgJsonPath} as JSON`) + throw err + } + + if (!workspaces) { + throw new Error( + `No "workspaces" prop found in ${relativePkgJsonPath}. This script is intended for use with multi-workspace projects only.` + ) + } + + if (!isStringArray(workspaces)) { + throw new Error( + `"workspaces" prop in ${relativePkgJsonPath} is invalid; must be array of strings` + ) + } + + /** + * @type {import('./types').GlobDirent[]} + * @see {@link https://github.com/isaacs/node-glob/issues/551} + */ + const dirents = await glob(workspaces, { + ...DEFAULT_GLOB_OPTS, + cwd, + fs: _fs, + }) + + if (!dirents.length) { + throw new Error( + `"workspaces" pattern in ${relativePkgJsonPath} matched no files/dirs: ${workspaces.join( + ', ' + )}` + ) + } + + /** @type {{ name: string; version: string }[]} */ + let pkgs + + try { + pkgs = /** @type {{ name: string; version: string }[]} */ ( + ( + await Promise.all( + dirents.map( + /** + * Given a dirent object from `glob`, returns the package name if + * it hasn't already been published + */ + async (dirent) => { + /** + * Parsed contents of `package.json` for the package in the dir + * represented by `dirent` + * + * @type {import('type-fest').PackageJson} + */ + let pkg + + /** + * `package.json` contents as read from file + * + * @type {Buffer | string} + */ + let pkgJsonContents + + const pkgDir = dirent.fullpath() + const relativePkgDir = this.relPath(pkgDir) + const workspacePkgJsonPath = path.join(pkgDir, 'package.json') + const relativeWorkspacePkgJsonPath = + this.relPath(workspacePkgJsonPath) + + try { + pkgJsonContents = await fs.readFile(workspacePkgJsonPath) + } catch (err) { + if ( + /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' + ) { + console.error( + `${WARN} Workspace dir ${relativePkgDir} contains no \`package.json\`. Please move whatever this is somewhere else, or update \`workspaces\` in the workspace root \`package.json\` to exclude this dir; skipping` + ) + return + } + console.error( + `${ERR} Failed to read ${relativeWorkspacePkgJsonPath}` + ) + throw err + } + + try { + pkg = parseJson(pkgJsonContents.toString('utf-8')) + } catch (err) { + console.error( + `${ERR} Failed to parse ${relativeWorkspacePkgJsonPath} as JSON` + ) + throw err + } + + // NOTE TO DEBUGGERS: it's possible, though unlikely, that `pkg` + // parses into something other than a plain object. if it does + // happen, the error may be opaque. + const { private: _private, name, version } = pkg + + // private workspaces should be ignored + if (_private) { + console.error( + `${INFO} Skipping private package ${Laverna.pkgToString( + name ?? '(unnamed)' + )}…` + ) + return + } + + if (!(name && version)) { + throw new Error( + `Missing package name and/or version in ${relativeWorkspacePkgJsonPath}; cannot be published` + ) + } + + const versions = await this.getVersions(name, cwd) + + if (versions.includes(version)) { + console.error( + `${INFO} Skipping already-published package ${Laverna.pkgToString( + name, + version + )}…` + ) + return + } + + return { name, version } + } + ) + ) + ).filter(Boolean) + ) + } catch (err) { + console.error( + `${ERR} Workspace analysis failed; no packages have been published.` + ) + throw err + } + + if (!pkgs.length) { + console.error(`${INFO} Nothing to publish`) + return + } + + const pkgNames = pkgs.map(({ name }) => name) + + // super unlikely + const dupes = new Set( + pkgNames.filter((pkgName, idx) => pkgNames.indexOf(pkgName) !== idx) + ) + if (dupes.size) { + throw new Error( + `Duplicate package name(s) found in workspaces: ${[...dupes] + .map((dupe) => Laverna.pkgToString(dupe)) + .join(', ')}` + ) + } + + const nameVersionPairs = pkgs + .map(({ name, version }) => Laverna.pkgToString(name, version)) + .sort() + console.error( + `${INFO} ${yellow( + `These package(s) will be published:` + )}\n${nameVersionPairs.join('\n')}` + ) + + if (this.opts.yes || (await this.confirm())) { + await this.invokePublish(pkgNames) + console.error(`${OK} Done!`) + } else { + console.error(`${ERR} Aborted; no packages haved been published.`) + } + } -exports.Laverna = require('./laverna').Laverna + /** + * Format a package name (with optional version) for display + * + * @param {string} name + * @param {string} [version] + * @returns {string} + * @internal + */ + static pkgToString(name, version) { + return version ? `${bold(name)}${gray('@')}${version}` : bold(name) + } +} diff --git a/packages/laverna/src/laverna.js b/packages/laverna/src/laverna.js deleted file mode 100644 index 857ce68502..0000000000 --- a/packages/laverna/src/laverna.js +++ /dev/null @@ -1,418 +0,0 @@ -const path = require('node:path') -const { INFO, ERR, WARN, OK } = require('./log-symbols') -const { yellow, bold, gray } = require('kleur') -const { - DEFAULT_SPAWN_OPTS, - DEFAULT_GLOB_OPTS, - DEFAULT_CAPS, - DEFAULT_OPTS, -} = require('./defaults') - -/** - * @param {unknown} arr - * @returns {arr is string[]} - */ -function isStringArray(arr) { - return Array.isArray(arr) && arr.every((v) => typeof v === 'string') -} - -/** - * Main class - */ -exports.Laverna = class Laverna { - /** - * Initializes options & capabilities - * - * @param {import('.').LavernaOptions} [opts] - * @param {import('.').LavernaCapabilities} [caps] - */ - constructor(opts = {}, caps = {}) { - this.opts = { ...DEFAULT_OPTS, ...opts } - this.caps = { ...DEFAULT_CAPS, ...caps } - this.getVersions = this.caps.getVersionsFactory - ? this.caps.getVersionsFactory(this.opts, this.caps) - : this.defaultGetVersions - this.invokePublish = this.caps.invokePublishFactory - ? this.caps.invokePublishFactory(this.opts, this.caps) - : this.defaultInvokePublish - } - - /** - * Make a path relative from root (with leading `.`). - * - * Intended for use with logs and exceptions only. - * - * @private - * @param {string} somePath - * @returns {string} - */ - relPath(somePath) { - const fromRoot = path.relative(this.opts.root, somePath) - return fromRoot ? `.${path.sep}${fromRoot}` : '.' - } - - /** - * Invoke `npm publish` - * - * @type {import('.').InvokePublish} - */ - async defaultInvokePublish(pkgs) { - const { dryRun, root: cwd } = this.opts - const { spawn, console } = this.caps - - await new Promise((resolve, reject) => { - const args = ['publish', ...pkgs.map((name) => `--workspace=${name}`)] - if (dryRun) { - args.push('--dry-run') - } - - const relativeCwd = this.relPath(cwd) - console.error( - `${INFO} Running command in ${relativeCwd}:\nnpm ${args.join(' ')}` - ) - - spawn('npm', args, { ...DEFAULT_SPAWN_OPTS, cwd }) - .once('error', reject) - .once('exit', (code) => { - if (code === 0) { - resolve(void 0) - } else { - reject(new Error(`npm publish exited with code ${code}`)) - } - }) - }) - } - - /** - * Prompts user to confirm before proceeding - * - * @returns {Promise} - */ - async confirm() { - if (this.opts.yes) { - throw new Error('Attempted to confirm with yes=true; this is a bug') - } - const rl = require('node:readline/promises').createInterface( - process.stdin, - process.stderr - ) - try { - const answer = await rl.question(`${yellow('Proceed?')} (y/N) `) - return answer?.toLowerCase() === 'y' - } finally { - rl.close() - } - } - - /** - * Default implementation of a {@link GetVersions} function. - * - * @type {import('.').GetVersions} - */ - async defaultGetVersions(name, cwd) { - const { execFile, parseJson, console } = this.caps - const { newPkg } = this.opts - - /** @type {string | undefined} */ - let versionContents - try { - versionContents = await execFile( - 'npm', - ['view', name, 'versions', '--json'], - { - cwd, - } - ).then(({ stdout }) => stdout.trim()) - } catch (e) { - if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') { - throw new Error(`Could not find npm: ${e}`) - } - if (newPkg.includes(name)) { - console.error( - `${INFO} Package ${Laverna.pkgToString(name)} confirmed as new` - ) - } else { - // it doesn't appear that the type of a rejection from a - // promisify'd `exec` is surfaced in the node typings - // anywhere. - const err = /** - * @type {import('node:child_process').ExecFileException & { - * stdout: string - * }} - */ (e) - - // when called with `--json`, you get a JSON error. - // this could also be handled in a catch() chained to the `exec` promise - if ('stdout' in err) { - /** - * @type {{ - * error: { - * code: string - * summary: string - * detail: string - * } - * }} - * @todo See if this type is defined anywhere in npm - */ - const errJson = parseJson(err.stdout) - - throw new Error( - `Querying for package ${Laverna.pkgToString( - name - )} failed (${errJson.error.summary.trim()}). Missing --newPkg=${name}?` - ) - } - throw err - } - } - - if (versionContents !== undefined) { - /** @type {unknown} */ - let json - try { - json = parseJson(versionContents) - } catch (err) { - console.error( - `${ERR} Failed to parse output from \`npm view\` for ${Laverna.pkgToString( - name - )} as JSON: ${versionContents}` - ) - throw err - } - - if (typeof json === 'string') { - json = [json] - } - if (!isStringArray(json)) { - throw new TypeError( - `Output from \`npm view\` for ${Laverna.pkgToString( - name - )} was not a JSON array of strings: ${versionContents}` - ) - } - - return json - } - return [] - } - - /** - * Inspects all workspaces and publishes any that have not yet been published - * - * @returns {Promise} - */ - async publishWorkspaces() { - const { dryRun, root: cwd } = this.opts - const { fs: _fs, glob, parseJson, console } = this.caps - - if (dryRun) { - console.error('🚨 DRY RUN 🚨 DRY RUN 🚨 DRY RUN 🚨') - } - - const { promises: fs } = _fs - - /** @type {string | Buffer} */ - let rootPkgJsonContents - const rootPkgJsonPath = path.resolve(cwd, 'package.json') - try { - rootPkgJsonContents = await fs.readFile(rootPkgJsonPath) - } catch (err) { - throw new Error( - `Could not read package.json in workspace root ${cwd}: ${err}` - ) - } - const relativePkgJsonPath = this.relPath(rootPkgJsonPath) - - /** @type {string[] | undefined} */ - let workspaces - try { - ;({ workspaces } = parseJson(rootPkgJsonContents.toString('utf-8'))) - } catch (err) { - console.error(`${ERR} Failed to parse ${relativePkgJsonPath} as JSON`) - throw err - } - - if (!workspaces) { - throw new Error( - `No "workspaces" prop found in ${relativePkgJsonPath}. This script is intended for use with multi-workspace projects only.` - ) - } - - if (!isStringArray(workspaces)) { - throw new Error( - `"workspaces" prop in ${relativePkgJsonPath} is invalid; must be array of strings` - ) - } - - /** - * @type {import('.').GlobDirent[]} - * @see {@link https://github.com/isaacs/node-glob/issues/551} - */ - const dirents = await glob(workspaces, { - ...DEFAULT_GLOB_OPTS, - cwd, - fs: _fs, - }) - - if (!dirents.length) { - throw new Error( - `"workspaces" pattern in ${relativePkgJsonPath} matched no files/dirs: ${workspaces.join( - ', ' - )}` - ) - } - - /** @type {{ name: string; version: string }[]} */ - let pkgs - - try { - pkgs = /** @type {{ name: string; version: string }[]} */ ( - ( - await Promise.all( - dirents.map( - /** - * Given a dirent object from `glob`, returns the package name if - * it hasn't already been published - */ - async (dirent) => { - /** - * Parsed contents of `package.json` for the package in the dir - * represented by `dirent` - * - * @type {import('type-fest').PackageJson} - */ - let pkg - - /** - * `package.json` contents as read from file - * - * @type {Buffer | string} - */ - let pkgJsonContents - - const pkgDir = dirent.fullpath() - const relativePkgDir = this.relPath(pkgDir) - const workspacePkgJsonPath = path.join(pkgDir, 'package.json') - const relativeWorkspacePkgJsonPath = - this.relPath(workspacePkgJsonPath) - - try { - pkgJsonContents = await fs.readFile(workspacePkgJsonPath) - } catch (err) { - if ( - /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' - ) { - console.error( - `${WARN} Workspace dir ${relativePkgDir} contains no \`package.json\`. Please move whatever this is somewhere else, or update \`workspaces\` in the workspace root \`package.json\` to exclude this dir; skipping` - ) - return - } - console.error( - `${ERR} Failed to read ${relativeWorkspacePkgJsonPath}` - ) - throw err - } - - try { - pkg = parseJson(pkgJsonContents.toString('utf-8')) - } catch (err) { - console.error( - `${ERR} Failed to parse ${relativeWorkspacePkgJsonPath} as JSON` - ) - throw err - } - - // NOTE TO DEBUGGERS: it's possible, though unlikely, that `pkg` - // parses into something other than a plain object. if it does - // happen, the error may be opaque. - const { private: _private, name, version } = pkg - - // private workspaces should be ignored - if (_private) { - console.error( - `${INFO} Skipping private package ${Laverna.pkgToString( - name ?? '(unnamed)' - )}…` - ) - return - } - - if (!(name && version)) { - throw new Error( - `Missing package name and/or version in ${relativeWorkspacePkgJsonPath}; cannot be published` - ) - } - - const versions = await this.getVersions(name, cwd) - - if (versions.includes(version)) { - console.error( - `${INFO} Skipping already-published package ${Laverna.pkgToString( - name, - version - )}…` - ) - return - } - - return { name, version } - } - ) - ) - ).filter(Boolean) - ) - } catch (err) { - console.error( - `${ERR} Workspace analysis failed; no packages have been published.` - ) - throw err - } - - if (!pkgs.length) { - console.error(`${INFO} Nothing to publish`) - return - } - - const pkgNames = pkgs.map(({ name }) => name) - - // super unlikely - const dupes = new Set( - pkgNames.filter((pkgName, idx) => pkgNames.indexOf(pkgName) !== idx) - ) - if (dupes.size) { - throw new Error( - `Duplicate package name(s) found in workspaces: ${[...dupes] - .map((dupe) => Laverna.pkgToString(dupe)) - .join(', ')}` - ) - } - - const nameVersionPairs = pkgs - .map(({ name, version }) => Laverna.pkgToString(name, version)) - .sort() - console.error( - `${INFO} ${yellow( - `These package(s) will be published:` - )}\n${nameVersionPairs.join('\n')}` - ) - - if (this.opts.yes || (await this.confirm())) { - await this.invokePublish(pkgNames) - console.error(`${OK} Done!`) - } else { - console.error(`${ERR} Aborted; no packages haved been published.`) - } - } - - /** - * Format a package name (with optional version) for display - * - * @param {string} name - * @param {string} [version] - * @returns {string} - * @internal - */ - static pkgToString(name, version) { - return version ? `${bold(name)}${gray('@')}${version}` : bold(name) - } -} diff --git a/packages/laverna/src/types.js b/packages/laverna/src/types.js new file mode 100644 index 0000000000..31262937f1 --- /dev/null +++ b/packages/laverna/src/types.js @@ -0,0 +1,153 @@ +/** + * `Dirent`-like object returned by `glob`; adds a `fullpath()` method and + * `parent` prop + * + * @defaultValue `Path` from `path-scurry`, which is resolved by `glob()` if the + * @typedef {import('fs').Dirent & { + * fullpath: () => string + * parent?: GlobDirent + * }} GlobDirent + */ + +/** + * Function used to spawn `npm publish`. + * + * Returned `EventEmitter` _must_ emit `exit` and _may_ emit `error`. + * + * @defaultValue `childProcess.spawn` + * @typedef {( + * cmd: string, + * args: string[], + * opts: import('node:child_process').SpawnOptions + * ) => import('node:events').EventEmitter} SpawnFn + */ + +/** + * Function used to execute commands and retrieve the `stdout` and `stderr` from + * execution. + * + * @typedef {( + * cmd: string, + * args?: string[], + * opts?: { cwd?: string; shell?: boolean } + * ) => Promise<{ stdout: string; stderr: string }>} ExecFileFn + */ + +/** + * Globbing function. + * + * Pretty tightly-bound to `glob`, unfortunately. + * + * @defaultValue `Glob.glob` + * @typedef {( + * pattern: string | string[], + * opts: import('glob').GlobOptionsWithFileTypesTrue + * ) => Promise} GlobFn + */ + +/** + * Function used to parse a JSON string + * + * @defaultValue `JSON.parse` + * @typedef {(json: string) => any} ParseJsonFn + */ +/** + * A loosey-gooesy `fs.promises` implementation + * + * @typedef {{ + * [K in keyof Pick< + * typeof import('node:fs/promises'), + * 'lstat' | 'readdir' | 'readlink' | 'realpath' | 'readFile' + * >]: (...args: any[]) => Promise + * }} MinimalFsPromises + */ + +/** + * Bare minimum `Console` implementation for our purposes + * + * @typedef MinimalConsole + * @property {Console['error']} error + */ + +/** + * Bare minimum `fs` implementation for our purposes + * + * @typedef MinimalFs + * @property {MinimalFsPromises} promises + */ + +/** + * Options for {@link Laverna}, merged with {@link DEFAULT_OPTS the defaults}. + * + * @typedef AllLavernaOptions + * @property {boolean} dryRun - Whether to publish in dry-run mode + * @property {string} root - Workspace root + * @property {string[]} newPkg - New packages to publish + * @property {boolean} yes - Skip confirmation prompt + */ +/** + * Options for controlling Laverna's behavior. + * + * @typedef {Partial} LavernaOptions + */ +/** + * Capabilities for {@link Laverna} merged with {@link DEFAULT_CAPS the defaults}. + * + * @typedef AllLavernaCapabilities + * @property {MinimalFs} fs - Bare minimum `fs` implementation + * @property {GlobFn} glob - Function to glob for files + * @property {ExecFileFn} execFile - Function to execute a command + * @property {SpawnFn} spawn - Function to spawn a process + * @property {ParseJsonFn} parseJson - Function to parse JSON + * @property {MinimalConsole} console - Console to use for logging + * @property {GetVersionsFactory} [getVersionsFactory] - Factory for + * {@link GetVersions} + * @property {InvokePublishFactory} [invokePublishFactory] - Factory for + * {@link InvokePublish} + */ +/** + * Capabilities for {@link Laverna}, allowing the user to override functionality. + * + * @typedef {Partial} LavernaCapabilities + */ + +/** + * Factory for a {@link GetVersions} function. + * + * @callback GetVersionsFactory + * @param {LavernaOptions} opts + * @param {LavernaCapabilities} caps + * @returns {GetVersions} + */ + +/** + * Function which resolves a list of the known versions of a package + * + * @callback GetVersions + * @param {string} pkgName + * @param {string} cwd + * @returns {Promise} + * @this {Laverna} + */ + +/** + * Factory for a {@link InvokePublish} function. + * + * @callback InvokePublishFactory + * @param {LavernaOptions} opts + * @param {LavernaCapabilities} caps + * @returns {InvokePublish} + */ + +/** + * Function which publishes a list of packages. + * + * Packages are expected to be workspaces in the current project. + * + * @callback InvokePublish + * @param {string[]} pkgs + * @returns {Promise} + * @this {Laverna} + */ + +module.exports = {} diff --git a/packages/laverna/test/laverna.spec.js b/packages/laverna/test/laverna.spec.js index 487876b53c..f40b19cc29 100644 --- a/packages/laverna/test/laverna.spec.js +++ b/packages/laverna/test/laverna.spec.js @@ -22,15 +22,15 @@ const { mock } = require('node:test') * * @typedef PublishTestContext * @property {{ error: import('node:test').Mock }} console - * @property {import('node:test').Mock} spawn + * @property {import('node:test').Mock} spawn * @property {( - * opts?: import('../src').LavernaOptions, - * caps?: import('../src').LavernaCapabilities + * opts?: import('../src/types').LavernaOptions, + * caps?: import('../src/types').LavernaCapabilities * ) => Promise} runLaverna * @property {( * pkgNames: string[], - * opts?: import('../src').LavernaOptions, - * caps?: import('../src').LavernaCapabilities + * opts?: import('../src/types').LavernaOptions, + * caps?: import('../src/types').LavernaCapabilities * ) => Promise} runInvokePublish */ @@ -61,7 +61,7 @@ function getRandomPkgName() { * Base options for {@link Laverna}, providing some stubs. */ const BASE_OPTS = Object.freeze( - /** @type {import('../src').LavernaOptions} */ ({ + /** @type {import('../src/types').LavernaOptions} */ ({ /** * Because the root of the phony `memfs` filesystem is `/`, we use it here. */ @@ -83,7 +83,7 @@ const BASE_OPTS = Object.freeze( * Base capabilities for {@link Laverna}, providing some stubs. */ const BASE_CAPS = Object.freeze( - /** @type {import('../src').LavernaCapabilities} */ ({ + /** @type {import('../src/types').LavernaCapabilities} */ ({ /** * The phony `fs` is given to `glob`--it supports a custom `fs` module--so * it can find files in there. @@ -155,8 +155,8 @@ test.beforeEach((t) => { * Does not create a child process * * @param {string[]} pkgNames - * @param {import('../src').LavernaOptions} opts - * @param {import('../src').LavernaCapabilities} caps + * @param {import('../src/types').LavernaOptions} opts + * @param {import('../src/types').LavernaCapabilities} caps * @returns {Promise} */ const runInvokePublish = async (pkgNames, opts = {}, caps = {}) => { @@ -180,8 +180,8 @@ test.beforeEach((t) => { * * Does not create a child process * - * @param {import('../src').LavernaOptions} opts - * @param {import('../src').LavernaCapabilities} caps + * @param {import('../src/types').LavernaOptions} opts + * @param {import('../src/types').LavernaCapabilities} caps * @returns {Promise} */ const runLaverna = async (opts = {}, caps = {}) => {