@lenne.tech/cli 1.43.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.
- package/build/commands/dev/doctor.js +30 -7
- package/build/commands/fullstack/add-api.js +6 -1
- package/build/commands/fullstack/add-app.js +6 -1
- package/build/commands/fullstack/init.js +7 -1
- package/build/commands/fullstack/update.js +24 -1
- package/build/config/vendor-runtime-deps.json +2 -3
- package/build/extensions/server.js +19 -8
- package/build/lib/heal-check-wrapper.js +189 -27
- package/build/lib/hoist-workspace-pnpm-config.js +284 -6
- package/build/lib/workspace-integration.js +36 -1
- package/build/templates/check/build-test-gate.mjs +107 -0
- package/build/templates/check/check.mjs +422 -52
- package/docs/commands.md +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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`
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-group mutual exclusion between CPU-heavy steps and contention-sensitive
|
|
3
|
+
* test suites (DEV-2524, cause 2).
|
|
4
|
+
*
|
|
5
|
+
* The root check wrapper (scripts/check.mjs) runs each workspace project's step
|
|
6
|
+
* chain concurrently by default. The api group's `test` step is the API e2e
|
|
7
|
+
* suite, whose Better-Auth session validation is sensitive to CPU contention:
|
|
8
|
+
* it tips into intermittent 401/500 the moment another group saturates the
|
|
9
|
+
* machine — most reliably the app group's `nuxt build`. Measured in DEV-2524:
|
|
10
|
+
* the same commit was red under the parallel run and green (2418/2418) under
|
|
11
|
+
* `--sequential`. This gate guarantees a heavy step and a sensitive test step
|
|
12
|
+
* never run at the same time, while:
|
|
13
|
+
* - same-class steps may still overlap (two builds, or two test suites)
|
|
14
|
+
* → the parallel design is preserved elsewhere;
|
|
15
|
+
* - every other step kind (format, lint, server-start, …) never touches the
|
|
16
|
+
* gate at all and stays fully parallel.
|
|
17
|
+
*
|
|
18
|
+
* Which steps belong to which class is `gateClass()` in check.mjs, not this
|
|
19
|
+
* file: the gate is a generic two-class lock and does not know what a "build"
|
|
20
|
+
* is. Note that the classes are named "build" and "test" only because those are
|
|
21
|
+
* the labels the caller passes; the lock treats them as two opaque, mutually
|
|
22
|
+
* exclusive classes.
|
|
23
|
+
*
|
|
24
|
+
* It is a two-class fair lock: whichever class is active admits every waiter of
|
|
25
|
+
* that class; the other class waits until the active class fully drains, then
|
|
26
|
+
* takes over as a batch. Handover is FIFO between the two classes, so neither
|
|
27
|
+
* can starve the other.
|
|
28
|
+
*
|
|
29
|
+
* Starvation-freedom is workload-bounded, not unconditional: an arriving acquire
|
|
30
|
+
* of the ACTIVE class barges ahead of an already-queued opposite-class waiter, so
|
|
31
|
+
* an unbounded stream of same-class arrivals could in theory starve the other
|
|
32
|
+
* class. That cannot happen here — the check wrapper issues FINITELY MANY gated
|
|
33
|
+
* steps per group, so the active class always drains and the queued batch is then
|
|
34
|
+
* admitted. (Do not restate this as "one test then one build per group": the root
|
|
35
|
+
* group has a `test` and no build at all, and a chain's shape is the project's to
|
|
36
|
+
* choose. Finiteness is what the argument needs, and finiteness is what holds.)
|
|
37
|
+
*/
|
|
38
|
+
export function createBuildTestGate() {
|
|
39
|
+
let activeClass = null; // 'test' | 'build' | null
|
|
40
|
+
let activeCount = 0;
|
|
41
|
+
const queue = []; // FIFO of { klass, resolve }
|
|
42
|
+
|
|
43
|
+
function admit(klass, resolve) {
|
|
44
|
+
activeClass = klass;
|
|
45
|
+
activeCount += 1;
|
|
46
|
+
resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function acquire(klass) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (activeClass === null || activeClass === klass) {
|
|
52
|
+
admit(klass, resolve);
|
|
53
|
+
} else {
|
|
54
|
+
queue.push({ klass, resolve });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function release() {
|
|
60
|
+
// Guard against an unbalanced release. Without it the counter goes negative
|
|
61
|
+
// and the lock stops excluding: with two holders and one stray release,
|
|
62
|
+
// `activeCount` reaches 0 while a holder is still running, so the opposite
|
|
63
|
+
// class is admitted alongside it — silently, and precisely when the machine
|
|
64
|
+
// is busiest. No current caller can double-release (runGroup acquires and
|
|
65
|
+
// releases exactly once per gated step), but this is shipped into every
|
|
66
|
+
// generated project and there are two release sites per acquire.
|
|
67
|
+
if (activeCount <= 0) return;
|
|
68
|
+
|
|
69
|
+
activeCount -= 1;
|
|
70
|
+
if (activeCount > 0) return;
|
|
71
|
+
|
|
72
|
+
activeClass = null;
|
|
73
|
+
if (queue.length === 0) return;
|
|
74
|
+
|
|
75
|
+
// Active class fully drained with waiters pending: hand over to the class of
|
|
76
|
+
// the oldest waiter, admitting every queued waiter of that class as a batch.
|
|
77
|
+
//
|
|
78
|
+
// Via the public API the queue only ever holds a SINGLE class at a time (a
|
|
79
|
+
// same-class acquire is admitted immediately and never queues, so only the
|
|
80
|
+
// opposite class waits while one class is active). The `carried` re-queue is
|
|
81
|
+
// therefore always empty in practice — kept as a defensive guard so the
|
|
82
|
+
// handover stays correct if the admission rule ever changes.
|
|
83
|
+
const nextClass = queue[0].klass;
|
|
84
|
+
const carried = [];
|
|
85
|
+
for (const waiter of queue) {
|
|
86
|
+
if (waiter.klass === nextClass) {
|
|
87
|
+
admit(waiter.klass, waiter.resolve);
|
|
88
|
+
} else {
|
|
89
|
+
carried.push(waiter);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
queue.length = 0;
|
|
93
|
+
queue.push(...carried);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
acquire,
|
|
98
|
+
release,
|
|
99
|
+
// Read-only accessors, exposed for assertions / telemetry only.
|
|
100
|
+
get activeClass() {
|
|
101
|
+
return activeClass;
|
|
102
|
+
},
|
|
103
|
+
get activeCount() {
|
|
104
|
+
return activeCount;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|