@lenne.tech/cli 1.44.0 → 1.46.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/up.js +3 -0
- 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/config/vendor-runtime-deps.json +2 -3
- package/build/extensions/server.js +19 -8
- package/build/lib/dev-env-bridge.js +14 -3
- package/build/lib/dev-env.js +117 -3
- package/build/lib/dev-patches.js +1 -1
- package/build/lib/dev-test-session.js +3 -0
- package/build/lib/ensure-root-dockerignore.js +7 -0
- package/build/lib/hoist-workspace-pnpm-config.js +284 -6
- package/build/lib/workspace-integration.js +36 -1
- package/docs/commands.md +23 -2
- package/package.json +7 -1
package/build/commands/dev/up.js
CHANGED
|
@@ -354,6 +354,9 @@ const UpCommand = {
|
|
|
354
354
|
// Build env per process.
|
|
355
355
|
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
356
356
|
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
357
|
+
// Lets `buildDevEnv` consult the app's own `.env` for keys a project may have set
|
|
358
|
+
// itself, so `lt dev` never replaces one of its values with a derived fallback.
|
|
359
|
+
appDir: layout.appDir,
|
|
357
360
|
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
|
|
358
361
|
baseEnv: process.env,
|
|
359
362
|
dbName,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"$
|
|
3
|
-
"
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
@@ -16,8 +16,11 @@ exports.writeEnvBridge = writeEnvBridge;
|
|
|
16
16
|
* Shell B does not inherit shell A's exports. Reading a file solves
|
|
17
17
|
* this without polluting global state.
|
|
18
18
|
*
|
|
19
|
-
* The file is gitignored via `.lt-dev
|
|
20
|
-
*
|
|
19
|
+
* The file is gitignored via `.lt-dev/` and written 0600, because it is no longer
|
|
20
|
+
* URLs only: `NUXT_SESSION_PASSWORD` is a session-sealing key. Usually that is the
|
|
21
|
+
* value `lt dev` derived itself, but when the project exported its own it is THAT
|
|
22
|
+
* one being copied here — so treat the file as credential-bearing, and think twice
|
|
23
|
+
* before adding a key to it. Format: standard dotenv KEY=VALUE.
|
|
21
24
|
*/
|
|
22
25
|
const fs_1 = require("fs");
|
|
23
26
|
const os_1 = require("os");
|
|
@@ -84,6 +87,11 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
|
|
|
84
87
|
'NUXT_PUBLIC_SITE_URL',
|
|
85
88
|
'NUXT_PUBLIC_STORAGE_PREFIX',
|
|
86
89
|
'NUXT_PUBLIC_API_PROXY',
|
|
90
|
+
// Not needed by the runner itself, but external suites check it to tell a stack that
|
|
91
|
+
// can log in from one that will 500 on every login, and a suite that seals its own
|
|
92
|
+
// session cookie to skip the login form needs the value (see dev-env.ts). This is the
|
|
93
|
+
// one credential-shaped key in the bridge — it is why the file is written 0600.
|
|
94
|
+
'NUXT_SESSION_PASSWORD',
|
|
87
95
|
'NSC__MONGOOSE__URI',
|
|
88
96
|
'DATABASE_URL',
|
|
89
97
|
// Legacy aliases — see dev-env.ts for the rationale.
|
|
@@ -107,6 +115,9 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
|
|
|
107
115
|
if ((0, fs_1.existsSync)(file) && (0, fs_1.readFileSync)(file, 'utf8') === content)
|
|
108
116
|
return file;
|
|
109
117
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
|
|
110
|
-
(0, fs_1.writeFileSync)(file, content, 'utf8');
|
|
118
|
+
(0, fs_1.writeFileSync)(file, content, { encoding: 'utf8', mode: 0o600 });
|
|
119
|
+
// `mode` only applies when the file is CREATED, so an existing bridge from an older
|
|
120
|
+
// lt version would keep its 0644. chmod unconditionally to heal those.
|
|
121
|
+
(0, fs_1.chmodSync)(file, 0o600);
|
|
111
122
|
return file;
|
|
112
123
|
}
|
package/build/lib/dev-env.js
CHANGED
|
@@ -14,6 +14,8 @@ exports.buildDevEnv = buildDevEnv;
|
|
|
14
14
|
* - `NUXT_PUBLIC_*` lock the App to its own API
|
|
15
15
|
* - `NUXT_PUBLIC_STORAGE_PREFIX` namespaces localStorage/sessionStorage
|
|
16
16
|
* - `NSC__MONGOOSE__URI` / `DATABASE_URL` namespace the database per project
|
|
17
|
+
* - `NUXT_SESSION_PASSWORD` is keyed per slug, so a session cookie sealed by one
|
|
18
|
+
* stack cannot be unsealed by another (dev vs. `-test` vs. `-test-N` shard)
|
|
17
19
|
*
|
|
18
20
|
* CA trust for SSR fetches:
|
|
19
21
|
* - Both API and App receive `NODE_EXTRA_CA_CERTS` pointing at the
|
|
@@ -21,6 +23,10 @@ exports.buildDevEnv = buildDevEnv;
|
|
|
21
23
|
* subdomains succeed. Without this Nuxt SSR fails with "unable to
|
|
22
24
|
* get local issuer certificate" when the app calls its own API.
|
|
23
25
|
*/
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
const node_fs_1 = require("node:fs");
|
|
28
|
+
const node_os_1 = require("node:os");
|
|
29
|
+
const node_path_1 = require("node:path");
|
|
24
30
|
const dev_env_bridge_1 = require("./dev-env-bridge");
|
|
25
31
|
/**
|
|
26
32
|
* Build the environment maps for both API and App processes.
|
|
@@ -29,7 +35,8 @@ const dev_env_bridge_1 = require("./dev-env-bridge");
|
|
|
29
35
|
* vars survive. `lt dev`-managed keys win on top.
|
|
30
36
|
*/
|
|
31
37
|
function buildDevEnv(input) {
|
|
32
|
-
|
|
38
|
+
var _a;
|
|
39
|
+
const { apiInternalPort, appDir, appInternalPort, baseEnv = {}, dbName, identity } = input;
|
|
33
40
|
const apiSub = identity.subdomains.api;
|
|
34
41
|
const appSub = identity.subdomains.app;
|
|
35
42
|
const apiUrl = apiSub ? `https://${apiSub.hostname}` : '';
|
|
@@ -52,14 +59,18 @@ function buildDevEnv(input) {
|
|
|
52
59
|
internalPort: apiInternalPort,
|
|
53
60
|
},
|
|
54
61
|
app: {
|
|
55
|
-
env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
|
|
62
|
+
env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
|
|
56
63
|
// See API note above: pin the dev server to IPv4 so Caddy's
|
|
57
64
|
// `127.0.0.1` upstream is unambiguous.
|
|
58
65
|
HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
|
|
59
66
|
// Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
|
|
60
67
|
// both subdomains under HTTPS with shared cookie domain, so
|
|
61
68
|
// same-origin trickery is no longer required.
|
|
62
|
-
NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug
|
|
69
|
+
NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug }), (baseEnv.NUXT_SESSION_PASSWORD
|
|
70
|
+
? {}
|
|
71
|
+
: {
|
|
72
|
+
NUXT_SESSION_PASSWORD: (_a = readEnvFileValue(appDir, 'NUXT_SESSION_PASSWORD')) !== null && _a !== void 0 ? _a : deriveSessionPassword(identity.slug),
|
|
73
|
+
})), { PORT: String(appInternalPort) }), (process.platform === 'darwin' ? { TMPDIR: '/tmp' } : {})),
|
|
63
74
|
internalPort: appInternalPort,
|
|
64
75
|
},
|
|
65
76
|
};
|
|
@@ -68,3 +79,106 @@ function buildDevEnv(input) {
|
|
|
68
79
|
function buildPostgresUrl(dbName) {
|
|
69
80
|
return `postgresql://${dbName}:${dbName}@localhost:5432/${dbName}`;
|
|
70
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Stable local session password for a project's app process.
|
|
84
|
+
*
|
|
85
|
+
* 32 hex chars — h3's floor. Deterministic per slug AND per machine: the same stack on the
|
|
86
|
+
* same machine always gets the same value, so a restart does not invalidate open sessions,
|
|
87
|
+
* while a different slug (or a different developer's machine) gets a different one.
|
|
88
|
+
*
|
|
89
|
+
* The machine salt is what makes it unguessable. Without it the only input is the slug, which
|
|
90
|
+
* the app publishes itself via `NUXT_PUBLIC_STORAGE_PREFIX`, so anyone who loaded a page —
|
|
91
|
+
* including any visitor of a `lt dev tunnel` URL — could recompute the key and forge a sealed
|
|
92
|
+
* session cookie. With it, an attacker would have to read the developer's home directory.
|
|
93
|
+
*/
|
|
94
|
+
function deriveSessionPassword(slug) {
|
|
95
|
+
var _a;
|
|
96
|
+
// No salt means the home directory is unwritable. Fall back to a constant key rather than a
|
|
97
|
+
// random one: determinism is the property every caller depends on, and a fresh value per run
|
|
98
|
+
// would log everybody out on every restart. The fallback is guessable — hence last resort.
|
|
99
|
+
return (0, node_crypto_1.createHmac)('sha256', (_a = machineSessionSalt()) !== null && _a !== void 0 ? _a : 'lt-dev')
|
|
100
|
+
.update(`lt-dev:session:${slug}`)
|
|
101
|
+
.digest('hex')
|
|
102
|
+
.slice(0, 32);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read (or create) the machine-local salt at `~/.lenneTech/dev-session-salt`, mode 0600.
|
|
106
|
+
*
|
|
107
|
+
* Returns `null` when it can be neither read nor created — see {@link deriveSessionPassword}
|
|
108
|
+
* for what happens then.
|
|
109
|
+
*/
|
|
110
|
+
function machineSessionSalt() {
|
|
111
|
+
const file = sessionSaltPath();
|
|
112
|
+
try {
|
|
113
|
+
const existing = (0, node_fs_1.readFileSync)(file, 'utf8').trim();
|
|
114
|
+
if (existing) {
|
|
115
|
+
return existing;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (_a) {
|
|
119
|
+
// Not created yet — fall through and create it.
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true });
|
|
123
|
+
// `wx` fails when the file already exists, so two shards racing on a cold machine can
|
|
124
|
+
// never end up with two different salts: the loser falls into the catch and re-reads.
|
|
125
|
+
const salt = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
126
|
+
(0, node_fs_1.writeFileSync)(file, `${salt}\n`, { flag: 'wx', mode: 0o600 });
|
|
127
|
+
return salt;
|
|
128
|
+
}
|
|
129
|
+
catch (_b) {
|
|
130
|
+
try {
|
|
131
|
+
return (0, node_fs_1.readFileSync)(file, 'utf8').trim() || null;
|
|
132
|
+
}
|
|
133
|
+
catch (_c) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Read one key out of `<dir>/.env` without pulling in a dotenv dependency.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately minimal: no interpolation, no multi-line values, no `.env.local` cascade. It
|
|
142
|
+
* exists to answer one question — did the project set this key itself? — for a value the built
|
|
143
|
+
* Nitro server would otherwise never see.
|
|
144
|
+
*/
|
|
145
|
+
function readEnvFileValue(dir, key) {
|
|
146
|
+
if (!dir) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, '.env'), 'utf8');
|
|
152
|
+
}
|
|
153
|
+
catch (_a) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
for (const line of content.split(/\r?\n/)) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const eq = trimmed.indexOf('=');
|
|
162
|
+
if (eq === -1) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (trimmed
|
|
166
|
+
.slice(0, eq)
|
|
167
|
+
.replace(/^export\s+/, '')
|
|
168
|
+
.trim() !== key) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const raw = trimmed.slice(eq + 1).trim();
|
|
172
|
+
const quoted = raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")));
|
|
173
|
+
return (quoted ? raw.slice(1, -1) : raw) || undefined;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Path of the machine-local salt. `LT_DEV_SESSION_SALT_PATH` overrides it, and `HOME` is read
|
|
179
|
+
* before `os.homedir()` — both so tests can redirect the write to a tmpdir, the same reason
|
|
180
|
+
* `dev-service.ts#userHome` does it.
|
|
181
|
+
*/
|
|
182
|
+
function sessionSaltPath() {
|
|
183
|
+
return process.env.LT_DEV_SESSION_SALT_PATH || (0, node_path_1.join)(process.env.HOME || (0, node_os_1.homedir)(), '.lenneTech', 'dev-session-salt');
|
|
184
|
+
}
|
package/build/lib/dev-patches.js
CHANGED
|
@@ -157,7 +157,7 @@ function patchClaudeMd(file, options) {
|
|
|
157
157
|
if (dbName)
|
|
158
158
|
lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
|
|
159
159
|
lines.push('');
|
|
160
|
-
lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
|
|
160
|
+
lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NUXT_SESSION_PASSWORD`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
|
|
161
161
|
lines.push('');
|
|
162
162
|
lines.push(endMarker);
|
|
163
163
|
const block = lines.join('\n');
|
|
@@ -274,6 +274,9 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
274
274
|
log.info('');
|
|
275
275
|
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
276
276
|
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
277
|
+
// The built server this stack runs reads no `.env` at all, so a key the project set
|
|
278
|
+
// there has to be forwarded explicitly — that is the whole reason this argument exists.
|
|
279
|
+
appDir: layout.appDir,
|
|
277
280
|
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
|
|
278
281
|
baseEnv: process.env,
|
|
279
282
|
dbName,
|
|
@@ -16,8 +16,15 @@ exports.ensureRootDockerignore = ensureRootDockerignore;
|
|
|
16
16
|
// bundle itself changes.
|
|
17
17
|
const REQUIRED_PATTERNS = [
|
|
18
18
|
'**/node_modules',
|
|
19
|
+
// `**/.output` matches a path component EXACTLY, so the isolated build dirs of
|
|
20
|
+
// `lt dev test` (`.output-test`, `.nuxt-test`, `.nuxt-test-2`, …) need their own
|
|
21
|
+
// globs. They are built with the app's full dev env, so Nitro freezes values like
|
|
22
|
+
// `NUXT_SESSION_PASSWORD` into them as runtime-config defaults — and a `--keep`
|
|
23
|
+
// run or a crashed teardown leaves the tree on disk for the next `docker build`.
|
|
19
24
|
'**/.output',
|
|
25
|
+
'**/.output-*',
|
|
20
26
|
'**/.nuxt',
|
|
27
|
+
'**/.nuxt-*',
|
|
21
28
|
'**/dist',
|
|
22
29
|
// `**/.env` matches a path component EXACTLY, so it does NOT cover
|
|
23
30
|
// `.env.production` / `.env.staging` / `.env.test` — all of which routinely
|
|
@@ -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`
|
package/docs/commands.md
CHANGED
|
@@ -451,9 +451,24 @@ lt dev up
|
|
|
451
451
|
| `NUXT_PUBLIC_SITE_URL` | Nuxt `useRuntimeConfig().public.siteUrl` + Playwright | `https://crm.localhost` |
|
|
452
452
|
| `NUXT_PUBLIC_STORAGE_PREFIX` | namespaces sessionStorage/localStorage | `crm` |
|
|
453
453
|
| `NUXT_PUBLIC_API_PROXY` | always `false` — Caddy + cookie-domain make it obsolete | `false` |
|
|
454
|
+
| `NUXT_SESSION_PASSWORD` | Nuxt/h3 `useSession` — only projects that added h3 sessions or `nuxt-auth-utils` read it; the Better-Auth default stack ignores it | `a1b2…` (32 hex chars) |
|
|
454
455
|
| `NSC__MONGOOSE__URI` | nest-server Mongoose URI | `mongodb://127.0.0.1/crm-local` |
|
|
455
456
|
| `DATABASE_URL` | Postgres convenience URL (for nest-base-style projects) | `postgresql://crm-local:crm-local@localhost:5432/crm-local` |
|
|
456
457
|
|
|
458
|
+
`NUXT_SESSION_PASSWORD` exists because `lt dev test` serves the **built** Nitro server, which
|
|
459
|
+
reads no `.env` — so a project that keeps its password there watched every login answer 500
|
|
460
|
+
(`H3Error: Empty password`). Resolution order: a value exported in the shell, then the app's own
|
|
461
|
+
`.env`, then a fallback derived as `HMAC-SHA256(machine salt, 'lt-dev:session:<slug>')` truncated
|
|
462
|
+
to 32 hex chars. `lt dev` only fills the gap — it never replaces a value the project chose.
|
|
463
|
+
|
|
464
|
+
The salt lives at `~/.lenneTech/dev-session-salt` (created once, mode 0600; override the path with
|
|
465
|
+
`LT_DEV_SESSION_SALT_PATH`). It is what makes the value unguessable: the slug itself is public —
|
|
466
|
+
the app ships it to every browser as `NUXT_PUBLIC_STORAGE_PREFIX`, and `lt dev tunnel` can put
|
|
467
|
+
that app on a public URL — so an unsalted derivation would hand any visitor the key that seals
|
|
468
|
+
the stack's session cookies. Keying on the slug also means the dev stack, the `lt dev test` stack
|
|
469
|
+
and each `--shard` stack get **different** passwords, so one stack's cookies never validate
|
|
470
|
+
against another's.
|
|
471
|
+
|
|
457
472
|
**Override the binary** for both spawns via `LT_PNPM_BIN` (e.g. `LT_PNPM_BIN=/usr/local/bin/pnpm lt dev up`).
|
|
458
473
|
|
|
459
474
|
**Pre-flight guards (exit code 1 each):**
|
|
@@ -685,10 +700,16 @@ lt dev test -- --ui spec.ts # everything after `--` is forwarded to playwri
|
|
|
685
700
|
| `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL` | Same URLs for Nuxt |
|
|
686
701
|
| `NUXT_PUBLIC_STORAGE_PREFIX` | Project slug |
|
|
687
702
|
| `NUXT_PUBLIC_API_PROXY` | Always `false` under `lt dev` |
|
|
703
|
+
| `NUXT_SESSION_PASSWORD` | The app process's h3 session password — lets a suite tell a stack that can log in from one that 500s on every login, and seal its own cookie to skip the login form |
|
|
688
704
|
| `NSC__MONGOOSE__URI`, `DATABASE_URL` | Project-namespaced DB URI (when `dbName` known) |
|
|
689
705
|
| `LT_DEV_ACTIVE`, `LT_DEV_DB_NAME` | Marker keys for consumers |
|
|
690
706
|
| `NODE_EXTRA_CA_CERTS` | Path to Caddy's root CA cert (auto-detected) |
|
|
691
707
|
|
|
708
|
+
Because `NUXT_SESSION_PASSWORD` is a session-sealing key, the bridge file is written with mode
|
|
709
|
+
`0600` (and chmod'ed on every rewrite, since a file created by an older `lt` would otherwise keep
|
|
710
|
+
its `0644`). It is gitignored via `.lt-dev/`. Treat it as credential-bearing when deciding what
|
|
711
|
+
else may be written there.
|
|
712
|
+
|
|
692
713
|
Additionally, `lt dev test` exports two build-directory keys into the app process it
|
|
693
714
|
spawns. They are **not** written to the bridge file — they scope one run, not the
|
|
694
715
|
project:
|
|
@@ -1167,7 +1188,7 @@ lt fullstack add-api [options]
|
|
|
1167
1188
|
- `projects/api/` already exists → suggests `lt fullstack init` in a fresh directory
|
|
1168
1189
|
- no workspace detected at the target path → asks the user to run `lt fullstack init` first
|
|
1169
1190
|
|
|
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).
|
|
1191
|
+
**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
1192
|
|
|
1172
1193
|
**Configuration:** Reads `commands.fullstack.*` (same keys as `lt fullstack init`).
|
|
1173
1194
|
|
|
@@ -1200,7 +1221,7 @@ lt fullstack add-app [options]
|
|
|
1200
1221
|
- `projects/app/` already exists → suggests `lt fullstack init` in a fresh directory
|
|
1201
1222
|
- no workspace detected at the target path → asks the user to run `lt fullstack init` first
|
|
1202
1223
|
|
|
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).
|
|
1224
|
+
**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
1225
|
|
|
1205
1226
|
**Configuration:** Reads `commands.fullstack.*` (same keys as `lt fullstack init`).
|
|
1206
1227
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"description": "lenne.Tech CLI: lt",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lenne.Tech",
|
|
@@ -77,7 +77,13 @@
|
|
|
77
77
|
"turndown-plugin-gfm": "1.0.2",
|
|
78
78
|
"typescript": "6.0.3"
|
|
79
79
|
},
|
|
80
|
+
"//devDependencies": {
|
|
81
|
+
"@emnapi/core": "Not imported by this CLI. @napi-rs/wasm-runtime 1.x moved @emnapi/core and @emnapi/runtime from dependencies to peerDependencies (napi-rs#3174), and the only path to them here is dev-only AND optional: eslint-plugin-import-x / jest-resolve > unrs-resolver > @unrs/resolver-binding-wasm32-wasi (cpu: wasm32) > @napi-rs/wasm-runtime. npm >= 11.5.0 (arborist 9.1.3) prunes nodes that are both peer and optional from the ideal tree, so it never writes them to the lockfile — while npm <= 11.4 still demands them and fails npm ci with \"Missing: @emnapi/core@1.11.3 from lock file\". Declaring them here is the remedy arborist's own source comment names: an optional peer stops being optional once a root dependency requires it, so both npm generations then agree. Verified: npm ci passes on npm 10 and 11.6, and npm install no longer strips the entries. Remove once @napi-rs/wasm-runtime declares them in peerDependenciesMeta as optional, or once npm >= 11.5 is the floor everywhere (GitHub Actions still runs Node 20 = npm 10).",
|
|
82
|
+
"@emnapi/runtime": "Same as @emnapi/core."
|
|
83
|
+
},
|
|
80
84
|
"devDependencies": {
|
|
85
|
+
"@emnapi/core": "^1.11.3",
|
|
86
|
+
"@emnapi/runtime": "^1.11.3",
|
|
81
87
|
"@lenne.tech/eslint-config-ts": "2.3.0",
|
|
82
88
|
"@lenne.tech/npm-package-helper": "0.0.12",
|
|
83
89
|
"@types/ejs": "3.1.5",
|