@mnci/cli 4.0.0 → 4.0.1

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
@@ -119,6 +119,39 @@ accordingly, `start` marked `isBackground` since it runs a process that
119
119
  doesn't exit on its own. Re-running `add` for the same project name
120
120
  overwrites its own scripts/tasks rather than duplicating them.
121
121
 
122
+ #### Run and Debug: the `launch` section
123
+
124
+ Tasks are reachable **only** through Terminal → Run Task. The **Run and Debug**
125
+ panel reads a separate `launch` section, so a workspace with tasks alone offers
126
+ nothing in the dropdown people actually open. Every generated workspace therefore
127
+ also gets four launch configurations, one per verify target:
128
+
129
+ | configuration | runs |
130
+ | --- | --- |
131
+ | `mnci: build` | `npm run build` |
132
+ | `mnci: test` | `npm run test` |
133
+ | `mnci: lint` | `npm run lint` |
134
+ | `mnci: typecheck` | `npm run typecheck` |
135
+
136
+ Three details are load-bearing rather than incidental:
137
+
138
+ - **`type: node-terminal`, not `node`.** `nx run-many` executes every target in a
139
+ **child** process. A plain `node` launch attaches to the Nx parent alone, so a
140
+ breakpoint inside a spec never binds; `node-terminal` runs the command in VS
141
+ Code's JS Debug Terminal, which instruments children as they spawn. It also avoids
142
+ a second trap — a `node` launch defaults to `internalConsole`, which renders none
143
+ of Nx's progress output, so a build there looks like it has hung.
144
+ - **They drive `npm run <script>`, never a path into `node_modules`.** The obvious
145
+ `program: node_modules/nx/bin/nx.js` is wrong: Nx ships its bin at
146
+ `dist/bin/nx.js`, and that path moves between versions. Driving the root script
147
+ tracks whatever it does, so a change to the scripts reaches these for free.
148
+ - **`cwd` is `${workspaceFolder:<name>}`, scoped by folder name.** A bare
149
+ `${workspaceFolder}` is ambiguous the moment a second folder joins the workspace,
150
+ and VS Code then refuses to resolve it — breaking all four at once.
151
+
152
+ Your own configurations are safe: `mnci upgrade` replaces only the entries named
153
+ `mnci: *` and carries every other one through untouched.
154
+
122
155
  `:start` resolves differently per kind — an existing generator target where
123
156
  one already exists, a small `nx:run-commands` target mnci writes where none
124
157
  did:
@@ -191,6 +224,12 @@ back and carried through unchanged**. Those tasks are per-project state written
191
224
  `mnci add`, not overlay-owned, so regenerating them wholesale would wipe every
192
225
  project's build/qa/start entry on upgrade.
193
226
 
227
+ The `launch` array is handled differently again — **merged, not carried through**.
228
+ mnci owns the four `mnci: *` configurations and replaces them, while any
229
+ configuration you added yourself survives. The asymmetry is deliberate: tasks are
230
+ written by `mnci add` and the overlay has no idea which projects exist, whereas the
231
+ launch entries are entirely overlay-authored and should track an upgrade.
232
+
194
233
  `upgrade` also **deletes** things, which is stronger than the overwriting it
195
234
  has always done — one more reason to run `git diff` first, as the command's own
196
235
  output tells you to:
@@ -508,6 +547,95 @@ repo settings. Either way it's mapped as `env` on the npm steps and read by
508
547
  the root `.npmrc`'s `_password` block — the PAT value never lands in a file.
509
548
  No `npmAuthenticate@0` task (it would overwrite the hand-set password).
510
549
 
550
+ #### Why `_password` and never `_authToken` — read this before "fixing" it
551
+
552
+ Basic auth here is not a legacy choice. Azure Artifacts answers an unauthenticated
553
+ `PUT` to its npm publish endpoint with:
554
+
555
+ ```
556
+ www-authenticate: Bearer authorization_uri=https://login.windows.net/<tenant>,
557
+ Basic realm="...", TFS-Federated
558
+ ```
559
+
560
+ `authorization_uri` pointing at `login.windows.net` means its **Bearer scheme expects
561
+ an Entra ID access token**. A PAT is not one. npm sends `_authToken` *verbatim* as a
562
+ Bearer header (it base64-decodes only `_password`), so a PAT placed there is rejected
563
+ with:
564
+
565
+ ```
566
+ npm publish error:
567
+ Unable to authenticate, your authentication token seems to be invalid.
568
+ ```
569
+
570
+ **A PAT can only authenticate through the Basic scheme**, which is
571
+ `username` + base64 `_password`. That is what this file emits.
572
+
573
+ The trap worth knowing: a PAT *is* accepted as a Bearer token by the Packaging REST
574
+ API (`https://feeds.dev.azure.com/<org>/<proj>/_apis/packaging/feeds` returns 200),
575
+ so testing there looks like proof and is not. **Measure the endpoint the code
576
+ actually calls.** An `_authToken` change was shipped and reverted on exactly this.
577
+
578
+ Both feed path forms are keyed (`…/npm/registry/` and `…/npm/`) because npm matches
579
+ credentials by URL prefix and walks only *up* a path, so an entry on
580
+ `/npm/registry/` is never found for a request to `/npm/`.
581
+
582
+ #### The alternative on Azure: build-identity auth, no PAT at all
583
+
584
+ mnci does not generate this, but it is the better setup for an Azure Artifacts feed
585
+ in the **same organisation** as the pipeline, and it is what a real workspace uses
586
+ today. `npmAuthenticate@0` injects the build service identity's token — which *is*
587
+ Entra-issued, so it satisfies the Bearer scheme the feed advertises — and there is no
588
+ secret to store, encode, rotate or let expire.
589
+
590
+ To switch a generated workspace over by hand:
591
+
592
+ 1. Delete the credential lines from the root `.npmrc`, keeping only the
593
+ `@scope:registry=` routing line. (`mnci upgrade` will rewrite them back — see the
594
+ caveat below.)
595
+ 2. Add this step **before** `npm ci` in `azure-pipelines.yml`:
596
+
597
+ ```yaml
598
+ - task: npmAuthenticate@0
599
+ displayName: Authenticate npm against the feed (build identity, no PAT)
600
+ inputs:
601
+ workingFile: .npmrc
602
+ ```
603
+
604
+ 3. Grant the build identity **Feed Publisher (Contributor)** on the feed (Artifacts →
605
+ Feed Settings → Permissions). Which identity depends on the job authorization
606
+ scope: `<Project> Build Service (<org>)` when scoped to the project (the default),
607
+ or `Project Collection Build Service (<org>)` when not. A **403** rather than a
608
+ 401 is the tell that the wrong identity is in play.
609
+
610
+ Caveats, stated rather than glossed. This is **Azure-only** — GitHub Actions has no
611
+ equivalent task, so a `--ci=github` or `--ci=both` workspace still needs the PAT.
612
+ Local development then needs `npx vsts-npm-auth -config .npmrc` (Windows) or a
613
+ hand-added credential, since developers no longer inherit one from the file.
614
+ And `mnci upgrade` rewrites `.npmrc`, so it will restore the PAT block — `git diff`
615
+ before committing an upgrade, which the upgrade docs already tell you to do.
616
+
617
+ #### Diagnosing an auth failure
618
+
619
+ The error text distinguishes the two schemes, and that is the fastest signal:
620
+
621
+ | message | meaning |
622
+ | --- | --- |
623
+ | `E401 Incorrect or missing password` | Basic auth was used and rejected — check the base64 `_password` value |
624
+ | `Unable to authenticate, your authentication token seems to be invalid` | a token went out as Bearer and was rejected — a PAT cannot go here |
625
+ | `E403` | the credential is valid but lacks publish rights on the feed |
626
+
627
+ Two things are easy to get wrong when chasing this:
628
+
629
+ - **`nx release` publishes from the workspace root, not from each project.**
630
+ `@nx/js`'s `runPublish` uses `cwd: context.root` and passes `packageRoot` only as
631
+ the directory argument, so a missing per-package `.npmrc` is never the cause.
632
+ - **npm resolves `_authToken` before `username`/`_password` for a given registry
633
+ key** (`hasAuth` in `npm-registry-fetch/lib/auth.js`), and *key* precedence is
634
+ decided before *file* precedence. So a stale `_authToken` in a persistent build
635
+ agent's user-level `.npmrc` outranks the project-level `_password` and the
636
+ workspace's own credential never reaches the wire. Check
637
+ `npm config get userconfig` on the agent before blaming the secret.
638
+
511
639
  On Azure, two one-time grants are required (project admin): **Contribute** on
512
640
  the repo for the _Project Collection Build Service_ account (tag push), and
513
641
  **publish** rights on the feed for the PAT's owner. On GitHub, the workflow's
package/dist/cli.js CHANGED
@@ -1275,6 +1275,7 @@ function npmrcContent(registry, scope) {
1275
1275
  }
1276
1276
  const feedUrl = registryUrl(registry);
1277
1277
  const feedKey = feedUrl.replace(/^https:/, "");
1278
+ const feedShortKey = feedKey.replace("npm/registry/", "npm/");
1278
1279
  return `; Publish + resolution routing for this workspace's own scope.
1279
1280
  ;
1280
1281
  ; '${scope}:registry' sends BOTH resolution and 'npm publish' of ${scope}/* to the
@@ -1290,9 +1291,24 @@ ${scope}:registry=${feedUrl}
1290
1291
  ; instructions give you, which is exactly what _password expects, so it is used
1291
1292
  ; as-is. (twine wants the RAW token; the CI release step decodes it there.)
1292
1293
  ; Azure ignores the username, and npm requires an email it never uses.
1294
+ ;
1295
+ ; NOT _authToken, and that is measured rather than assumed. The feed answers an
1296
+ ; unauthenticated PUT to the publish endpoint with:
1297
+ ; www-authenticate: Bearer authorization_uri=https://login.windows.net/<tenant>,
1298
+ ; Basic realm="...", TFS-Federated
1299
+ ; so its Bearer scheme wants an Entra ID access token, NOT a PAT. npm sends
1300
+ ; _authToken verbatim as a Bearer header, so a PAT there is rejected with
1301
+ ; "Unable to authenticate, your authentication token seems to be invalid". A PAT
1302
+ ; authenticates through the Basic scheme, which is username/_password. Note the
1303
+ ; Packaging REST API DOES accept a PAT as Bearer - do not generalise from it.
1304
+ ;
1305
+ ; Both path forms are keyed because npm walks only UP a URL when matching.
1293
1306
  ${feedKey}:username=AzureArtifacts
1294
1307
  ${feedKey}:_password=\${PAT}
1295
1308
  ${feedKey}:email=npm-requires-this-and-never-uses-it
1309
+ ${feedShortKey}:username=AzureArtifacts
1310
+ ${feedShortKey}:_password=\${PAT}
1311
+ ${feedShortKey}:email=npm-requires-this-and-never-uses-it
1296
1312
  `;
1297
1313
  }
1298
1314
  function releaseConfig(ci) {
@@ -1433,6 +1449,9 @@ var ESLINT_PEER_OVERRIDES = {
1433
1449
  // even a trade, just a version nobody had bumped yet.
1434
1450
  postcss: { nanoid: "^3.3.18" }
1435
1451
  };
1452
+ var SECURITY_OVERRIDES = {
1453
+ "brace-expansion@4.0.0 - 5.0.8": "^5.0.9"
1454
+ };
1436
1455
  function eslintToolchainDependencies(nxVersion) {
1437
1456
  return {
1438
1457
  eslint: ESLINT_VERSION,
@@ -1588,7 +1607,27 @@ function vscodeSettings() {
1588
1607
  )
1589
1608
  };
1590
1609
  }
1591
- function vscodeWorkspace(workspaceName, existingTasks) {
1610
+ var LAUNCH_CONFIG_PREFIX = "mnci: ";
1611
+ var LAUNCH_CONFIGURATIONS = ["build", "test", "lint", "typecheck"];
1612
+ function launchConfigurations(workspaceName) {
1613
+ return LAUNCH_CONFIGURATIONS.map((script, index) => ({
1614
+ type: "node-terminal",
1615
+ request: "launch",
1616
+ name: `${LAUNCH_CONFIG_PREFIX}${script}`,
1617
+ command: `npm run ${script}`,
1618
+ // Scoped by folder name rather than a bare ${workspaceFolder}: that variable is
1619
+ // ambiguous once a second folder is added to the workspace, and VS Code then
1620
+ // refuses to resolve it.
1621
+ cwd: `\${workspaceFolder:${workspaceName}}`,
1622
+ // One shared group keeps the four together in the dropdown; a per-entry group
1623
+ // would make four groups of one. `order` holds them in verify order.
1624
+ presentation: { group: "mnci", order: index + 1 }
1625
+ }));
1626
+ }
1627
+ function vscodeWorkspace(workspaceName, existingTasks, existingLaunch) {
1628
+ const userConfigurations = (existingLaunch?.configurations ?? []).filter(
1629
+ (configuration) => !String(configuration.name ?? "").startsWith(LAUNCH_CONFIG_PREFIX)
1630
+ );
1592
1631
  return JSON.stringify(
1593
1632
  {
1594
1633
  folders: [{ path: ".", name: workspaceName }],
@@ -1597,6 +1636,10 @@ function vscodeWorkspace(workspaceName, existingTasks) {
1597
1636
  tasks: {
1598
1637
  version: existingTasks?.version ?? "2.0.0",
1599
1638
  tasks: existingTasks?.tasks ?? []
1639
+ },
1640
+ launch: {
1641
+ version: existingLaunch?.version ?? "0.2.0",
1642
+ configurations: [...launchConfigurations(workspaceName), ...userConfigurations]
1600
1643
  }
1601
1644
  },
1602
1645
  null,
@@ -1720,15 +1763,40 @@ trigger:
1720
1763
  # reason: two concurrent 'nx release' runs would race to create the same tag.
1721
1764
  batch: true
1722
1765
  branches:
1723
- include: [main]
1724
-
1725
- # Note: cancelling a superseded PR validation run is NOT a YAML setting on Azure
1726
- # \u2014 it lives in the branch policy ("Build validation -> automatically cancel").
1727
- # Nothing here can express it, so it is left to whoever configures the policy
1728
- # rather than faked with a batch setting that would only delay PR runs.
1729
- pr:
1730
- branches:
1731
- include: [main]
1766
+ include:
1767
+ - main
1768
+ # EVERY other branch too, and that breadth is a correction rather than
1769
+ # generosity. On Azure Repos Git a YAML 'pr:' block does nothing at all
1770
+ # (see the note below), so a CI trigger is the only pre-merge
1771
+ # verification this file can switch on by itself. Listing main alone \u2014
1772
+ # which is what this pipeline used to do, next to a 'pr:' block that
1773
+ # looked like it covered the rest \u2014 means a workspace verifies NOTHING
1774
+ # until a change has already reached the branch it was supposed to
1775
+ # protect.
1776
+ #
1777
+ # Release stays gated on main (the 'condition:' on every release step
1778
+ # below), so a topic-branch run installs, audits, syncs, formats,
1779
+ # verifies and packs, and publishes nothing.
1780
+ - '*'
1781
+
1782
+ # PR validation is deliberately NOT configured here, because on Azure Repos Git
1783
+ # it CANNOT be: "For an Azure Repos Git repo, you cannot configure a PR trigger
1784
+ # in the YAML file. You need to use branch policies." A 'pr:' block here is not
1785
+ # an error \u2014 it is silently ignored, which is worse, because the file then
1786
+ # documents a gate that does not exist. (The same block IS honoured for GitHub
1787
+ # and Bitbucket repos, which is why .github/workflows/ci.yml keeps its
1788
+ # 'pull_request:' trigger.)
1789
+ #
1790
+ # To get PR validation, configure it once in the UI: Project Settings ->
1791
+ # Repositories -> <repo> -> Policies -> <branch> -> Build Validation, pointing
1792
+ # at this pipeline. That is also the only thing that sets
1793
+ # System.PullRequest.TargetBranch, which the verify step below reads to scope
1794
+ # itself to affected projects \u2014 without a policy that variable is never set and
1795
+ # the step verifies every project instead. Correct either way, just slower.
1796
+ #
1797
+ # Cancelling a superseded PR run lives in that same policy ("automatically
1798
+ # cancel"), and likewise has no YAML expression.
1799
+ pr: none
1732
1800
 
1733
1801
  pool:
1734
1802
  ${poolBlock(agent)}
@@ -2186,7 +2254,8 @@ function applyOverlay(workspaceRoot, options) {
2186
2254
  });
2187
2255
  const overrides = {
2188
2256
  ...manifest.overrides,
2189
- ...ESLINT_PEER_OVERRIDES
2257
+ ...ESLINT_PEER_OVERRIDES,
2258
+ ...SECURITY_OVERRIDES
2190
2259
  };
2191
2260
  const existingNx = manifest.nx;
2192
2261
  const nx = {
@@ -2226,7 +2295,7 @@ function applyOverlay(workspaceRoot, options) {
2226
2295
  const existing = readCodeWorkspace(codeWorkspacePath);
2227
2296
  writeFileEnsured(
2228
2297
  codeWorkspacePath,
2229
- vscodeWorkspace(options.workspaceName, existing?.tasks)
2298
+ vscodeWorkspace(options.workspaceName, existing?.tasks, existing?.launch)
2230
2299
  );
2231
2300
  if (options.workspaceName !== "undefined") {
2232
2301
  (0, import_node_fs6.rmSync)((0, import_node_path11.join)(workspaceRoot, "undefined.code-workspace"), { force: true });
@@ -2249,11 +2318,14 @@ function applyOverlay(workspaceRoot, options) {
2249
2318
 
2250
2319
  // src/commands/doctor.ts
2251
2320
  var SUPPORTED_ESLINT_MAJOR = ESLINT_VERSION.replace(/^\D*/, "").split(".", 1)[0];
2321
+ function toPosix(path) {
2322
+ return path.replaceAll("\\", "/");
2323
+ }
2252
2324
  function checkEslintConfigs(workspaceRoot) {
2253
2325
  const rootConfigs = (0, import_node_fs7.globSync)("eslint.config.{js,mjs,cjs,ts,mts,cts}", { cwd: workspaceRoot });
2254
2326
  const projectConfigs = (0, import_node_fs7.globSync)("{apps,libs,packages}/*/eslint.config.{js,mjs,cjs,ts,mts,cts}", {
2255
2327
  cwd: workspaceRoot
2256
- });
2328
+ }).map((config) => toPosix(config));
2257
2329
  return [
2258
2330
  {
2259
2331
  check: "root ESLint config",
@@ -2348,7 +2420,7 @@ function checkVersionActions(workspaceRoot) {
2348
2420
  const candidates = [
2349
2421
  ...(0, import_node_fs7.globSync)("packages/*/pubspec.yaml", { cwd: workspaceRoot }),
2350
2422
  ...(0, import_node_fs7.globSync)("python-packages/*/pyproject.toml", { cwd: workspaceRoot })
2351
- ];
2423
+ ].map((manifest) => toPosix(manifest));
2352
2424
  return candidates.flatMap((manifest) => {
2353
2425
  const projectRoot = manifest.slice(0, manifest.lastIndexOf("/"));
2354
2426
  const projectJsonPath = (0, import_node_path12.join)(workspaceRoot, projectRoot, "project.json");
@@ -2368,6 +2440,63 @@ function checkVersionActions(workspaceRoot) {
2368
2440
  ];
2369
2441
  });
2370
2442
  }
2443
+ var TARGET_FILE_OPTIONS = ["main", "tsConfig", "packageJson"];
2444
+ function declaredTargets(workspaceRoot, projectRoot) {
2445
+ const sources = [];
2446
+ for (const [file, extract] of [
2447
+ ["project.json", (json) => json["targets"]],
2448
+ [
2449
+ "package.json",
2450
+ (json) => json["nx"]?.["targets"]
2451
+ ]
2452
+ ]) {
2453
+ try {
2454
+ const targets = extract(
2455
+ readJson((0, import_node_path12.join)(workspaceRoot, projectRoot, file))
2456
+ );
2457
+ if (targets && typeof targets === "object") {
2458
+ sources.push({
2459
+ source: `${projectRoot}/${file}`,
2460
+ targets
2461
+ });
2462
+ }
2463
+ } catch {
2464
+ }
2465
+ }
2466
+ return sources;
2467
+ }
2468
+ function checkTargetFilesExist(workspaceRoot) {
2469
+ const projectRoots = new Set(
2470
+ [
2471
+ ...(0, import_node_fs7.globSync)("{apps,libs,packages}/*/project.json", { cwd: workspaceRoot }),
2472
+ ...(0, import_node_fs7.globSync)("{apps,libs,packages}/*/package.json", { cwd: workspaceRoot })
2473
+ ].map((manifest) => toPosix(manifest).split("/").slice(0, 2).join("/"))
2474
+ );
2475
+ return [...projectRoots].flatMap(
2476
+ (projectRoot) => declaredTargets(workspaceRoot, projectRoot).flatMap(
2477
+ ({ source, targets }) => Object.entries(targets).flatMap(
2478
+ ([targetName, target]) => checkOneTargetsFiles(workspaceRoot, source, targetName, target)
2479
+ )
2480
+ )
2481
+ );
2482
+ }
2483
+ function checkOneTargetsFiles(workspaceRoot, source, targetName, target) {
2484
+ return TARGET_FILE_OPTIONS.flatMap((option) => {
2485
+ const value = target?.options?.[option];
2486
+ const checkable = typeof value === "string" && !value.includes("{");
2487
+ if (!checkable || fileExists((0, import_node_path12.join)(workspaceRoot, value))) {
2488
+ return [];
2489
+ }
2490
+ return [
2491
+ {
2492
+ check: `${source} \u2192 ${targetName}.${option} points at a real file`,
2493
+ ok: false,
2494
+ detail: `${value} does not exist \u2014 the target fails as if the compiler were misconfigured`,
2495
+ remedy: `create ${value}, or correct ${option} in ${source}`
2496
+ }
2497
+ ];
2498
+ });
2499
+ }
2371
2500
  function checkSync(workspaceRoot) {
2372
2501
  return {
2373
2502
  check: "TypeScript project references synced",
@@ -2391,6 +2520,7 @@ function collectFindings(workspaceRoot) {
2391
2520
  checkResolvedEslint(workspaceRoot),
2392
2521
  checkNpmrc(workspaceRoot, nxJson.mnci?.registry, nxJson.mnci?.scope),
2393
2522
  ...checkVersionActions(workspaceRoot),
2523
+ ...checkTargetFilesExist(workspaceRoot),
2394
2524
  checkSync(workspaceRoot)
2395
2525
  ].filter((finding) => finding !== void 0);
2396
2526
  }