|
| 1 | +#!/usr/bin/env node |
| 2 | +// tools/nx-npm-access/src/check.mjs |
| 3 | +// |
| 4 | +// Validates that a single @abapify/* package (located in $cwd) is ready to be |
| 5 | +// published from CI. Run per-package via Nx (`nx run <pkg>:npm-check`) or for |
| 6 | +// all publishable packages at once (`nx run-many -t npm-check`). |
| 7 | +// |
| 8 | +// Checks performed (all read-only, no network writes): |
| 9 | +// 1. package.json hygiene — name, version, publishConfig.access, exports. |
| 10 | +// 2. Does the package exist on npm? (new packages report "first publish"). |
| 11 | +// 3. `npm access get status` — current public/private visibility on npm. |
| 12 | +// 4. `npm access list collaborators` — who can publish (incl. bot account |
| 13 | +// used by GitHub Actions when OIDC is not in play). |
| 14 | +// 5. Trusted publisher hint — prints a ready-to-click settings URL where |
| 15 | +// the OIDC trusted publisher for this package can be verified. npm has |
| 16 | +// no stable CLI for listing trusted publishers yet (as of npm 11). |
| 17 | +// |
| 18 | +// The script exits with code 0 when the package is "ready to publish from |
| 19 | +// CI", 1 otherwise. Errors are printed to stderr; the structured report is |
| 20 | +// emitted to stdout as a single JSON line, prefixed with a human summary. |
| 21 | +// |
| 22 | +// Registry handling: to avoid the repo-level `.npmrc` pinning |
| 23 | +// `@abapify:registry=https://npm.pkg.github.com/`, the script passes |
| 24 | +// `--<scope>:registry=<registry>` on every npm invocation. |
| 25 | + |
| 26 | +import { readFileSync, existsSync } from 'node:fs'; |
| 27 | +import { join } from 'node:path'; |
| 28 | +import { spawnSync } from 'node:child_process'; |
| 29 | + |
| 30 | +const args = process.argv.slice(2); |
| 31 | +const getFlag = (name, def) => { |
| 32 | + const hit = args.find((a) => a.startsWith(`--${name}=`)); |
| 33 | + return hit ? hit.slice(name.length + 3) : def; |
| 34 | +}; |
| 35 | +const registry = getFlag('registry', 'https://registry.npmjs.org/'); |
| 36 | +const quiet = args.includes('--quiet'); |
| 37 | + |
| 38 | +const pkgPath = join(process.cwd(), 'package.json'); |
| 39 | +if (!existsSync(pkgPath)) { |
| 40 | + console.error(`[npm-check] no package.json in ${process.cwd()}`); |
| 41 | + process.exit(2); |
| 42 | +} |
| 43 | +const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); |
| 44 | + |
| 45 | +if (pkg.private) { |
| 46 | + if (!quiet) console.log(`[skip] ${pkg.name ?? '<no-name>'} is private`); |
| 47 | + process.exit(0); |
| 48 | +} |
| 49 | + |
| 50 | +const name = pkg.name; |
| 51 | +const scope = name?.startsWith('@') ? name.split('/')[0] : null; |
| 52 | +const scopeFlag = scope ? [`--${scope}:registry=${registry}`] : []; |
| 53 | + |
| 54 | +/** |
| 55 | + * Run an npm subcommand. We deliberately do NOT inherit the repo `.npmrc` — |
| 56 | + * instead we pass registry overrides explicitly. Auth-free commands are |
| 57 | + * preferred (`view`); anything that requires login is still called but |
| 58 | + * failure is reported as "needs auth" rather than blocking. |
| 59 | + */ |
| 60 | +function npm(cmdArgs) { |
| 61 | + const result = spawnSync( |
| 62 | + 'npm', |
| 63 | + [...cmdArgs, `--registry=${registry}`, ...scopeFlag, '--json'], |
| 64 | + { |
| 65 | + encoding: 'utf-8', |
| 66 | + // Individual npm calls are short-lived; if the network (or corporate |
| 67 | + // proxy) hangs, fail fast instead of wedging the whole `nx run-many`. |
| 68 | + timeout: 20_000, |
| 69 | + }, |
| 70 | + ); |
| 71 | + let parsed = null; |
| 72 | + if (result.stdout) { |
| 73 | + try { |
| 74 | + parsed = JSON.parse(result.stdout); |
| 75 | + } catch { |
| 76 | + parsed = result.stdout.trim(); |
| 77 | + } |
| 78 | + } |
| 79 | + return { |
| 80 | + code: result.status ?? -1, |
| 81 | + timedOut: result.signal === 'SIGTERM' || result.error?.code === 'ETIMEDOUT', |
| 82 | + stdout: result.stdout, |
| 83 | + stderr: result.stderr, |
| 84 | + json: parsed, |
| 85 | + }; |
| 86 | +} |
| 87 | + |
| 88 | +const report = { |
| 89 | + name, |
| 90 | + version: pkg.version, |
| 91 | + access: pkg.publishConfig?.access ?? null, |
| 92 | + registry, |
| 93 | + scope, |
| 94 | + checks: {}, |
| 95 | + readyForCi: false, |
| 96 | + problems: [], |
| 97 | +}; |
| 98 | + |
| 99 | +// 1. package.json hygiene |
| 100 | +if (!name) report.problems.push('package.json: missing "name"'); |
| 101 | +if (!pkg.version) report.problems.push('package.json: missing "version"'); |
| 102 | +if (!pkg.publishConfig || pkg.publishConfig.access !== 'public') { |
| 103 | + report.problems.push( |
| 104 | + 'package.json: publishConfig.access should be "public" (scoped packages default to restricted on npm; CI publishes would 402 Payment Required)', |
| 105 | + ); |
| 106 | +} |
| 107 | +if (pkg.files === undefined) { |
| 108 | + report.problems.push( |
| 109 | + 'package.json: "files" not declared — publish will include everything (incl. node_modules, dist build artefacts, tests). Add a narrow allowlist (e.g. ["dist", "README.md"]).', |
| 110 | + ); |
| 111 | +} |
| 112 | + |
| 113 | +// 2. Does it exist on npm? |
| 114 | +const view = npm(['view', name]); |
| 115 | +if (view.code === 0 && view.json && typeof view.json === 'object') { |
| 116 | + report.checks.exists = true; |
| 117 | + report.checks.latestVersion = view.json.version; |
| 118 | + report.checks.maintainers = view.json.maintainers ?? []; |
| 119 | + report.checks.distTags = view.json['dist-tags'] ?? {}; |
| 120 | +} else if (view.stderr?.includes('E404') || view.json?.error?.code === 'E404') { |
| 121 | + report.checks.exists = false; |
| 122 | +} else if (view.timedOut) { |
| 123 | + report.checks.exists = 'unknown'; |
| 124 | + report.problems.push( |
| 125 | + `npm view timed out after 20s — registry ${registry} unreachable from this host`, |
| 126 | + ); |
| 127 | +} else { |
| 128 | + report.checks.exists = 'unknown'; |
| 129 | + report.problems.push( |
| 130 | + `npm view failed unexpectedly: ${view.stderr?.split('\n')[0] ?? 'no stderr'}`, |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +// 3. access status (only meaningful if published) |
| 135 | +if (report.checks.exists === true) { |
| 136 | + const status = npm(['access', 'get', 'status', name]); |
| 137 | + if (status.code === 0) { |
| 138 | + report.checks.accessStatus = status.json ?? status.stdout.trim(); |
| 139 | + } else { |
| 140 | + report.checks.accessStatus = `ERR: ${status.stderr?.split('\n')[0] ?? status.code}`; |
| 141 | + } |
| 142 | + |
| 143 | + // 4. collaborators |
| 144 | + const collabs = npm(['access', 'list', 'collaborators', name]); |
| 145 | + if (collabs.code === 0) { |
| 146 | + report.checks.collaborators = collabs.json ?? collabs.stdout.trim(); |
| 147 | + } else { |
| 148 | + report.checks.collaborators = `ERR: ${collabs.stderr?.split('\n')[0] ?? collabs.code}`; |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +// 5. trusted publisher hint (npm has no stable CLI for listing these yet) |
| 153 | +if (name?.startsWith('@')) { |
| 154 | + const [s, p] = name.slice(1).split('/'); |
| 155 | + report.checks.trustedPublisherSettingsUrl = `https://www.npmjs.com/settings/${s}/packages?q=${p}`; |
| 156 | + report.checks.trustedPublisherPackageUrl = `https://www.npmjs.com/package/${name}/access`; |
| 157 | +} else { |
| 158 | + report.checks.trustedPublisherPackageUrl = `https://www.npmjs.com/package/${name}/access`; |
| 159 | +} |
| 160 | + |
| 161 | +// Decide overall readiness |
| 162 | +const hasPublishConfig = pkg.publishConfig?.access === 'public'; |
| 163 | +const existsOrNew = |
| 164 | + report.checks.exists === true || report.checks.exists === false; |
| 165 | +report.readyForCi = hasPublishConfig && existsOrNew && name && pkg.version; |
| 166 | + |
| 167 | +// Human summary |
| 168 | +const symbol = report.readyForCi ? '✓' : '✗'; |
| 169 | +const existsTag = |
| 170 | + report.checks.exists === true |
| 171 | + ? `on npm @ ${report.checks.latestVersion}` |
| 172 | + : report.checks.exists === false |
| 173 | + ? 'NOT on npm — first publish' |
| 174 | + : 'npm state unknown'; |
| 175 | +const problemsSummary = |
| 176 | + report.problems.length > 0 |
| 177 | + ? `\n problems:\n - ${report.problems.join('\n - ')}` |
| 178 | + : ''; |
| 179 | + |
| 180 | +console.log( |
| 181 | + `${symbol} ${name}@${pkg.version} (${existsTag})${problemsSummary}`, |
| 182 | +); |
| 183 | +// Structured line for aggregation: |
| 184 | +console.log(`__NPM_CHECK_JSON__ ${JSON.stringify(report)}`); |
| 185 | + |
| 186 | +process.exit(report.readyForCi && report.problems.length === 0 ? 0 : 1); |
0 commit comments