@mnci/cli 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ first-party (or established community) Nx equivalent:
24
24
  | Hand-written Azure Function templates | `@nx/node:application` (plain Node app) + a thin Azure Functions v4 overlay |
25
25
  | doctor/drift sync of tool-owned files | Nothing to drift: this CLI owns 5 small files, Nx owns the rest |
26
26
 
27
- ## Commands (deliberately just three)
27
+ ## Commands (deliberately just four)
28
28
 
29
29
  ```sh
30
30
  mnci new my-repo # create a monorepo (prompts scope + registry)
@@ -61,8 +61,30 @@ mnci add flutter-internal-lib core # private shared package -> libs/
61
61
 
62
62
  mnci upgrade # re-apply the latest overlay (see below)
63
63
  mnci upgrade --agent windows-latest # ...with an explicit override
64
+
65
+ mnci doctor # check this workspace's invariants (read-only)
64
66
  ```
65
67
 
68
+ ## `mnci doctor`: checking the invariants actually hold
69
+
70
+ Read-only — it never edits the workspace. Every failing finding names the command
71
+ that fixes it (usually `mnci upgrade`), and it exits non-zero when anything failed,
72
+ so it works as a CI step as well as a local command.
73
+
74
+ Every check corresponds to an invariant that has **actually** been violated, in
75
+ this repo or in a workspace it generated. None are hypothetical; a check nobody has
76
+ ever needed is noise that trains people to ignore the output.
77
+
78
+ | Check | The failure it catches |
79
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
80
+ | 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 |
81
+ | No stray `.prettierrc` | It outranks `.prettierrc.json`, so the whole formatting opinion is silently discarded while both files look fine |
82
+ | `@nx/eslint/plugin` registered in `nx.json` | Without it `npm run lint` exits 0 while linting nothing |
83
+ | The **resolved** `eslint` major | A declared range and an installed version are different things — manifests once said `^10` while the pin said 9 |
84
+ | `.npmrc` matches the recorded registry | The two registry kinds get different files; an Azure workspace also needs its scope routed |
85
+ | `versionActions` on publishable Dart/Python packages | Its absence aborts `nx release` for the **whole** workspace, not just that project |
86
+ | `nx sync:check` | A stale TypeScript project reference that was never committed |
87
+
66
88
  Everything else is plain Nx, surfaced as a small curated set of root scripts —
67
89
  each a single cross-platform command:
68
90
 
@@ -126,7 +148,7 @@ that would just fail felt worse than being upfront that it doesn't exist yet.
126
148
  is ever pushed to `main`; future runs resolve versions from tag names.
127
149
  3. Writes `eslint.config.mjs` (three lines importing `@mnci/eslint-config` —
128
150
  the whole linting opinion, in one root config), `.prettierrc.json` +
129
- `.prettierignore`, `.npmrc` (comment-only — see **Known gaps** below),
151
+ `.prettierignore`, `.npmrc` (publish auth — see **Publish auth** below),
130
152
  `commitlint.config.mjs`, a husky `commit-msg` hook, the chosen CI provider's
131
153
  pipeline file(s)
132
154
  (`azure-pipelines.yml` and/or `.github/workflows/ci.yml`, `--ci`, default
@@ -404,6 +426,42 @@ lint,test,build`. Pushes to `main` then:
404
426
  _only_ configured provider, since that's the one case a `GITHUB_TOKEN` is
405
427
  guaranteed to exist.
406
428
 
429
+ ### Publish auth
430
+
431
+ The generated `.npmrc` differs by registry kind, because the honest answer does.
432
+
433
+ **`--registry azure-artifacts`** routes the workspace's own scope to the feed and
434
+ supplies the feed's credentials:
435
+
436
+ ```ini
437
+ @my:registry=https://pkgs.dev.azure.com/<org>/<proj>/_packaging/<feed>/npm/registry/
438
+ //pkgs.dev.azure.com/.../npm/registry/:username=AzureArtifacts
439
+ //pkgs.dev.azure.com/.../npm/registry/:_password=${PAT}
440
+ ```
441
+
442
+ Scope routing is **real protection** here, not decoration: npm prefers a scope's
443
+ registry over the global one when publishing a scoped package, so `@my/*` cannot
444
+ reach npmjs.org by accident. Verified against a real registry — with only the
445
+ scope line set, npm reports `Publishing to <feed>` — and again from a generated
446
+ workspace, whose `npm publish --dry-run` targets the feed.
447
+
448
+ Only the scope is routed, deliberately. A global `registry=` would push every
449
+ install through the feed as well, so `npm ci` would need feed auth just to fetch
450
+ public packages; as generated, public dependencies still come from npmjs.org and
451
+ a developer with no `PAT` set can install normally.
452
+
453
+ **`--registry npm`** gets the auth line and nothing else:
454
+
455
+ ```ini
456
+ //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
457
+ ```
458
+
459
+ There is no `@scope:registry` line, and the generated file explains why: npmjs.org
460
+ is already the default, so routing the scope there changes nothing — and calling
461
+ it protection against an accidental public publish would be false, because the
462
+ public registry is the intended target. Worth stating plainly because this file
463
+ previously claimed exactly that protection while emitting no routing line at all.
464
+
407
465
  **npm auth** is the base64 `PAT`, read the same way on both providers but from
408
466
  a different place: on Azure Pipelines, a **variable group**
409
467
  (`--variable-group`, default `Build`) exposes it as `$(PAT)`; on GitHub
@@ -516,13 +574,10 @@ than a surprise:
516
574
 
517
575
  ## Known gaps (accepted for the experiment)
518
576
 
519
- - **The generated `.npmrc` is empty (comments only), so `npm publish` will not
520
- authenticate.** This is a deliberate deferral, not an oversight: the previous
521
- file claimed here that scope routing made accidental public publishes
522
- impossible, when in fact no `@scope:registry` line was ever emitted. Rather
523
- than ship half-wired auth, the CI token export (`NODE_AUTH_TOKEN`/`PAT`) is
524
- left in place so wiring it later is one line, and the generated `.npmrc`
525
- carries a comment saying so. Publish-path e2e assertions are relaxed to match.
577
+ - **A scoped package on public npm has no accidental-publish protection**, and
578
+ cannot: npmjs.org is the intended target there, so no `.npmrc` line could
579
+ prevent it. Generate with `--registry azure-artifacts` if you need a scope kept
580
+ off the public registry — that variant routes it (see **Publish auth** above).
526
581
  - No `doctor`/`resurrect`/`spell` — out of scope until the model is proven.
527
582
  - **Flutter apps build for web only.** Android would require the Android SDK and
528
583
  NDK on every build agent; iOS is impossible on a Linux agent regardless. Add
package/dist/cli.js CHANGED
@@ -1234,11 +1234,8 @@ function readWorkspaceStack(workspaceRoot) {
1234
1234
  };
1235
1235
  }
1236
1236
 
1237
- // src/commands/interactive.ts
1238
- var import_node_path14 = require("path");
1239
- var import_prompts8 = require("@inquirer/prompts");
1240
-
1241
- // src/commands/new.ts
1237
+ // src/commands/doctor.ts
1238
+ var import_node_fs7 = require("fs");
1242
1239
  var import_node_path12 = require("path");
1243
1240
 
1244
1241
  // src/overlay.ts
@@ -1250,15 +1247,49 @@ var TS_COMPILER_DEPENDENCIES = {
1250
1247
  "@typescript/native": "npm:typescript@^7.0.2",
1251
1248
  typescript: "npm:@typescript/typescript6@^6.0.2"
1252
1249
  };
1253
- function npmrcContent(_registry, _scope) {
1254
- return `; Intentionally empty.
1250
+ function registryUrl(registry) {
1251
+ if (registry.kind === "azure-artifacts") {
1252
+ return `https://pkgs.dev.azure.com/${registry.organization}/${registry.project}/_packaging/${registry.artifactsFeed}/npm/registry/`;
1253
+ }
1254
+ return void 0;
1255
+ }
1256
+ function npmrcContent(registry, scope) {
1257
+ if (registry.kind === "npm") {
1258
+ return `; Publish authentication for the public npm registry.
1255
1259
  ;
1256
- ; Publish authentication is not configured yet. The generated CI exports a
1257
- ; token for the release step (NODE_AUTH_TOKEN for public npm, PAT for Azure
1258
- ; Artifacts), but nothing here consumes it, so 'npm publish' will not
1259
- ; authenticate until this is wired up.
1260
+ ; NODE_AUTH_TOKEN is exported by the generated CI's release step; an unset value
1261
+ ; is harmless locally, so no token is needed for day-to-day work.
1262
+ //registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}
1263
+
1264
+ ; There is deliberately NO '${scope}:registry' line here. npmjs.org is already
1265
+ ; the default registry, so routing the scope to it would change nothing \u2014 and
1266
+ ; presenting that as protection against an accidental public publish would be
1267
+ ; false, because the public registry is the intended target. (An earlier version
1268
+ ; of this file made exactly that claim while emitting no routing line at all.)
1269
+ ; To keep a scoped package off npmjs.org, use a private feed: generate with
1270
+ ; --registry azure-artifacts, which does route the scope.
1271
+ `;
1272
+ }
1273
+ const feedUrl = registryUrl(registry);
1274
+ const feedKey = feedUrl.replace(/^https:/, "");
1275
+ return `; Publish + resolution routing for this workspace's own scope.
1276
+ ;
1277
+ ; '${scope}:registry' sends BOTH resolution and 'npm publish' of ${scope}/* to the
1278
+ ; feed: npm prefers a scope's registry over the global one when publishing a
1279
+ ; scoped package, so a ${scope}/* package cannot reach npmjs.org by accident.
1260
1280
  ;
1261
- ; See "Known gaps" in the @mnci/cli README.
1281
+ ; Only the scope is routed, on purpose. A global 'registry=' would send every
1282
+ ; install through the feed as well, so 'npm ci' would need feed auth just to fetch
1283
+ ; public packages.
1284
+ ${scope}:registry=${feedUrl}
1285
+
1286
+ ; Feed credentials. PAT is the BASE64 value Azure Artifacts' "Connect to feed"
1287
+ ; instructions give you, which is exactly what _password expects, so it is used
1288
+ ; as-is. (twine wants the RAW token; the CI release step decodes it there.)
1289
+ ; Azure ignores the username, and npm requires an email it never uses.
1290
+ ${feedKey}:username=AzureArtifacts
1291
+ ${feedKey}:_password=\${PAT}
1292
+ ${feedKey}:email=npm-requires-this-and-never-uses-it
1262
1293
  `;
1263
1294
  }
1264
1295
  function releaseConfig(ci) {
@@ -1502,9 +1533,18 @@ function azurePipelinesYaml(agent, variableGroup, pythonPublishUrl2, registryKin
1502
1533
  # runs unchanged on Linux, macOS and Windows agents.
1503
1534
 
1504
1535
  trigger:
1536
+ # While a run is in progress, queue further pushes and run them together rather
1537
+ # than concurrently. This is the closest thing Azure Pipelines YAML has to
1538
+ # GitHub's concurrency group, and it is load-bearing on main for the same
1539
+ # reason: two concurrent 'nx release' runs would race to create the same tag.
1540
+ batch: true
1505
1541
  branches:
1506
1542
  include: [main]
1507
1543
 
1544
+ # Note: cancelling a superseded PR validation run is NOT a YAML setting on Azure
1545
+ # \u2014 it lives in the branch policy ("Build validation -> automatically cancel").
1546
+ # Nothing here can express it, so it is left to whoever configures the policy
1547
+ # rather than faked with a batch setting that would only delay PR runs.
1508
1548
  pr:
1509
1549
  branches:
1510
1550
  include: [main]
@@ -1517,6 +1557,12 @@ variables:
1517
1557
  # for an Azure Artifacts feed, or a raw npm automation token as \`NPM_TOKEN\`
1518
1558
  # for public npm. Mark it secret in Library. Add app build vars here too if needed.
1519
1559
  - group: ${variableGroup}
1560
+ # Relocates npm's cache inside the pipeline workspace so Cache@2 can restore it
1561
+ # (the default ~/.npm is outside the cacheable area on hosted agents). npm reads
1562
+ # this as an ordinary env var, so it needs no config file and works on every
1563
+ # agent OS.
1564
+ - name: npm_config_cache
1565
+ value: $(Pipeline.Workspace)/.npm
1520
1566
 
1521
1567
  steps:
1522
1568
  - checkout: self
@@ -1541,6 +1587,20 @@ steps:
1541
1587
  inputs:
1542
1588
  version: 24.x
1543
1589
 
1590
+ # The Azure counterpart of \`cache: npm\` on actions/setup-node: restores npm's
1591
+ # download cache so \`npm ci\` does not re-fetch every tarball on every run.
1592
+ # Keyed on the lockfile, so a dependency change misses and anything else hits;
1593
+ # restoreKeys falls back to the newest cache for this OS on a miss, which still
1594
+ # avoids a fully cold install. Agent.OS is in the key because a cached native
1595
+ # module built for one OS is not reusable on another.
1596
+ - task: Cache@2
1597
+ displayName: Cache npm packages
1598
+ inputs:
1599
+ key: 'npm | "$(Agent.OS)" | package-lock.json'
1600
+ restoreKeys: |
1601
+ npm | "$(Agent.OS)"
1602
+ path: $(npm_config_cache)
1603
+
1544
1604
  - script: npm ci
1545
1605
  displayName: Install dependencies
1546
1606
  env:
@@ -1698,6 +1758,19 @@ on:
1698
1758
  pull_request:
1699
1759
  branches: [main]
1700
1760
 
1761
+ concurrency:
1762
+ # One run at a time per ref, but \`cancel-in-progress\` is an **expression** on
1763
+ # purpose rather than a flat \`true\`.
1764
+ #
1765
+ # A superseded PR run is pure waste, so cancel it. A run on \`main\` must never
1766
+ # be cancelled: it publishes packages and pushes release tags, and killing it
1767
+ # part-way can leave a tag pushed with the publish only half done \u2014 a state no
1768
+ # rerun repairs cleanly, because the version is then already tagged. Those runs
1769
+ # queue instead, which also stops two \`nx release\` invocations racing to create
1770
+ # the same tag when two commits land close together.
1771
+ group: \${{ github.workflow }}-\${{ github.ref }}
1772
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}
1773
+
1701
1774
  permissions:
1702
1775
  # Lets the release step push the version tag nx release creates back to main,
1703
1776
  # and (github-only provider) create the GitHub Release itself.
@@ -1721,6 +1794,11 @@ jobs:
1721
1794
  - uses: actions/setup-node@v4
1722
1795
  with:
1723
1796
  node-version: 24
1797
+ # Caches ~/.npm keyed on package-lock.json, so \`npm ci\` restores from
1798
+ # the local cache instead of re-downloading every tarball on every run.
1799
+ # Nothing to invalidate by hand: the action keys on the lockfile, so a
1800
+ # dependency change misses the cache and a no-op change hits it.
1801
+ cache: npm
1724
1802
 
1725
1803
  - run: npm ci
1726
1804
  name: Install dependencies
@@ -1972,7 +2050,166 @@ function applyOverlay(workspaceRoot, options) {
1972
2050
  }
1973
2051
  }
1974
2052
 
2053
+ // src/commands/doctor.ts
2054
+ var SUPPORTED_ESLINT_MAJOR = ESLINT_VERSION.replace(/^\D*/, "").split(".")[0];
2055
+ function checkEslintConfigs(workspaceRoot) {
2056
+ const rootConfigs = (0, import_node_fs7.globSync)("eslint.config.{js,mjs,cjs,ts,mts,cts}", { cwd: workspaceRoot });
2057
+ const projectConfigs = (0, import_node_fs7.globSync)("{apps,libs,packages}/*/eslint.config.{js,mjs,cjs,ts,mts,cts}", {
2058
+ cwd: workspaceRoot
2059
+ });
2060
+ return [
2061
+ {
2062
+ check: "root ESLint config",
2063
+ ok: rootConfigs.length === 1,
2064
+ detail: rootConfigs.length === 0 ? "no eslint.config.* at the workspace root" : `${rootConfigs.length} root configs: ${rootConfigs.join(", ")}`,
2065
+ remedy: "run `mnci upgrade` to rewrite the root config"
2066
+ },
2067
+ {
2068
+ check: "no per-project ESLint configs",
2069
+ ok: projectConfigs.length === 0,
2070
+ detail: `found ${projectConfigs.length}: ${projectConfigs.join(", ")}`,
2071
+ remedy: "run `mnci upgrade`, which sweeps {apps,libs,packages}/*/eslint.config.*"
2072
+ }
2073
+ ];
2074
+ }
2075
+ function checkPrettierConfig(workspaceRoot) {
2076
+ const strayExists = fileExists((0, import_node_path12.join)(workspaceRoot, ".prettierrc"));
2077
+ return {
2078
+ check: "Prettier config is mnci\u2019s",
2079
+ ok: !strayExists,
2080
+ detail: ".prettierrc exists and outranks .prettierrc.json, so mnci\u2019s config is ignored",
2081
+ remedy: "delete .prettierrc (or run `mnci upgrade`, which deletes it)"
2082
+ };
2083
+ }
2084
+ function checkEslintPlugin(nxJson) {
2085
+ const plugins = nxJson.plugins ?? [];
2086
+ const registered = plugins.some(
2087
+ (entry) => typeof entry === "string" ? entry === "@nx/eslint/plugin" : entry.plugin === "@nx/eslint/plugin"
2088
+ );
2089
+ return {
2090
+ check: "@nx/eslint/plugin registered",
2091
+ ok: registered,
2092
+ detail: "not in nx.json plugins \u2014 every project silently loses its lint target",
2093
+ remedy: "run `mnci upgrade`"
2094
+ };
2095
+ }
2096
+ function checkResolvedEslint(workspaceRoot) {
2097
+ const manifestPath = (0, import_node_path12.join)(workspaceRoot, "node_modules/eslint/package.json");
2098
+ if (!fileExists(manifestPath)) {
2099
+ return void 0;
2100
+ }
2101
+ try {
2102
+ const { version } = readJson(manifestPath);
2103
+ const major = version.split(".")[0];
2104
+ return {
2105
+ check: `resolved eslint is ${SUPPORTED_ESLINT_MAJOR}.x`,
2106
+ ok: major === SUPPORTED_ESLINT_MAJOR,
2107
+ detail: `node_modules/eslint is ${version}, but this stack supports ${SUPPORTED_ESLINT_MAJOR}.x (eslint-plugin-react has no release beyond it)`,
2108
+ remedy: `pin eslint to ${ESLINT_VERSION} in every package manifest, then reinstall`
2109
+ };
2110
+ } catch {
2111
+ return void 0;
2112
+ }
2113
+ }
2114
+ function checkNpmrc(workspaceRoot, registry, scope) {
2115
+ const npmrcPath = (0, import_node_path12.join)(workspaceRoot, ".npmrc");
2116
+ if (!registry || !fileExists(npmrcPath)) {
2117
+ return void 0;
2118
+ }
2119
+ const npmrc = (0, import_node_fs7.readFileSync)(npmrcPath, "utf8");
2120
+ if (registry.kind === "npm") {
2121
+ return {
2122
+ check: ".npmrc authenticates the public registry",
2123
+ ok: npmrc.includes("//registry.npmjs.org/:_authToken="),
2124
+ detail: "no npmjs.org token line \u2014 `npm publish` cannot authenticate",
2125
+ remedy: "run `mnci upgrade`"
2126
+ };
2127
+ }
2128
+ return {
2129
+ check: ".npmrc routes the scope to the feed",
2130
+ ok: scope !== void 0 && npmrc.includes(`${scope}:registry=`),
2131
+ detail: `no '${scope ?? "@scope"}:registry=' line \u2014 a scoped package could publish to npmjs.org instead of the feed`,
2132
+ remedy: "run `mnci upgrade`"
2133
+ };
2134
+ }
2135
+ function checkVersionActions(workspaceRoot) {
2136
+ const candidates = [
2137
+ ...(0, import_node_fs7.globSync)("packages/*/pubspec.yaml", { cwd: workspaceRoot }),
2138
+ ...(0, import_node_fs7.globSync)("python-packages/*/pyproject.toml", { cwd: workspaceRoot })
2139
+ ];
2140
+ return candidates.flatMap((manifest) => {
2141
+ const projectRoot = manifest.slice(0, manifest.lastIndexOf("/"));
2142
+ const projectJsonPath = (0, import_node_path12.join)(workspaceRoot, projectRoot, "project.json");
2143
+ let hasOverride = false;
2144
+ try {
2145
+ const projectJson = readJson(projectJsonPath);
2146
+ hasOverride = Boolean(projectJson.release?.version?.versionActions);
2147
+ } catch {
2148
+ hasOverride = false;
2149
+ }
2150
+ return [
2151
+ {
2152
+ check: `${projectRoot} keeps its versionActions override`,
2153
+ ok: hasOverride,
2154
+ detail: "missing \u2014 nx release aborts for the ENTIRE workspace, not just this project",
2155
+ remedy: `add release.version.versionActions to ${projectRoot}/project.json`
2156
+ }
2157
+ ];
2158
+ });
2159
+ }
2160
+ function checkSync(workspaceRoot) {
2161
+ return {
2162
+ check: "TypeScript project references synced",
2163
+ ok: runShell("npx", ["nx", "sync:check"], workspaceRoot) === 0,
2164
+ detail: "nx sync:check failed \u2014 a stale project reference was never committed",
2165
+ remedy: "run `npx nx sync` and commit the result"
2166
+ };
2167
+ }
2168
+ function collectFindings(workspaceRoot) {
2169
+ const nxJsonPath = (0, import_node_path12.join)(workspaceRoot, "nx.json");
2170
+ if (!fileExists(nxJsonPath)) {
2171
+ throw new Error(
2172
+ `No nx.json found in ${workspaceRoot} \u2014 run 'mnci doctor' from the workspace root.`
2173
+ );
2174
+ }
2175
+ const nxJson = readJson(nxJsonPath);
2176
+ return [
2177
+ ...checkEslintConfigs(workspaceRoot),
2178
+ checkPrettierConfig(workspaceRoot),
2179
+ checkEslintPlugin(nxJson),
2180
+ checkResolvedEslint(workspaceRoot),
2181
+ checkNpmrc(workspaceRoot, nxJson.mnci?.registry, nxJson.mnci?.scope),
2182
+ ...checkVersionActions(workspaceRoot),
2183
+ checkSync(workspaceRoot)
2184
+ ].filter((finding) => finding !== void 0);
2185
+ }
2186
+ function runDoctor(workspaceRoot) {
2187
+ const findings = collectFindings(workspaceRoot);
2188
+ const failed = findings.filter((finding) => !finding.ok);
2189
+ for (const finding of findings) {
2190
+ if (finding.ok) {
2191
+ logger.success(finding.check);
2192
+ } else {
2193
+ logger.error(`${finding.check} \u2014 ${finding.detail ?? "failed"}`);
2194
+ if (finding.remedy) {
2195
+ logger.info(` fix: ${finding.remedy}`);
2196
+ }
2197
+ }
2198
+ }
2199
+ if (failed.length === 0) {
2200
+ logger.success(`All ${findings.length} checks passed.`);
2201
+ return;
2202
+ }
2203
+ logger.error(`${failed.length} of ${findings.length} checks failed.`);
2204
+ process.exitCode = 1;
2205
+ }
2206
+
2207
+ // src/commands/interactive.ts
2208
+ var import_node_path15 = require("path");
2209
+ var import_prompts8 = require("@inquirer/prompts");
2210
+
1975
2211
  // src/commands/new.ts
2212
+ var import_node_path13 = require("path");
1976
2213
  function nxCloudProviderValue(ci) {
1977
2214
  return ci === "azure" ? "azure" : "github";
1978
2215
  }
@@ -2037,7 +2274,7 @@ async function runNew(name, options) {
2037
2274
  ],
2038
2275
  process.cwd()
2039
2276
  );
2040
- const workspaceRoot = (0, import_node_path12.join)(process.cwd(), workspaceName);
2277
+ const workspaceRoot = (0, import_node_path13.join)(process.cwd(), workspaceName);
2041
2278
  logger.step(
2042
2279
  "Applying MoNecromanCI overlay (VS Code workspace, release config, .npmrc, commitlint, pipeline, stack)"
2043
2280
  );
@@ -2071,8 +2308,8 @@ async function runNew(name, options) {
2071
2308
  }
2072
2309
 
2073
2310
  // src/commands/upgrade.ts
2074
- var import_node_fs7 = require("fs");
2075
- var import_node_path13 = require("path");
2311
+ var import_node_fs8 = require("fs");
2312
+ var import_node_path14 = require("path");
2076
2313
  function resolveRegistry2(options, persisted) {
2077
2314
  if (options.registry === "azure-artifacts" || options.organization && options.artifactsFeed) {
2078
2315
  const persistedAzure = persisted?.kind === "azure-artifacts" ? persisted : void 0;
@@ -2101,7 +2338,7 @@ function resolveWorkspaceName(workspaceRoot, persisted) {
2101
2338
  return persisted.workspaceName;
2102
2339
  }
2103
2340
  try {
2104
- const existing = (0, import_node_fs7.readdirSync)(workspaceRoot).find(
2341
+ const existing = (0, import_node_fs8.readdirSync)(workspaceRoot).find(
2105
2342
  (file) => file.endsWith(".code-workspace") && file !== "undefined.code-workspace"
2106
2343
  );
2107
2344
  if (existing) {
@@ -2109,7 +2346,7 @@ function resolveWorkspaceName(workspaceRoot, persisted) {
2109
2346
  }
2110
2347
  } catch {
2111
2348
  }
2112
- return (0, import_node_path13.basename)(workspaceRoot);
2349
+ return (0, import_node_path14.basename)(workspaceRoot);
2113
2350
  }
2114
2351
  function resolveOverlayOptions(workspaceRoot, options, persisted) {
2115
2352
  const scope = options.scope ?? persisted.scope;
@@ -2147,7 +2384,7 @@ function resolveOverlayOptions(workspaceRoot, options, persisted) {
2147
2384
  };
2148
2385
  }
2149
2386
  function runUpgrade(workspaceRoot, options) {
2150
- if (!fileExists((0, import_node_path13.join)(workspaceRoot, "nx.json"))) {
2387
+ if (!fileExists((0, import_node_path14.join)(workspaceRoot, "nx.json"))) {
2151
2388
  throw new Error(
2152
2389
  `No nx.json found in ${workspaceRoot} \u2014 this does not look like an Nx workspace. Run 'mnci upgrade' from the workspace root.`
2153
2390
  );
@@ -2164,7 +2401,7 @@ function runUpgrade(workspaceRoot, options) {
2164
2401
 
2165
2402
  // src/commands/interactive.ts
2166
2403
  async function runInteractive() {
2167
- const inWorkspace = fileExists((0, import_node_path14.join)(process.cwd(), "nx.json"));
2404
+ const inWorkspace = fileExists((0, import_node_path15.join)(process.cwd(), "nx.json"));
2168
2405
  const newChoice = { name: "Create a new monorepo", value: "new" };
2169
2406
  const addChoice = { name: "Add a project to this workspace", value: "add" };
2170
2407
  const upgradeChoice = {
@@ -2185,8 +2422,8 @@ async function runInteractive() {
2185
2422
  }
2186
2423
 
2187
2424
  // src/util/versionChecker.ts
2188
- var import_node_fs8 = require("fs");
2189
- var import_node_path15 = require("path");
2425
+ var import_node_fs9 = require("fs");
2426
+ var import_node_path16 = require("path");
2190
2427
  var import_cross_spawn2 = __toESM(require("cross-spawn"));
2191
2428
  var PACKAGE_NAME = "@mnci/cli";
2192
2429
  var REGISTRY_TIMEOUT_MS = 2e3;
@@ -2242,8 +2479,8 @@ function releaseTriple(version) {
2242
2479
  }
2243
2480
  function readCliVersion(packageDirectory) {
2244
2481
  try {
2245
- const manifestPath = (0, import_node_path15.join)(packageDirectory, "..", "package.json");
2246
- const manifest = JSON.parse((0, import_node_fs8.readFileSync)(manifestPath, "utf8"));
2482
+ const manifestPath = (0, import_node_path16.join)(packageDirectory, "..", "package.json");
2483
+ const manifest = JSON.parse((0, import_node_fs9.readFileSync)(manifestPath, "utf8"));
2247
2484
  return manifest.version ?? "0.0.0";
2248
2485
  } catch {
2249
2486
  return "0.0.0";
@@ -2268,6 +2505,11 @@ function buildProgram(cliVersion) {
2268
2505
  ).action((options) => {
2269
2506
  runUpgrade(process.cwd(), options);
2270
2507
  });
2508
+ program.command("doctor").description(
2509
+ "Check this workspace against the invariants mnci maintains (one ESLint config, no stray .prettierrc, the eslint plugin registered, the resolved eslint major, .npmrc vs the recorded registry, versionActions overrides, nx sync) \u2014 read-only; exits non-zero if anything failed"
2510
+ ).action(() => {
2511
+ runDoctor(process.cwd());
2512
+ });
2271
2513
  program.command("add").addArgument(new import_commander.Argument("[kind]", "project kind").choices(PROJECT_KINDS)).argument("[name]", "project name").description("Add a project by delegating to the matching Nx plugin generator").option("--scope <scope>", "npm scope for a publishable lib (defaults to @<workspace name>)").option(
2272
2514
  "--framework <framework>",
2273
2515
  "node-app only: express | fastify | koa | nest | none (default: none)"