From feed000095eca41c3afbdbf09e516748ad45a168 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Tue, 29 Sep 2026 07:05:44 -0400 Subject: [PATCH] fix: normalize bin path for npm 11 and bump to 0.2.1 - Remove leading './' from bin.gapwise path to avoid npm 11 package normalization warnings - Add package artifact and bin normalization unit test - Harden verify-package.mjs to assert zero npm auto-correction warnings, valid tarball permissions, isolated consumer installs, and npx execution - Bump version to 0.2.1 and update release notes --- RELEASING.md | 14 +++++-- package.json | 4 +- scripts/verify-package.mjs | 68 +++++++++++++++++++++++++++------ tests/package-artifact.test.mjs | 38 ++++++++++++++++++ tests/public-api.test.mjs | 4 +- 5 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 tests/package-artifact.test.mjs diff --git a/RELEASING.md b/RELEASING.md index b00a658..4327880 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,14 +22,22 @@ npm run check npm publish --access public ``` -The intended first version is `0.2.0`; check `package.json` before publishing and do not publish from an old checkout. If npm reports scope ownership, organization, billing, or OTP requirements, resolve those requirements in npm. Do not publish under a personal scope as a substitute. After the first release appears on the registry, add an npm Trusted Publisher in the `@gapwise/cli` package settings: +The initial 0.2.0 bootstrap was published manually to establish `@gapwise/cli` on the npm registry. For version 0.2.1 (which normalizes the bin path to `bin/gapwise.mjs` and verifies clean npx/global execution), from a clean checkout of merged `main`: +```sh +npm login +npm whoami +npm run check +npm publish --access public +``` + +If npm Trusted Publishing has been configured for `@gapwise/cli`: - Provider: GitHub Actions - Organization/user: `GapwiseHQ` - Repository: `cli` - Workflow filename: `release.yml` - Allowed action: direct `npm publish` -Then create and push an annotated `v0.2.0` tag on the same merged `main` commit. The workflow checks the existing registry version, verifies its install, and creates the corresponding GitHub Release. Subsequent new versions publish from the tag workflow through OIDC and get npm provenance. Keep the initial manual publish distinct from provenance-bearing OIDC releases; do not claim provenance for the manual first release. +Then create and push an annotated `v0.2.1` tag on the same merged `main` commit. The workflow checks the existing registry version, verifies its install, and creates the corresponding GitHub Release. Subsequent new versions publish from the tag workflow through OIDC and get npm provenance. Keep the initial manual publish distinct from provenance-bearing OIDC releases; do not claim provenance for manual releases. -If an npm owner authorizes this workspace with an interactive login, the maintainer can complete the first publish here and perform the external verification. Never commit auth tokens or `.npmrc` credentials. +If an npm owner authorizes this workspace with an interactive login, the maintainer can complete the publish here and perform the external verification. Never commit auth tokens or `.npmrc` credentials. diff --git a/package.json b/package.json index f0d830d..17c39a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gapwise/cli", - "version": "0.2.0", + "version": "0.2.1", "description": "Official Gapwise CLI for multi-university campus discovery and integration tooling", "type": "module", "license": "MIT", @@ -11,7 +11,7 @@ }, "bugs": { "url": "https://github.com/GapwiseHQ/cli/issues" }, "keywords": ["gapwise", "university", "campus", "campus-data", "cli"], - "bin": { "gapwise": "./bin/gapwise.mjs" }, + "bin": { "gapwise": "bin/gapwise.mjs" }, "scripts": { "test": "node --test tests/*.test.mjs", "test:package": "node scripts/verify-package.mjs", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index c0861be..8dd9e59 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -11,27 +12,72 @@ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); function run(command, args, cwd = root) { const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 }); assert.equal(result.status, 0, `${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`); - return result.stdout; + return result; } try { + // 1. Manifest structure & bin configuration assert.equal(manifest.name, '@gapwise/cli'); - assert.equal(manifest.publishConfig.access, 'public'); - assert.ok(readFileSync(join(root, manifest.bin.gapwise), 'utf8').startsWith('#!/usr/bin/env node\n')); + assert.equal(manifest.publishConfig?.access, 'public'); + assert.equal( + manifest.bin?.gapwise, + 'bin/gapwise.mjs', + 'bin.gapwise must be bare relative path "bin/gapwise.mjs" (no leading ./) to avoid npm normalization warnings' + ); + + // 2. Shebang, LF line endings, and file permissions + const binContent = readFileSync(join(root, manifest.bin.gapwise), 'utf8'); + assert.ok(binContent.startsWith('#!/usr/bin/env node\n'), 'CLI entrypoint must start with #!/usr/bin/env node\\n'); + assert.ok(!binContent.includes('\r\n'), 'CLI entrypoint must use LF line endings'); assert.ok(statSync(join(root, manifest.bin.gapwise)).mode & 0o111, 'CLI entrypoint must be executable'); - const [packed] = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp])); + // 3. Dry-run publish must emit zero normalization warnings + const dryRun = spawnSync('npm', ['publish', '--dry-run'], { cwd: root, encoding: 'utf8' }); + assert.equal(dryRun.status, 0, `npm publish --dry-run failed:\n${dryRun.stdout}\n${dryRun.stderr}`); + assert.ok( + !dryRun.stderr.includes('npm auto-corrected') && !dryRun.stderr.includes('was invalid and removed'), + `npm publish emitted bin normalization warning:\n${dryRun.stderr}` + ); + + // 4. Pack tarball and verify contents & metadata + const packResult = run('npm', ['pack', '--json', '--pack-destination', temp]); + const [packed] = JSON.parse(packResult.stdout); assert.ok(packed.size < 100_000, `Package exceeds 100 KB: ${packed.size}`); const paths = packed.files.map(({ path }) => path).sort(); assert.deepEqual(paths, ['LICENSE', 'README.md', 'bin/gapwise.mjs', 'bin/public-api.mjs', 'package.json']); - const tarListing = run('tar', ['-tvzf', join(temp, packed.filename)]); + + const tarballPath = join(temp, packed.filename); + const tarListing = run('tar', ['-tvzf', tarballPath]).stdout; assert.match(tarListing, /-rwxr-xr-x\s+[^\n]*package\/bin\/gapwise\.mjs/); - run('npm', ['install', '--global', '--prefix', temp, '--ignore-scripts', '--no-audit', '--no-fund', join(temp, packed.filename)]); - const binary = join(temp, 'bin/gapwise'); - assert.equal(run(binary, ['--version']).trim(), manifest.version); - assert.match(run(binary, ['--help']), /gapwise universities/); - console.log(`Verified ${manifest.name}@${manifest.version}: ${packed.size} bytes, ${paths.length} files, clean global install and executable.`); + // Verify packed package.json bin property + const packedPkgJson = JSON.parse(run('tar', ['-xOf', tarballPath, 'package/package.json']).stdout); + assert.equal(packedPkgJson.bin?.gapwise, 'bin/gapwise.mjs'); + + // 5. Global installation test (prefix in isolated temp) + const globalPrefix = join(temp, 'global'); + run('npm', ['install', '--global', '--prefix', globalPrefix, '--ignore-scripts', '--no-audit', '--no-fund', tarballPath]); + const globalBinary = join(globalPrefix, 'bin/gapwise'); + assert.equal(run(globalBinary, ['--version']).stdout.trim(), manifest.version); + assert.match(run(globalBinary, ['--help']).stdout, /gapwise universities/); + + // 6. Local consumer installation test in isolated project + const consumerDir = join(temp, 'consumer'); + mkdirSync(consumerDir, { recursive: true }); + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'consumer-test', private: true })); + run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], consumerDir); + const localBinary = join(consumerDir, 'node_modules/.bin/gapwise'); + assert.equal(run(localBinary, ['--version'], consumerDir).stdout.trim(), manifest.version); + assert.equal(run('npx', ['gapwise', '--version'], consumerDir).stdout.trim(), manifest.version); + + // 7. npx --package execution from an isolated directory + const npxTestDir = join(temp, 'npx-test'); + mkdirSync(npxTestDir, { recursive: true }); + const npxRun = run('npx', ['--yes', '--package', tarballPath, 'gapwise', '--version'], npxTestDir); + assert.equal(npxRun.stdout.trim(), manifest.version); + assert.ok(!npxRun.stderr.includes('not found'), `npx stderr contained "not found": ${npxRun.stderr}`); + + console.log(`Verified ${manifest.name}@${manifest.version}: ${packed.size} bytes, ${paths.length} files, zero npm warnings, clean global & npx execution.`); } finally { rmSync(temp, { recursive: true, force: true }); } diff --git a/tests/package-artifact.test.mjs b/tests/package-artifact.test.mjs new file mode 100644 index 0000000..2c13b82 --- /dev/null +++ b/tests/package-artifact.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const pkgPath = resolve(root, 'package.json'); +const manifest = JSON.parse(readFileSync(pkgPath, 'utf8')); + +test('package.json metadata and bin path adhere to npm 11 normalization standards', () => { + assert.equal(manifest.name, '@gapwise/cli'); + assert.equal(manifest.publishConfig?.access, 'public'); + assert.equal(manifest.engines?.node, '>=22'); + + // npm 11 @npmcli/package-json emits warnings and strips leading './' if present. + // bin paths must be bare relative paths like "bin/gapwise.mjs". + assert.equal( + manifest.bin?.gapwise, + 'bin/gapwise.mjs', + 'bin.gapwise must be "bin/gapwise.mjs" without leading "./"' + ); + assert.doesNotMatch( + manifest.bin?.gapwise, + /^\.\//, + 'bin path must not start with ./' + ); +}); + +test('CLI executable has valid shebang, LF line endings, and executable mode', () => { + const binPath = resolve(root, manifest.bin.gapwise); + const content = readFileSync(binPath, 'utf8'); + + assert.ok(content.startsWith('#!/usr/bin/env node\n'), 'CLI entrypoint must start with #!/usr/bin/env node\\n'); + assert.ok(!content.includes('\r\n'), 'CLI entrypoint must use LF line endings, not CRLF'); + + const stat = statSync(binPath); + assert.ok((stat.mode & 0o111) !== 0, 'CLI entrypoint must have executable permission bits set'); +}); diff --git a/tests/public-api.test.mjs b/tests/public-api.test.mjs index 04e5567..67c6573 100644 --- a/tests/public-api.test.mjs +++ b/tests/public-api.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { resolve } from 'node:path'; @@ -51,9 +52,10 @@ test('help and version are useful without a checkout or network', async () => { assert.equal(help.code, 0); assert.match(help.stdout, /gapwise universities/); assert.match(help.stdout, /--university ID/); + const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')); const version = await run(['--version']); assert.equal(version.code, 0); - assert.match(version.stdout, /^0\.2\.0\n$/); + assert.equal(version.stdout.trim(), pkg.version); }); test('discovery and JSON output use the public API', async (t) => {