Skip to content

Commit 4da7ad1

Browse files
committed
feat(nx-npm-access): add npm-fix target — auto-remediation, not just checks
Previously the plugin only registered the read-only `npm-check` target. Add a sibling `npm-fix` target that runs the same script with `--fix` and applies safe, idempotent remediations: 1. package.json patch — sets `publishConfig.access = "public"` in place when it is missing or wrong. Offline, no auth required. 2. `npm access set status=public <pkg>` — only for packages already on npm whose remote visibility drifted to private. 3. `npm access set mfa=<target> <pkg>` — only when `--mfa=<target>` is explicitly passed (e.g. `--mfa=none` before moving a package to OIDC trusted publishing). Trusted publisher (OIDC) registration is deliberately NOT automated — npm v11 still ships no CLI for it. The script keeps printing the settings URL for manual UI work, which is the same behaviour as `npm-check`. Every mutation is recorded in a new `fixes: string[]` field on the structured JSON report, so a CI step can diff "what changed". Usage: bunx nx run-many -t npm-fix # fix everywhere bunx nx run adt-cli:npm-fix --args="--mfa=none" # per package The targets now pass `forwardAllArgs: true` so ad-hoc flags (e.g. `--mfa=...`, `--quiet`) reach the script via nx `--args=...`. Plugin options renamed: `targetName` → `checkTargetName`, plus a new `fixTargetName` (defaults preserve backwards-compatible target names). Locally verified end-to-end on `@abapify/adt-locks` (the only package still missing `publishConfig.access`): `--fix` successfully patched the manifest; the local change was reverted from the commit since applying the actual fix to adt-locks is a separate, intentional action the user should run via `nx run adt-locks:npm-fix`.
1 parent ba33e9a commit 4da7ad1

3 files changed

Lines changed: 151 additions & 38 deletions

File tree

tools/nx-npm-access/README.md

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,48 @@
11
# @abapify/nx-npm-access
22

3-
Internal Nx plugin that adds an `npm-check` target to every publishable
4-
`packages/*` workspace package. The target verifies that a package is
5-
ready to be published from CI before a release is attempted.
3+
Internal Nx plugin that registers two targets on every publishable
4+
`packages/*` workspace package:
5+
6+
- `npm-check` — read-only readiness probe (`npm view`, `npm access get`).
7+
- `npm-fix` — safe auto-remediation (patches `package.json`, runs
8+
`npm access set status=public`, optionally `npm access set mfa=...`).
69

710
## Usage
811

912
```bash
10-
# Check every publishable package at once:
13+
# Read-only — run this as a CI gate before `nx release publish`:
1114
bunx nx run-many -t npm-check
1215

13-
# Check a single package:
16+
# Apply safe fixes across the whole workspace (requires `npm login` or an
17+
# authenticated OIDC context for the `npm access set` calls):
18+
bunx nx run-many -t npm-fix
19+
20+
# Target one package:
1421
bunx nx run adt-cli:npm-check
22+
bunx nx run adt-cli:npm-fix
1523

16-
# Silence the success lines in CI, keep only problems:
17-
bunx nx run-many -t npm-check --parallel=8 --output-style=stream | \
18-
grep -E '^(✗| problems|__NPM_CHECK_JSON__)'
24+
# Extra flags are forwarded through nx:
25+
bunx nx run adt-cli:npm-fix --args="--mfa=none"
1926
```
2027

21-
The target exits non-zero when a package has `publishConfig.access` missing,
22-
when `npm view` fails unexpectedly, or when required metadata (name,
23-
version) is absent. **First publish** (package not yet on npm) is NOT an
24-
error — it is reported as `NOT on npm — first publish`.
28+
`npm-check` exits non-zero when a package has `publishConfig.access`
29+
missing, when `npm view` fails unexpectedly, or when required metadata
30+
(name, version) is absent. **First publish** (package not yet on npm) is
31+
NOT an error — it is reported as `NOT on npm — first publish`.
32+
33+
`npm-fix` does the same checks and additionally applies:
34+
35+
1. **package.json patch** — sets `publishConfig.access = "public"` in
36+
place when missing or wrong. Safe, offline, idempotent.
37+
2. **`npm access set status=public <pkg>`** — only for packages already
38+
on npm whose remote visibility drifted to `private`.
39+
3. **`npm access set mfa=<target> <pkg>`** — only when `--mfa=<target>`
40+
is passed (e.g. `--mfa=none` before switching a package to OIDC
41+
trusted publishing).
42+
43+
Trusted publisher (OIDC) registration is **not automated** — npm v11
44+
ships no CLI for it. The script prints a ready-to-click settings URL
45+
instead.
2546

2647
## What is actually checked
2748

@@ -62,13 +83,14 @@ Registered in the root `nx.json` under `plugins`:
6283
```
6384

6485
The plugin's `createNodesV2` matches `packages/*/package.json`, reads each
65-
manifest, skips packages with `"private": true`, and attaches an
66-
`npm-check` target that invokes `src/check.ts` via `bun` (same runtime
67-
used everywhere else in the monorepo — no separate `.mjs` build step).
86+
manifest, skips packages with `"private": true`, and attaches both the
87+
`npm-check` and `npm-fix` targets. Both invoke `src/check.ts` via `bun`
88+
(same runtime used everywhere else in the monorepo — no build step).
6889

6990
Options (all optional, set via `nx.json` plugin options):
7091

71-
| option | default | purpose |
72-
| ------------ | ------------------------------- | ------------------------------------------ |
73-
| `targetName` | `"npm-check"` | Name of the target registered per package. |
74-
| `registry` | `"https://registry.npmjs.org/"` | Registry probed by the script. |
92+
| option | default | purpose |
93+
| ----------------- | ------------------------------- | ------------------------------------------------ |
94+
| `checkTargetName` | `"npm-check"` | Name of the read-only target registered per pkg. |
95+
| `fixTargetName` | `"npm-fix"` | Name of the auto-remediating target. |
96+
| `registry` | `"https://registry.npmjs.org/"` | Registry probed by the script. |

tools/nx-npm-access/src/check.ts

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// published from CI. Run per-package via Nx (`nx run <pkg>:npm-check`) or for
66
// all publishable packages at once (`nx run-many -t npm-check`).
77
//
8-
// Checks performed (all read-only, no network writes):
8+
// Checks performed (all read-only by default, no network writes):
99
// 1. package.json hygiene — name, version, publishConfig.access, exports.
1010
// 2. Does the package exist on npm? (new packages report "first publish").
1111
// 3. `npm access get status` — current public/private visibility on npm.
@@ -15,6 +15,17 @@
1515
// the OIDC trusted publisher for this package can be verified. npm has
1616
// no stable CLI for listing trusted publishers yet (as of npm 11).
1717
//
18+
// With `--fix` the script also applies safe remediations:
19+
// - patches `publishConfig.access = "public"` into package.json if missing;
20+
// - runs `npm access set status=public <pkg>` on published packages whose
21+
// remote visibility drifted to `private`;
22+
// - runs `npm access set mfa=none <pkg>` (only if `--mfa=none` is passed)
23+
// — required for OIDC trusted publishing without interactive 2FA.
24+
// Every mutation is logged to the structured report under `fixes`.
25+
//
26+
// Trusted publisher registration (OIDC) still has no CLI, so the script only
27+
// prints the UI link — it does NOT try to automate that step.
28+
//
1829
// The script exits with code 0 when the package is "ready to publish from
1930
// CI", 1 otherwise. Errors are printed to stderr; the structured report is
2031
// emitted to stdout as a single JSON line, prefixed with a human summary.
@@ -23,7 +34,7 @@
2334
// `@abapify:registry=https://npm.pkg.github.com/`, the script passes
2435
// `--<scope>:registry=<registry>` on every npm invocation.
2536

26-
import { readFileSync, existsSync } from 'node:fs';
37+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2738
import { join } from 'node:path';
2839
import { spawnSync } from 'node:child_process';
2940

@@ -49,7 +60,9 @@ interface Report {
4960
access: string | null;
5061
registry: string;
5162
scope: string | null;
63+
mode: 'check' | 'fix';
5264
checks: Record<string, unknown>;
65+
fixes: string[];
5366
readyForCi: boolean;
5467
problems: string[];
5568
}
@@ -59,8 +72,14 @@ const getFlag = (name: string, def: string): string => {
5972
const hit = args.find((a) => a.startsWith(`--${name}=`));
6073
return hit ? hit.slice(name.length + 3) : def;
6174
};
75+
const hasFlag = (name: string): boolean => args.includes(`--${name}`);
76+
6277
const registry = getFlag('registry', 'https://registry.npmjs.org/');
63-
const quiet = args.includes('--quiet');
78+
const quiet = hasFlag('quiet');
79+
const fix = hasFlag('fix');
80+
// Optional MFA setting applied only in fix mode. Useful before switching to
81+
// OIDC trusted publishing (`--mfa=none`). Omit to leave MFA untouched.
82+
const mfaTarget = getFlag('mfa', '');
6483

6584
const pkgPath = join(process.cwd(), 'package.json');
6685
if (!existsSync(pkgPath)) {
@@ -126,20 +145,38 @@ const report: Report = {
126145
access: pkg.publishConfig?.access ?? null,
127146
registry,
128147
scope,
148+
mode: fix ? 'fix' : 'check',
129149
checks: {},
150+
fixes: [],
130151
readyForCi: false,
131152
problems: [],
132153
};
133154

134-
// 1. package.json hygiene
155+
// 1. package.json hygiene — and patch in --fix mode.
135156
if (!name) report.problems.push('package.json: missing "name"');
136157
if (!pkg.version) report.problems.push('package.json: missing "version"');
137-
if (!pkg.publishConfig || pkg.publishConfig.access !== 'public') {
138-
report.problems.push(
139-
'package.json: publishConfig.access should be "public" (scoped packages default to restricted on npm; CI publishes would 402 Payment Required)',
140-
);
158+
159+
const needsPublishConfigFix =
160+
!pkg.publishConfig || pkg.publishConfig.access !== 'public';
161+
if (needsPublishConfigFix) {
162+
if (fix) {
163+
const patched: Pkg = {
164+
...pkg,
165+
publishConfig: { ...(pkg.publishConfig ?? {}), access: 'public' },
166+
};
167+
// Preserve trailing newline + 2-space indent to match the rest of the
168+
// monorepo's package.json style (Prettier will normalise anyway).
169+
writeFileSync(pkgPath, JSON.stringify(patched, null, 2) + '\n', 'utf-8');
170+
report.fixes.push('package.json: set publishConfig.access = "public"');
171+
report.access = 'public';
172+
} else {
173+
report.problems.push(
174+
'package.json: publishConfig.access should be "public" (scoped packages default to restricted on npm; CI publishes would 402 Payment Required) — re-run with `--fix` to patch',
175+
);
176+
}
141177
}
142178
if (pkg.files === undefined) {
179+
// Not auto-fixable: the correct files list is package-specific.
143180
report.problems.push(
144181
'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"]).',
145182
);
@@ -188,6 +225,41 @@ if (report.checks.exists === true && name) {
188225
} else {
189226
report.checks.collaborators = `ERR: ${collabs.stderr.split('\n')[0] ?? collabs.code}`;
190227
}
228+
229+
// --fix: flip visibility to public if npm reports it as private.
230+
if (fix) {
231+
const currentStatus =
232+
typeof report.checks.accessStatus === 'object' &&
233+
report.checks.accessStatus !== null
234+
? (report.checks.accessStatus as Record<string, string>)[name]
235+
: typeof report.checks.accessStatus === 'string'
236+
? report.checks.accessStatus
237+
: undefined;
238+
if (currentStatus && currentStatus !== 'public') {
239+
const setPub = npm(['access', 'set', 'status=public', name]);
240+
if (setPub.code === 0) {
241+
report.fixes.push(`npm access set status=public ${name}`);
242+
report.checks.accessStatus = 'public';
243+
} else {
244+
report.problems.push(
245+
`npm access set status=public failed: ${setPub.stderr.split('\n')[0] ?? 'no stderr'} — needs login (\`npm login\`) or OIDC env`,
246+
);
247+
}
248+
}
249+
250+
// Optional: align MFA policy (e.g. `--mfa=none` for OIDC trusted pub).
251+
if (mfaTarget) {
252+
const setMfa = npm(['access', 'set', `mfa=${mfaTarget}`, name]);
253+
if (setMfa.code === 0) {
254+
report.fixes.push(`npm access set mfa=${mfaTarget} ${name}`);
255+
report.checks.mfa = mfaTarget;
256+
} else {
257+
report.problems.push(
258+
`npm access set mfa=${mfaTarget} failed: ${setMfa.stderr.split('\n')[0] ?? 'no stderr'}`,
259+
);
260+
}
261+
}
262+
}
191263
}
192264

193265
// 5. trusted publisher hint (npm has no stable CLI for listing these yet)
@@ -215,13 +287,18 @@ const existsTag =
215287
: report.checks.exists === false
216288
? 'NOT on npm — first publish'
217289
: 'npm state unknown';
290+
const fixesSummary =
291+
report.fixes.length > 0
292+
? `\n fixes applied:\n + ${report.fixes.join('\n + ')}`
293+
: '';
218294
const problemsSummary =
219295
report.problems.length > 0
220296
? `\n problems:\n - ${report.problems.join('\n - ')}`
221297
: '';
222298

299+
const modeTag = fix ? ' [fix]' : '';
223300
console.log(
224-
`${symbol} ${name}@${pkg.version} (${existsTag})${problemsSummary}`,
301+
`${symbol} ${name}@${pkg.version}${modeTag} (${existsTag})${fixesSummary}${problemsSummary}`,
225302
);
226303
// Structured line for aggregation:
227304
console.log(`__NPM_CHECK_JSON__ ${JSON.stringify(report)}`);

tools/nx-npm-access/src/plugin.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ import { existsSync, readFileSync } from 'node:fs';
44

55
interface NxNpmAccessOptions {
66
/**
7-
* Target name registered on each publishable package.
7+
* Target name registered on each publishable package for the read-only
8+
* readiness check.
89
* @default "npm-check"
910
*/
10-
targetName?: string;
11+
checkTargetName?: string;
12+
/**
13+
* Target name registered on each publishable package for the
14+
* auto-remediating fix (patches `publishConfig.access` locally and runs
15+
* `npm access set` remotely).
16+
* @default "npm-fix"
17+
*/
18+
fixTargetName?: string;
1119
/**
1220
* npm registry used for access probes. Defaults to the public registry.
1321
* The script also overrides scope-level registry pins via
@@ -47,10 +55,13 @@ function shouldSkipPath(projectRoot: string): boolean {
4755
export const createNodesV2: CreateNodesV2<NxNpmAccessOptions> = [
4856
'**/package.json',
4957
(configFiles, options = {}) => {
50-
const targetName = options.targetName ?? 'npm-check';
58+
const checkTargetName = options.checkTargetName ?? 'npm-check';
59+
const fixTargetName = options.fixTargetName ?? 'npm-fix';
5160
const registry = options.registry ?? 'https://registry.npmjs.org/';
5261

5362
const scriptPath = join(__dirname, 'check.ts');
63+
const scriptArg = JSON.stringify(scriptPath);
64+
const baseCmd = `bun ${scriptArg} --registry=${registry}`;
5465

5566
return configFiles
5667
.map((configFile) => {
@@ -83,27 +94,30 @@ export const createNodesV2: CreateNodesV2<NxNpmAccessOptions> = [
8394
return null;
8495
}
8596

86-
log(`register ${targetName} for ${pkg.name}`);
97+
log(`register ${checkTargetName} + ${fixTargetName} for ${pkg.name}`);
8798

88-
const target = {
99+
// Shared shape: cwd + no arg forwarding, no cache (network-dependent).
100+
const makeTarget = (extraArg: string | null) => ({
89101
executor: 'nx:run-commands' as const,
90102
options: {
91-
command: `bun ${JSON.stringify(scriptPath)} --registry=${registry}`,
103+
command: extraArg ? `${baseCmd} ${extraArg}` : baseCmd,
92104
cwd: projectRoot,
93-
// Prevents nx from swallowing stderr from npm.
94-
forwardAllArgs: false,
105+
// Prevents nx from swallowing stderr from npm, and lets the user
106+
// pass extra flags (e.g. `--args="--mfa=none"`) through nx.
107+
forwardAllArgs: true,
95108
},
96109
cache: false,
97110
inputs: [`{projectRoot}/package.json`],
98-
};
111+
});
99112

100113
return [
101114
configFile,
102115
{
103116
projects: {
104117
[projectRoot]: {
105118
targets: {
106-
[targetName]: target,
119+
[checkTargetName]: makeTarget(null),
120+
[fixTargetName]: makeTarget('--fix'),
107121
},
108122
},
109123
},

0 commit comments

Comments
 (0)