@memberjunction/cli 6.1.0-edge.5 → 6.1.0-edge.7

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.
Files changed (36) hide show
  1. package/README.md +23 -0
  2. package/dist/commands/dev/workspace/index.d.ts +9 -0
  3. package/dist/commands/dev/workspace/index.d.ts.map +1 -1
  4. package/dist/commands/dev/workspace/index.js +82 -6
  5. package/dist/commands/dev/workspace/index.js.map +1 -1
  6. package/dist/config.d.ts +127 -2
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +24 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/hooks/prerun.d.ts +6 -0
  11. package/dist/hooks/prerun.d.ts.map +1 -1
  12. package/dist/hooks/prerun.js +29 -3
  13. package/dist/hooks/prerun.js.map +1 -1
  14. package/dist/lib/dev-workspace/build.d.ts +155 -18
  15. package/dist/lib/dev-workspace/build.d.ts.map +1 -1
  16. package/dist/lib/dev-workspace/build.js +522 -39
  17. package/dist/lib/dev-workspace/build.js.map +1 -1
  18. package/dist/lib/dev-workspace/detect.d.ts +14 -1
  19. package/dist/lib/dev-workspace/detect.d.ts.map +1 -1
  20. package/dist/lib/dev-workspace/detect.js +60 -3
  21. package/dist/lib/dev-workspace/detect.js.map +1 -1
  22. package/dist/lib/dev-workspace/doctor.d.ts +17 -1
  23. package/dist/lib/dev-workspace/doctor.d.ts.map +1 -1
  24. package/dist/lib/dev-workspace/doctor.js +92 -1
  25. package/dist/lib/dev-workspace/doctor.js.map +1 -1
  26. package/dist/lib/dev-workspace/types.d.ts +185 -2
  27. package/dist/lib/dev-workspace/types.d.ts.map +1 -1
  28. package/dist/lib/dev-workspace/usage.d.ts.map +1 -1
  29. package/dist/lib/dev-workspace/usage.js +5 -3
  30. package/dist/lib/dev-workspace/usage.js.map +1 -1
  31. package/dist/lib/dynamic-packages.d.ts +31 -0
  32. package/dist/lib/dynamic-packages.d.ts.map +1 -0
  33. package/dist/lib/dynamic-packages.js +56 -0
  34. package/dist/lib/dynamic-packages.js.map +1 -0
  35. package/oclif.manifest.json +1802 -1742
  36. package/package.json +22 -21
@@ -13,11 +13,18 @@
13
13
  * so detection filters them out before they reach this module). A member with no
14
14
  * workspace file of its own contributes the proven `packages/*` default; the MJ
15
15
  * monorepo contributes its 42 nested globs (#3795).
16
- * - .npmrc: exactly three settings lines. There is deliberately no
16
+ * The same file carries the pnpm SETTINGS (overrides, patches, peer rules):
17
+ * pnpm 10 reads them only from `pnpm-workspace.yaml` — a `pnpm` block in the
18
+ * root package.json is ignored with a per-field warning that drowns in the
19
+ * install output (946 overrides silently inert).
20
+ * - .npmrc: exactly one settings line. There is deliberately no
17
21
  * `public-hoist-pattern[]` block — see "Why no hoist block" below.
18
- * - package.json: private root manifest, pnpm `packageManager` pin, the
22
+ * A package name two members both provide (every MJ-based app repo has a
23
+ * `mj_generatedentities`) gets consumer-scoped `link:` overrides so each
24
+ * member's own consumers keep its own copy — see {@link ResolveDuplicateProviderLinks}.
25
+ * - package.json: private root manifest, pnpm `packageManager` pin, and the
19
26
  * devDependency union of member roots (highest version wins, every conflict
20
- * reported), and the proven peerDependencyRules bridge block.
27
+ * reported). No `pnpm` block see pnpm-workspace.yaml above.
21
28
  * - turbo.json: copied verbatim from a member, with a minimal fallback.
22
29
  *
23
30
  * Why no hoist block: the 78-entry `public-hoist-pattern[]` set the manual setup
@@ -37,7 +44,7 @@
37
44
  *
38
45
  * @module lib/dev-workspace/build
39
46
  */
40
- import { DeriveLockfilePins } from './lockfile.js';
47
+ import { DeriveLockfilePins, IsResolvedVersion, MajorOf } from './lockfile.js';
41
48
  /** Build-scripts allowlist proven on the core-monorepo spike. */
42
49
  export const ONLY_BUILT_DEPENDENCIES = [
43
50
  '@apollo/protobufjs',
@@ -57,12 +64,25 @@ export const ONLY_BUILT_DEPENDENCIES = [
57
64
  'sharp',
58
65
  'tesseract.js',
59
66
  ];
60
- /** The three .npmrc settings lines — strict peers has been the standard since 2026-08-07. */
61
- export const NPMRC_BASE_LINES = [
62
- 'package-manager-strict=false',
63
- 'strict-peer-dependencies=true',
64
- 'auto-install-peers=true',
65
- ];
67
+ /**
68
+ * The one .npmrc settings line. The peer settings that used to sit beside it
69
+ * (`strict-peer-dependencies`, `auto-install-peers`) now live in
70
+ * `pnpm-workspace.yaml` with the rest of the pnpm settings — see
71
+ * {@link PEER_INSTALL_SETTINGS}.
72
+ */
73
+ export const NPMRC_BASE_LINES = ['package-manager-strict=false'];
74
+ /**
75
+ * Peer-resolution settings for the parent workspace, emitted into
76
+ * `pnpm-workspace.yaml`. Peers are auto-installed but NOT strict: a mixed
77
+ * workspace links MJ from source while a member may still resolve published
78
+ * packages (Skip-Brain's `@mj-biz-apps/*` 5.33) whose peer ranges name an older
79
+ * MJ major than the linked source — a strict install fails on peers no member
80
+ * can fix, while a non-strict one reports them and completes.
81
+ */
82
+ export const PEER_INSTALL_SETTINGS = {
83
+ strictPeerDependencies: false,
84
+ autoInstallPeers: true,
85
+ };
66
86
  /**
67
87
  * Packages an MJ library declares as a `peerDependency` because the choice belongs
68
88
  * to the app shell, not the library — the auth SDK family especially, where a shell
@@ -72,6 +92,11 @@ export const NPMRC_BASE_LINES = [
72
92
  *
73
93
  * Kept here (rather than as prose) so the command's guidance text has one source of
74
94
  * truth. Grouped by the MJ package whose peer declaration creates the requirement.
95
+ *
96
+ * Scope: this list covers MJ's OWN libraries. Open App client packages declare shell-provided
97
+ * peers too, and they are NOT enumerated here — MJ's CLI does not hardcode knowledge of specific
98
+ * Open App repos. They are DERIVED instead, per shell, by {@link ResolveShellPeerGaps} from the
99
+ * members' committed `mj-app.json` files (#4364).
75
100
  */
76
101
  export const SHELL_PROVIDED_PEERS = [
77
102
  {
@@ -152,14 +177,99 @@ function memberGlobLines(member) {
152
177
  }
153
178
  lines.push(negated ? ` - '!${member.Name}/${body}'` : ` - '${member.Name}/${glob}'`);
154
179
  }
180
+ // App shells the user admitted for this member with --apps (already validated by SelectAppGlobs).
181
+ for (const glob of member.AppGlobs ?? []) {
182
+ if (glob.startsWith('!') || glob.startsWith('packages/')) {
183
+ throw new Error(`Member ${member.Name} app glob '${glob}' is not an admitted app-shell glob — SelectAppGlobs must run first`);
184
+ }
185
+ lines.push(` - '${member.Name}/${glob}'`);
186
+ }
187
+ return lines;
188
+ }
189
+ /**
190
+ * The #3795 guard, made explicit for admitted app shells: every package an `--apps` glob
191
+ * enumerates must have a name no other workspace package uses, or pnpm would silently pick one.
192
+ * Throws naming both sides; returns the admitted app package names otherwise. Pure.
193
+ */
194
+ export function AssertAppPackageNamesUnique(members) {
195
+ const isUnder = (relPath, glob) => glob.endsWith('/*') ? relPath.startsWith(`${glob.slice(0, -2)}/`) && !relPath.slice(glob.length - 1).includes('/') : relPath === glob;
196
+ const owners = new Map(); // package name -> "member/relPath"
197
+ const appNames = [];
198
+ for (const member of members) {
199
+ for (const pkg of member.Packages) {
200
+ const name = pkg.PackageJson.name;
201
+ if (!name)
202
+ continue;
203
+ const isApp = (member.AppGlobs ?? []).some((g) => isUnder(pkg.RelPath, g));
204
+ const here = `${member.Name}/${pkg.RelPath}`;
205
+ const other = owners.get(name);
206
+ if (other !== undefined && (isApp || appNames.includes(name))) {
207
+ throw new Error(`--apps: package name '${name}' is declared by both ${other} and ${here} — app-shell names must be unique across the workspace`);
208
+ }
209
+ if (other === undefined)
210
+ owners.set(name, here);
211
+ if (isApp)
212
+ appNames.push(name);
213
+ }
214
+ }
215
+ return appNames;
216
+ }
217
+ /** A YAML mapping key: bare when it is a plain identifier (the pnpm setting names), single-quoted otherwise. */
218
+ function yamlKey(key) {
219
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : yamlScalar(key);
220
+ }
221
+ /** A YAML scalar: booleans and numbers bare, strings always single-quoted (a `'` doubles). */
222
+ function yamlScalar(value) {
223
+ if (typeof value === 'boolean' || typeof value === 'number')
224
+ return String(value);
225
+ if (typeof value === 'string')
226
+ return `'${value.replace(/'/g, "''")}'`;
227
+ throw new Error(`pnpm settings may hold only strings, numbers, booleans, arrays and objects — got ${typeof value}`);
228
+ }
229
+ /**
230
+ * Renders one JSON-shaped record as block-style YAML lines at the given indent.
231
+ * Deliberately tiny: the pnpm settings are strings, booleans, string lists and
232
+ * nested records, nothing else — anything else throws rather than mis-rendering.
233
+ */
234
+ function yamlMappingLines(record, indent) {
235
+ const pad = ' '.repeat(indent);
236
+ const lines = [];
237
+ for (const [key, value] of Object.entries(record)) {
238
+ if (Array.isArray(value)) {
239
+ if (value.length === 0) {
240
+ lines.push(`${pad}${yamlKey(key)}: []`);
241
+ continue;
242
+ }
243
+ lines.push(`${pad}${yamlKey(key)}:`);
244
+ for (const item of value)
245
+ lines.push(`${pad} - ${yamlScalar(item)}`);
246
+ }
247
+ else if (value !== null && typeof value === 'object') {
248
+ const nested = value;
249
+ if (Object.keys(nested).length === 0) {
250
+ lines.push(`${pad}${yamlKey(key)}: {}`);
251
+ continue;
252
+ }
253
+ lines.push(`${pad}${yamlKey(key)}:`);
254
+ lines.push(...yamlMappingLines(nested, indent + 2));
255
+ }
256
+ else {
257
+ lines.push(`${pad}${yamlKey(key)}: ${yamlScalar(value)}`);
258
+ }
259
+ }
155
260
  return lines;
156
261
  }
157
262
  /**
158
263
  * Builds `pnpm-workspace.yaml`: per member (sorted by name) the repo root plus the
159
264
  * member's own packages-rooted workspace globs re-prefixed with its directory name.
160
265
  * Producer packages only — never `apps/*` (app-shell names collide across repos).
266
+ *
267
+ * `settings` — the assembled pnpm settings from {@link BuildRootPackageJson}
268
+ * (`PnpmSettings`: overrides, patches, peer rules, the peer install switches) —
269
+ * are appended as block YAML. pnpm 10 honours them ONLY in this file; the same
270
+ * block in the root package.json is ignored with a per-field warning.
161
271
  */
162
- export function BuildWorkspaceYaml(members) {
272
+ export function BuildWorkspaceYaml(members, settings = {}) {
163
273
  if (members.length === 0) {
164
274
  throw new Error('BuildWorkspaceYaml requires at least one member repo');
165
275
  }
@@ -174,6 +284,10 @@ export function BuildWorkspaceYaml(members) {
174
284
  for (const member of [...members].sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0))) {
175
285
  lines.push(...memberGlobLines(member));
176
286
  }
287
+ if (Object.keys(settings).length > 0) {
288
+ lines.push('', '# pnpm settings — pnpm 10 reads these ONLY from this file; a package.json#pnpm block is ignored at a workspace root.');
289
+ lines.push(...yamlMappingLines(settings, 0));
290
+ }
177
291
  return `${lines.join('\n')}\n`;
178
292
  }
179
293
  /**
@@ -190,7 +304,7 @@ export function BuildShellPeerGuidance() {
190
304
  return lines;
191
305
  }
192
306
  /**
193
- * Builds `.npmrc`: exactly the three proven settings lines.
307
+ * Builds `.npmrc`: exactly the one settings line pnpm still needs there.
194
308
  *
195
309
  * No `public-hoist-pattern[]` block by design — see the "Why no hoist block" note
196
310
  * at the top of this module.
@@ -312,6 +426,10 @@ function workspaceName(parentDirName) {
312
426
  const cleaned = parentDirName.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
313
427
  return `${cleaned.length > 0 ? cleaned : 'mj'}-dev-workspace`;
314
428
  }
429
+ /** Members in plain codepoint order by name — the order every derivation resolves ties in. */
430
+ function sortedByName(members) {
431
+ return [...members].sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0));
432
+ }
315
433
  /**
316
434
  * Collects every package name the workspace members provide, from the members'
317
435
  * OWN package enumerations. These names get `workspace:*` overrides in the
@@ -323,7 +441,7 @@ function workspaceName(parentDirName) {
323
441
  */
324
442
  export function CollectFamilyPackages(members) {
325
443
  const providers = new Map();
326
- for (const member of [...members].sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0))) {
444
+ for (const member of sortedByName(members)) {
327
445
  for (const pkg of member.Packages) {
328
446
  const name = pkg.PackageJson.name;
329
447
  if (name === undefined || name.length === 0)
@@ -340,6 +458,252 @@ export function CollectFamilyPackages(members) {
340
458
  }
341
459
  return { Names: [...providers.keys()].sort(), Duplicates: duplicates.sort((a, b) => (a.Package < b.Package ? -1 : 1)) };
342
460
  }
461
+ /** True when any dependency section of the manifest names `name`. */
462
+ function declaresDependency(manifest, name) {
463
+ return [manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].some((section) => section !== undefined && name in section);
464
+ }
465
+ /**
466
+ * Keeps each member's OWN copy of a duplicated package name in front of that
467
+ * member's own consumers. A plain `name: workspace:*` override can only pick
468
+ * one provider (pnpm takes the first by sort order), so every MJ-based app repo's
469
+ * `mj_generatedentities` / `mj_generatedactions` collided as soon as two such
470
+ * repos shared a workspace: Skip-Brain's packages type-checked against MJ's
471
+ * generated entities and could not find their own.
472
+ * pnpm's `parent>child` override selector with a root-relative `link:` value
473
+ * scopes the fix to the consumer: `@skip-brain/core>mj_generatedentities:
474
+ * link:Skip-Brain/packages/GeneratedEntities` (verified live).
475
+ *
476
+ * Two consumer shapes cannot be linked and are reported instead: a consumer whose
477
+ * own name is duplicated (the selector would hit every copy of it), and a consumer
478
+ * in a member that provides no copy (nothing of its own to link to — it gets the
479
+ * sort-order provider like before). Pure; deterministic over sorted names.
480
+ */
481
+ export function ResolveDuplicateProviderLinks(members, duplicates) {
482
+ const duplicatedNames = new Set(duplicates.map((d) => d.Package));
483
+ const overrides = {};
484
+ const links = [];
485
+ const unlinked = [];
486
+ const sortedMembers = [...members].sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0));
487
+ for (const dup of [...duplicates].sort((a, b) => (a.Package < b.Package ? -1 : 1))) {
488
+ for (const member of sortedMembers) {
489
+ const provider = [...member.Packages]
490
+ .filter((p) => p.PackageJson.name === dup.Package)
491
+ .sort((a, b) => (a.RelPath < b.RelPath ? -1 : 1))[0];
492
+ for (const consumer of [...member.Packages].sort((a, b) => (a.RelPath < b.RelPath ? -1 : 1))) {
493
+ const consumerName = consumer.PackageJson.name;
494
+ if (consumerName === undefined || consumerName === dup.Package || !declaresDependency(consumer.PackageJson, dup.Package))
495
+ continue;
496
+ if (provider === undefined) {
497
+ unlinked.push({ Consumer: consumerName, Package: dup.Package, Repo: member.Name, Reason: 'no-own-copy' });
498
+ }
499
+ else if (duplicatedNames.has(consumerName)) {
500
+ unlinked.push({ Consumer: consumerName, Package: dup.Package, Repo: member.Name, Reason: 'ambiguous-consumer' });
501
+ }
502
+ else {
503
+ const target = `link:${member.Name}/${provider.RelPath}`;
504
+ overrides[`${consumerName}>${dup.Package}`] = target;
505
+ links.push({ Consumer: consumerName, Package: dup.Package, Target: target, Repo: member.Name });
506
+ }
507
+ }
508
+ }
509
+ }
510
+ return { Overrides: sortedRecord(overrides), Links: links, Unlinked: unlinked };
511
+ }
512
+ /**
513
+ * Indexes every package the workspace members provide, by package name. One lookup structure
514
+ * serves both "does the workspace provide this?" (what {@link CollectFamilyPackages} answers with
515
+ * a name list) and "what does this package declare?" — the peer resolution in
516
+ * {@link ResolveShellPeerGaps} needs the manifest, not just the name.
517
+ */
518
+ export function IndexWorkspacePackages(members) {
519
+ const index = new Map();
520
+ for (const member of sortedByName(members)) {
521
+ for (const pkg of member.Packages) {
522
+ const name = pkg.PackageJson.name;
523
+ if (name === undefined || name.length === 0)
524
+ continue;
525
+ if (!index.has(name))
526
+ index.set(name, pkg.PackageJson);
527
+ }
528
+ }
529
+ return index;
530
+ }
531
+ /**
532
+ * The client-side packages the members' own `mj-app.json` files declare.
533
+ *
534
+ * These are invisible to every other derivation in this module: nothing in the tree DEPENDS on
535
+ * them. A host registers them (`dynamicPackages.client`) and `mj codegen manifest` appends a
536
+ * side-effect import to the app shell's generated class-registrations manifest — so the shell
537
+ * imports a package it never declared, and pnpm, correctly, never linked (#4364). Emitting them as
538
+ * parent dependencies is the only place that can be fixed.
539
+ *
540
+ * Deliberately registration-INDEPENDENT: a member's client package is collected whether or not a
541
+ * host has registered it, per the linking ⊥ registration axiom
542
+ * (`guides/OPEN_APP_WORKSPACE_LINKING_SPEC.md` §17). Over-linking is the sanctioned direction —
543
+ * an unregistered package resolves but does not load.
544
+ */
545
+ /**
546
+ * Validates and returns one declared package array from a member's `mj-app.json`.
547
+ *
548
+ * `mj-app.json` is committed in a SIBLING repo and hand-editable, so its parsed shape is genuinely
549
+ * unknown here — {@link MjAppJson} is an assertion about a file this repo does not own, and
550
+ * `readJsonFile` validates only that the TEXT parses. Narrowing it with real guards is what makes
551
+ * that type true for every consumer downstream, and it is the same fail-fast-and-name-the-file
552
+ * contract `readJsonFile` already sets ("Unparseable JSON at <path>"). Without it a missing `name`
553
+ * surfaced as `Cannot read properties of undefined (reading 'length')`, which names neither the
554
+ * repo nor the file — and a developer may have a dozen of them.
555
+ */
556
+ function readDeclaredEntries(member, section) {
557
+ const declared = member.MjAppJson?.packages?.[section];
558
+ if (declared === undefined || declared === null)
559
+ return [];
560
+ const where = `${member.Name}/mj-app.json: packages.${section}`;
561
+ if (!Array.isArray(declared)) {
562
+ throw new Error(`${where} must be an array of package entries, got ${typeof declared}`);
563
+ }
564
+ for (const [index, entry] of declared.entries()) {
565
+ if (entry === null || typeof entry !== 'object') {
566
+ throw new Error(`${where}[${index}] must be an object, got ${entry === null ? 'null' : typeof entry}`);
567
+ }
568
+ if (typeof entry.name !== 'string') {
569
+ throw new Error(`${where}[${index}] has no "name" string — a package entry must name its package`);
570
+ }
571
+ }
572
+ return declared;
573
+ }
574
+ /**
575
+ * Every package a member declares that an app shell will be asked to import.
576
+ *
577
+ * This set mirrors the HOST's rule exactly, because the host is the only thing that decides what
578
+ * the shell imports. `GetClientPackagesFromManifest`
579
+ * (`packages/OpenApp/Engine/src/install/config-manager.ts`) builds the client dynamic-package list
580
+ * as `[...packages.client, ...packages.shared]` with **no role test** — its own comment: "every
581
+ * client/shared package is emitted regardless of startupExport — client entries are side-effect
582
+ * imports" — and `mj codegen manifest --open-app-client-bootstrap` turns every enabled entry into
583
+ * an import in the shell's generated class-registrations manifest, with no role field even present
584
+ * on its entry type.
585
+ *
586
+ * So there is deliberately no `role` filter here. An earlier revision kept only `role: 'bootstrap'`
587
+ * on the premise that other roles "are imported normally"; the host contradicts that, and `role`
588
+ * is a required seven-value enum whose `components` / `module` members are the documented Angular
589
+ * roles. Anything narrower leaves a schema-valid package imported by the shell and linked by
590
+ * nobody — the exact page-load-with-a-green-build failure this module exists to prevent (#4364).
591
+ *
592
+ * `packages.server[]` is NOT here, and that is not an oversight: the host routes it to
593
+ * `dynamicPackages.server`, a Node process that resolves importer-relative rather than from the
594
+ * vite root, so it is not part of the shell's resolution problem.
595
+ */
596
+ function readShellImportedEntries(member) {
597
+ return [...readDeclaredEntries(member, 'client'), ...readDeclaredEntries(member, 'shared')];
598
+ }
599
+ export function CollectOpenAppClientPackages(members, workspacePackages) {
600
+ const collected = new Map();
601
+ const declaredBy = new Map();
602
+ for (const member of sortedByName(members)) {
603
+ // Detection carried this rather than throwing, so an excluded sibling's broken file could not
604
+ // abort the command. This is the consumer that actually reads the declaration, so it raises.
605
+ if (member.MjAppJsonError) {
606
+ throw new Error(`${member.Name}/mj-app.json could not be read: ${member.MjAppJsonError}`);
607
+ }
608
+ const seenInMember = new Set();
609
+ for (const entry of readShellImportedEntries(member)) {
610
+ if (entry.name.length === 0)
611
+ continue;
612
+ // One repo naming a package in BOTH client[] and shared[] is not an ambiguity — it is one
613
+ // member declaring one package twice, and the link target is not in question.
614
+ if (!seenInMember.has(entry.name)) {
615
+ seenInMember.add(entry.name);
616
+ declaredBy.set(entry.name, [...(declaredBy.get(entry.name) ?? []), member.Name]);
617
+ }
618
+ if (collected.has(entry.name))
619
+ continue;
620
+ collected.set(entry.name, { Package: entry.name, Repo: member.Name, Provided: workspacePackages.has(entry.name) });
621
+ }
622
+ }
623
+ const byPackage = (a, b) => (a.Package < b.Package ? -1 : a.Package > b.Package ? 1 : 0);
624
+ return {
625
+ Packages: [...collected.values()].sort(byPackage),
626
+ Duplicates: [...declaredBy.entries()]
627
+ .filter(([, repos]) => repos.length > 1)
628
+ .map(([Package, Repos]) => ({ Package, Repos }))
629
+ .sort(byPackage),
630
+ };
631
+ }
632
+ /** The prefix every MJ Angular library carries — the surface an Open App client package registers into. */
633
+ const MJ_ANGULAR_PREFIX = '@memberjunction/ng-';
634
+ /**
635
+ * The MJ Angular app shells the workspace enumerates — the shells that could actually host an Open
636
+ * App client package.
637
+ *
638
+ * Structural, with no configuration, and all three conditions earn their place:
639
+ * - depends on `@angular/core` — a LIBRARY takes it as a peer, so this alone separates app from library;
640
+ * - carries an Angular application builder (`@angular/cli` / `@angular/build`) in devDependencies;
641
+ * - declares at least one `@memberjunction/ng-*` package — it has an MJ Angular surface.
642
+ *
643
+ * The third condition is not decoration. Without it the sole other Angular app in the MJ monorepo,
644
+ * `mj_angular_elements_demo`, is reported as missing five peers of packages it will never load —
645
+ * five warnings on every regenerate, which is how a report teaches people to stop reading it. It
646
+ * declares zero `@memberjunction/ng-*` packages while MJExplorer declares 22, and an Open App
647
+ * client package peer-depends on eleven of them, so a shell with none cannot be hosting one. It is
648
+ * a predicate, deliberately not a count threshold.
649
+ *
650
+ * Scope, stated rather than implied: only packages the workspace enumerates are candidates, and
651
+ * detection filters members' `apps/*` globs out because every Open App repo names its shells
652
+ * `mj_api`/`mj_explorer` and they collide (#3795). A shell under `apps/` is outside the workspace
653
+ * by design and is not checked here.
654
+ */
655
+ export function CollectWorkspaceShells(members) {
656
+ const shells = [];
657
+ for (const member of sortedByName(members)) {
658
+ for (const pkg of member.Packages) {
659
+ const { name, dependencies = {}, devDependencies = {} } = pkg.PackageJson;
660
+ if (name === undefined || name.length === 0)
661
+ continue;
662
+ const isApp = dependencies['@angular/core'] !== undefined;
663
+ const hasBuilder = devDependencies['@angular/cli'] !== undefined || devDependencies['@angular/build'] !== undefined;
664
+ const hostsMJAngular = Object.keys(dependencies).some((dep) => dep.startsWith(MJ_ANGULAR_PREFIX));
665
+ if (!isApp || !hasBuilder || !hostsMJAngular)
666
+ continue;
667
+ shells.push({
668
+ Name: name,
669
+ RelPath: pkg.RelPath,
670
+ Repo: member.Name,
671
+ Declares: [...Object.keys(dependencies), ...Object.keys(devDependencies)].sort(),
672
+ });
673
+ }
674
+ }
675
+ return shells.sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0));
676
+ }
677
+ /**
678
+ * Peers of the registered client-side packages that a shell cannot resolve.
679
+ *
680
+ * A peer is a gap for a shell when the workspace does not PROVIDE it and the shell does not
681
+ * DECLARE it. Both halves are required: without the first, every `@memberjunction/*` peer is
682
+ * noise; without the second, `@angular/core` is a false positive in every shell.
683
+ *
684
+ * Why this must not be auto-added to the parent manifest: an Angular dev server externalizes
685
+ * `@angular/*` and resolves it from the VITE ROOT (the shell), so the copy `auto-install-peers`
686
+ * places in the consumer's own tree is never consulted — measured, with a control, on 2026-09-11.
687
+ * The fix belongs in the shell's own tracked package.json, which is what {@link SHELL_PROVIDED_PEERS}
688
+ * has always said and what MJExplorer already does for `@angular/service-worker`.
689
+ */
690
+ export function ResolveShellPeerGaps(clientPackages, shells, workspacePackages, overrides) {
691
+ const gaps = [];
692
+ for (const shell of shells) {
693
+ const declares = new Set(shell.Declares);
694
+ for (const client of clientPackages) {
695
+ const manifest = workspacePackages.get(client.Package);
696
+ if (manifest === undefined)
697
+ continue; // unprovided: reported by OpenAppClientPackages, nothing to resolve
698
+ for (const [peer, range] of Object.entries(manifest.peerDependencies ?? {})) {
699
+ if (workspacePackages.has(peer) || declares.has(peer))
700
+ continue;
701
+ gaps.push({ Shell: shell.Name, Package: client.Package, Peer: peer, Range: range, Pin: findPeerPin(overrides, peer, range) });
702
+ }
703
+ }
704
+ }
705
+ return gaps;
706
+ }
343
707
  /** Records a first-member-wins value, reporting any differing later declaration as a conflict. */
344
708
  function recordFirstWins(chosen, conflicts, key, candidate) {
345
709
  const incumbent = chosen.get(key);
@@ -420,6 +784,31 @@ function overrideKeyName(key) {
420
784
  const at = key.lastIndexOf('@');
421
785
  return at <= 0 ? key : key.slice(0, at);
422
786
  }
787
+ /** First integer in a version or range — `^21.1.3` -> `21`, `21.2.22` -> `21`. Null when there is none. */
788
+ function majorOf(versionOrRange) {
789
+ return /(\d+)/.exec(versionOrRange)?.[1] ?? null;
790
+ }
791
+ /**
792
+ * The exact version the parent's assembled overrides pin for a peer, or null when nothing does.
793
+ *
794
+ * Must not be a bare `overrides[peer]` lookup. {@link DeriveLockfilePins} emits a bare key ONLY
795
+ * when a package resolves to a single major across every member's graph; the moment two majors
796
+ * exist anywhere it emits per-major selector keys instead (`chalk@^5` / `chalk@^4`). A bare lookup
797
+ * misses those entirely and the command then reports "nothing in the parent pins it" for a peer the
798
+ * parent does pin — inverting the one actionable fact the warning carries. When several majors are
799
+ * pinned, the useful one is the major the client package actually asks for.
800
+ */
801
+ function findPeerPin(overrides, peer, range) {
802
+ const exact = overrides[peer];
803
+ if (exact !== undefined)
804
+ return exact;
805
+ const matches = Object.entries(overrides).filter(([key]) => overrideKeyName(key) === peer);
806
+ if (matches.length === 0)
807
+ return null;
808
+ const wantedMajor = majorOf(range);
809
+ const sameMajor = matches.find(([key]) => majorOf(key.slice(peer.length)) === wantedMajor);
810
+ return (sameMajor ?? matches[0])[1];
811
+ }
423
812
  /** Removes every pin entry (plain or per-major selector) for a name; records what was displaced. */
424
813
  function displacePinsForName(overrides, name, newValue, superseded) {
425
814
  for (const key of Object.keys(overrides)) {
@@ -431,14 +820,51 @@ function displacePinsForName(overrides, name, newValue, superseded) {
431
820
  }
432
821
  }
433
822
  /**
434
- * Layers the three override sources into the parent `pnpm.overrides`, weakest
435
- * first: lockfile-derived pins < explicit member overrides < family
436
- * `workspace:*` (local source always wins). A member's or family's whole-name
437
- * entry displaces every per-major pin selector for that name (a plain key and
438
- * a `name@^N` selector must not fight). Displacements are returned so the
439
- * command reports them nothing is overwritten silently.
823
+ * Pins a patched package to the exact version its patch is keyed to. A patch
824
+ * `name@version` only ever applies to that version: letting a lockfile pin or a
825
+ * member override resolve `name` elsewhere leaves the patch inert (pnpm merely
826
+ * warns "patches were not used" SaaS's committed
827
+ * `type-graphql@2.0.0-rc.3` out-voted MJ's patched `2.0.0-beta.3`, and every
828
+ * consumer of MJ's server then type-checked against the wrong type-graphql).
829
+ * The pin takes the per-major selector shape when other majors of the name are
830
+ * already pinned (never forcing another major), the plain name otherwise.
831
+ * Returns the override key it wrote, or null when the patch key is not pinnable
832
+ * (a range or a bare name — pnpm allows those; there is nothing exact to pin).
833
+ */
834
+ function pinPatchedPackage(overrides, patchKey, superseded) {
835
+ const name = overrideKeyName(patchKey);
836
+ const version = name === patchKey ? '' : patchKey.slice(name.length + 1);
837
+ if (!IsResolvedVersion(version))
838
+ return null;
839
+ const selector = `${name}@^${MajorOf(version)}`;
840
+ const otherSelectors = Object.keys(overrides).filter((key) => key !== name && overrideKeyName(key) === name);
841
+ const target = otherSelectors.length > 0 ? selector : name;
842
+ if (target === selector && name in overrides) {
843
+ // a plain-name entry and a selector must not fight: the plain entry gives way
844
+ if (overrides[name] !== version)
845
+ superseded.add(name);
846
+ delete overrides[name];
847
+ }
848
+ else if (target === name) {
849
+ displacePinsForName(overrides, name, version, superseded);
850
+ }
851
+ if (target in overrides && overrides[target] !== version)
852
+ superseded.add(target);
853
+ overrides[target] = version;
854
+ return target;
855
+ }
856
+ /**
857
+ * Layers the override sources into the parent overrides, weakest first:
858
+ * lockfile-derived pins < explicit member overrides < patched-package pins <
859
+ * family `workspace:*` (local source always wins). A member's or family's
860
+ * whole-name entry displaces every per-major pin selector for that name (a plain
861
+ * key and a `name@^N` selector must not fight). Displacements are returned so
862
+ * the command reports them — nothing is overwritten silently.
863
+ *
864
+ * `patchedPackages` are the members' `patchedDependencies` keys (`name@version`);
865
+ * see {@link pinPatchedPackage} for why a patch outranks an override.
440
866
  */
441
- export function AssembleParentOverrides(lockfilePins, memberOverrides, familyNames) {
867
+ export function AssembleParentOverrides(lockfilePins, memberOverrides, familyNames, patchedPackages = []) {
442
868
  const overrides = { ...lockfilePins };
443
869
  const superseded = new Set();
444
870
  for (const [key, version] of Object.entries(memberOverrides)) {
@@ -451,66 +877,117 @@ export function AssembleParentOverrides(lockfilePins, memberOverrides, familyNam
451
877
  }
452
878
  overrides[key] = version;
453
879
  }
880
+ const patchPins = new Set();
881
+ for (const patchKey of [...patchedPackages].sort()) {
882
+ const written = pinPatchedPackage(overrides, patchKey, superseded);
883
+ if (written !== null)
884
+ patchPins.add(written);
885
+ }
454
886
  for (const name of familyNames) {
455
887
  displacePinsForName(overrides, name, 'workspace:*', superseded);
456
888
  overrides[name] = 'workspace:*';
889
+ for (const key of [...patchPins])
890
+ if (overrideKeyName(key) === name)
891
+ patchPins.delete(key); // source beats patch
457
892
  }
458
- return { Overrides: sortedRecord(overrides), SupersededPins: [...superseded].sort() };
893
+ return { Overrides: sortedRecord(overrides), SupersededPins: [...superseded].sort(), PatchPins: [...patchPins].sort() };
459
894
  }
460
- /** Builds the manifest's `pnpm` block from the assembled parts, omitting empty sections. */
461
- function buildPnpmBlock(overrides, blocks) {
895
+ /**
896
+ * Builds the pnpm settings for `pnpm-workspace.yaml` from the assembled parts,
897
+ * omitting empty sections: the peer install switches, the peer bridge rules, the
898
+ * layered overrides, hoisted patches and package extensions. Key order is fixed
899
+ * so a regenerated workspace is byte-identical.
900
+ */
901
+ export function BuildPnpmWorkspaceSettings(overrides, blocks) {
462
902
  const peerDependencyRules = {
463
903
  allowedVersions: sortedRecord({ ...PEER_DEPENDENCY_RULES.allowedVersions, ...blocks.PeerAllowedVersions }),
464
904
  ignoreMissing: [...new Set([...PEER_DEPENDENCY_RULES.ignoreMissing, ...blocks.PeerIgnoreMissing])].sort(),
465
905
  };
466
- const block = { peerDependencyRules };
906
+ const settings = { ...PEER_INSTALL_SETTINGS, peerDependencyRules };
467
907
  if (Object.keys(overrides).length > 0)
468
- block.overrides = overrides;
908
+ settings.overrides = overrides;
469
909
  if (Object.keys(blocks.PatchedDependencies).length > 0) {
470
- block.patchedDependencies = blocks.PatchedDependencies;
910
+ settings.patchedDependencies = blocks.PatchedDependencies;
471
911
  // A member patch is keyed to pkg@version; when the parent graph never resolves
472
912
  // that exact version, pnpm hard-fails the WHOLE install with ERR_PNPM_UNUSED_PATCH.
473
913
  // One member's stale patch must not brick every member's workspace — allow it,
474
- // and rely on the assembly report, which names every hoisted patch.
475
- block.allowUnusedPatches = true;
914
+ // and rely on the assembly report, which names every hoisted patch. (The
915
+ // patched-package pins in AssembleParentOverrides make this the rare case.)
916
+ settings.allowUnusedPatches = true;
476
917
  }
477
918
  if (Object.keys(blocks.PackageExtensions).length > 0)
478
- block.packageExtensions = blocks.PackageExtensions;
479
- return block;
919
+ settings.packageExtensions = blocks.PackageExtensions;
920
+ return settings;
480
921
  }
481
922
  /**
482
- * Builds the private parent `package.json`: pnpm pin, the cleaned member
483
- * devDependency union, and the full absorbed `pnpm` block member overrides and
484
- * patches hoisted, lockfile-derived pins, and `workspace:*` overrides for every
485
- * member-provided package. Every decision lands in the returned Report.
923
+ * Builds the private parent `package.json` pnpm pin and the cleaned member
924
+ * devDependency union and, alongside it, the pnpm settings the workspace yaml
925
+ * carries: member overrides and patches hoisted, lockfile-derived pins,
926
+ * patched-package pins, and `workspace:*` overrides for every member-provided
927
+ * package. The settings are returned rather than written into the manifest
928
+ * because pnpm 10 ignores a `pnpm` block at a workspace root. Every decision
929
+ * lands in the returned Report.
486
930
  */
931
+ /**
932
+ * The parent manifest's `dependencies`: every member-PROVIDED client-side package at
933
+ * `workspace:*`.
934
+ *
935
+ * This key is what puts the package at the parent's `node_modules` root, which is the only place
936
+ * an app shell that never declared it can resolve it from. An unprovided package is skipped — a
937
+ * `workspace:*` specifier on a package nothing provides is unresolvable and fails the install, the
938
+ * same reasoning as the devDependency union's `DroppedWorkspace` branch.
939
+ */
940
+ export function BuildClientDependencies(clientPackages) {
941
+ const deps = {};
942
+ for (const client of clientPackages) {
943
+ if (client.Provided)
944
+ deps[client.Package] = 'workspace:*';
945
+ }
946
+ return deps;
947
+ }
487
948
  export function BuildRootPackageJson(parentDirName, members) {
488
949
  if (members.length === 0) {
489
950
  throw new Error('BuildRootPackageJson requires at least one member repo');
490
951
  }
952
+ const workspacePackages = IndexWorkspacePackages(members);
953
+ const clients = CollectOpenAppClientPackages(members, workspacePackages);
954
+ const clientPackages = clients.Packages;
955
+ const clientDependencies = BuildClientDependencies(clientPackages);
491
956
  const family = CollectFamilyPackages(members);
492
957
  const union = ResolveDevDependencyUnion(members, new Set(family.Names));
958
+ // A provided client package is by definition family-provided, so classifyDevDep already mapped it
959
+ // to workspace:* in the union — and a member devDepending on its own client package is ordinary.
960
+ // Emitting it in both blocks of a GENERATED manifest is noise, not a conflict (identical
961
+ // specifier), so `dependencies` keeps it: that block is what puts the package at the parent root,
962
+ // which is the whole point of registering it.
963
+ for (const name of Object.keys(clientDependencies))
964
+ delete union.DevDependencies[name];
493
965
  const pins = DeriveLockfilePins(members.flatMap((m) => (m.Lockfile !== null && m.Lockfile.Kind !== 'unsupported' ? [{ Repo: m.Name, Lockfile: m.Lockfile }] : [])), new Set(family.Names));
494
966
  const blocks = ResolveMemberPnpmBlocks(members);
495
- const assembled = AssembleParentOverrides(pins.Pins, blocks.Overrides, family.Names);
967
+ const assembled = AssembleParentOverrides(pins.Pins, blocks.Overrides, family.Names, Object.keys(blocks.PatchedDependencies));
968
+ const duplicateLinks = ResolveDuplicateProviderLinks(members, family.Duplicates);
969
+ const shellPeerGaps = ResolveShellPeerGaps(clientPackages, CollectWorkspaceShells(members), workspacePackages, assembled.Overrides);
496
970
  const { Pin, Source } = ResolvePnpmPin(members);
497
971
  const manifest = {
498
972
  name: workspaceName(parentDirName),
499
973
  private: true,
500
974
  packageManager: Pin,
975
+ // Omitted entirely when empty, so a parent with no Open App member regenerates byte-identically.
976
+ ...(Object.keys(clientDependencies).length > 0 ? { dependencies: clientDependencies } : {}),
501
977
  devDependencies: union.DevDependencies,
502
- pnpm: buildPnpmBlock(assembled.Overrides, blocks),
503
978
  };
504
979
  return {
505
980
  Content: `${JSON.stringify(manifest, null, 2)}\n`,
981
+ // scoped `consumer>package` keys sit beside the plain ones — they never collide
982
+ PnpmSettings: BuildPnpmWorkspaceSettings(sortedRecord({ ...assembled.Overrides, ...duplicateLinks.Overrides }), blocks),
506
983
  Conflicts: union.Conflicts,
507
984
  PinSource: Source,
508
985
  Pin,
509
- Report: buildManifestReport(members, family, union, pins, blocks, assembled.SupersededPins),
986
+ Report: buildManifestReport(members, family, union, pins, blocks, assembled, duplicateLinks, clients, shellPeerGaps),
510
987
  };
511
988
  }
512
989
  /** Assembles the absorption report — one place, so nothing the build decided goes unreported. */
513
- function buildManifestReport(members, family, union, pins, blocks, supersededPins) {
990
+ function buildManifestReport(members, family, union, pins, blocks, assembled, duplicateLinks, clients, shellPeerGaps) {
514
991
  const lockfileSkips = members.flatMap((m) => m.Lockfile !== null && m.Lockfile.Kind !== 'unsupported' ? m.Lockfile.Skipped.map((skip) => ({ Repo: m.Name, Skip: skip })) : []);
515
992
  const unsupportedLockfiles = members.flatMap((m) => m.Lockfile !== null && m.Lockfile.Kind === 'unsupported' ? [{ Repo: m.Name, File: m.Lockfile.File, Version: m.Lockfile.Version }] : []);
516
993
  return {
@@ -523,9 +1000,15 @@ function buildManifestReport(members, family, union, pins, blocks, supersededPin
523
1000
  Patches: blocks.Patches,
524
1001
  FamilyOverrideCount: family.Names.length,
525
1002
  DuplicateFamilyPackages: family.Duplicates,
1003
+ DuplicateProviderLinks: duplicateLinks.Links,
1004
+ UnlinkedDuplicateConsumers: duplicateLinks.Unlinked,
526
1005
  SkippedTypesDevDeps: [...union.SkippedTypes].sort(),
527
1006
  DroppedWorkspaceDevDeps: union.DroppedWorkspace,
528
- SupersededPins: supersededPins,
1007
+ SupersededPins: assembled.SupersededPins,
1008
+ PatchPins: assembled.PatchPins,
1009
+ OpenAppClientPackages: clients.Packages,
1010
+ ShellPeerGaps: shellPeerGaps,
1011
+ DuplicateClientPackages: clients.Duplicates,
529
1012
  };
530
1013
  }
531
1014
  /**