import { Command, Option } from "clipanion"; import { assertDefined, isSemanticVersion } from "complete-common"; import { $, fatalError, getPackageJSONFieldsMandatory, getPackageManagerForProject, getPackageManagerInstallCommand, getPackageManagerLockFileName, getPackageManagersForProject, isFile, isGitDirectoryClean, isGitRepository, PackageManager, readFile, updatePackageJSONDependencies, writeFile, } from "complete-node"; import path from "node:path"; import { CWD, DEFAULT_PACKAGE_MANAGER } from "../constants.js"; export class PublishCommand extends Command { static override paths = [["publish"], ["p"]]; static override usage = Command.Usage({ description: "Bumps the version & publishes a new release.", }); // The first positional argument. versionBumpType = Option.String({ required: true, }); dryRun = Option.Boolean("--dry-run", false, { description: "Skip committing/uploading & perform a Git reset afterward.", }); skipLint = Option.Boolean("--skip-lint", false, { description: "Skip linting before publishing.", }); skipUpdate = Option.Boolean("--skip-update", false, { description: "Skip updating the npm dependencies.", }); async execute(): Promise { await validate(); await prePublish( this.versionBumpType, this.dryRun, this.skipLint, this.skipUpdate, ); await publish(this.dryRun); } } async function validate() { const isRepository = await isGitRepository(CWD); if (!isRepository) { fatalError( "Failed to publish since the current working directory is not inside of a git repository.", ); } const isRepositoryClean = await isGitDirectoryClean(CWD); if (!isRepositoryClean) { fatalError( "Failed to publish since the Git repository was dirty. Before publishing, you must push any current changes to git. (Version commits should not contain any code changes.)", ); } const packageJSONExists = await isFile("package.json"); if (!packageJSONExists) { fatalError( 'Failed to find the "package.json" file in the current working directory.', ); } // Fail fast if we are not currently logged in. (There is no analogous bun command.) const command = await getPublishCommand(); if (command === "npm") { await $`${command} whoami`; } } async function getPublishCommand() { const packageManager = await getPackageManagerForProject(CWD); assertDefined( packageManager, `Failed to get the package manager for the project at directory: ${CWD}`, ); return packageManager === PackageManager.bun ? "bun" : "npm"; } /** * Before uploading the project, we want to update dependencies, increment the version, and perform * some other steps. */ async function prePublish( versionBumpType: string, dryRun: boolean, skipLint: boolean, skipUpdate: boolean, ) { const packageManager = await getPackageManagerUsedForExistingProject(); await $`git pull --rebase`; await $`git push`; await updateDependencies(skipUpdate, dryRun, packageManager); await incrementVersion(versionBumpType); await unsetDevelopmentConstants(); await tryRunPackageScript("build", packageManager); if (!skipLint) { await tryRunPackageScript("lint", packageManager); } } async function getPackageManagerUsedForExistingProject(): Promise { const packageManagers = await getPackageManagersForProject(CWD); if (packageManagers.length > 1) { const packageManagerLockFileNames = packageManagers .map((packageManager) => getPackageManagerLockFileName(packageManager)) .map((packageManagerLockFileName) => `"${packageManagerLockFileName}"`) .join(" & "); fatalError( `Multiple different kinds of package manager lock files were found (${packageManagerLockFileNames}). You should delete the ones that you are not using so that this program can correctly detect your package manager.`, ); } const packageManager = packageManagers[0]; if (packageManager !== undefined) { return packageManager; } return DEFAULT_PACKAGE_MANAGER; } async function updateDependencies( skipUpdate: boolean, dryRun: boolean, packageManager: PackageManager, ) { if (skipUpdate) { return; } console.log('Updating dependencies in the "package.json" file...'); const hasNewDependencies = await updatePackageJSONDependencies(undefined); if (hasNewDependencies) { const command = getPackageManagerInstallCommand(packageManager); const commandParts = command.split(" "); await $`${commandParts}`; if (!dryRun) { await gitCommitAllAndPush("chore: update dependencies"); } } } async function gitCommitAllAndPush(commitMessage: string) { await $`git add --all`; await $`git commit --message ${commitMessage}`; await $`git push`; console.log( `Committed and pushed to the git repository with a message of: ${commitMessage}`, ); } async function incrementVersion(versionBumpType: string) { if (versionBumpType === "none") { return; } if (versionBumpType === "dev") { throw new Error( 'The version bump type of "dev" is not currently supported.', ); } if ( versionBumpType !== "major" && versionBumpType !== "minor" && versionBumpType !== "patch" && versionBumpType !== "dev" && !isSemanticVersion(versionBumpType) ) { fatalError( 'The version must be one of "major", "minor", "patch", "dev", "none", or a specific semantic version like "1.2.3".', ); } // We always use `npm` here to avoid differences with the version command between package // managers. The "--no-git-tag-version" flag will prevent npm from both making a commit and adding // a tag. (`bun version` is not implemented.) await $`npm version ${versionBumpType} --no-git-tag-version`; } async function unsetDevelopmentConstants() { const constantsTSPath = path.join(CWD, "src", "constants.ts"); const constantsTSExists = await isFile(constantsTSPath); if (!constantsTSExists) { return; } const constantsTS = await readFile(constantsTSPath); const newConstantsTS = constantsTS .replace("const IS_DEV = true", "const IS_DEV = false") .replace("const DEBUG = true", "const DEBUG = false"); await writeFile(constantsTSPath, newConstantsTS); } async function tryRunPackageScript( scriptName: string, packageManager: PackageManager, ) { console.log(`Running: ${scriptName}`); const $$ = $({ reject: false, }); const { exitCode } = await $$`${packageManager} run ${scriptName}`; if (exitCode !== 0) { await $`git reset --hard`; // Revert the version changes. fatalError(`Failed to run "${scriptName}".`); } } async function publish(dryRun: boolean) { const { name, version } = await getPackageJSONFieldsMandatory( undefined, "name", "version", ); if (dryRun) { await $`git reset --hard`; // Revert the version changes. } else { const commitMessage = `chore: release ${version}`; await gitCommitAllAndPush(commitMessage); const command = await getPublishCommand(); // - The "--access=public" flag is only technically needed for the first publish (unless the // package is a scoped package), but it is saved here for posterity. // - The "--ignore-scripts" flag is needed since the "npm publish" command will run the // "publish" script in the "package.json" file, causing an infinite loop. await $`${command} publish --access=public --ignore-scripts`; } const dryRunSuffix = dryRun ? " (dry-run)" : ""; console.log( `Published ${name} version ${version} successfully${dryRunSuffix}.`, ); }