diff --git a/docs/publishing.md b/docs/publishing.md index 0789cf7..941fbfd 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -19,6 +19,14 @@ npm view loop-engineer name version dist-tags --json An npm `E404` response means the package has no public release at the time of the check. It does not reserve the name. +`npm login` authenticates the CLI but does not satisfy the publish security requirement by itself. Enable two-factor authentication on the npm account before the first publish: + +```bash +npm profile enable-2fa auth-and-writes +``` + +npm will open the browser and ask you to register a security key or platform authenticator. A granular access token with write access and **Bypass 2FA** can also publish, but interactive 2FA avoids storing a long-lived publishing credential. + ## Prepare the release Update `package.json`, `package-lock.json` and `CHANGELOG.md` in the same pull request. The CLI version comes from `package.json`, so these commands must print the same version: @@ -70,6 +78,8 @@ npm view loop-engineer name version dist-tags --json `git status --short` must print nothing. npm does not allow a publisher to replace an existing version. Fix a failed release with a new patch version instead of reusing the old version number. +If npm returns `E403` with “Two-factor authentication ... is required,” finish the account 2FA setup and run the same publish command again. npm prompts for the second factor during the publish. Do not place a one-time code or access token in the repository, shell scripts or documentation. + Create and push the Git tag only after npm confirms the release: ```bash diff --git a/package.json b/package.json index 6d4a99a..ca78c47 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "node": ">=20" }, "bin": { - "loopeng": "./dist/index.js" + "loopeng": "dist/index.js" }, "main": "./dist/index.js", "files": [ diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 6a59d4a..eb79f0b 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /* global process */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -56,8 +56,25 @@ export function validatePackManifest(manifest) { ); } +export function validatePackageMetadata(packageJson) { + assert(packageJson.name === 'loop-engineer', 'package.json must name loop-engineer.'); + assert(typeof packageJson.version === 'string', 'package.json must define a version.'); + assert( + packageJson.bin?.loopeng === 'dist/index.js', + 'package.json bin.loopeng must use dist/index.js without a leading ./ for npm publish.', + ); +} + +export function npmPublishNeedsPackFallback(result) { + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return ( + result.status !== 0 && /cannot publish over the previously published versions/i.test(output) + ); +} + export function verifyEntrypoint(root = process.cwd()) { const packageJson = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + validatePackageMetadata(packageJson); const relativeEntrypoint = packageJson.bin?.loopeng; assert(typeof relativeEntrypoint === 'string', 'package.json must define the loopeng binary.'); @@ -81,13 +98,23 @@ export function verifyEntrypoint(root = process.cwd()) { } function main() { - const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + const publishResult = spawnSync('npm', ['publish', '--dry-run', '--json', '--ignore-scripts'], { encoding: 'utf8', }); - const manifests = JSON.parse(output); - assert(manifests.length === 1, `Expected one package manifest, received ${manifests.length}.`); + const result = npmPublishNeedsPackFallback(publishResult) + ? spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + encoding: 'utf8', + }) + : publishResult; + const npmWarnings = `${publishResult.stderr ?? ''}\n${result.stderr ?? ''}`; + assert(result.status === 0, `npm publish --dry-run failed:\n${result.stderr.trim()}`); + assert( + !npmWarnings.includes('auto-corrected') && !npmWarnings.includes('invalid and removed'), + `npm would rewrite package.json during publish:\n${npmWarnings.trim()}`, + ); - const manifest = manifests[0]; + const parsedManifest = JSON.parse(result.stdout); + const manifest = Array.isArray(parsedManifest) ? parsedManifest[0] : parsedManifest; validatePackManifest(manifest); const { version } = verifyEntrypoint(); const size = new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format( diff --git a/tests/unit/package-verifier.test.ts b/tests/unit/package-verifier.test.ts index 1c091d8..18011d3 100644 --- a/tests/unit/package-verifier.test.ts +++ b/tests/unit/package-verifier.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; // Build tooling stays in scripts/ and does not ship as a TypeScript library. // @ts-expect-error The verifier is an ESM build script with no declaration file. -import { validatePackManifest } from '../../scripts/verify-package.mjs'; +import * as packageVerifier from '../../scripts/verify-package.mjs'; + +const { npmPublishNeedsPackFallback, validatePackageMetadata, validatePackManifest } = + packageVerifier; const validManifest = { name: 'loop-engineer', @@ -19,6 +22,33 @@ const validManifest = { }; describe('npm package verifier', () => { + it('falls back to a pack dry run when the version already exists on npm', () => { + expect( + npmPublishNeedsPackFallback({ + status: 1, + stdout: '', + stderr: 'npm error You cannot publish over the previously published versions: 0.1.0.', + }), + ).toBe(true); + expect( + npmPublishNeedsPackFallback({ + status: 1, + stdout: '', + stderr: 'npm error code E403\nnpm error Two-factor authentication is required.', + }), + ).toBe(false); + }); + + it('requires the canonical npm binary path', () => { + expect(() => + validatePackageMetadata({ + name: 'loop-engineer', + version: '0.1.0', + bin: { loopeng: './dist/index.js' }, + }), + ).toThrow(/dist\/index\.js without a leading/); + }); + it('accepts the publishable package contract', () => { expect(() => validatePackManifest(validManifest)).not.toThrow(); });