@lenne.tech/cli 1.44.0 → 1.45.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.
@@ -330,7 +330,11 @@ const NewCommand = {
330
330
  // non-root level pnpm ignores), drop nested lockfiles the root supersedes,
331
331
  // and guarantee a root `.dockerignore` (Docker never reads a sub-project's
332
332
  // own when building from the root context).
333
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
333
+ // A disagreement between the two repos is not something the merge may decide
334
+ // silently. Reported here, and repeated in the closing block — this call site
335
+ // sits directly above the install output that would otherwise bury it.
336
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
337
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
334
338
  // Run install + format unless explicitly skipped (CI/agents may
335
339
  // want to chain multiple add-* calls before installing once).
336
340
  if (!skipInstall && !experimental) {
@@ -357,6 +361,7 @@ const NewCommand = {
357
361
  info('');
358
362
  success(`API integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
359
363
  info('');
364
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
360
365
  info('Next:');
361
366
  if (experimental) {
362
367
  info(` $ cd ${workspaceDir}/projects/api && bun install`);
@@ -259,7 +259,11 @@ const NewCommand = {
259
259
  // carry pnpm.overrides too), drop the frontend starter's standalone
260
260
  // pnpm-lock.yaml the root supersedes, and guarantee a root `.dockerignore`
261
261
  // (else the image inherits the host's node_modules, .output and .env).
262
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
262
+ // A disagreement between the two repos is not something the merge may decide
263
+ // silently. Reported here, and repeated in the closing block — this call site
264
+ // sits directly above the install output that would otherwise bury it.
265
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
266
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
263
267
  if (!skipInstall) {
264
268
  const installSpinner = spin('Install workspace packages');
265
269
  try {
@@ -278,6 +282,7 @@ const NewCommand = {
278
282
  info('');
279
283
  success(`App integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
280
284
  info('');
285
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
281
286
  info('Next:');
282
287
  info(` $ cd ${workspaceDir}`);
283
288
  info(` $ ${toolbox.pm.run('start')}`);
@@ -653,7 +653,12 @@ const NewCommand = {
653
653
  // guarantees a root `.dockerignore` (else the images carry the host's
654
654
  // node_modules, a stale .output, and a local .env that `nuxt build`
655
655
  // bakes in).
656
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
656
+ // Two sources setting the same pnpm key to different values is not a merge
657
+ // to resolve — it is a disagreement between two repos, and the merge would
658
+ // pick one silently. Say so here, where both halves are still visible, and
659
+ // again at the end (the install output below buries this one).
660
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
661
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
657
662
  // Install all packages
658
663
  if (!experimental) {
659
664
  const installSpinner = spin('Install all packages');
@@ -734,6 +739,7 @@ const NewCommand = {
734
739
  info('');
735
740
  success(`Generated fullstack workspace with ${frontend} in ${projectDir} with ${name} app in ${helper.msToMinutesAndSeconds(timer())}m.`);
736
741
  info('');
742
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
737
743
  info('Next:');
738
744
  if (experimental) {
739
745
  info(` $ cd ${projectDir}`);
@@ -1,5 +1,4 @@
1
1
  {
2
- "$schema": "./vendor-runtime-deps.schema.json",
3
- "description": "Upstream @lenne.tech/nest-server devDependencies that are actually needed at runtime in a consumer project. When vendoring, these entries are promoted from upstream devDependencies into the project's dependencies so production runtime has them available.",
4
- "runtimeHelpers": ["find-file-up"]
2
+ "$comment": "Upstream @lenne.tech/nest-server DEVDEPENDENCIES that must land in a vendored project's `dependencies`. Vendoring copies upstream `dependencies` verbatim and does not read `peerDependencies` at all, so a package the framework imports at runtime while keeping it in devDependencies would never reach the production runtime. Entries here are matched against upstream `devDependencies` only — see `isVendorRuntimeDep`, consulted in the `upstreamDevDeps` loop in `extensions/server.ts` — and promoted into `dependencies`, preferring the upstream peer RANGE over the exact devDep pin where one exists, so a generated project still receives patch-level security fixes. A package that appears ONLY in upstream `peerDependencies` cannot be promoted by this list: listing it here is a silent no-op. Two live cases: `find-file-up` (imported by the config loader, devDependency only), and the better-auth trio (declared as peerDependencies AND mirrored into devDependencies upstream; the mirror is what makes the promotion fire, so this list is the fallback if the starter ever stops declaring them itself).",
3
+ "runtimeHelpers": ["@better-auth/core", "@better-auth/passkey", "better-auth", "find-file-up"]
5
4
  }
@@ -951,6 +951,7 @@ class Server {
951
951
  */
952
952
  convertCloneToVendored(options) {
953
953
  return __awaiter(this, void 0, void 0, function* () {
954
+ var _a;
954
955
  const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nest-server.git' } = options;
955
956
  const filesystem = this.filesystem;
956
957
  const { system } = this.toolbox;
@@ -995,16 +996,22 @@ class Server {
995
996
  // transitive deps into the project's package.json (step 5 below).
996
997
  let upstreamDeps = {};
997
998
  let upstreamDevDeps = {};
999
+ let upstreamPeerDeps = {};
998
1000
  let upstreamVersion = '';
999
1001
  try {
1000
1002
  const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
1001
1003
  if (upstreamPkg && typeof upstreamPkg === 'object') {
1002
1004
  upstreamDeps = upstreamPkg.dependencies || {};
1003
1005
  upstreamDevDeps = upstreamPkg.devDependencies || {};
1006
+ // Read for the version RANGE only, never as a source of packages: the
1007
+ // promotion below still keys off devDependencies. A peer range is the
1008
+ // spec upstream actually supports, whereas the devDep entry beside it is
1009
+ // an exact pin that exists for the framework's own CI.
1010
+ upstreamPeerDeps = upstreamPkg.peerDependencies || {};
1004
1011
  upstreamVersion = upstreamPkg.version || '';
1005
1012
  }
1006
1013
  }
1007
- catch (_a) {
1014
+ catch (_b) {
1008
1015
  // Best-effort — if we can't read upstream pkg, the starter's own
1009
1016
  // deps should still cover most of the framework's needs.
1010
1017
  }
@@ -1018,7 +1025,7 @@ class Server {
1018
1025
  try {
1019
1026
  upstreamLockRaw = filesystem.read(`${tmpClone}/pnpm-lock.yaml`) || '';
1020
1027
  }
1021
- catch (_b) {
1028
+ catch (_c) {
1022
1029
  // Best-effort — without the lockfile, the closure step falls back to
1023
1030
  // the upstream package.json ranges (still better than nothing).
1024
1031
  }
@@ -1034,7 +1041,7 @@ class Server {
1034
1041
  try {
1035
1042
  upstreamWorkspaceYaml = filesystem.read(`${tmpClone}/pnpm-workspace.yaml`) || '';
1036
1043
  }
1037
- catch (_c) {
1044
+ catch (_d) {
1038
1045
  // Best-effort: an older framework revision may predate the file. The project
1039
1046
  // then keeps exactly the allowlist its own template shipped.
1040
1047
  }
@@ -1050,7 +1057,7 @@ class Server {
1050
1057
  upstreamClaudeMd = claudeMdContent;
1051
1058
  }
1052
1059
  }
1053
- catch (_d) {
1060
+ catch (_e) {
1054
1061
  // Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
1055
1062
  }
1056
1063
  // Snapshot the upstream commit SHA for traceability in VENDOR.md.
@@ -1059,7 +1066,7 @@ class Server {
1059
1066
  const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
1060
1067
  upstreamCommit = (sha || '').trim();
1061
1068
  }
1062
- catch (_e) {
1069
+ catch (_f) {
1063
1070
  // Non-fatal — VENDOR.md will just show an empty SHA.
1064
1071
  }
1065
1072
  try {
@@ -1342,7 +1349,7 @@ class Server {
1342
1349
  }
1343
1350
  }
1344
1351
  }
1345
- catch (_f) {
1352
+ catch (_g) {
1346
1353
  // skip unreadable file
1347
1354
  }
1348
1355
  }
@@ -1484,9 +1491,13 @@ class Server {
1484
1491
  pkg.devDependencies = {};
1485
1492
  const devDeps = pkg.devDependencies;
1486
1493
  for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
1487
- // Runtime-needed devDeps → promote to dependencies
1494
+ // Runtime-needed devDeps → promote to dependencies. Prefer the peer
1495
+ // RANGE when upstream declares one: the devDep entry beside it is an
1496
+ // exact pin for the framework's own CI, and baking that into a
1497
+ // generated project means `pnpm update` never delivers a patch-level
1498
+ // fix — the wrong default for an auth library like better-auth.
1488
1499
  if (this.isVendorRuntimeDep(depName) && !(depName in deps)) {
1489
- deps[depName] = depVersion;
1500
+ deps[depName] = (_a = upstreamPeerDeps[depName]) !== null && _a !== void 0 ? _a : depVersion;
1490
1501
  continue;
1491
1502
  }
1492
1503
  // @types/<pkg> where <pkg> is a runtime dep → accept as devDep
@@ -323,6 +323,15 @@ function hoistWorkspacePnpmConfig(options) {
323
323
  // the root's wording wins — it is the file a maintainer of THIS workspace edits.
324
324
  const comments = extractKeyComments((_b = filesystem.read(rootWsPath)) !== null && _b !== void 0 ? _b : '');
325
325
  let rootChanged = false;
326
+ // Shared across all sources: who set which key, and where two disagree.
327
+ const provenance = new Map();
328
+ const conflicts = [];
329
+ const rootOverrides = [];
330
+ const auditWidenedBy = new Set();
331
+ // Sub-projects that are present and eligible but hand over nothing, because an
332
+ // earlier run already hoisted their settings and deleted the source. They are
333
+ // what turns a root-vs-sub override into a sub-vs-sub disagreement.
334
+ const dormant = [];
326
335
  for (const subDir of subProjects) {
327
336
  const subPath = `${projectDir}/${subDir}`;
328
337
  if (!filesystem.exists(subPath))
@@ -331,13 +340,31 @@ function hoistWorkspacePnpmConfig(options) {
331
340
  // checkout in link mode.
332
341
  if ((0, fs_utils_1.isSymlink)(subPath))
333
342
  continue;
334
- if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
343
+ // Labelled per FILE, not per sub-project: a repo that contradicts itself
344
+ // across its own two config sources is the same silent-winner bug one scope
345
+ // down, and a shared label hides it.
346
+ const fromPkg = hoistFromSubPackageJson({
347
+ context: { auditWidenedBy, conflicts, provenance, rootOverrides, sourceLabel: `${subDir}/package.json#pnpm` },
348
+ filesystem,
349
+ rootWs,
350
+ subPath,
351
+ });
352
+ const fromYaml = hoistFromSubWorkspaceYaml({
353
+ comments,
354
+ context: { auditWidenedBy, conflicts, provenance, rootOverrides, sourceLabel: `${subDir}/pnpm-workspace.yaml` },
355
+ filesystem,
356
+ rootWs,
357
+ subPath,
358
+ });
359
+ if (fromPkg || fromYaml) {
335
360
  rootChanged = true;
336
361
  }
337
- if (hoistFromSubWorkspaceYaml({ comments, filesystem, rootWs, subPath })) {
338
- rootChanged = true;
362
+ else {
363
+ dormant.push(subDir);
339
364
  }
340
365
  }
366
+ reportRootOverridesFromDormantSiblings(rootOverrides, dormant, conflicts);
367
+ reportAuditConfigWidening(auditWidenedBy, conflicts);
341
368
  if (rootChanged) {
342
369
  // Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
343
370
  // the build-script allowlist survives regardless of which key pnpm reads.
@@ -345,6 +372,7 @@ function hoistWorkspacePnpmConfig(options) {
345
372
  const dumped = (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false });
346
373
  filesystem.write(rootWsPath, annotateAuditConfig(reattachKeyComments(dumped, comments)));
347
374
  }
375
+ return { conflicts };
348
376
  }
349
377
  /**
350
378
  * Mark a hoisted `auditConfig` as workspace-wide, in the file itself.
@@ -368,6 +396,30 @@ function annotateAuditConfig(yaml) {
368
396
  }
369
397
  return yaml.replace(/^auditConfig:/m, `${AUDIT_CONFIG_NOTE}\nauditConfig:`);
370
398
  }
399
+ /** `false` from either side wins — an install script nobody vouched for must not run. */
400
+ function applyDenyWins(rootValue, subValue) {
401
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
402
+ if (!isMap(rootValue) || !isMap(subValue))
403
+ return subValue;
404
+ const out = Object.assign({}, subValue);
405
+ for (const [pkg, enabled] of Object.entries(rootValue)) {
406
+ if (enabled === false)
407
+ out[pkg] = false;
408
+ }
409
+ return out;
410
+ }
411
+ /**
412
+ * Compare the value each field's own downstream normalisation will actually use.
413
+ *
414
+ * `allowBuilds` is the case that matters: js-yaml 4 follows the YAML 1.2 core
415
+ * schema, so `esbuild: no` parses as the STRING `'no'` while the same intent in a
416
+ * `package.json#pnpm` block is the BOOLEAN `false`. `syncBuildAllowlists` narrows
417
+ * both to `false` (`enabled === true`), so reporting them as a disagreement sends
418
+ * someone hunting through two repositories over two spellings of "deny".
419
+ */
420
+ function canonicaliseForCompare(field, value) {
421
+ return field === 'allowBuilds' ? value === true : value;
422
+ }
371
423
  /**
372
424
  * Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
373
425
  * Returns >0 if `a` is newer, <0 if older, 0 if equal. Numeric segment-wise
@@ -388,15 +440,100 @@ function comparePmVersions(a, b) {
388
440
  }
389
441
  return 0;
390
442
  }
443
+ /**
444
+ * Structural equality, enough for the scalar/array/object shapes these fields hold.
445
+ *
446
+ * The scalar fast path is not just a shortcut: `overrides` values are version
447
+ * strings and `allowBuilds` values are booleans, so it answers nearly every real
448
+ * comparison without allocating. The `try` guards the rest — `js-yaml` resolves
449
+ * recursive anchors into genuinely circular objects, on which `JSON.stringify`
450
+ * throws, and an exception here would abort the whole hoist (and with it
451
+ * `lt fullstack init`). Two values we cannot compare are reported as differing:
452
+ * a spurious warning is recoverable, a silent merge is the thing this exists to
453
+ * prevent.
454
+ */
455
+ function deepEqual(a, b) {
456
+ if (a === b)
457
+ return true;
458
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
459
+ return false;
460
+ try {
461
+ return JSON.stringify(a) === JSON.stringify(b);
462
+ }
463
+ catch (_a) {
464
+ return false;
465
+ }
466
+ }
467
+ /**
468
+ * Render a value for a warning that lands in a terminal and in CI logs.
469
+ *
470
+ * `JSON.stringify` supplies the quoting and escapes every control character, so
471
+ * an ESC smuggled through a version spec cannot repaint the line. The redaction
472
+ * covers the one shape a version spec may legitimately carry a secret in: a git
473
+ * or tarball URL with embedded credentials (`git+https://user:token@host/…`).
474
+ * It only ever leaks something already committed to a sub-project manifest, but
475
+ * a conflict warning should not be a new place it surfaces.
476
+ */
477
+ function describeValue(value) {
478
+ if (typeof value !== 'string') {
479
+ // A recursive YAML anchor resolves to a circular object, which `stringify`
480
+ // refuses. Naming its shape is enough for a warning, and beats aborting the
481
+ // scaffold over an unprintable value.
482
+ try {
483
+ return JSON.stringify(value);
484
+ }
485
+ catch (_a) {
486
+ return Array.isArray(value) ? '<circular list>' : '<circular value>';
487
+ }
488
+ }
489
+ return JSON.stringify(value
490
+ .replace(/\/\/[^/@\s]+:[^/@\s]+@/, '//***:***@')
491
+ .replace(/([?&](?:token|access_token|auth|password)=)[^&\s]+/gi, '$1***'));
492
+ }
493
+ /** True when an `auditConfig` block actually suppresses something (empty lists do not). */
494
+ function hasAuditSuppressions(value) {
495
+ if (!value || typeof value !== 'object' || Array.isArray(value))
496
+ return false;
497
+ return Object.values(value).some((v) => Array.isArray(v) && v.length > 0);
498
+ }
391
499
  /**
392
500
  * Move the workspace-scoped pnpm fields from `source` into `rootWs`,
393
501
  * deleting each moved field from `source`. Returns true if anything moved.
502
+ *
503
+ * `context` drives conflict DETECTION for the object-valued fields. The merge
504
+ * itself is last-writer-wins (`{...root, ...sub}`), which is correct for
505
+ * root-vs-sub — a sub-project owns the authoritative list for its own transitive
506
+ * deps — but is a trap for sub-vs-sub: two projects pinning the same package to
507
+ * different versions produce one silent winner, decided by iteration order.
508
+ * Nothing downstream can tell that apart from a deliberate choice, so it is
509
+ * caught here, where both values are still visible.
510
+ *
511
+ * `allowBuilds` is the one field where detection is not enough and the merge
512
+ * itself is corrected — see the deny-wins block below.
394
513
  */
395
- function hoistFields(rootWs, source) {
514
+ function hoistFields(rootWs, source, context) {
396
515
  let changed = false;
397
516
  for (const field of WORKSPACE_SCOPED_PNPM_FIELDS) {
398
517
  if (source[field] === undefined)
399
518
  continue;
519
+ if (context && isObjectField(field)) {
520
+ recordObjectFieldProvenance(field, source[field], context);
521
+ recordRootOverrides(field, rootWs[field], source[field], context);
522
+ }
523
+ if (context && field === 'auditConfig' && hasAuditSuppressions(source[field])) {
524
+ context.auditWidenedBy.add(context.sourceLabel);
525
+ }
526
+ // A build-script allowance is the one field with a safe direction: a deny
527
+ // declared anywhere outranks an allow from elsewhere. Without this the
528
+ // last-writer-wins merge resolves the disagreement in favour of RUNNING an
529
+ // install script that another sub-project explicitly refused — and the
530
+ // warning above does not stop it. `syncBuildAllowlists` is already
531
+ // fail-closed across the two allowlist forms; this makes the sub-vs-sub
532
+ // path agree with it. No such direction exists for `overrides` (a higher
533
+ // version is not a safer one), so there the warning is the whole answer.
534
+ if (field === 'allowBuilds') {
535
+ source[field] = applyDenyWins(rootWs[field], source[field]);
536
+ }
400
537
  rootWs[field] = mergePnpmFieldValue(field, rootWs[field], source[field]);
401
538
  delete source[field];
402
539
  changed = true;
@@ -412,7 +549,7 @@ function hoistFromSubPackageJson(options) {
412
549
  const subPkg = filesystem.read(subPkgPath, 'json');
413
550
  if (!(subPkg === null || subPkg === void 0 ? void 0 : subPkg.pnpm))
414
551
  return false;
415
- if (!hoistFields(rootWs, subPkg.pnpm))
552
+ if (!hoistFields(rootWs, subPkg.pnpm, options.context))
416
553
  return false;
417
554
  // If the sub-project's pnpm section is now empty, drop it entirely.
418
555
  if (Object.keys(subPkg.pnpm).length === 0) {
@@ -439,7 +576,7 @@ function hoistFromSubWorkspaceYaml(options) {
439
576
  if (!comments.has(key))
440
577
  comments.set(key, block);
441
578
  }
442
- if (!hoistFields(rootWs, ws))
579
+ if (!hoistFields(rootWs, ws, options.context))
443
580
  return false;
444
581
  // A settings-only file (no `packages:`) exists solely to carry these
445
582
  // hoisted keys — once emptied it would only declare a nested workspace
@@ -453,6 +590,10 @@ function hoistFromSubWorkspaceYaml(options) {
453
590
  }
454
591
  return true;
455
592
  }
593
+ /** True for the fields merged key-by-key, where two sub-projects can contradict each other. */
594
+ function isObjectField(field) {
595
+ return OBJECT_FIELDS.includes(field);
596
+ }
456
597
  /**
457
598
  * Merge two values for a pnpm workspace-scoped field.
458
599
  *
@@ -525,6 +666,143 @@ function readYaml(filesystem, path) {
525
666
  return null;
526
667
  return parsed;
527
668
  }
669
+ /**
670
+ * Note who set each key of an object-valued field, and flag a second source
671
+ * setting the same key to a different value.
672
+ *
673
+ * `sourceLabel` identifies the FILE, not the sub-project, so a sub-project that
674
+ * contradicts itself across its own `package.json#pnpm` and its own
675
+ * `pnpm-workspace.yaml` is caught too. That is not a hypothetical shape — both
676
+ * starters ship a settings-only `pnpm-workspace.yaml`, and a stale leftover in
677
+ * either file otherwise downgrades a pin with no output at all.
678
+ */
679
+ function recordObjectFieldProvenance(field, value, context) {
680
+ if (!value || typeof value !== 'object' || Array.isArray(value))
681
+ return;
682
+ for (const [key, incoming] of Object.entries(value)) {
683
+ const id = `${field}.${key}`;
684
+ const previous = context.provenance.get(id);
685
+ if (previous &&
686
+ previous.source !== context.sourceLabel &&
687
+ !deepEqual(canonicaliseForCompare(field, previous.value), canonicaliseForCompare(field, incoming))) {
688
+ // `allowBuilds` is resolved deterministically (deny outranks allow), so
689
+ // saying "one of them would silently win" there would understate what
690
+ // already happened and leave the reader unsure which value shipped.
691
+ const kept = resolveKeptValue(field, previous.value, incoming);
692
+ const outcome = deepEqual(kept, incoming)
693
+ ? 'One of them would silently win.'
694
+ : `The workspace keeps ${describeValue(kept)}, because a denied build script outranks an allowed one.`;
695
+ context.conflicts.push(`${field}: ${JSON.stringify(key)} is set to ${describeValue(previous.value)} by ${previous.source} ` +
696
+ `and to ${describeValue(incoming)} by ${context.sourceLabel}. ` +
697
+ `${outcome} Agree on a single value in BOTH repos before assembling.`);
698
+ }
699
+ context.provenance.set(id, { source: context.sourceLabel, value: incoming });
700
+ }
701
+ }
702
+ /**
703
+ * Remember where this source changes a value the ROOT already carried.
704
+ *
705
+ * On its own that is the documented and intended precedence — the root seeds,
706
+ * a sub-project owns the authoritative list for its own transitive deps — so
707
+ * nothing is reported here. It only becomes a disagreement once the run ends
708
+ * with a sub-project that contributed nothing, which is what tells us the root
709
+ * value was itself hoisted from that sibling on an earlier run.
710
+ *
711
+ * The `kept` value is resolved here rather than described later, because for
712
+ * `allowBuilds` the incoming value does NOT necessarily win: `applyDenyWins`
713
+ * runs a few lines further down and a deny from either side outranks an allow.
714
+ * A message that announced the incoming value as the winner would state the
715
+ * opposite of what the file ends up containing.
716
+ */
717
+ function recordRootOverrides(field, rootValue, subValue, context) {
718
+ if (!rootValue || typeof rootValue !== 'object' || Array.isArray(rootValue))
719
+ return;
720
+ if (!subValue || typeof subValue !== 'object' || Array.isArray(subValue))
721
+ return;
722
+ const rootMap = rootValue;
723
+ for (const [key, incoming] of Object.entries(subValue)) {
724
+ if (!(key in rootMap))
725
+ continue;
726
+ if (deepEqual(canonicaliseForCompare(field, rootMap[key]), canonicaliseForCompare(field, incoming)))
727
+ continue;
728
+ context.rootOverrides.push({
729
+ field,
730
+ incoming,
731
+ keptValue: resolveKeptValue(field, rootMap[key], incoming),
732
+ key,
733
+ rootValue: rootMap[key],
734
+ source: context.sourceLabel,
735
+ });
736
+ }
737
+ }
738
+ /**
739
+ * One line, not one per advisory: say that hoisting widened the audit
740
+ * suppressions to the whole workspace, and name who brought them.
741
+ *
742
+ * `annotateAuditConfig` already writes this into the emitted YAML, but that note
743
+ * reaches whoever opens `pnpm-workspace.yaml` later — not the person running
744
+ * `lt fullstack init` now. In a generated project the deploy-blocking audit job
745
+ * is the only automated vulnerability control there is, so a suppression
746
+ * assessed against one package's dependency tree quietly covering the other's is
747
+ * worth a line on screen.
748
+ */
749
+ function reportAuditConfigWidening(auditWidenedBy, conflicts) {
750
+ if (auditWidenedBy.size === 0)
751
+ return;
752
+ conflicts.push(`auditConfig: advisory suppressions from ${[...auditWidenedBy].join(', ')} now apply to EVERY package in ` +
753
+ `this workspace, not just the one they were assessed against. Review them, and drop each entry once its ` +
754
+ `advisory is fixed.`);
755
+ }
756
+ /**
757
+ * Turn root-vs-sub overrides into conflicts when a sibling sub-project sat out
758
+ * the run — the incremental `add-api` → `add-app` case.
759
+ *
760
+ * Hoisting is destructive: `hoistFields` deletes each field from the source once
761
+ * it reaches the root. So by the time `lt fullstack add-app` runs, the api's
762
+ * settings are already IN the root and gone from `projects/api/package.json`.
763
+ * The app's differing value then looks exactly like the documented root-vs-sub
764
+ * precedence, and the sub-vs-sub check never sees two values. Confirmed: api
765
+ * pinning `better-auth` to 1.7.1 followed later by app pinning 1.7.2 yielded no
766
+ * conflict and a root silently on 1.7.2.
767
+ *
768
+ * A sub-project that is present but contributed nothing is the signal that this
769
+ * happened. It is not proof — the root value could equally be a hand-edit or the
770
+ * lt-monorepo template's own seed — so the message says "if", names the sibling,
771
+ * and asks rather than asserts. Being present and contributing everything (the
772
+ * `lt fullstack init` path) leaves `dormant` empty, which is why the intended
773
+ * root-seed precedence stays silent there.
774
+ */
775
+ function reportRootOverridesFromDormantSiblings(rootOverrides, dormant, conflicts) {
776
+ if (rootOverrides.length === 0 || dormant.length === 0)
777
+ return;
778
+ const siblings = dormant.join(', ');
779
+ for (const { field, incoming, keptValue, key, rootValue, source } of rootOverrides) {
780
+ // Name the value the workspace actually ends up with. For `allowBuilds` that
781
+ // is not the incoming one — a deny outranks an allow — and announcing the
782
+ // wrong winner would send the reader looking for a problem that is already
783
+ // resolved, while hiding the one that is not.
784
+ const outcome = deepEqual(keptValue, incoming)
785
+ ? `${describeValue(incoming)}, which now wins`
786
+ : `${describeValue(incoming)}; the workspace keeps ${describeValue(keptValue)}, because a denied build ` +
787
+ `script outranks an allowed one`;
788
+ conflicts.push(`${field}: ${JSON.stringify(key)} was already pinned to ${describeValue(rootValue)} in this workspace, ` +
789
+ `and ${source} changes it to ${outcome}. ` +
790
+ `If the existing value came from ${siblings} — whose settings an earlier run already hoisted — ` +
791
+ `the two repos disagree. Agree on a single value in BOTH before assembling.`);
792
+ }
793
+ }
794
+ /**
795
+ * What the workspace will actually carry once this key is merged.
796
+ *
797
+ * Mirrors `applyDenyWins` for `allowBuilds` — a deny on either side is kept —
798
+ * and last-writer-wins for everything else. Kept next to the merge it predicts
799
+ * so the two cannot drift apart silently.
800
+ */
801
+ function resolveKeptValue(field, rootValue, incoming) {
802
+ if (field === 'allowBuilds' && (rootValue === false || incoming === false))
803
+ return false;
804
+ return incoming;
805
+ }
528
806
  /**
529
807
  * Keep the two build-allowlist forms consistent and complete after hoisting:
530
808
  * - `allowBuilds` (pnpm 11) is a `{ pkg: boolean }` map.
@@ -75,6 +75,7 @@ exports.finalizeWorkspaceRoot = finalizeWorkspaceRoot;
75
75
  exports.findWorkspaceRoot = findWorkspaceRoot;
76
76
  exports.isNonInteractive = isNonInteractive;
77
77
  exports.reconfigureUpstreamForDownstream = reconfigureUpstreamForDownstream;
78
+ exports.reportWorkspaceConflicts = reportWorkspaceConflicts;
78
79
  exports.runExperimentalNestBaseRename = runExperimentalNestBaseRename;
79
80
  exports.runStandaloneWorkspaceGate = runStandaloneWorkspaceGate;
80
81
  exports.shouldProceedAsStandalone = shouldProceedAsStandalone;
@@ -149,15 +150,30 @@ function detectWorkspaceLayout(workspaceDir, filesystem) {
149
150
  * sub-project's own `.dockerignore` when building from the root context).
150
151
  *
151
152
  * Each step is a no-op when there is nothing to do, so re-runs are safe.
153
+ *
154
+ * Returns what step 1 found and could not resolve on its own: keys set to
155
+ * different values by two different sources, audit suppressions that hoisting
156
+ * has just widened to the whole workspace, and — in the incremental flow — a
157
+ * value this run changes that an earlier run had hoisted from the other
158
+ * sub-project. Callers should surface all of them; a contradiction here reaches
159
+ * every project generated from the templates.
160
+ *
161
+ * Detection is per invocation and hoisting is destructive: a field is deleted
162
+ * from its source once it reaches the root. So `lt fullstack init` on a fresh
163
+ * directory, where both halves are still unhoisted, is the only path that sees
164
+ * two competing values directly. `add-api` → `add-app` is covered by the weaker
165
+ * "the root already pinned this, and a sibling contributed nothing" signal,
166
+ * which names the sibling and asks rather than asserts.
152
167
  */
153
168
  function finalizeWorkspaceRoot(options) {
154
169
  var _a;
155
170
  const { filesystem, projectDir } = options;
156
171
  const subProjects = (_a = options.subProjects) !== null && _a !== void 0 ? _a : ['projects/api', 'projects/app'];
157
- (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects });
172
+ const { conflicts } = (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects });
158
173
  (0, hoist_workspace_pnpm_config_1.hoistPackageManager)({ filesystem, projectDir, subProjects });
159
174
  (0, remove_nested_lockfiles_1.removeNestedLockfiles)({ filesystem, projectDir, subProjects });
160
175
  (0, ensure_root_dockerignore_1.ensureRootDockerignore)({ filesystem, projectDir });
176
+ return { conflicts };
161
177
  }
162
178
  /**
163
179
  * Walk up from `startDir` until a workspace marker is found or the
@@ -264,6 +280,25 @@ function reconfigureUpstreamForDownstream(options) {
264
280
  filesystem.write(upstreamPath, next, { jsonIndent: 2 });
265
281
  return { updated: true };
266
282
  }
283
+ /**
284
+ * Surface what `finalizeWorkspaceRoot` could not decide.
285
+ *
286
+ * Shared by all three fullstack scaffolders so the wording — and the fact that
287
+ * it is printed at all — cannot drift between them. Called TWICE per command:
288
+ * once where the conflict arises, and once in the closing block, because the
289
+ * first call lands immediately before `pnpm install` and several hundred lines
290
+ * of install output bury it. Nothing here changes the exit code: the workspace
291
+ * is still assembled, and the disagreement needs a human decision in two
292
+ * repositories rather than a failed scaffold.
293
+ *
294
+ * A no-op for the overwhelmingly common empty case, so callers can call it
295
+ * unconditionally.
296
+ */
297
+ function reportWorkspaceConflicts(conflicts, warn) {
298
+ for (const conflict of conflicts) {
299
+ warn(`[workspace] ${conflict}`);
300
+ }
301
+ }
267
302
  /**
268
303
  * Run the experimental `bun run rename <projectDir>` step. Only relevant
269
304
  * for the `--next` nest-base template (it ships hard-coded `nest-base`
package/docs/commands.md CHANGED
@@ -1167,7 +1167,7 @@ lt fullstack add-api [options]
1167
1167
  - `projects/api/` already exists → suggests `lt fullstack init` in a fresh directory
1168
1168
  - no workspace detected at the target path → asks the user to run `lt fullstack init` first
1169
1169
 
1170
- **Side effects:** writes `projects/api/lt.config.json` with the resolved `apiMode` + `frameworkMode`, hoists workspace-scoped `pnpm.overrides` from sub-projects to the root, runs `pnpm install` + `oxfmt` on the new sub-project (unless `--skip-install` is set).
1170
+ **Side effects:** writes `projects/api/lt.config.json` with the resolved `apiMode` + `frameworkMode`, hoists workspace-scoped `pnpm.overrides` from sub-projects to the root, runs `pnpm install` + `oxfmt` on the new sub-project (unless `--skip-install` is set). It warns (`[workspace] …`) when two sources set the same pnpm key to different values, when hoisting widens the audit suppressions workspace-wide, or when it overwrites a value an earlier run hoisted from the other sub-project; the warnings do not change the exit code and are repeated at the end of the run.
1171
1171
 
1172
1172
  **Configuration:** Reads `commands.fullstack.*` (same keys as `lt fullstack init`).
1173
1173
 
@@ -1200,7 +1200,7 @@ lt fullstack add-app [options]
1200
1200
  - `projects/app/` already exists → suggests `lt fullstack init` in a fresh directory
1201
1201
  - no workspace detected at the target path → asks the user to run `lt fullstack init` first
1202
1202
 
1203
- **Side effects:** patches `projects/app/.env` with a project-specific `NUXT_PUBLIC_STORAGE_PREFIX`, optionally vendorizes `nuxt-extensions` into `app/core/`, hoists workspace-scoped `pnpm.overrides`, runs `pnpm install` + `oxfmt` on the new sub-project (unless `--skip-install` is set).
1203
+ **Side effects:** patches `projects/app/.env` with a project-specific `NUXT_PUBLIC_STORAGE_PREFIX`, optionally vendorizes `nuxt-extensions` into `app/core/`, hoists workspace-scoped `pnpm.overrides`, runs `pnpm install` + `oxfmt` on the new sub-project (unless `--skip-install` is set). It warns (`[workspace] …`) when two sources set the same pnpm key to different values, when hoisting widens the audit suppressions workspace-wide, or when it overwrites a value an earlier run hoisted from the other sub-project; the warnings do not change the exit code and are repeated at the end of the run.
1204
1204
 
1205
1205
  **Configuration:** Reads `commands.fullstack.*` (same keys as `lt fullstack init`).
1206
1206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",