@mnci/cli
v4.29.2
Published
MoNecromanCI CLI — a thin CLI over official and community Nx plugins: create an opinionated Nx monorepo (interactive wizard or flags; ESLint + Prettier, Jest/Vitest) and add React apps, Node apps, Azure Function apps (Node or Python), and npm/Python/Go/Fl
Maintainers
Readme
@mnci/cli
A thin CLI over what Nx already ships: an opinionated one-command Nx monorepo with automatic commit-message versioning, instead of hand-rolling templates, configs and CI engines.
The thesis
Most of what a monorepo tool needs to hand-roll — a template engine, a shared config package, a custom CI engine, a dependency-injection step for published packages, a doctor/drift-sync system to keep it all consistent — already has a first-party (or established community) Nx equivalent:
| Hand-rolled elsewhere | This CLI uses instead |
| ------------------------------------------ | ----------------------------------------------------------------------------- |
| Template engine + per-project config files | create-nx-workspace --preset=ts + nx g plugin generators |
| A shared toolchain package for configs | The configs the Nx generators emit (one root ESLint/tsconfig) |
| A custom multi-step CI engine | nx affected -t lint,typecheck,test,build + nx release (~60-line pipeline) |
| A dist-package dependency injector | nx release updates dependent versions natively |
| Hand-written Azure Function templates | @nx/node:application (plain Node app) + a thin Azure Functions v4 overlay |
| doctor/drift sync of tool-owned files | Nothing to drift: this CLI owns 5 small files, Nx owns the rest |
How this package is organised
Source is arranged in vertical slices: one folder per outcome, each with an
index.ts that is its whole public API. A sibling is reached only through that
barrel, never by a path into its files, and a file's suffix says what role it
plays (.use-case, .client, .repository, .algorithm, .validator,
.handler). Tests sit beside what they test.
src/
main.ts the CLI transport — decodes argv, calls one use case
workspace-overlay/ the config files mnci owns and rewrites
workspace-creation/ mnci new, and the interactive wizard
workspace-upgrade/ mnci upgrade
workspace-diagnostics/ mnci doctor
project-scaffolding/ mnci add — one use case per kind, plus post-generation repairs
rollup-library/ what a rollup-bundled library needs repaired to build, type and publish
dependency-management/ mnci sync / mnci up, and the manifest + registry + semver machinery
nx-workspace/ runs the Nx and npm CLIs, always via an argv array
terminal/ prompts in, coloured status out
file-system/ JSON, JSONC workspace files, ensured writes
project-name/ name validation
cli-version/ the update checkThe dependency graph is acyclic and flows one way: main → the command
slices → the infrastructure slices → file-system as a leaf. That is checked,
not assumed — and now enforced: the root eslint.config.mjs turns on
@mnci/eslint-config's verticalSlices rules for packages/cli/src, so a
deep sibling import, a file without a role suffix, a nested subfeature or a
cycle between slices fails npm run lint rather than needing someone to
re-run the checks by hand. It is scoped to this package deliberately; the
config file says which packages were measured and why they are excluded.
Exceptions, and when they go away
Two places hold more than the one responsibility their name claims, and are named here because the next reader deserves to know before opening them:
| Path | Rule waived | Why, and removal condition |
|---|---|---|
| workspace-overlay/overlay.use-case.ts | one responsibility per file | ~5k lines covering CI YAML for two providers (including the native-app job), .npmrc, nuget.config, the VS Code workspace, release config and the CI guard scripts. Splitting it is a decomposition, not a move, so it was deliberately kept out of the change that created these slices. Temporary — removed when that decomposition lands. |
| rollup-library/repair-rollup-config.use-case.spec.ts | a test takes its subject's basename | It also holds the withUpgradedDeclarationSpecifierPlugin describe, whose subject is rollup-config.algorithm.ts. That transform shares three fixtures with the repairs that apply it (OLD_DTS_PLUGIN_CONFIG, EXTENSION_ONLY_DTS_PLUGIN_CONFIG, loadWriteBundle), and duplicating them across two spec files is the worse trade. Permanent unless those fixtures stop being shared. |
rollup-library/ exists because of the own-the-concept rule. Its contents used
to live in project-scaffolding, which meant workspace-diagnostics and
workspace-upgrade depended on the scaffolding slice only to reach repair
helpers — they have no interest in adding a project. The concept now sits in its
own slice that depends on nothing above it (file-system alone), and all three
consumers point at it.
Commands (deliberately few)
mnci new my-repo # create a monorepo (prompts scope + registry)
mnci new my-repo --yes --registry npm --scope @my
mnci new my-repo --yes --registry npm --scope @my --nx-cloud # opt in to Nx Cloud
mnci new --into . # ...or bootstrap into a clone that already exists
cd my-repo
mnci add react-app web # @nx/react (Vite + Jest)
mnci add node-app svc # @nx/node (plain Node app, esbuild)
mnci add node-app api --framework express # ...or fastify | koa | nest
mnci add npm-lib core --empty # slice skeleton only, no sample (also internal-lib, react-lib, react-internal-lib)
mnci add node-function-app api # @nx/node + an Azure Functions v4 overlay
mnci add npm-lib sdk # @nx/js publishable lib -> packages/
mnci add internal-lib utils # @nx/js private lib -> libs/
mnci add react-lib ui # @nx/react publishable component lib -> packages/
mnci add react-internal-lib design # @nx/react private component lib -> libs/
# Python (@mnci/nx-python-pip — pip + Ruff + pytest + PyPA build/twine, no uv)
mnci add python-app svc # app -> apps/ (wheel, zipped into the drop)
mnci add python-function-app fn # Azure Functions (Python v2) -> apps/
mnci add python-lib shared # publishable -> python-packages/ (twine upload)
mnci add python-internal-lib core # private shared lib -> libs/
mnci add python-vendor shared --lib core # wire core's module into shared's built wheel
# Go (@nx-go/nx-go — multi-module: one go.mod per project + a root go.work, golangci-lint + go test)
mnci add go-app api # executable -> apps/ (binary, zipped into the drop)
mnci add go-app cli --release # ...released: tag + per-platform zips on the GitHub Release
mnci add go-app tray --cgo # needs a C toolchain: built on a runner of each OS
mnci add go-app site --web web # embeds and serves the React app apps/web in one binary
mnci add go-function-app fn # serverless handler -> apps/
mnci add go-lib core # publishable (by git tag) -> packages/
mnci add go-internal-lib util # private shared package -> libs/
# Flutter (@mnci/nx-flutter — one root pubspec.yaml pub workspace, analyze + test)
mnci add flutter-app hello # Flutter web app -> apps/ (bundle, zipped into the drop)
mnci add flutter-lib shared # publishable (by git tag) -> packages/
mnci add flutter-internal-lib core # private shared package -> libs/
# VS Code extensions (@nx/node + vsce — bundled, packaged per platform, published by nx release)
mnci add vscode-extension editor # one universal .vsix -> dist/drop/
mnci add vscode-extension editor --sidecar api # one .vsix per platform, go-app api's binary in bin/
mnci add vscode-extension editor --publisher acme # Marketplace publisher (default: the scope without @)
mnci upgrade # re-apply the latest overlay (see below)
mnci upgrade --agent windows-latest # ...with an explicit override
mnci doctor # check this workspace's invariants (read-only)
mnci ci verify # the pipeline's verify phase, run here: see `mnci ci` below
mnci sync # converge dependency ranges + nx sync (TS project refs)
mnci sync --check # ...report and exit non-zero, writing nothing
mnci up # what has a newer release, grouped; pick what to update
mnci up --check # ...report only (the default when output is piped)
mnci i -w web left-pad # add a dep to ONE project, using its toolchain (alias: mnci install)
mnci i -w api github.com/x/y # ...go get in the Go module; dotnet/flutter/pip all dispatched the same way
mnci i -w ui -w web react # ...-w repeats, to add to several projects at once
mnci i -w svc jest -D # ...--save-dev where the ecosystem has a dev-dependency notion
mnci i # no -w: install/restore the whole workspace (the one-shot bootstrap)Where a dependency belongs: root vs project
One rule, and it is the same in every language mnci supports:
Shared development and tool packages live at the root. Runtime dependencies belong to the package that imports them.
| | Runtime deps declared in | What the root file holds |
| -------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| npm | each project's package.json | package.json — scripts, devDependencies, overrides (root-only by npm's rules) |
| pip | each project's pyproject.toml (a function app: its requirements.txt) | requirements-dev.txt — the shared toolchain, nothing else |
| pub | each member's pubspec.yaml | pubspec.yaml — the member list and an SDK floor, no dependency blocks |
| go | each project's own go.mod (multi-module) | go.work — the use list only; mnci owns it, no requirements |
Go follows the same rule as everything else (MoNecromanCI/MoNecromanCi#289). Each
project has its own go.mod with a fetchable module path
(<host>/<org>/<repo>/<dir>, derived from the git origin), tied together by a
root go.work that mnci owns. That is what lets mnci i -w <go-project> <pkg>
go get into exactly that module. The one hazard it introduces —
a stale use entry whose directory is gone makes go list -m fail and breaks
the whole Nx project graph — is caught by mnci doctor, which fails on it and
names the line to remove.
Why hoisting a runtime dependency to the root is a bug, not a tidy-up
It looks like centralisation and it is not. Two independent reasons:
- The root manifest is
privateand never published. A runtime dependency declared there reaches no consumer of any package; an installed@scope/libsimply fails to resolve it. @nx/rollupexternalises exactly what a project's OWN manifest declares. Pull a dependency out ofpackages/thing/package.jsonand rollup stops treating it as external — it inlines a private copy into the bundle. Measured on a real generated workspace: movingaxiosout of one package's manifest took its published bundle from 14.5 KB to 832 KB, silently.
Two things catch this, from opposite directions. @nx/dependency-checks (in the
root ESLint config, so it runs as part of lint) fails the project whose import
is now undeclared, and mnci doctor fails the root that took it.
Debugging: breakpoints in the TypeScript, not the built JavaScript
A publishable library is bundled by @nx/rollup, so what runs is dist/*.js.
A breakpoint in the .ts binds only if the build emitted a source map that
points back at real files. Three separate things had to be fixed for that to be
true, and a generated workspace now gets all three:
| | The default | What mnci writes |
| --- | --- | --- |
| sourceMap | unset, so no .js.map at all | true, in withNx's first argument |
| compiler | 'swc', hardcoded by @nx/js:lib | 'babel' — see below |
| sources paths | OS-native, one parent segment too many | repaired by sourcemapPathTransform |
Each one alone leaves breakpoints grey, and none of them reports an error.
sourceMap has to go in the first argument. The obvious spot is
output: { sourcemap: true } in the second — the generator's own placeholder
comment even suggests it — and it silently does nothing: withNx spreads your
output and then assigns sourcemap: options.sourceMap, so its own undefined
value always wins.
The compiler swap is not a preference. @nx/rollup's swc plugin calls
swc's transform() without sourceMaps, so swc returns no map, the rollup
chain breaks, and the map comes out valid-looking and empty — sources: [].
Measured on a real package: swc gave 0 sources, babel gave 9. Revert the swap
once Nx passes sourceMaps through; issue #308 has the one-line upstream fix.
Re-measured against the exact toolchain mnci new pins today (Nx 23.2.0,
@swc/core 1.15.8): @nx/rollup's swc plugin still does not pass
sourceMaps to transform() — read straight from the installed package, not
assumed — yet a real build with compiler: 'swc' left unmodified now emits a
map with real sources/sourcesContent, and tracing a generated position
through it with @jridgewell/trace-mapping resolves to the correct original
line and column. So the empty-map failure this section exists to route around
did not reproduce on the current pinned versions, even though the documented
root cause (the plugin's own missing sourceMaps option) is still there
verbatim. That is not the revert condition stated above — nothing upstream
changed the call this section is about — so the swap stays in place as a
safety net rather than being removed on an unexplained, unpinned-by-upstream
behavior change that a future @swc/core patch could revert without notice.
A real, separate bug in the swap itself was found and fixed, independent
of the above: it matched compiler: 'swc' with a plain string, one space
after the colon. @stylistic/key-spacing (aligned on value) — which
eslint --fix applies to every mnci-generated file — pads that column out to
whatever the object's longest key is, so on any config reached after even one
lint pass (most concretely: mnci upgrade repairing a project whose add
partially failed) the literal silently stopped matching and the swap
silently no-op'd, while sourceMap: true and sourcemapPathTransform — added
by separate, whitespace-tolerant repairs — went in regardless. mnci doctor's
check did not catch it either, since it only verifies the flag, not the
compiler. Confirmed end to end and fixed with the same whitespace-tolerant
approach sourceMap: true's own guard already used.
The paths are wrong twice over. rollup hands sourcemapPathTransform a
path like ..\..\src\index.ts for a map in dist/ — one parent segment too
many, so it resolves above the project to a file that does not exist, and
back-slashed, which is invalid in a sourcemap sources entry on every platform
(a sources entry is URL-style — the same bug class as the declaration stub).
Both are repaired, by collapsing the parent-segment run rather than stripping a
fixed prefix, so it cannot go stale at another nesting depth.
Maps are built always and published never. !**/*.js.map joins files, so
npm run <lib>:build is debuggable while the tarball stays lean — the same
trade already made for .d.ts.map. There is deliberately no dev-build flag: a
build you have to remember to run differently is one you will not have run at
the moment you need it.
mnci doctor reports any rollup config missing this, and mnci upgrade sweeps
packages/* and libs/* to add it — a rollup config is written once at add
time, so a workspace generated earlier would never fix itself otherwise. The
sweep is idempotent.
mnci sync: making every project agree
nx sync runs the workspace's sync generators, and the only one a generated
workspace registers is @nx/js:typescript-sync — it reconciles TypeScript project
references and has no opinion whatsoever about dependency versions. And npm has no
catalog:, pnpm's one-version-per-workspace mechanism, so keeping two projects on
the same range is a convention nothing enforces.
mnci sync is both halves:
- Every external package declared at more than one version converges on one spec.
nx syncthen reconciles the TypeScript project references.
The winning spec is the one matching what is actually resolved —
node_modules for npm, pubspec.lock for pub, the interpreter for pip. That is
the same source @nx/dependency-checks pins a drifted range to when it
auto-fixes, so the command and the lint rule converge on one answer instead of
overwriting each other. With nothing installed, the highest declared range wins
instead, and the report says which rule was applied.
It keeps the range operator the majority of sites already use, so a workspace that pins exactly stays pinned.
Three things it deliberately never touches:
- Peer ranges.
>=21.0.0on@nx/devkitis a compatibility declaration, not a version choice — narrowing it to the 23.x you happen to resolve drops two majors of consumers. The first run of this command against mnci's own repo reported six findings, five of which were exactly that mistake. - The workspace's own projects. An internal
@scope/libis symlinked and versioned bynx release; its loose range is what lets both the link and the tag satisfy it. - A spec whose shape it cannot safely edit — a
git:/path:/URL target, aworkspace:protocol, annpm:pkg@rangealias, a pubgit:map. Those are reported as a warning and left alone.
Go reports "nothing to sync" rather than a silent pass, because one root
go.mod means one version of every module — there is nothing that could
disagree.
--check reports and exits non-zero without writing anything, so it works as a CI
step. --ecosystem npm|pip|pub|go narrows the run.
mnci up: what has a newer release, and who is using it
Modelled on npm-check -u — the same four sections in the same order, the same
interactive multiselect — with one addition that npm-check cannot give you in a
monorepo: every project declaring the package.
Minor Update New backwards-compatible features.
@nx/devkit devDep/peerDep 23.1.1 › 23.2.0 (root), packages/nx-python-pip, packages/nx-flutter
@typescript-eslint/parser dep/devDep 8.68.0 › 8.69.0 packages/eslint-config, packages/az-durableThat column is the point. It is what tells you an upgrade touches three projects
before you pick it, and picking one rewrites every declaration of it — which
is what stops mnci up from creating the drift mnci sync then has to repair.
Latest versions come from each ecosystem's own tooling, never a hand-rolled HTTP
call: npm view (so a scoped Azure Artifacts feed and its .npmrc credentials
just work), pip index versions, one go list -m -u -json all, one
flutter pub outdated --json. An ecosystem whose toolchain is absent is reported
as a loud SKIPPED, never quietly dropped.
The same three exclusions as mnci sync apply, plus two more that only matter
here: an indirect Go module (go mod tidy owns those) and an aliased
install, where the manifest key names a different package than the one on disk.
The alias case is not hypothetical — mnci's own root manifest pins the dual
TypeScript compiler as typescript: npm:@typescript/typescript6@^6.0.2, and the
first run of this command offered "typescript 6.0.2 › 7.0.2", which is real
TypeScript's version, about a package the workspace does not have.
A selected Go module is upgraded with go get <module>@<version>, never by
editing go.mod — that file is the toolchain's to write.
Flags: --check (report only; also the automatic behaviour when stdout is not a
TTY, so a piped or CI run reports instead of hanging on a prompt), -y/--yes
(take everything), --ecosystem, and --no-install (edit the manifests but skip
the reinstall).
mnci ci: the pipeline's phases, as a command
mnci ci verify runs the pipeline's verify phase on your machine: nx sync:check, then
every project (or, when a pull request's target branch is set, only the projects affected
since the merge-base with it) through lint, typecheck, test and build. It exits with
the failing command's own status, so it can stand in for the pipeline's step.
It is not a shortcut for those Nx targets, and the CLI still has no wrapper for nx test.
It is the pipeline's own logic, which until now lived as a node -e one-liner inside the
generated YAML, ported to tested code, so a laptop and a pipeline run the same thing. The
design (one npx mnci ci call in the pipeline, with room for your own steps around it) is
tracked in #269, and this is its first phase: the generated pipelines do not call it yet
and are unchanged, so nothing about an existing workspace changes. Phases follow as their
guards are ported.
- Same scoping as the guard. No pull-request target means every project, so a push to
main verifies in full. A pull request verifies what is affected since the merge-base with
origin/<target>, fetching the target once if that ref is missing, and falls back to every project when no merge-base can be found, since a run that verifies too little still reports green. The target comes fromGITHUB_BASE_REForSYSTEM_PULLREQUEST_TARGETBRANCH, so to reproduce a pull request run locally, set one:GITHUB_BASE_REF=main mnci ci verify. - Native (cgo) apps are left out, as in the pipeline, since one agent cannot build them.
- Log groups. Under GitHub Actions and Azure Pipelines each part is a collapsible group
(
::group::,##[group]); locally it is a plain heading. - Checked against the guard it replaces. An integration spec runs the inline guard and
this command against the same real git repository, with a recording stand-in for
npx, across a push, a pull request, Azure'srefs/heads/form, a missingorigin/<target>and an unresolvable one, and requires the same Nx commands in each.
mnci doctor: checking the invariants actually hold
Read-only — it never edits the workspace. Every failing finding names the command
that fixes it (usually mnci upgrade), and it exits non-zero when anything failed,
so it works as a CI step as well as a local command.
Every check corresponds to an invariant that has actually been violated, in this repo or in a workspace it generated. None are hypothetical; a check nobody has ever needed is noise that trains people to ignore the output.
| Check | The failure it catches |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Exactly one root ESLint config, and no per-project ones | The config fragmenting — every @nx/* generator writes one, so each project ends up linting against whichever config sits nearest |
| @nx/eslint/plugin registered in nx.json | Without it npm run lint exits 0 while linting nothing |
| The resolved eslint major | A declared range and an installed version are different things — manifests once said ^10 while the pin said 9 |
| .npmrc matches the recorded registry | The two registry kinds get different files; an Azure workspace also needs its scope routed |
| versionActions on publishable Dart/Python packages | Its absence aborts nx release for the whole workspace, not just that project |
| nx sync:check | A stale TypeScript project reference that was never committed |
| No runtime dependency in the root manifest | A dependency hoisted to the root, which reaches no consumer (the root is private) and makes @nx/rollup inline a private copy into the package that imports it — 14.5 KB to 832 KB on a real measurement. See "Where a dependency belongs" above |
| Source maps enabled in every rollup config | A build that emits no .js.map, so every breakpoint in a .ts file stays grey and unbound with nothing reporting why. A rollup config is written once at add time, so an older workspace never fixes itself — mnci upgrade sweeps them |
| No retired formatter is still configured | A leftover .prettierrc*, .oxfmtrc.json or oxlint.config.ts, or a prettier/oxlint/oxfmt devDependency, runs from no command line — which is what makes it dangerous, since an editor extension still resolves it and reformats on save, undoing Standard after every gate has passed |
Everything else is plain Nx, surfaced as a small curated set of root scripts — each a single cross-platform command:
| Script | Runs |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| npm run build | nx run-many -t build |
| npm run lint | nx run-many -t lint |
| npm run test | nx run-many -t test |
| npm run typecheck | nx run-many -t typecheck — its own script because a bundler-built project's build strips types without reading them |
| npm run affected | nx affected -t lint,typecheck,test,build (vs main) |
| npm run graph | nx graph |
| npm run release:preview | nx release --dry-run |
| npm run python:install | fixed Python toolchain (ruff/pytest/build/twine) + editable-install every Python project — the same two guards CI runs, for local dev |
| prepare | husky (commit-msg lint hook) |
Every add also wires local-dev commands
Every mnci add (and the inline internal-lib case) finishes by calling
registerProjectCommands (project-scaffolding/post-generation.use-case.ts), which writes up to three
root package.json scripts for the project just added:
| Script | Runs | When it's added |
| -------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| <name>:build | nx run <name>:build | the kind has a build target (not every kind does — a private lib with nothing to publish, or a Python function app deployed as source, has none) |
| <name>:qa | nx run <name>:lint && nx run <name>:test | always — every kind has both |
| <name>:start | the kind's real local-dev command | only kinds with a genuine dev-server story — never a library |
The same three (when present) are appended as VS Code Tasks into the
workspace's <workspace-name>.code-workspace file, so they also show up
under Terminal → Run Task / the Command Palette — build/qa grouped
accordingly, start marked isBackground since it runs a process that
doesn't exit on its own. Re-running add for the same project name
overwrites its own scripts/tasks rather than duplicating them.
Run and Debug: the launch section
Tasks are reachable only through Terminal → Run Task. The Run and Debug
panel reads a separate launch section, so a workspace with tasks alone offers
nothing in the dropdown people actually open. Every generated workspace therefore
also gets four launch configurations, one per verify target:
| configuration | runs |
| --- | --- |
| mnci: build | npm run build |
| mnci: test | npm run test |
| mnci: lint | npm run lint |
| mnci: typecheck | npm run typecheck |
Three details are load-bearing rather than incidental:
type: node-terminal, notnode.nx run-manyexecutes every target in a child process. A plainnodelaunch attaches to the Nx parent alone, so a breakpoint inside a spec never binds;node-terminalruns the command in VS Code's JS Debug Terminal, which instruments children as they spawn. It also avoids a second trap — anodelaunch defaults tointernalConsole, which renders none of Nx's progress output, so a build there looks like it has hung.- They drive
npm run <script>, never a path intonode_modules. The obviousprogram: node_modules/nx/bin/nx.jsis wrong: Nx ships its bin atdist/bin/nx.js, and that path moves between versions. Driving the root script tracks whatever it does, so a change to the scripts reaches these for free. cwdis${workspaceFolder:<name>}, scoped by folder name. A bare${workspaceFolder}is ambiguous the moment a second folder joins the workspace, and VS Code then refuses to resolve it — breaking all four at once.
Your own configurations are safe: mnci upgrade replaces only the entries named
mnci: * and carries every other one through untouched.
:start resolves differently per kind — an existing generator target where
one already exists, a small nx:run-commands target mnci writes where none
did:
| Kind(s) | :start runs |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| react-app, node-app | nx run <name>:serve — the generator's own inferred dev-server target |
| node-function-app, python-function-app | nx run <name>:start → func start (Azure Functions Core Tools, install separately — never a prerequisite for add itself) |
| python-app | nx run <name>:start → python3 main.py — mnci writes a runnable main.py, since the plugin's own sample module has no entry point |
| go-app | nx run <name>:start → go run . |
| flutter-app | nx run <name>:start → flutter run -d chrome (web is the only platform this plugin builds for) |
| every library, go-function-app | no :start at all — see below |
go-function-app is a known gap, not an oversight: unlike the Node and
Python function-app kinds, it writes no host.json/custom-handler config, so
there is nothing for func start to attach to. Shipping a :start script
that would just fail felt worse than being upfront that it doesn't exist yet.
What new actually does
npx create-nx-workspace@latest <name> --preset=ts— npm workspaces + TypeScript project references. Libraries get noproject.json; targets are inferred from each project's tsconfig/package.json.- Patches
nx.jsonwith the release opinion (the only config Nx has no default for): independent versioning from conventional commits,{projectName}@{version}tags, tag-only git (commit: false) — nothing is ever pushed tomain; future runs resolve versions from tag names. Also fills innamedInputs.sharedGlobalswith the root config files (eslint.config.mjs,eslint.config.mnci.mjs,tsconfig.base.json,package.json), without whichnx affectedon a pull request is blind to them: they live in no project, so changing one marked only the root pseudo-project — which has no lint/typecheck/test/build target — and the affected-scoped verify step ran nothing at all while reporting green. - Writes two ESLint files, and only one of them is mnci's:
eslint.config.mnci.mjsholds the whole linting opinion (one import from@mnci/eslint-config, plus a commented inventory naming every config block) and is rewritten on every upgrade;eslint.config.mjsis the file ESLint actually loads, imports that one, holds your blocks, and is written once and then never touched again. See Your half of the ESLint config below. There is no formatter config, because ESLint is the formatter. Also writes.npmrc(publish auth — see Publish auth below),commitlint.config.mjs, a huskycommit-msghook, the chosen CI provider's pipeline file(s) (azure-pipelines.ymland/or.github/workflows/ci.yml,--ci, defaultazure;github/bothalso gets.github/dependabot.yml— weekly dependency-update PRs), a<workspace-name>.code-workspacefile (VS Code workspace configuration with folder structure, ESLint settings, recommended extensions, and an emptytasksarray thatmnci addfills in per project — see below — open it in VS Code viaFile > Open Workspace from File), and the curated root scripts. - Installs the chosen stack (see below),
husky+@commitlint/*for real, so versions resolve at generation time.
mnci upgrade: re-applying the overlay to an existing workspace
Every fix to workspace-overlay/overlay.use-case.ts — a release-config correction, a CI guard rewritten,
a new Windows code path — only ever reached future mnci new calls until
this existed; nothing let an already-generated workspace pick one up.
mnci upgrade, run from the workspace root, closes that gap: it resolves the
same options new would have and calls the exact same applyOverlay new
itself calls — the one function that does every bit of mnci-owned file
writing (nx.json's release/sync/generators/namedInputs.sharedGlobals/
mnci blocks, .npmrc,
eslint.config.mnci.mjs (not eslint.config.mjs — see below),
commitlint.config.mjs,
.husky/commit-msg, the CI pipeline file(s), .devcontainer/devcontainer.json, the
<workspace-name>.code-workspace file, and the curated root package.json
scripts). Nothing else in the workspace — app/lib source, project.json targets
from mnci add — is ever touched, and it finishes by running eslint --fix
over the result, the same way new and every add do.
The .code-workspace file is the one partial case, and deliberately so: its
folders, settings and extensions are regenerated, but the tasks array is read
back and carried through unchanged. Those tasks are per-project state written by
mnci add, not overlay-owned, so regenerating them wholesale would wipe every
project's build/qa/start entry on upgrade.
The launch array is handled differently again — merged, not carried through.
mnci owns the four mnci: * configurations and replaces them, while any
configuration you added yourself survives. The asymmetry is deliberate: tasks are
written by mnci add and the overlay has no idea which projects exist, whereas the
launch entries are entirely overlay-authored and should track an upgrade.
upgrade also deletes things, which is stronger than the overwriting it
has always done — one more reason to run git diff first, as the command's own
output tells you to:
create-nx-workspace's.prettierrcand.vscode/, plus every formatter config a past mnci version wrote —.prettierrc.json,.prettierrc.mjs,.prettierignore,.oxfmtrc.json,oxlint.config.ts. None of them runs from a command line any more, and that is exactly what makes them dangerous: a globally installedesbenp.prettier-vscodeoroxc.oxc-vscodestill resolves one and still reformats on save, undoing Standard whilelintstays green because the damage lands after the check..vscode/is superseded by the.code-workspacefile.- every per-project
eslint.config.*underapps/,libs/andpackages/. This is the migration path for a workspace generated before mnci owned linting: without it an upgrade would install the root config while each project kept linting against its own stale copy. The root config is never touched, and neither is a config anywhere outside those three directories.
mnci upgrade # re-apply from persisted config alone
mnci upgrade --agent windows-latest # override one field; the override is
# persisted too, so the next upgrade
# remembers itWhere the options come from: mnci new now persists the full set it resolved
(scope, registry, agent, variableGroup, ci, the stack) into
nx.json's mnci block — previously only the stack was kept. upgrade
reads that block back; an explicit flag on the upgrade command line always
wins over the persisted value. A workspace generated before this was
persisted (or hand-edited to remove a field) gets a clear, specific error
naming the one flag needed (No npm scope found in nx.json's persisted
config. Pass --scope explicitly.) rather than a prompt or a guess.
There is deliberately no diff preview or confirmation prompt built in:
applyOverlay is a plain, deterministic file-writer (same content in, same
content out, every time), and virtually every generated workspace is already
a git repo — review the result with git diff before committing, the
same way you'd review any other regenerated file. This does mean upgrade
will overwrite hand customizations to any of the files it owns (e.g. an extra
CI job appended by hand to the pipeline file) — git diff is exactly how
you'd notice and re-apply those on top.
Stack: one choice asked up front
mnci new (run bare, or with flags) asks one question — the test runner. It is
stored where every later mnci add honours it, so the whole workspace stays one
stack:
| Question | Options | Default | Stored as / honoured via |
| --------------- | ------------------ | ------- | ---------------------------------------------------------------------------------------- |
| --test-runner | jest | vitest | jest | nx.json generator unitTestRunner default; the hand-built function app follows it too |
Your half of the ESLint config
There are two root ESLint files, and the split exists because the old
single-file layout lost work. eslint.config.mjs used to be mnci-owned and
rewritten wholesale on every mnci upgrade — so a block appended to it, in the
way a comment mnci itself wrote three lines above described, was deleted
without a word. Worse, upgrade then tells you to run npm run format, so the
first thing that happens after your overrides vanish is every file in the
repository being rewritten against the rules you thought you had changed.
So:
| File | Owner | On mnci upgrade |
| --- | --- | --- |
| eslint.config.mnci.mjs | mnci | rewritten every time |
| eslint.config.mjs | you | written once, then never touched |
eslint.config.mjs is what ESLint loads, because eslint.config.mjs is
ESLint's own default filename — the file the tool looks for has to be the one
you own, or the tool's default is the one mnci overwrites.
import mnci from './eslint.config.mnci.mjs'
export default [
...mnci(),
{ name: 'local/legacy-app-allows-any', files: ['apps/legacy/**/*.ts'], rules: { … } },
]The owned file exports a function, not a resolved array, so options still
reach @mnci/eslint-config from the file you own:
...mnci({ verticalSlices: ['packages/*/src/**/*.ts'] }). An array would have
had nowhere to receive them — mnci's own repository passes verticalSlices,
which is how that was caught.
Upgrading an existing workspace. If your eslint.config.mjs is still the
old single-file one and you never edited it, mnci upgrade moves it onto the
split for you: it replaces the file only when it provably holds nothing but
mnci's own output. If you did edit it, it is left exactly as it is and
mnci doctor reports that it no longer imports the rules, with the line to
add. mnci does not rewrite that file any more — which is the point, and also
why it cannot do this part for you.
Linting and formatting are unified across the workspace, from exactly one
pair of config files. The rules are three lines importing
@mnci/eslint-config; every @nx/* generator
drops a config into the project it creates, and mnci add deletes it. Projects
still get their lint target: @nx/eslint/plugin infers it by mapping config
directories onto the project roots beneath them, so the root config covers
them all. (Verified, not assumed — and the e2e enforces both "every project has
a lint target" and "the root config genuinely reports violations in a project
with no config of its own", because a future Nx change there would silently
switch linting off workspace-wide.)
mnci runs eslint --fix itself at the end of new and every add, so a
generated workspace passes its own lint immediately — Nx's generators emit
semicolons and double quotes, and without that pass the first commit buries every
real change under generator noise.
One linter, which is also the formatter. There is no --linter flag and no
choice to make: @mnci/eslint-config carries code quality, type-aware rules and
JavaScript Standard Style formatting in a single ESLint config at the root.
npm run format is eslint . --fix --cache; there is no format:check,
because lint already reports formatting as ordinary errors with a rule name,
a line and a column.
That is the point of the arrangement rather than a side effect. A formatter and
a linter that both hold style opinions have to be kept in agreement, and the
older setup dodged that only by having ESLint hold no style opinion at all —
which is exactly why a formatting mistake produced no squiggle and no message in
the editor. It also makes space-before-function-paren enforceable for the
first time: every Prettier-compatible formatter, oxfmt included, rewrites
function f (a) back to function f(a).
npm run lint checks one thing npm run format does not. lint is
nx run-many -t lint; format is a bare eslint . --fix --cache. The
@nx/dependency-checks rule needs the Nx project graph, and outside a target it
prints No cached ProjectGraph is available. The rule will be skipped. So a
dependency problem shows up in lint and never in format, your editor, or a
pre-commit hook.
That asymmetry is now a safety property rather than a hazard. The rule is
fixable, and format passes --fix; when the graph was warm — which it is
after any nx command in the same workspace — a format run could and did
rewrite package.json. @mnci/eslint-config turns off the two checks whose
fixers do that (checkObsoleteDependencies, checkVersionMismatches), so
neither path is destructive, and the skip means format cannot reach a manifest
at all. Warm the graph deliberately (npx nx show projects) if you want the
rule evaluated in format too; mnci does not, because it would make every
format run pay for a graph computation to enforce what lint already gates.
Upgrading an older workspace. mnci upgrade deletes every config a previous
version could have written for a second tool — .prettierrc, .prettierrc.json,
.prettierrc.mjs, .prettierignore, .oxfmtrc.json, oxlint.config.ts — and
drops prettier, oxlint, oxfmt and @mnci/oxlint-config from
devDependencies. Both halves matter: the files are inert from the command line,
but an editor extension still resolves them, and the VS Code extension resolves a
formatter from the project's dependencies, so a stale declaration is enough
to reformat on save against an opinion nothing checks. mnci doctor reports a
workspace that has not been upgraded yet.
--into: bootstrapping into a repository that already exists
create-nx-workspace <name> creates the directory itself and exits with
DIRECTORY_EXISTS when one is already there. That rules out the most common
way a repository actually starts: the host creates it, you clone it, and the
clone holds a .git directory, a README and a licence. Doing it by hand means
generating into a temp parent, copying everything except .git and
node_modules across, and reinstalling — four steps, each of which can quietly
lose a file.
mnci new --into <dir> is that, done once and tested:
git clone [email protected]:me/my-repo.git
cd my-repo
mnci new --into .The workspace name defaults to the directory's name, because the directory is already named and retyping it is a way to get the two out of step; pass a name argument to override it.
What it does with the files that are already there is the whole of the risk, so every case is decided in advance:
| Already in the directory | What happens |
| --- | --- |
| .git | Never touched. Keeping it is the point. |
| README.md, LICENSE* | Kept. The generator's README is boilerplate; yours is usually the only hand-written file in the repository. |
| .gitignore | Merged. Your lines stay, and the generated ones (.nx/cache, dist, out-tsc, …) are appended under a labelled heading. Lines already present are not repeated, so it is idempotent. |
| Anything else the new workspace also writes | Refused, naming every collision, before a single file is written. |
| Anything the new workspace does not write | Left alone. |
Two checks, not one. A directory holding package.json, nx.json,
tsconfig.base.json, node_modules, apps/, libs/ or packages/ is
rejected before anything is generated — it is a project already, and the
answer there is mnci upgrade, not mnci new. The full collision check needs
the generated tree, so it runs afterwards, but still before the first write:
a refusal leaves the target byte-identical to how it was found, and the staging
copy is discarded.
Adopting a flat Go module
A repository with its own go.mod, a root main.go and internal/ packages is the
other common starting point, and --into takes it as it is. Measured on a clone of a
real one (twelve internal/ packages, its own release.yml):
go.modandgo.sumare never touched.add go-*skips the module bootstrap when ago.modexists, so the module path, therequirelines and the Go version survive byte for byte, and nogo.workis created.- The Go plugin is registered anyway. The bootstrap that registers it in
nx.jsonis the one that is skipped, which used to leave the plugin installed and unlisted: every target worked, and Nx had no Go project graph, sonx affectedskipped an app that imports a changed library, silently.add go-*now registers it,mnci upgraderepairs a workspace that adopted before, andmnci doctorfails while Go projects exist and it is missing. - Your own workflow coexists. A release workflow that fires on
v*tags and the generatedci.yml, which fires on pushes and pull requests tomain, do not overlap. mnci tags a released app<name>@<version>, so once--releaseand its GitHub Release assets do what yours did, delete the old workflow. - The format step can fail on a file you already had, and says so without failing
the run: a UTF-16
.jsonat the root madeeslint .report a parse error. The root lint covers root-level files, so a repository's existing JSON, Markdown and YAML are now inside it; fix or delete what it names.
Landing the code is mechanical, so it is a recipe, not a command (git mv and one
import rewrite are the whole job, and a command would have to guess your layout). With
youtube-downloader as the module path, mvd-cli as the app and mvd-core as the
library:
mnci add go-app mvd-cli
mnci add go-internal-lib mvd-core
# The generated starters are placeholders: drop them.
rm apps/mvd-cli/main.go apps/mvd-cli/main_test.go
rm -r libs/mvd-core/mvdcore
# git mv, not mv, so `git log --follow` reaches the history before the move.
git mv main.go apps/mvd-cli/main.go
for slice in internal/*/; do git mv "$slice" "libs/mvd-core/$(basename "$slice")"; done
# Rewrite the import paths (macOS: sed -i '').
grep -rl 'youtube-downloader/internal/' --include=*.go apps libs \
| xargs sed -i 's#youtube-downloader/internal/#youtube-downloader/libs/mvd-core/#g'
npx nx run-many -t test,build --projects=mvd-cli,mvd-coreUse nx and not a bare go test ./... from the root: ./... also walks
node_modules, where npm packages that contain Go code sit (one did here). On the
repository measured, this left go build clean, every test passing, 62 renames
detected by git, and the app depending on the library in the project graph. The e2e
(go adoption) runs this recipe on a fixture and asserts each of those.
Not measured here: whether golangci-lint, which is what each Go project's lint
runs, passes on a codebase that has never been linted. Run nx run <lib>:lint before
relying on it, and expect to fix or silence what it reports.
Layout convention = release scoping
| Directory | Contents | Released? |
| ------------------ | -------------------------------------------------------------- | -------------------------------------- |
| apps/ | React / Node / Python / Go / Flutter apps (plain or Functions) | Never (packed into the drop) |
| apps/ (tagged) | VS Code extensions (type:vscode-extension) | Yes — nx release, vsce publish |
| apps/ (tagged) | Go apps added with --release (release:go) | Yes — nx release tag, zips on the GitHub Release |
| packages/ | Publishable npm libraries, plus Go and Dart packages | Yes — nx release, per-package tags |
| python-packages/ | Publishable Python packages (hatchling wheels) | Yes — twine upload (Azure Artifacts) |
| libs/ | Internal libraries (TS, Python, Go or Dart), never published | Never |
The directory is very nearly the whole model — one exception, and it is a bug
fix rather than a nicety. go-lib also lives in packages/, but a Go package
has no per-project manifest (mnci puts every Go project in one root
go.mod), so Nx's default versionActions looks for a package.json that is
not there and aborts while building the release graph — killing nx release
for the whole workspace, not just the Go project. It is therefore excluded with
!tag:type:go-lib, which is also the semantically correct call: one module
means its packages have no independent versions to bump. A publishable Dart
package in packages/ needs no such exclusion — pubspec.yaml has a real
version: field, and @mnci/nx-flutter stamps a versionActions override that
reads it. Publishable Python packages get their own
python-packages/ dir so the npm nx release (packages/*) is never entangled
with Python publishing.
The second exception goes the other way: a VS Code extension lives in
apps/ (it is an application, and the slice lint treats it as one) but is
versioned and published like a package. release.projects matches it by its tag,
tag:type:vscode-extension, rather than by path: the array is mnci's and is
rewritten by every mnci upgrade, so a hand-added apps/my-extension would be
lost, while a tag matcher is the same on every upgrade and covers every extension
there will ever be.
The third is an opt-in: a Go app added with mnci add go-app <name> --release
is tagged release:go and joins the release the same way. Without the flag an app
stays unreleased, because most Go apps are internal tools. See Releasing a Go
app in the Go section.
Every kind builds to its own Nx-default output location (apps/<name>/dist,
packages/<name>/dist, ...) — no post-generation build-output rewiring for
any kind. mnci add is pure delegation to the official generators; each
one's own default is left exactly as-is.
Published packages CAN depend on internal libraries
Import an internal lib from an npm-lib directly — and do not add it to the
npm-lib's dependencies (npm workspaces links every workspace member into the
root node_modules regardless):
// packages/sdk/src/lib/sdk.ts
import { utils } from '@demo/utils' // libs/utils — private, never publishedIt works because npm-libs are rollup bundles: @nx/rollup's withNx
externalizes exactly what the manifest declares (dependencies +
peerDependencies), so real npm deps stay external and declared, while the
undeclared internal lib is compiled from source INTO the bundle — the private
name never reaches the published package.json. Trade-off: the published
output is a single bundle (no per-file deep imports).
React apps go the other way (Vite bundles everything by default), and the e2e
proves both directions for real: unlike the published npm-lib, which must
keep real npm dependencies external (declared, not bundled) for the
published tarball to install correctly downstream, a react-app build has no
install step at deploy/runtime, so it inlines everything — the private
internal lib AND real npm dependencies alike.
Node apps (node-app/node-function-app) are a third case: @nx/node:application's
esbuild build is non-bundled — it transpiles each file individually and
mirrors the workspace tree into dist, so nothing is ever textually inlined.
A private internal lib is compiled by its own tsc build and copied into
dist at its own path (resolved by a real require at run time, the same
way npm workspaces resolve it during development); a real npm dependency
stays a real require too, resolved from node_modules — present locally,
or installed at deploy time (see "How Node apps work" below).
Cross-project imports (@scope/lib) resolve through TypeScript project
references under --preset=ts, and those references are maintained by
nx sync, not by the generators. mnci add runs nx sync for you right
after generation — but references also go stale any time you hand-edit a
file to add a new cross-project import later (nothing about that is an
mnci add, so that step can't catch it). For that case every generated
workspace sets sync.applyChanges: true in nx.json: --preset=ts already
registers the @nx/js:typescript-sync generator on the build/typecheck
targets, so instead of just prompting ("Would you like to sync the
identified changes?") on your next nx build/typecheck/affected, Nx fixes
the references automatically — no prompt, no manual npx nx sync. A
brand-new package may still need one VSCode window reload to be picked up by
the TypeScript server.
applyChanges only affects interactive runs, by design: CI always runs sync
generators in dry-run mode and fails instead of silently patching an ephemeral
checkout that never gets committed. That's what the pipeline's nx sync:check
step (below) surfaces early — if it fails, run npx nx sync locally and
commit the result.
CI (Azure Pipelines and/or GitHub Actions, any agent OS)
mnci new asks which CI provider(s) to write a pipeline file for (--ci,
default azure): azure writes azure-pipelines.yml, github writes
.github/workflows/ci.yml, both writes both — pick github for a
GitHub-hosted repo, or both while migrating between the two. Whichever
provider(s), the pipeline does the exact same thing: both files are built
from the same shared guard scripts (workspace-overlay/overlay.use-case.ts's PYTHON_INSTALL_GUARD,
PACK_APPS_GUARD, releaseGuard, AFFECTED_OR_ALL_GUARD), so they can never
drift on what CI actually runs — only the provider's own syntax differs. That
matters most for the last of those: the two providers detect a pull request
through different environment variables, so the guard reads both, and a
provider-specific copy would change what CI verifies rather than merely how it
is spelled.
The pipeline contains no bash and no PowerShell: every step is a built-in
task/action or a single-line git/npm/npx/node command that cmd.exe
and sh execute identically, so it runs unchanged on Linux, macOS and Windows
agents. The build agent/runner is your choice at mnci new (--agent,
default ubuntu-latest): on Azure a Microsoft-hosted image
(ubuntu-/windows-/macos-…) becomes pool.vmImage, anything else a
self-hosted pool.name; on GitHub the same value is passed straight through
as runs-on: (GitHub's own hosted runner labels already match the common
Azure vmImage names, and a self-hosted label is just as valid there).
Every run (PR and main) installs dependencies, then runs npm audit
(non-blocking) and, once the Python toolchain is installed, pip-audit
(also non-blocking) — visibility, not enforcement: verified empirically that
a real npm audit on this monorepo's own tree flagged nothing but
already-latest upstream packages (nx, verdaccio) bundling their own not-
yet-patched transitive dependencies, nothing an edit to this workspace's
manifest could fix. A hard-failing audit step would turn CI red for a
problem with no user-actionable fix, for as long as upstream took to patch
it — so both steps always exit 0 regardless of findings, surfacing results
as a clearly labelled section in every CI log instead. The actionable
response to a real finding (a targeted overrides entry on just the
vulnerable transitive package) is exactly what this monorepo's own
fix(deps) commit did — a manual, reviewed response, not something CI
attempts automatically.
Then nx sync:check (fails fast and clearly if the workspace wasn't
synced+committed locally — see above), then one verify step running
lint,typecheck,test,build. typecheck is in that list
because a bundler-built project strips types without reading them, so build
passing proves nothing about type correctness.
That step verifies the affected projects on a pull request and every
project on anything else — including a push to main, so a release is always
verified in full. There is deliberately no separate npm run lint step: that is
nx run-many -t lint, a strict subset of the list above, and on an
affected-scoped PR it would re-lint every project and throw the benefit away.
There is no format:check step either, and its removal is not a saving but a
consequence: lint reports formatting itself now, so a second step would run
the same binary twice over the same tree.
Every fallback in that step verifies everything: no PR target branch, an
unresolvable merge-base (shallow clone, absent remote branch), any non-PR run.
That direction is deliberate — resolving the base too wide costs a few minutes,
while resolving it too narrow means CI runs almost nothing, reports green and has
verified nothing. The base is a git merge-base, not either provider's "base SHA"
field and not the GitHub-only nrwl/nx-set-shas, so one mechanism serves both
providers and is correct in each by construction.
Pushes to main then:
- Pack all apps — each app's
packagetarget zips its build output intodist/drop/<type>-<name>.zip(e.g.node-function-app-api.zip,react-app-web.zip); the wholedist/dropis published as thedropartifact. - Tag the run per app (Azure only) — one build tag per zip, exactly
<type>-<name>(derived from the zip filenames, so the tag can never drift from the artifact). A classic Azure release/CD pipeline keys its trigger off these; GitHub Actions has no equivalent mechanism, so thedropartifact (one zip per app inside it) is the portable substitute there. - Release — version, tag and publish — one
npx nx release --yescovering npm (packages/*), Python (python-packages/*) and C# (acsharp-lib's NuGet publish): version bump from conventional commits →{projectName}@{version}git tag pushed tomain(tag-only, never a commit) → publish to the feed (npm via.npmrc, Python viatwinewhen an Azure feed is configured — installed from the generatedrequirements-dev.txt, no uv, no Poetry — and acsharp-lib's ownnx-release-publishtarget runningdotnet pack/dotnet nuget pushwhenNUGET_PATis set, self-gating to a no-op otherwise). Reuses the base64PAT, decoded to the raw token twine needs for the Python publish and to the raw token NuGet's%NUGET_PAT%substitution needs. Skipped cleanly when there is nothing to release. A guarded step installs the fixed Python toolchain (ruff/pytest/build/twine/pip-audit) before any Python target runs, skipped cleanly on a workspace with no Python projects. Go and Flutter libraries publish by git tag only — see their own sections below — so neither needs a step here. On a--ci=githubworkspace this same step also creates a GitHub Release per project, with a changelog Nx generates from conventional commits —nx releasepushes the tag itself here (needsGITHUB_TOKEN, which GitHub Actions provides for free under the workflow's owncontents: writepermission), so there's no separate explicitgit push origin --tagsstep on this provider.--ci=azureand--ci=bothkeep today's behaviour (no GitHub Release, explicit tag push) — GitHub Release creation only turns on when GitHub Actions is the only configured provider, since that's the one case aGITHUB_TOKENis guaranteed to exist.
Publish auth
The generated .npmrc differs by registry kind, because the honest answer does.
--registry azure-artifacts routes the workspace's own scope to the feed and
supplies the feed's credentials:
@my:registry=https://pkgs.dev.azure.com/<org>/<proj>/_packaging/<feed>/npm/registry/
//pkgs.dev.azure.com/.../npm/registry/:username=AzureArtifacts
//pkgs.dev.azure.com/.../npm/registry/:_password=${PAT}Scope routing is real protection here, not decoration: npm prefers a scope's
registry over the global one when publishing a scoped package, so @my/* cannot
reach npmjs.org by accident. Verified against a real registry — with only the
scope line set, npm reports Publishing to <feed> — and again from a generated
workspace, whose npm publish --dry-run targets the feed.
Only the scope is routed, deliberately. A global registry= would push every
install through the feed as well, so npm ci would need feed auth just to fetch
public packages; as generated, public dependencies still come from npmjs.org and
a developer with no PAT set can install normally.
--registry npm gets the auth line and nothing else:
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}There is no @scope:registry line, and the generated file explains why: npmjs.org
is already the default, so routing the scope there changes nothing — and calling
it protection against an accidental public publish would be false, because the
public registry is the intended target. Worth stating plainly because this file
previously claimed exactly that protection while emitting no routi
