@lenne.tech/cli 1.42.0 → 1.43.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/fullstack/add-api.js +6 -0
- package/build/commands/fullstack/add-app.js +7 -0
- package/build/commands/fullstack/init.js +32 -3
- package/build/commands/git/reset.js +1 -1
- package/build/commands/git/update.js +2 -2
- package/build/extensions/frontend-helper.js +19 -0
- package/build/extensions/git.js +23 -2
- package/build/extensions/server.js +28 -3
- package/build/lib/adopt-upstream-build-allowlist.js +140 -0
- package/build/lib/fail-run.js +38 -0
- package/build/lib/hoist-workspace-pnpm-config.js +173 -5
- package/build/lib/strip-vendor-schema-augmentation.js +213 -0
- package/build/lib/vendor-claude-md.js +15 -0
- package/docs/VENDOR-MODE-WORKFLOW.md +35 -0
- package/docs/commands.md +9 -0
- package/package.json +8 -4
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.help = void 0;
|
|
13
|
+
const fail_run_1 = require("../../lib/fail-run");
|
|
13
14
|
const workspace_integration_1 = require("../../lib/workspace-integration");
|
|
14
15
|
/**
|
|
15
16
|
* Add an API (`projects/api/`) to a fullstack workspace that currently
|
|
@@ -68,6 +69,7 @@ const NewCommand = {
|
|
|
68
69
|
info('Add API to fullstack workspace');
|
|
69
70
|
toolbox.tools.nonInteractiveHint('lt fullstack add-api --api-mode <Rest|GraphQL|Both> --framework-mode <npm|vendor> [--api-branch <ref>] [--next] [--dry-run] --noConfirm');
|
|
70
71
|
if (!(yield git.gitInstalled())) {
|
|
72
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
75
|
const ltConfig = config.loadConfig();
|
|
@@ -126,10 +128,12 @@ const NewCommand = {
|
|
|
126
128
|
const layout = (0, workspace_integration_1.detectWorkspaceLayout)(workspaceDir, filesystem);
|
|
127
129
|
if (!layout.hasWorkspace) {
|
|
128
130
|
error(`No fullstack workspace detected at "${workspaceDir}". Expected pnpm-workspace.yaml, package.json#workspaces, or a projects/ directory. Use \`lt fullstack init\` for a fresh workspace.`);
|
|
131
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
134
|
if (layout.hasApi) {
|
|
132
135
|
error(`An API already exists at "${workspaceDir}/projects/api". Remove it first or use \`lt fullstack init\` in a fresh directory.`);
|
|
136
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
// Resolve api mode (CLI > experimental override > config > global > interactive/default).
|
|
@@ -177,6 +181,7 @@ const NewCommand = {
|
|
|
177
181
|
}
|
|
178
182
|
else if (cliFrameworkMode) {
|
|
179
183
|
error(`Invalid --framework-mode value "${cliFrameworkMode}". Use "npm" or "vendor".`);
|
|
184
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
180
185
|
return;
|
|
181
186
|
}
|
|
182
187
|
else if (configFrameworkMode === 'npm' || configFrameworkMode === 'vendor') {
|
|
@@ -282,6 +287,7 @@ const NewCommand = {
|
|
|
282
287
|
});
|
|
283
288
|
if (!apiResult.success) {
|
|
284
289
|
apiSpinner.fail(`Failed to set up API: ${apiResult.path}`);
|
|
290
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
285
291
|
return;
|
|
286
292
|
}
|
|
287
293
|
apiSpinner.succeed(`API integrated (${apiResult.method})`);
|
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.help = void 0;
|
|
13
|
+
const fail_run_1 = require("../../lib/fail-run");
|
|
13
14
|
const workspace_integration_1 = require("../../lib/workspace-integration");
|
|
14
15
|
/**
|
|
15
16
|
* Add a frontend app (`projects/app/`) to a fullstack workspace that
|
|
@@ -57,6 +58,7 @@ const NewCommand = {
|
|
|
57
58
|
info('Add app to fullstack workspace');
|
|
58
59
|
toolbox.tools.nonInteractiveHint('lt fullstack add-app --frontend <nuxt|angular> [--frontend-branch <ref>] [--next] [--dry-run] --noConfirm');
|
|
59
60
|
if (!(yield git.gitInstalled())) {
|
|
61
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
64
|
const ltConfig = config.loadConfig();
|
|
@@ -110,10 +112,12 @@ const NewCommand = {
|
|
|
110
112
|
const layout = (0, workspace_integration_1.detectWorkspaceLayout)(workspaceDir, filesystem);
|
|
111
113
|
if (!layout.hasWorkspace) {
|
|
112
114
|
error(`No fullstack workspace detected at "${workspaceDir}". Expected pnpm-workspace.yaml, package.json#workspaces, or a projects/ directory. Use \`lt fullstack init\` for a fresh workspace.`);
|
|
115
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
113
116
|
return;
|
|
114
117
|
}
|
|
115
118
|
if (layout.hasApp) {
|
|
116
119
|
error(`An app already exists at "${workspaceDir}/projects/app". Remove it first or use \`lt fullstack init\` in a fresh directory.`);
|
|
120
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
117
121
|
return;
|
|
118
122
|
}
|
|
119
123
|
// Resolve frontend.
|
|
@@ -123,6 +127,7 @@ const NewCommand = {
|
|
|
123
127
|
}
|
|
124
128
|
else if (cliFrontend) {
|
|
125
129
|
error('Invalid --frontend option. Use "angular" or "nuxt".');
|
|
130
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
126
131
|
return;
|
|
127
132
|
}
|
|
128
133
|
else if (configFrontend === 'angular' || configFrontend === 'nuxt') {
|
|
@@ -150,6 +155,7 @@ const NewCommand = {
|
|
|
150
155
|
}
|
|
151
156
|
else if (cliFrontendFrameworkMode) {
|
|
152
157
|
error(`Invalid --frontend-framework-mode value "${cliFrontendFrameworkMode}". Use "npm" or "vendor".`);
|
|
158
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
153
159
|
return;
|
|
154
160
|
}
|
|
155
161
|
else if (configFrontendFrameworkMode === 'npm' || configFrontendFrameworkMode === 'vendor') {
|
|
@@ -225,6 +231,7 @@ const NewCommand = {
|
|
|
225
231
|
});
|
|
226
232
|
if (!result.success) {
|
|
227
233
|
appSpinner.fail(`Failed to set up ${frontend} frontend: ${result.path}`);
|
|
234
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
228
235
|
return;
|
|
229
236
|
}
|
|
230
237
|
appSpinner.succeed(`${frontend} integrated (${result.method})`);
|
|
@@ -16,6 +16,7 @@ const gluegun_1 = require("gluegun");
|
|
|
16
16
|
const caddy_1 = require("../../lib/caddy");
|
|
17
17
|
const dev_migrate_helper_1 = require("../../lib/dev-migrate-helper");
|
|
18
18
|
const dev_project_1 = require("../../lib/dev-project");
|
|
19
|
+
const fail_run_1 = require("../../lib/fail-run");
|
|
19
20
|
const package_name_1 = require("../../lib/package-name");
|
|
20
21
|
const vendor_claude_md_1 = require("../../lib/vendor-claude-md");
|
|
21
22
|
const workspace_integration_1 = require("../../lib/workspace-integration");
|
|
@@ -38,9 +39,10 @@ const NewCommand = {
|
|
|
38
39
|
// Info
|
|
39
40
|
info('Create a new fullstack workspace');
|
|
40
41
|
// Hint for non-interactive callers (e.g. Claude Code)
|
|
41
|
-
toolbox.tools.nonInteractiveHint('lt fullstack init --name <name> --frontend <nuxt|angular> --api-mode <Rest|GraphQL|Both> --framework-mode <npm|vendor> [--framework-upstream-branch <ref>] [--next: implies nuxt-base-starter#next unless --frontend-branch overrides] [--dry-run] --noConfirm');
|
|
42
|
+
toolbox.tools.nonInteractiveHint('lt fullstack init --name <name> --frontend <nuxt|angular> --api-mode <Rest|GraphQL|Both> --framework-mode <npm|vendor: workspace-wide, applies to API and frontend> [--frontend-framework-mode <npm|vendor>: overrides --framework-mode for the frontend only] [--framework-upstream-branch <ref>] [--next: implies nuxt-base-starter#next unless --frontend-branch overrides] [--dry-run] --noConfirm');
|
|
42
43
|
// Check git
|
|
43
44
|
if (!(yield git.gitInstalled())) {
|
|
45
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
46
48
|
// Load configuration
|
|
@@ -92,6 +94,7 @@ const NewCommand = {
|
|
|
92
94
|
if (cwdLayout.hasApi && cwdLayout.hasApp) {
|
|
93
95
|
error('Workspace already has both projects/api and projects/app — nothing to add.');
|
|
94
96
|
info('Use `lt fullstack add-api --help-json` or `lt fullstack add-app --help-json` to inspect options.');
|
|
97
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
95
98
|
return;
|
|
96
99
|
}
|
|
97
100
|
if (cwdLayout.hasApp && !cwdLayout.hasApi) {
|
|
@@ -124,6 +127,7 @@ const NewCommand = {
|
|
|
124
127
|
if (filesystem.exists(projectDir)) {
|
|
125
128
|
info('');
|
|
126
129
|
error(`There's already a folder named "${projectDir}" here.`);
|
|
130
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
127
131
|
return;
|
|
128
132
|
}
|
|
129
133
|
// Determine frontend with priority: CLI > config > interactive
|
|
@@ -132,6 +136,7 @@ const NewCommand = {
|
|
|
132
136
|
frontend = cliFrontend === 'angular' ? 'angular' : cliFrontend === 'nuxt' ? 'nuxt' : null;
|
|
133
137
|
if (!frontend) {
|
|
134
138
|
error('Invalid frontend option. Use "angular" or "nuxt".');
|
|
139
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
135
140
|
return;
|
|
136
141
|
}
|
|
137
142
|
}
|
|
@@ -220,6 +225,7 @@ const NewCommand = {
|
|
|
220
225
|
}
|
|
221
226
|
else if (cliFrameworkMode) {
|
|
222
227
|
error(`Invalid --framework-mode value "${cliFrameworkMode}". Use "npm" or "vendor".`);
|
|
228
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
223
229
|
return;
|
|
224
230
|
}
|
|
225
231
|
else if (configFrameworkMode === 'npm' || configFrameworkMode === 'vendor') {
|
|
@@ -245,20 +251,34 @@ const NewCommand = {
|
|
|
245
251
|
}
|
|
246
252
|
// ── Frontend framework mode ─────────────────────────────────────────
|
|
247
253
|
const configFrontendFrameworkMode = (_0 = (_z = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _z === void 0 ? void 0 : _z.fullstack) === null || _0 === void 0 ? void 0 : _0.frontendFrameworkMode;
|
|
254
|
+
// Precedence: frontend-specific CLI flag > workspace-wide CLI flag >
|
|
255
|
+
// frontend-specific config > workspace-wide config > vendor default.
|
|
256
|
+
//
|
|
257
|
+
// `--framework-mode` is the workspace-wide default and MUST propagate here.
|
|
258
|
+
// Without that inheritance, `--framework-mode npm` silently produced an
|
|
259
|
+
// npm API next to a vendored frontend — visible only as the M1..M3 steps in
|
|
260
|
+
// a `--dry-run` plan, or afterwards as an unexpected `app/core/` tree.
|
|
261
|
+
// Consumers who genuinely want mixed modes pass `--frontend-framework-mode`.
|
|
248
262
|
let frontendFrameworkMode;
|
|
249
263
|
if (cliFrontendFrameworkMode === 'npm' || cliFrontendFrameworkMode === 'vendor') {
|
|
250
264
|
frontendFrameworkMode = cliFrontendFrameworkMode;
|
|
251
265
|
}
|
|
252
266
|
else if (cliFrontendFrameworkMode) {
|
|
253
267
|
error(`Invalid --frontend-framework-mode value "${cliFrontendFrameworkMode}". Use "npm" or "vendor".`);
|
|
268
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
254
269
|
return;
|
|
255
270
|
}
|
|
271
|
+
else if (cliFrameworkMode === 'npm' || cliFrameworkMode === 'vendor') {
|
|
272
|
+
frontendFrameworkMode = cliFrameworkMode;
|
|
273
|
+
info(`Using frontend framework mode from --framework-mode: ${frontendFrameworkMode}`);
|
|
274
|
+
}
|
|
256
275
|
else if (configFrontendFrameworkMode === 'npm' || configFrontendFrameworkMode === 'vendor') {
|
|
257
276
|
frontendFrameworkMode = configFrontendFrameworkMode;
|
|
258
277
|
info(`Using frontend framework mode from lt.config: ${frontendFrameworkMode}`);
|
|
259
278
|
}
|
|
260
|
-
else if (
|
|
261
|
-
frontendFrameworkMode =
|
|
279
|
+
else if (configFrameworkMode === 'npm' || configFrameworkMode === 'vendor') {
|
|
280
|
+
frontendFrameworkMode = configFrameworkMode;
|
|
281
|
+
info(`Using frontend framework mode from lt.config frameworkMode: ${frontendFrameworkMode}`);
|
|
262
282
|
}
|
|
263
283
|
else {
|
|
264
284
|
// Default to vendor without asking (unless user sets it explicitly)
|
|
@@ -403,11 +423,13 @@ const NewCommand = {
|
|
|
403
423
|
}
|
|
404
424
|
catch (err) {
|
|
405
425
|
workspaceSpinner.fail(`Failed to clone monorepo: ${err.message}`);
|
|
426
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
406
427
|
return;
|
|
407
428
|
}
|
|
408
429
|
// Check for directory
|
|
409
430
|
if (!filesystem.isDirectory(`./${projectDir}`)) {
|
|
410
431
|
workspaceSpinner.fail(`The directory "${projectDir}" could not be created.`);
|
|
432
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
411
433
|
return;
|
|
412
434
|
}
|
|
413
435
|
workspaceSpinner.succeed(`Create fullstack workspace with ${frontend} in ${projectDir} for ${name} created`);
|
|
@@ -467,6 +489,7 @@ const NewCommand = {
|
|
|
467
489
|
}
|
|
468
490
|
catch (err) {
|
|
469
491
|
error(`Failed to initialize git: ${err.message}`);
|
|
492
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
470
493
|
return;
|
|
471
494
|
}
|
|
472
495
|
// Add remote if push is configured
|
|
@@ -476,6 +499,7 @@ const NewCommand = {
|
|
|
476
499
|
}
|
|
477
500
|
catch (err) {
|
|
478
501
|
error(`Failed to add remote: ${err.message}`);
|
|
502
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
479
503
|
return;
|
|
480
504
|
}
|
|
481
505
|
}
|
|
@@ -503,6 +527,7 @@ const NewCommand = {
|
|
|
503
527
|
}
|
|
504
528
|
if (!frontendResult.success) {
|
|
505
529
|
error(`Failed to set up ${frontend} frontend: ${frontendResult.path}`);
|
|
530
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
506
531
|
return;
|
|
507
532
|
}
|
|
508
533
|
// Patch frontend .env with project-specific values (skip for linked templates)
|
|
@@ -546,6 +571,7 @@ const NewCommand = {
|
|
|
546
571
|
});
|
|
547
572
|
if (!apiResult.success) {
|
|
548
573
|
serverSpinner.fail(`Failed to set up API: ${apiResult.path}`);
|
|
574
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
549
575
|
return;
|
|
550
576
|
}
|
|
551
577
|
// Auto-run `bun run rename <projectDir>` for the experimental nest-base
|
|
@@ -638,6 +664,7 @@ const NewCommand = {
|
|
|
638
664
|
}
|
|
639
665
|
catch (err) {
|
|
640
666
|
installSpinner.fail(`Failed to install packages: ${err.message}`);
|
|
667
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
641
668
|
return;
|
|
642
669
|
}
|
|
643
670
|
}
|
|
@@ -673,6 +700,7 @@ const NewCommand = {
|
|
|
673
700
|
}
|
|
674
701
|
catch (err) {
|
|
675
702
|
error(`Failed to create initial commit: ${err.message}`);
|
|
703
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
676
704
|
return;
|
|
677
705
|
}
|
|
678
706
|
// Push to remote if configured
|
|
@@ -682,6 +710,7 @@ const NewCommand = {
|
|
|
682
710
|
}
|
|
683
711
|
catch (err) {
|
|
684
712
|
error(`Failed to push to remote: ${err.message}`);
|
|
713
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
685
714
|
return;
|
|
686
715
|
}
|
|
687
716
|
}
|
|
@@ -46,7 +46,7 @@ const NewCommand = {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
// Check remote (use short SSH timeout so ls-remote doesn't hang in offline environments)
|
|
49
|
-
const remoteBranch = yield system.run(`GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git ls-remote --heads origin ${branch} 2>/dev/null || true`);
|
|
49
|
+
const remoteBranch = yield system.run(`GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git ls-remote --heads origin ${branch} 2>/dev/null || true`);
|
|
50
50
|
if (!remoteBranch) {
|
|
51
51
|
error(`No remote branch ${branch} found!`);
|
|
52
52
|
return;
|
|
@@ -48,7 +48,7 @@ const NewCommand = {
|
|
|
48
48
|
info(`Current branch: ${branch}`);
|
|
49
49
|
info('');
|
|
50
50
|
// Fetch to see incoming changes (use short SSH timeout so it doesn't hang offline)
|
|
51
|
-
yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git fetch 2>/dev/null || true');
|
|
51
|
+
yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true');
|
|
52
52
|
// Check for incoming commits
|
|
53
53
|
const incomingCommits = yield run(`git log ${branch}..origin/${branch} --oneline 2>/dev/null || echo ""`);
|
|
54
54
|
const commits = (incomingCommits === null || incomingCommits === void 0 ? void 0 : incomingCommits.trim().split('\n').filter((c) => c)) || [];
|
|
@@ -78,7 +78,7 @@ const NewCommand = {
|
|
|
78
78
|
const timer = startTimer();
|
|
79
79
|
// Update
|
|
80
80
|
const updateSpin = spin(`Update branch ${branch}`);
|
|
81
|
-
yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git fetch 2>/dev/null || true && GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git pull --rebase');
|
|
81
|
+
yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true && GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git pull --rebase');
|
|
82
82
|
updateSpin.succeed();
|
|
83
83
|
// Install packages (unless skipped) with correctly detected package manager (supports monorepo lockfiles)
|
|
84
84
|
if (!skipInstall) {
|
|
@@ -13,6 +13,7 @@ exports.FrontendHelper = void 0;
|
|
|
13
13
|
const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
|
|
14
14
|
const markdown_table_1 = require("../lib/markdown-table");
|
|
15
15
|
const strip_comments_1 = require("../lib/strip-comments");
|
|
16
|
+
const strip_vendor_schema_augmentation_1 = require("../lib/strip-vendor-schema-augmentation");
|
|
16
17
|
const vendor_claude_md_1 = require("../lib/vendor-claude-md");
|
|
17
18
|
/**
|
|
18
19
|
* Frontend helper functions for project scaffolding
|
|
@@ -520,6 +521,24 @@ class FrontendHelper {
|
|
|
520
521
|
this.rewriteConsumerImportsToVendor(dest);
|
|
521
522
|
// ── 4. Rewrite nuxt.config.ts module entry ──────────────────────────
|
|
522
523
|
this.rewriteNuxtConfig(dest, 'vendor');
|
|
524
|
+
// ── 4b. Drop the core's `nuxt/schema` runtime-config augmentation ────
|
|
525
|
+
//
|
|
526
|
+
// Harmless inside node_modules, poisonous as project source: it augments the
|
|
527
|
+
// same interface under both `nuxt/schema` and `@nuxt/schema` (the former
|
|
528
|
+
// re-exports the latter) and closes a cycle with Nuxt's generated
|
|
529
|
+
// runtime-config types. TS2310 — suppressed by `skipLibCheck`, so all a
|
|
530
|
+
// developer sees is every `config.public.*` typed `unknown`. See the lib for
|
|
531
|
+
// the measurement.
|
|
532
|
+
const strippedAugmentation = (0, strip_vendor_schema_augmentation_1.stripVendorSchemaAugmentation)({ coreDir, filesystem });
|
|
533
|
+
if (strippedAugmentation.touched.length > 0) {
|
|
534
|
+
this.toolbox.print.info(` vendored core: removed the nuxt/schema runtime-config augmentation from ${strippedAugmentation.touched.length} file(s) — keeps config.public.* typed`);
|
|
535
|
+
}
|
|
536
|
+
// A block the transform could not process is left in place, which means the
|
|
537
|
+
// TS2310 bug ships with the project. Silence there would be the worst of both
|
|
538
|
+
// worlds: the conversion reports success and the typing is broken anyway.
|
|
539
|
+
for (const warning of strippedAugmentation.warnings) {
|
|
540
|
+
this.toolbox.print.warning(` ⚠ vendored core: ${warning}`);
|
|
541
|
+
}
|
|
523
542
|
// ── 5. package.json: remove @lenne.tech/nuxt-extensions, merge deps ─
|
|
524
543
|
const pkgPath = path.join(dest, 'package.json');
|
|
525
544
|
if (filesystem.exists(pkgPath)) {
|
package/build/extensions/git.js
CHANGED
|
@@ -120,7 +120,7 @@ class Git {
|
|
|
120
120
|
// Toolbox features
|
|
121
121
|
const { system } = this.toolbox;
|
|
122
122
|
// Get branches (use short SSH timeout so fetch doesn't hang in offline environments)
|
|
123
|
-
const branches = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git fetch 2>/dev/null; git show-branch --list');
|
|
123
|
+
const branches = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null; git show-branch --list');
|
|
124
124
|
branches.split('\n').forEach((item) => {
|
|
125
125
|
const matches = item.match(/\[(.*?)]/);
|
|
126
126
|
if (matches) {
|
|
@@ -197,6 +197,27 @@ class Git {
|
|
|
197
197
|
/**
|
|
198
198
|
* Check if git is installed (cached for performance)
|
|
199
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* Why the git calls in this repo spell `GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-…}"` (shell default)
|
|
202
|
+
* rather than assigning it outright.
|
|
203
|
+
*
|
|
204
|
+
* These commands do a best-effort `git fetch` to see whether the branch is
|
|
205
|
+
* behind. They set `BatchMode=yes` so ssh fails instead of PROMPTING — but an
|
|
206
|
+
* agent that stalls is not a prompt. On a 1Password-backed machine the agent is
|
|
207
|
+
* reachable and every signature needs an interactive approval; unattended it
|
|
208
|
+
* waits and then reports `communication with agent failed`. Measured: **61 s per
|
|
209
|
+
* fetch**, so `lt git update --dry-run` took 62 s and `lt git create --dry-run`
|
|
210
|
+
* 123 s (two fetches). `ConnectTimeout` does not bound it — that covers the TCP
|
|
211
|
+
* connect, not the agent.
|
|
212
|
+
*
|
|
213
|
+
* Waiting for a human to approve a key is legitimate for an interactive command,
|
|
214
|
+
* so the default is unchanged. What was wrong is that the assignment was
|
|
215
|
+
* UNCONDITIONAL: it overrode a caller who had deliberately configured ssh,
|
|
216
|
+
* including a test harness trying to make the behaviour deterministic. The
|
|
217
|
+
* `:-` default respects an existing value and keeps the old behaviour when there
|
|
218
|
+
* is none. `IdentityAgent=none` in the caller's env then drops the same fetch to
|
|
219
|
+
* ~1 s with a clean `Permission denied (publickey)`.
|
|
220
|
+
*/
|
|
200
221
|
gitInstalled() {
|
|
201
222
|
return __awaiter(this, void 0, void 0, function* () {
|
|
202
223
|
// Return cached result if available
|
|
@@ -252,7 +273,7 @@ class Git {
|
|
|
252
273
|
searchSpin = spin(opts.spinText);
|
|
253
274
|
}
|
|
254
275
|
// Update infos (use short SSH timeout so fetch doesn't hang in offline environments)
|
|
255
|
-
const fetch = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o ConnectTimeout=5 -o BatchMode=yes" git fetch 2>/dev/null || true');
|
|
276
|
+
const fetch = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true');
|
|
256
277
|
if (fetch.length && !fetch.startsWith('remote')) {
|
|
257
278
|
info(`Could not update infos ${fetch.length}`);
|
|
258
279
|
}
|
|
@@ -46,6 +46,7 @@ exports.Server = void 0;
|
|
|
46
46
|
const crypto = __importStar(require("crypto"));
|
|
47
47
|
const path_1 = require("path");
|
|
48
48
|
const ts = __importStar(require("typescript"));
|
|
49
|
+
const adopt_upstream_build_allowlist_1 = require("../lib/adopt-upstream-build-allowlist");
|
|
49
50
|
const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
|
|
50
51
|
const markdown_table_1 = require("../lib/markdown-table");
|
|
51
52
|
const strip_comments_1 = require("../lib/strip-comments");
|
|
@@ -1021,6 +1022,22 @@ class Server {
|
|
|
1021
1022
|
// Best-effort — without the lockfile, the closure step falls back to
|
|
1022
1023
|
// the upstream package.json ranges (still better than nothing).
|
|
1023
1024
|
}
|
|
1025
|
+
// Snapshot the upstream pnpm-workspace.yaml. It carries `allowBuilds`, the map
|
|
1026
|
+
// that decides which packages may run install scripts — and vendoring is what
|
|
1027
|
+
// makes the framework's entries the PROJECT's problem: step 5b turns the core's
|
|
1028
|
+
// import closure into direct dependencies, so packages nest-server knew about
|
|
1029
|
+
// (bullmq → msgpackr → msgpackr-extract) become the project's own. pnpm 11 aborts
|
|
1030
|
+
// the install outright on an unlisted one, so a missing entry is an unusable
|
|
1031
|
+
// project rather than a warning. This file is NOT in nest-server's npm tarball,
|
|
1032
|
+
// so the clone is the only moment it can be read at all.
|
|
1033
|
+
let upstreamWorkspaceYaml = '';
|
|
1034
|
+
try {
|
|
1035
|
+
upstreamWorkspaceYaml = filesystem.read(`${tmpClone}/pnpm-workspace.yaml`) || '';
|
|
1036
|
+
}
|
|
1037
|
+
catch (_c) {
|
|
1038
|
+
// Best-effort: an older framework revision may predate the file. The project
|
|
1039
|
+
// then keeps exactly the allowlist its own template shipped.
|
|
1040
|
+
}
|
|
1024
1041
|
// Snapshot the upstream CLAUDE.md for section-merge into projects/api/CLAUDE.md.
|
|
1025
1042
|
// The nest-server CLAUDE.md contains framework-specific instructions that
|
|
1026
1043
|
// Claude Code needs to work correctly with the vendored source (API conventions,
|
|
@@ -1033,7 +1050,7 @@ class Server {
|
|
|
1033
1050
|
upstreamClaudeMd = claudeMdContent;
|
|
1034
1051
|
}
|
|
1035
1052
|
}
|
|
1036
|
-
catch (
|
|
1053
|
+
catch (_d) {
|
|
1037
1054
|
// Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
|
|
1038
1055
|
}
|
|
1039
1056
|
// Snapshot the upstream commit SHA for traceability in VENDOR.md.
|
|
@@ -1042,7 +1059,7 @@ class Server {
|
|
|
1042
1059
|
const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
|
|
1043
1060
|
upstreamCommit = (sha || '').trim();
|
|
1044
1061
|
}
|
|
1045
|
-
catch (
|
|
1062
|
+
catch (_e) {
|
|
1046
1063
|
// Non-fatal — VENDOR.md will just show an empty SHA.
|
|
1047
1064
|
}
|
|
1048
1065
|
try {
|
|
@@ -1325,7 +1342,7 @@ class Server {
|
|
|
1325
1342
|
}
|
|
1326
1343
|
}
|
|
1327
1344
|
}
|
|
1328
|
-
catch (
|
|
1345
|
+
catch (_f) {
|
|
1329
1346
|
// skip unreadable file
|
|
1330
1347
|
}
|
|
1331
1348
|
}
|
|
@@ -1503,6 +1520,14 @@ class Server {
|
|
|
1503
1520
|
upstreamDeps,
|
|
1504
1521
|
upstreamDevDeps,
|
|
1505
1522
|
});
|
|
1523
|
+
// The closure above just made framework-only packages direct dependencies of
|
|
1524
|
+
// this project. Their build-script decisions have to come along, or the first
|
|
1525
|
+
// `pnpm install` stops on ERR_PNPM_IGNORED_BUILDS. Additive only — a decision
|
|
1526
|
+
// the project already made is never overwritten.
|
|
1527
|
+
const adoptedBuilds = (0, adopt_upstream_build_allowlist_1.adoptUpstreamBuildAllowlist)({ dest, filesystem, upstreamWorkspaceYaml });
|
|
1528
|
+
if (adoptedBuilds.length > 0) {
|
|
1529
|
+
this.toolbox.print.info(` vendored core allowlist: adopted ${adoptedBuilds.length} build decision(s) from nest-server → ${adoptedBuilds.join(', ')}`);
|
|
1530
|
+
}
|
|
1506
1531
|
// Add a script to run the local bin/migrate.js. The starter's
|
|
1507
1532
|
// existing migrate:* scripts are already correct for npm mode; we
|
|
1508
1533
|
// need them pointing at the local bin + local ts-compiler.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.adoptUpstreamBuildAllowlist = adoptUpstreamBuildAllowlist;
|
|
4
|
+
const js_yaml_1 = require("js-yaml");
|
|
5
|
+
const fs_utils_1 = require("./fs-utils");
|
|
6
|
+
const hoist_workspace_pnpm_config_1 = require("./hoist-workspace-pnpm-config");
|
|
7
|
+
/**
|
|
8
|
+
* Carry the framework's build-script allowlist into a project that has just
|
|
9
|
+
* vendored it.
|
|
10
|
+
*
|
|
11
|
+
* `allowBuilds` decides which packages may run install scripts. pnpm 11 does not
|
|
12
|
+
* treat an unlisted one as "deny" — it ABORTS the install with
|
|
13
|
+
* `ERR_PNPM_IGNORED_BUILDS` and writes a `set this to true or false` placeholder
|
|
14
|
+
* into the workspace file. So a single missing entry is not a hardening gap; it
|
|
15
|
+
* is a project that cannot be installed at all, on the very first
|
|
16
|
+
* `lt fullstack init`, before anyone has written a line of code.
|
|
17
|
+
*
|
|
18
|
+
* Vendoring is exactly where that gap opens. The conversion resolves the core's
|
|
19
|
+
* import closure into DIRECT dependencies — `import('bullmq')` in
|
|
20
|
+
* core-cron-jobs.service.ts becomes a real `bullmq` dep, which pulls
|
|
21
|
+
* `msgpackr` → `msgpackr-extract`, a package with an install script. nest-server
|
|
22
|
+
* knows about it and lists it. The project does not, and cannot: nest-server's
|
|
23
|
+
* `pnpm-workspace.yaml` is not part of its npm tarball — `files` ships `dist`,
|
|
24
|
+
* `src`, `bin` and the docs, never a repo-root config file — so nothing
|
|
25
|
+
* downstream can read it. Until now the only bridge was a
|
|
26
|
+
* human copying entries into nest-server-starter by hand — and that bridge has
|
|
27
|
+
* already been observed to rot: `@scarf/scarf` sat at `true` in the starter while
|
|
28
|
+
* the framework denied it, for long enough that the drift shipped.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately additive. A key the project has already decided is left exactly as
|
|
31
|
+
* it is, including an explicit `false`: the project is the more specific context,
|
|
32
|
+
* and silently flipping its decision to match the framework would be a worse bug
|
|
33
|
+
* than the one this fixes. Only genuinely absent keys are taken over — together
|
|
34
|
+
* with the comment that explains them, because an entry like
|
|
35
|
+
* `'msgpackr-extract': false` reads as dead weight without it and gets deleted by
|
|
36
|
+
* the next person who runs `pnpm why`.
|
|
37
|
+
*
|
|
38
|
+
* @returns the keys actually adopted, for the caller to report.
|
|
39
|
+
*/
|
|
40
|
+
function adoptUpstreamBuildAllowlist(options) {
|
|
41
|
+
var _a;
|
|
42
|
+
const { dest, filesystem, upstreamWorkspaceYaml } = options;
|
|
43
|
+
if (!upstreamWorkspaceYaml)
|
|
44
|
+
return [];
|
|
45
|
+
const upstream = parseYamlObject(upstreamWorkspaceYaml);
|
|
46
|
+
const upstreamAllow = asStringBoolMap(upstream === null || upstream === void 0 ? void 0 : upstream.allowBuilds);
|
|
47
|
+
if (Object.keys(upstreamAllow).length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
// Never write through a symlinked project: with `--api-link` it points at the
|
|
50
|
+
// user's own nest-server-starter checkout, and this would edit their repo. The
|
|
51
|
+
// same guard already protects `hoistWorkspacePnpmConfig` and
|
|
52
|
+
// `removeNestedLockfiles`; these two libs were the odd ones out.
|
|
53
|
+
if ((0, fs_utils_1.isSymlink)(dest))
|
|
54
|
+
return [];
|
|
55
|
+
const destPath = `${dest}/pnpm-workspace.yaml`;
|
|
56
|
+
// No file to extend means no pnpm settings of the project's own. Writing one
|
|
57
|
+
// here would invent a workspace root the scaffolding did not ask for, so the
|
|
58
|
+
// absence is respected rather than filled in.
|
|
59
|
+
if (!filesystem.exists(destPath))
|
|
60
|
+
return [];
|
|
61
|
+
const destRaw = (_a = filesystem.read(destPath)) !== null && _a !== void 0 ? _a : '';
|
|
62
|
+
const destWs = parseYamlObject(destRaw);
|
|
63
|
+
if (!destWs)
|
|
64
|
+
return [];
|
|
65
|
+
// The project's map is read RAW, not through `asStringBoolMap`. Narrowing it to
|
|
66
|
+
// booleans first was a live deny-bypass with no attacker involved: js-yaml 4
|
|
67
|
+
// uses the YAML 1.2 core schema, so `esbuild: no` and `esbuild: off` parse as
|
|
68
|
+
// STRINGS. A maintainer writing `no` to mean "deny" was read as "no opinion",
|
|
69
|
+
// upstream's `true` was adopted, and the original entry was dropped from the
|
|
70
|
+
// rewritten map as well. Same for `'false'`, for an empty value (null), and for
|
|
71
|
+
// pnpm's own `set this to true or false` placeholder — all five verified.
|
|
72
|
+
//
|
|
73
|
+
// `Object.create(null)` and `hasOwnProperty.call`: with a plain object,
|
|
74
|
+
// `'constructor' in map` is true via the prototype, so eight real package names
|
|
75
|
+
// ('constructor', 'toString', 'valueOf', …) would be treated as already decided
|
|
76
|
+
// and silently never adopted — the ERR_PNPM_IGNORED_BUILDS abort this whole
|
|
77
|
+
// function exists to prevent.
|
|
78
|
+
const merged = Object.assign(Object.create(null), asRawMap(destWs.allowBuilds));
|
|
79
|
+
const adopted = [];
|
|
80
|
+
for (const [pkg, value] of Object.entries(upstreamAllow)) {
|
|
81
|
+
// ANY existing key is a decision, whatever shape YAML gave it.
|
|
82
|
+
if (Object.prototype.hasOwnProperty.call(merged, pkg))
|
|
83
|
+
continue;
|
|
84
|
+
merged[pkg] = value;
|
|
85
|
+
adopted.push(pkg);
|
|
86
|
+
}
|
|
87
|
+
if (adopted.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
destWs.allowBuilds = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
90
|
+
// The project's own annotations win where both files comment the same key; the
|
|
91
|
+
// upstream ones fill in only for the keys just adopted, which by definition the
|
|
92
|
+
// project had nothing to say about.
|
|
93
|
+
const comments = (0, hoist_workspace_pnpm_config_1.extractKeyComments)(destRaw);
|
|
94
|
+
for (const [key, block] of (0, hoist_workspace_pnpm_config_1.extractKeyComments)(upstreamWorkspaceYaml)) {
|
|
95
|
+
if (!comments.has(key))
|
|
96
|
+
comments.set(key, block);
|
|
97
|
+
}
|
|
98
|
+
filesystem.write(destPath, (0, hoist_workspace_pnpm_config_1.reattachKeyComments)((0, js_yaml_1.dump)(destWs, { lineWidth: -1, sortKeys: false }), comments));
|
|
99
|
+
return adopted;
|
|
100
|
+
}
|
|
101
|
+
/** The value as a plain key/value map, or an empty one — no narrowing of values. */
|
|
102
|
+
function asRawMap(value) {
|
|
103
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
104
|
+
return {};
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Narrow an unknown value to a `{ pkg: boolean }` map, dropping other shapes.
|
|
109
|
+
*
|
|
110
|
+
* Used for the UPSTREAM side only. There, dropping a non-boolean is right — we
|
|
111
|
+
* adopt only decisions we understand. On the DEST side it is the opposite: see
|
|
112
|
+
* the comment at the merge above.
|
|
113
|
+
*/
|
|
114
|
+
function asStringBoolMap(value) {
|
|
115
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
116
|
+
return {};
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [key, v] of Object.entries(value)) {
|
|
119
|
+
if (typeof v === 'boolean')
|
|
120
|
+
out[key] = v;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** Parse YAML into a plain object, or null on empty/malformed/non-object input. */
|
|
125
|
+
function parseYamlObject(raw) {
|
|
126
|
+
if (!raw)
|
|
127
|
+
return null;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = (0, js_yaml_1.load)(raw);
|
|
131
|
+
}
|
|
132
|
+
catch (_a) {
|
|
133
|
+
// Malformed upstream YAML must not take the conversion down with it — the
|
|
134
|
+
// vendored project is still usable, it just does not inherit the allowlist.
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
138
|
+
return null;
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.failRun = failRun;
|
|
4
|
+
/**
|
|
5
|
+
* Mark the current command run as failed.
|
|
6
|
+
*
|
|
7
|
+
* A gluegun command signals failure by printing and returning, and a bare
|
|
8
|
+
* `return` leaves the process at exit code 0. So a scaffold that died halfway
|
|
9
|
+
* reported SUCCESS to every caller that checks `$?` — a CI job, a wrapper
|
|
10
|
+
* script, an agent. That is not theoretical: a `lt fullstack init` whose
|
|
11
|
+
* `pnpm install` aborted on a native build script printed a red spinner and
|
|
12
|
+
* still exited 0, and the half-built workspace was only noticed later, by hand.
|
|
13
|
+
*
|
|
14
|
+
* Call it immediately before every `return` on an error path:
|
|
15
|
+
*
|
|
16
|
+
* failRun(toolbox);
|
|
17
|
+
* return;
|
|
18
|
+
*
|
|
19
|
+
* **`process.exitCode`, not `process.exit()`** — the latter can truncate
|
|
20
|
+
* buffered output, including the spinner's own failure message, which is the one
|
|
21
|
+
* line the operator actually needs.
|
|
22
|
+
*
|
|
23
|
+
* **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
|
|
24
|
+
* (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
|
|
25
|
+
* interactive `lt` menu a command is one step of a longer session, and failing
|
|
26
|
+
* the whole session because one step errored is the same over-reach in reverse.
|
|
27
|
+
*
|
|
28
|
+
* Shared rather than redeclared per command: `fullstack init` delegates to
|
|
29
|
+
* `add-api` / `add-app` inside an existing workspace, so all three have to agree
|
|
30
|
+
* on the contract or the exit code depends on which directory the user happened
|
|
31
|
+
* to be standing in — which was exactly the state before this helper existed.
|
|
32
|
+
*/
|
|
33
|
+
function failRun(toolbox) {
|
|
34
|
+
var _a, _b;
|
|
35
|
+
if (!((_b = (_a = toolbox.parameters) === null || _a === void 0 ? void 0 : _a.options) === null || _b === void 0 ? void 0 : _b.fromGluegunMenu)) {
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractKeyComments = extractKeyComments;
|
|
4
|
+
exports.reattachKeyComments = reattachKeyComments;
|
|
3
5
|
exports.hoistPackageManager = hoistPackageManager;
|
|
4
6
|
exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
|
|
5
7
|
const js_yaml_1 = require("js-yaml");
|
|
@@ -35,11 +37,162 @@ const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
|
35
37
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
36
38
|
/** Objects whose values are arrays to be unioned, not replaced. */
|
|
37
39
|
const NESTED_ARRAY_FIELDS = ['auditConfig'];
|
|
40
|
+
/** The union of all three, in declaration order. Declared here, with its inputs,
|
|
41
|
+
* because the comment-carrying helpers below default their `fields` parameter to it. */
|
|
42
|
+
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
|
|
43
|
+
/**
|
|
44
|
+
* Separator for the composite map key.
|
|
45
|
+
*
|
|
46
|
+
* `\0` rather than a space, because a YAML mapping key may legally contain
|
|
47
|
+
* spaces — `overrides` selectors like `minimatch@>=5.0.0 <10.2.6` do — and a
|
|
48
|
+
* space would let two different (field, key) pairs collide on one entry,
|
|
49
|
+
* silently attaching one entry's reasoning to another's. Written as an escape
|
|
50
|
+
* rather than a literal control character: a raw NUL in the source makes git
|
|
51
|
+
* treat this file as binary, which costs every future reviewer the diff.
|
|
52
|
+
*/
|
|
53
|
+
const KEY_SEPARATOR = '\0';
|
|
54
|
+
/**
|
|
55
|
+
* Control characters that must never survive into an emitted YAML comment.
|
|
56
|
+
*
|
|
57
|
+
* Everything below U+0020 except TAB (U+0009) and LF (U+000A) — CR included,
|
|
58
|
+
* deliberately: it is the one that reads as whitespace and parses as a line
|
|
59
|
+
* break. LF cannot appear here (the harvest splits on it) and TAB is harmless.
|
|
60
|
+
*/
|
|
61
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
62
|
+
const commentKey = (field, key) => `${field}${KEY_SEPARATOR}${key}`;
|
|
63
|
+
/**
|
|
64
|
+
* Comment blocks attached to the entries of each top-level mapping in `raw`.
|
|
65
|
+
*
|
|
66
|
+
* Only contiguous `#` lines DIRECTLY above an entry are taken, and a blank line
|
|
67
|
+
* ends the block — a comment separated from a key by an empty line belongs to the
|
|
68
|
+
* section, not to that key, and re-attaching it would silently move a section
|
|
69
|
+
* header onto whichever entry happened to come first.
|
|
70
|
+
*/
|
|
71
|
+
function extractKeyComments(raw, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
if (!raw)
|
|
74
|
+
return out;
|
|
75
|
+
const lines = raw.split('\n');
|
|
76
|
+
let field = null;
|
|
77
|
+
let fieldIndent = 0;
|
|
78
|
+
let pending = [];
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
|
|
81
|
+
if (topLevel) {
|
|
82
|
+
field = fields.includes(topLevel[1]) ? topLevel[1] : null;
|
|
83
|
+
fieldIndent = 0;
|
|
84
|
+
pending = [];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (field === null)
|
|
88
|
+
continue;
|
|
89
|
+
if (/^\s*$/.test(line)) {
|
|
90
|
+
pending = [];
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const indent = line.search(/\S/);
|
|
94
|
+
// Back at column 0 → the mapping is over (a new top-level key or a list item).
|
|
95
|
+
if (indent === 0) {
|
|
96
|
+
field = null;
|
|
97
|
+
pending = [];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/^\s*#/.test(line)) {
|
|
101
|
+
// A bare CR is NOT a line break to `String.split('\n')` but IS one to every
|
|
102
|
+
// YAML parser. So a comment containing one is re-emitted verbatim, and
|
|
103
|
+
// everything after the CR becomes real YAML at a column of its author's
|
|
104
|
+
// choosing. Verified against pnpm 11: a comment carrying
|
|
105
|
+
// `\r left-pad: 9.9.9` installs as a workspace-wide `overrides` entry —
|
|
106
|
+
// an arbitrary version force in every generated project — while the line
|
|
107
|
+
// still renders as an ordinary comment in editors and diffs.
|
|
108
|
+
//
|
|
109
|
+
// Dropping the whole block is the right response rather than sanitising it:
|
|
110
|
+
// a rationale nobody can read is worth less than the risk of guessing what
|
|
111
|
+
// the author meant.
|
|
112
|
+
if (CONTROL_CHARS.test(line)) {
|
|
113
|
+
pending = [];
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
pending.push(line.trimStart());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
|
|
120
|
+
if (!entry) {
|
|
121
|
+
pending = [];
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Nested deeper than the first entry level (e.g. `auditConfig.ignoreGhsas`
|
|
125
|
+
// items) — the block belongs to the inner key, which this pass does not carry.
|
|
126
|
+
if (fieldIndent === 0)
|
|
127
|
+
fieldIndent = indent;
|
|
128
|
+
if (indent === fieldIndent && pending.length) {
|
|
129
|
+
out.set(commentKey(field, unquoteYamlKey(entry[1])), pending.join('\n'));
|
|
130
|
+
}
|
|
131
|
+
pending = [];
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Put the harvested comment blocks back above their keys in dumped YAML.
|
|
137
|
+
*
|
|
138
|
+
* A key whose comment is already present is left alone, so re-running the hoist
|
|
139
|
+
* over an already-annotated file is idempotent rather than stuttering.
|
|
140
|
+
*/
|
|
141
|
+
function reattachKeyComments(yaml, comments, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
|
|
142
|
+
var _a;
|
|
143
|
+
if (comments.size === 0)
|
|
144
|
+
return yaml;
|
|
145
|
+
const lines = yaml.split('\n');
|
|
146
|
+
const out = [];
|
|
147
|
+
let field = null;
|
|
148
|
+
let fieldIndent = 0;
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
|
|
151
|
+
if (topLevel) {
|
|
152
|
+
field = fields.includes(topLevel[1]) ? topLevel[1] : null;
|
|
153
|
+
fieldIndent = 0;
|
|
154
|
+
out.push(line);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (field !== null && !/^\s*$/.test(line)) {
|
|
158
|
+
const indent = line.search(/\S/);
|
|
159
|
+
if (indent === 0) {
|
|
160
|
+
field = null;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
|
|
164
|
+
if (entry) {
|
|
165
|
+
if (fieldIndent === 0)
|
|
166
|
+
fieldIndent = indent;
|
|
167
|
+
if (indent === fieldIndent) {
|
|
168
|
+
const block = comments.get(commentKey(field, unquoteYamlKey(entry[1])));
|
|
169
|
+
// `out[out.length - 1]`, not `.at(-1)`: this project's tsconfig lib
|
|
170
|
+
// predates ES2022.
|
|
171
|
+
const already = ((_a = out[out.length - 1]) !== null && _a !== void 0 ? _a : '').trim().startsWith('#');
|
|
172
|
+
// Second gate on purpose: harvest is one source of blocks today, and a
|
|
173
|
+
// control character reaching the emitted file is the whole exploit.
|
|
174
|
+
if (block && !already && !CONTROL_CHARS.test(block)) {
|
|
175
|
+
const pad = ' '.repeat(indent);
|
|
176
|
+
out.push(...block.split('\n').map((l) => `${pad}${l}`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out.push(line);
|
|
183
|
+
}
|
|
184
|
+
return out.join('\n');
|
|
185
|
+
}
|
|
186
|
+
/** `'msgpackr-extract'` / `"foo"` / `foo` all denote the same mapping key. */
|
|
187
|
+
function unquoteYamlKey(raw) {
|
|
188
|
+
const trimmed = raw.trim();
|
|
189
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
|
|
190
|
+
return quoted ? quoted[2] : trimmed;
|
|
191
|
+
}
|
|
38
192
|
/** Provenance note written above a hoisted `auditConfig` — see `annotateAuditConfig`. */
|
|
39
193
|
const AUDIT_CONFIG_NOTE = '# Hoisted from the sub-projects by the lt CLI. These advisory suppressions now\n' +
|
|
40
194
|
'# apply to EVERY package in this workspace, not just the one that justified\n' +
|
|
41
195
|
'# them — review before adding, and drop entries once the advisory is fixed.';
|
|
42
|
-
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
|
|
43
196
|
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
44
197
|
const isNestedArrayField = (field) => NESTED_ARRAY_FIELDS.includes(field);
|
|
45
198
|
/**
|
|
@@ -157,13 +310,18 @@ function hoistPackageManager(options) {
|
|
|
157
310
|
* @param options.subProjects Sub-project dirs relative to projectDir
|
|
158
311
|
*/
|
|
159
312
|
function hoistWorkspacePnpmConfig(options) {
|
|
160
|
-
var _a;
|
|
313
|
+
var _a, _b;
|
|
161
314
|
const { filesystem, projectDir, subProjects } = options;
|
|
162
315
|
const rootWsPath = `${projectDir}/pnpm-workspace.yaml`;
|
|
163
316
|
// The root pnpm-workspace.yaml is the destination. It normally exists (the
|
|
164
317
|
// lt-monorepo clone ships one declaring `packages:`); start from it so
|
|
165
318
|
// `packages:` and any root-owned settings are preserved.
|
|
166
319
|
const rootWs = (_a = readYaml(filesystem, rootWsPath)) !== null && _a !== void 0 ? _a : {};
|
|
320
|
+
// Why the reasons are harvested rather than regenerated: they are prose written
|
|
321
|
+
// by whoever added the entry, and no rule can reconstruct them. The root's own
|
|
322
|
+
// comments are collected FIRST so that where two sources annotate the same key,
|
|
323
|
+
// the root's wording wins — it is the file a maintainer of THIS workspace edits.
|
|
324
|
+
const comments = extractKeyComments((_b = filesystem.read(rootWsPath)) !== null && _b !== void 0 ? _b : '');
|
|
167
325
|
let rootChanged = false;
|
|
168
326
|
for (const subDir of subProjects) {
|
|
169
327
|
const subPath = `${projectDir}/${subDir}`;
|
|
@@ -176,7 +334,7 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
176
334
|
if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
|
|
177
335
|
rootChanged = true;
|
|
178
336
|
}
|
|
179
|
-
if (hoistFromSubWorkspaceYaml({ filesystem, rootWs, subPath })) {
|
|
337
|
+
if (hoistFromSubWorkspaceYaml({ comments, filesystem, rootWs, subPath })) {
|
|
180
338
|
rootChanged = true;
|
|
181
339
|
}
|
|
182
340
|
}
|
|
@@ -184,7 +342,8 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
184
342
|
// Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
|
|
185
343
|
// the build-script allowlist survives regardless of which key pnpm reads.
|
|
186
344
|
syncBuildAllowlists(rootWs);
|
|
187
|
-
|
|
345
|
+
const dumped = (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false });
|
|
346
|
+
filesystem.write(rootWsPath, annotateAuditConfig(reattachKeyComments(dumped, comments)));
|
|
188
347
|
}
|
|
189
348
|
}
|
|
190
349
|
/**
|
|
@@ -264,13 +423,22 @@ function hoistFromSubPackageJson(options) {
|
|
|
264
423
|
}
|
|
265
424
|
/** Source 2: the sub-project's pnpm-workspace.yaml. */
|
|
266
425
|
function hoistFromSubWorkspaceYaml(options) {
|
|
267
|
-
|
|
426
|
+
var _a;
|
|
427
|
+
const { comments, filesystem, rootWs, subPath } = options;
|
|
268
428
|
const subWsPath = `${subPath}/pnpm-workspace.yaml`;
|
|
269
429
|
if (!filesystem.exists(subWsPath))
|
|
270
430
|
return false;
|
|
431
|
+
const raw = (_a = filesystem.read(subWsPath)) !== null && _a !== void 0 ? _a : '';
|
|
271
432
|
const ws = readYaml(filesystem, subWsPath);
|
|
272
433
|
if (!ws)
|
|
273
434
|
return false;
|
|
435
|
+
// Harvest BEFORE hoisting: this file is about to be deleted (or stripped of
|
|
436
|
+
// exactly these keys), and with it the only copy of the reasoning. An entry
|
|
437
|
+
// already annotated by the root keeps the root's wording.
|
|
438
|
+
for (const [key, block] of extractKeyComments(raw)) {
|
|
439
|
+
if (!comments.has(key))
|
|
440
|
+
comments.set(key, block);
|
|
441
|
+
}
|
|
274
442
|
if (!hoistFields(rootWs, ws))
|
|
275
443
|
return false;
|
|
276
444
|
// A settings-only file (no `packages:`) exists solely to carry these
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stripAugmentationBlocks = stripAugmentationBlocks;
|
|
4
|
+
exports.stripVendorSchemaAugmentation = stripVendorSchemaAugmentation;
|
|
5
|
+
const fs_utils_1 = require("./fs-utils");
|
|
6
|
+
/**
|
|
7
|
+
* Drop `declare module '<nuxt|@nuxt>/schema' { … }` blocks that only augment
|
|
8
|
+
* `PublicRuntimeConfig`, leaving a note in their place.
|
|
9
|
+
*
|
|
10
|
+
* Brace-counted rather than regex-matched to the closing brace: a block may grow
|
|
11
|
+
* more members, and a pattern pinned to today's exact two lines would silently
|
|
12
|
+
* stop matching the moment it does — reintroducing the bug with no test failing,
|
|
13
|
+
* because the symptom only appears in a generated project.
|
|
14
|
+
*
|
|
15
|
+
* A block that augments anything OTHER than `PublicRuntimeConfig` is left alone.
|
|
16
|
+
* Those carry real declarations (module options, hooks) with no cycle, and
|
|
17
|
+
* deleting them would trade a typing bug for a worse one.
|
|
18
|
+
*/
|
|
19
|
+
function stripAugmentationBlocks(source, onSkip) {
|
|
20
|
+
const OPEN = /declare module ['"](?:@nuxt|nuxt)\/schema['"]\s*\{/g;
|
|
21
|
+
let out = source;
|
|
22
|
+
// A forward cursor rather than restarting `exec` from 0 after every rewrite.
|
|
23
|
+
// The restart made the matching branch O(k²) in the block count — measured
|
|
24
|
+
// 40 ms at 400 blocks, 558 ms at 1600. Real input is k=2, so this is shape
|
|
25
|
+
// rather than cost; the cursor is simply the honest way to write it, and it
|
|
26
|
+
// removes the recursion the non-matching branch needed.
|
|
27
|
+
let searchFrom = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
OPEN.lastIndex = searchFrom;
|
|
30
|
+
const match = OPEN.exec(out);
|
|
31
|
+
if (!match)
|
|
32
|
+
break;
|
|
33
|
+
const bodyStart = match.index + match[0].length;
|
|
34
|
+
const end = matchingBrace(out, bodyStart);
|
|
35
|
+
if (end === -1) {
|
|
36
|
+
// Unbalanced (or a brace the scanner could not follow). Leaving it is the
|
|
37
|
+
// safe direction — truncating would break the file — but it must NOT be
|
|
38
|
+
// silent: the augmentation stays, so `config.public.*` is `unknown` in the
|
|
39
|
+
// generated project and nothing said so. Report and stop.
|
|
40
|
+
onSkip === null || onSkip === void 0 ? void 0 : onSkip("could not find the end of a `declare module '…/schema'` block; the augmentation was left in place. " +
|
|
41
|
+
'Remove it by hand, or `config.public.*` will type as `unknown` in this project.');
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const body = out.slice(bodyStart, end);
|
|
45
|
+
if (!/\bPublicRuntimeConfig\b/.test(body) || /\binterface\s+(?!PublicRuntimeConfig\b)/.test(body)) {
|
|
46
|
+
// Not ours, or carries other declarations too — keep it and move past it.
|
|
47
|
+
searchFrom = end + 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const note = '// The `nuxt/schema` PublicRuntimeConfig augmentation was removed by `lt` when this\n' +
|
|
51
|
+
'// core was vendored. In node_modules it is harmless; as project source it augments\n' +
|
|
52
|
+
'// the same interface twice (`nuxt/schema` re-exports `@nuxt/schema`) and closes a\n' +
|
|
53
|
+
"// cycle with Nuxt's generated runtime-config types — TS2310, hidden by skipLibCheck,\n" +
|
|
54
|
+
'// which makes every `config.public.*` read `unknown`. The keys are unaffected: Nuxt\n' +
|
|
55
|
+
"// writes them into the generated types from the module's runtime-config defaults.\n" +
|
|
56
|
+
'// Do not restore it here; fix it upstream in @lenne.tech/nuxt-extensions.';
|
|
57
|
+
out = `${out.slice(0, match.index)}${note}${out.slice(end + 1)}`;
|
|
58
|
+
searchFrom = match.index + note.length;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Remove the `nuxt/schema` runtime-config augmentations from a vendored
|
|
64
|
+
* nuxt-extensions core.
|
|
65
|
+
*
|
|
66
|
+
* ## What breaks without this
|
|
67
|
+
*
|
|
68
|
+
* `runtime/types/module.ts` in nuxt-extensions ends with:
|
|
69
|
+
*
|
|
70
|
+
* declare module 'nuxt/schema' { interface PublicRuntimeConfig extends LtExtensionsPublicRuntimeConfig {} }
|
|
71
|
+
* declare module '@nuxt/schema' { interface PublicRuntimeConfig extends LtExtensionsPublicRuntimeConfig {} }
|
|
72
|
+
*
|
|
73
|
+
* In npm mode that file ships as a `.d.ts` inside `node_modules` and never
|
|
74
|
+
* enters the consumer's TypeScript program. Vendoring copies it to
|
|
75
|
+
* `app/core/runtime/types/module.ts`, which the project's own `include` picks up
|
|
76
|
+
* unconditionally — and `nuxt/schema` re-exports `@nuxt/schema`, so augmenting
|
|
77
|
+
* both names decorates ONE interface twice. Nuxt's generated
|
|
78
|
+
* `.nuxt/types/runtime-config.d.ts` then closes the loop with its own
|
|
79
|
+
* `interface PublicRuntimeConfig extends UserPublicRuntimeConfig` (imported from
|
|
80
|
+
* `nuxt/schema`), and TypeScript reports:
|
|
81
|
+
*
|
|
82
|
+
* .nuxt/types/runtime-config.d.ts: error TS2310:
|
|
83
|
+
* Type 'PublicRuntimeConfig' recursively references itself as a base type.
|
|
84
|
+
*
|
|
85
|
+
* An interface in that state resolves every member to `unknown`. So in every
|
|
86
|
+
* vendor-mode project — the DEFAULT for `lt fullstack init` — `config.public.x`
|
|
87
|
+
* is `unknown` rather than its declared type, and `nuxt typecheck` fails on
|
|
88
|
+
* ordinary, correct code.
|
|
89
|
+
*
|
|
90
|
+
* ## Why it took so long to find
|
|
91
|
+
*
|
|
92
|
+
* Nuxt sets `skipLibCheck: true`, which suppresses TS2310 because it is reported
|
|
93
|
+
* in a `.d.ts`. The cause is therefore invisible and only the consequence shows:
|
|
94
|
+
* a plain `Argument of type 'unknown' is not assignable to parameter of type
|
|
95
|
+
* 'string'` at a call site that is not wrong. That is why the trap was previously
|
|
96
|
+
* written up as "vendor mode does not emit the schema block" — in
|
|
97
|
+
* `nuxt-base-starter/nuxt-base-template/CLAUDE.md` and in the JSDoc of that
|
|
98
|
+
* template's `app/utils/app-origin.ts`, both since corrected. The block IS
|
|
99
|
+
* emitted, and is byte-identical between the two modes (`diff` of the two
|
|
100
|
+
* generated `.nuxt/types/runtime-config.d.ts` is empty). Measured 2026-08-22 by
|
|
101
|
+
* converting the template and re-running the type gate with
|
|
102
|
+
* `--skipLibCheck false`.
|
|
103
|
+
*
|
|
104
|
+
* ## Why removing it costs nothing
|
|
105
|
+
*
|
|
106
|
+
* `ltExtensions` does not reach the consumer through this augmentation. The
|
|
107
|
+
* module sets its runtime-config defaults at build time, so Nuxt writes the whole
|
|
108
|
+
* shape into `SharedPublicRuntimeConfig` in the generated file. Verified after
|
|
109
|
+
* stripping: `config.public.ltExtensions.auth.enabled` is `boolean`,
|
|
110
|
+
* `.basePath` is `string`, `config.public.siteUrl` is `string`, and the type gate
|
|
111
|
+
* is clean.
|
|
112
|
+
*
|
|
113
|
+
* The conversion is the right owner: it is the step that turns package typings
|
|
114
|
+
* into project source, so it owns what that change of status implies.
|
|
115
|
+
*
|
|
116
|
+
* @returns the files that were modified, plus any block it could not process —
|
|
117
|
+
* which the caller MUST surface, because a skipped block means the bug
|
|
118
|
+
* is still there and only the transform knows it.
|
|
119
|
+
*/
|
|
120
|
+
function stripVendorSchemaAugmentation(options) {
|
|
121
|
+
var _a;
|
|
122
|
+
const { coreDir, filesystem } = options;
|
|
123
|
+
if (!filesystem.isDirectory(coreDir))
|
|
124
|
+
return { touched: [], warnings: [] };
|
|
125
|
+
// A linked sub-project points at the user's own checkout; rewriting files there
|
|
126
|
+
// would edit their repository. Same guard the workspace helpers already apply.
|
|
127
|
+
if ((0, fs_utils_1.isSymlink)(coreDir))
|
|
128
|
+
return { touched: [], warnings: [] };
|
|
129
|
+
const touched = [];
|
|
130
|
+
const warnings = [];
|
|
131
|
+
for (const file of (_a = filesystem.find(coreDir, { matching: '**/*.ts' })) !== null && _a !== void 0 ? _a : []) {
|
|
132
|
+
const content = filesystem.read(file);
|
|
133
|
+
if (!content || !content.includes('PublicRuntimeConfig'))
|
|
134
|
+
continue;
|
|
135
|
+
const patched = stripAugmentationBlocks(content, (reason) => {
|
|
136
|
+
warnings.push(`${file}: ${reason}`);
|
|
137
|
+
});
|
|
138
|
+
if (patched === content)
|
|
139
|
+
continue;
|
|
140
|
+
filesystem.write(file, patched);
|
|
141
|
+
touched.push(file);
|
|
142
|
+
}
|
|
143
|
+
return { touched, warnings };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Index of the `}` closing the block whose body starts at `from`, or -1.
|
|
147
|
+
*
|
|
148
|
+
* Skips over string literals, template literals and comments. Counting raw
|
|
149
|
+
* braces looked adequate — the augmentation bodies are two plain
|
|
150
|
+
* `interface … extends … {}` lines — but a fuzz pass found both failure modes,
|
|
151
|
+
* and both are silent:
|
|
152
|
+
*
|
|
153
|
+
* - a `}` inside a string (`{ open: '}' }`) drops depth to 0 early, so the strip
|
|
154
|
+
* cuts mid-block and leaves a stray `}` behind. The vendored file then does not
|
|
155
|
+
* compile, in a project the developer just generated.
|
|
156
|
+
* - a `{` inside a string (`type X = '{'`) never balances, this returns -1, the
|
|
157
|
+
* caller stops, and the augmentation is silently RETAINED — the exact TS2310
|
|
158
|
+
* bug the whole transform exists to remove, with nothing printed.
|
|
159
|
+
*
|
|
160
|
+
* Neither triggers on today's nuxt-extensions. But this file's own contract is
|
|
161
|
+
* that the block may grow members (that is why it counts braces instead of
|
|
162
|
+
* matching a fixed pattern), and the day a member carries a brace in a string is
|
|
163
|
+
* the day it misfires.
|
|
164
|
+
*/
|
|
165
|
+
function matchingBrace(text, from) {
|
|
166
|
+
let depth = 1;
|
|
167
|
+
for (let i = from; i < text.length; i++) {
|
|
168
|
+
const ch = text[i];
|
|
169
|
+
// Line comment — nothing structural until the newline.
|
|
170
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
171
|
+
const nl = text.indexOf('\n', i);
|
|
172
|
+
if (nl === -1)
|
|
173
|
+
return -1;
|
|
174
|
+
i = nl;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Block comment.
|
|
178
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
179
|
+
const close = text.indexOf('*/', i + 2);
|
|
180
|
+
if (close === -1)
|
|
181
|
+
return -1;
|
|
182
|
+
i = close + 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// String or template literal. Templates may nest `${…}`, which would need a
|
|
186
|
+
// full parser to follow — so a template is treated as opaque, which is the
|
|
187
|
+
// safe direction: at worst a brace inside `${}` is ignored and the caller
|
|
188
|
+
// gets -1 and warns, rather than cutting the file in the wrong place.
|
|
189
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
190
|
+
const quote = ch;
|
|
191
|
+
i++;
|
|
192
|
+
while (i < text.length && text[i] !== quote) {
|
|
193
|
+
if (text[i] === '\\')
|
|
194
|
+
i++;
|
|
195
|
+
// An unterminated single/double-quoted string cannot span a newline.
|
|
196
|
+
else if (text[i] === '\n' && quote !== '`')
|
|
197
|
+
return -1;
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
if (i >= text.length)
|
|
201
|
+
return -1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '{')
|
|
205
|
+
depth++;
|
|
206
|
+
else if (ch === '}') {
|
|
207
|
+
depth--;
|
|
208
|
+
if (depth === 0)
|
|
209
|
+
return i;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return -1;
|
|
213
|
+
}
|
|
@@ -92,6 +92,21 @@ function buildFrontendVendorBlock() {
|
|
|
92
92
|
'- **Contribute back:** run `/lt-dev:frontend:contribute-nuxt-extensions-core`.',
|
|
93
93
|
'- **Freshness check:** `pnpm run check:vendor-freshness` warns when',
|
|
94
94
|
' upstream has a newer release than the baseline.',
|
|
95
|
+
'',
|
|
96
|
+
'**If `config.public.*` types as `unknown`** (projects vendored before lt CLI',
|
|
97
|
+
'1.43.0), check with:',
|
|
98
|
+
'',
|
|
99
|
+
' grep -rn "declare module \'@nuxt/schema\'" app/core/',
|
|
100
|
+
'',
|
|
101
|
+
'A match means the vendored core still augments `PublicRuntimeConfig` under both',
|
|
102
|
+
'`nuxt/schema` and `@nuxt/schema`. Those are one interface (the former re-exports',
|
|
103
|
+
"the latter), and as project source they close a cycle with Nuxt's generated",
|
|
104
|
+
'runtime-config types — TS2310, which `skipLibCheck` hides, so every',
|
|
105
|
+
'`config.public.*` read silently becomes `unknown`. Delete both blocks from',
|
|
106
|
+
'`app/core/runtime/types/module.ts`; nothing is lost, because `ltExtensions`',
|
|
107
|
+
"reaches the app through the module's runtime-config defaults either way.",
|
|
108
|
+
'New conversions strip them automatically — but a core update copies upstream',
|
|
109
|
+
'verbatim, so re-run the grep after every sync.',
|
|
95
110
|
]);
|
|
96
111
|
}
|
|
97
112
|
/**
|
|
@@ -443,6 +443,41 @@ git commit -m "chore: revert fullstack to npm mode
|
|
|
443
443
|
|
|
444
444
|
## Troubleshooting
|
|
445
445
|
|
|
446
|
+
### Problem: `config.public.*` is `unknown` in a project vendored before 1.43.0
|
|
447
|
+
|
|
448
|
+
**Symptom.** `nuxt typecheck` fails with `Argument of type 'unknown' is not
|
|
449
|
+
assignable to parameter of type 'string'` on code that is correct, at any
|
|
450
|
+
`useRuntimeConfig().public.x` read.
|
|
451
|
+
|
|
452
|
+
**Check whether you are affected** — version-independent:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
grep -rn "declare module '@nuxt/schema'" projects/app/app/core/
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Cause.** The vendored nuxt-extensions core augments `PublicRuntimeConfig` under
|
|
459
|
+
both `nuxt/schema` and `@nuxt/schema`. The former re-exports the latter, so that
|
|
460
|
+
is one interface decorated twice; as project source it closes a cycle with Nuxt's
|
|
461
|
+
generated runtime-config types. TypeScript reports
|
|
462
|
+
`TS2310: Type 'PublicRuntimeConfig' recursively references itself as a base type`,
|
|
463
|
+
which Nuxt's `skipLibCheck: true` suppresses — so only the confusing symptom is
|
|
464
|
+
visible. See it with:
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
cd projects/app && npx vue-tsc --noEmit -p .nuxt-check/tsconfig.json --skipLibCheck false | grep TS2310
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**Repair.** Delete the two `declare module '…/schema'` blocks from
|
|
471
|
+
`projects/app/app/core/runtime/types/module.ts`. Nothing is lost — `ltExtensions`
|
|
472
|
+
reaches the consumer through the module's runtime-config defaults, which Nuxt
|
|
473
|
+
writes into the generated types either way.
|
|
474
|
+
|
|
475
|
+
New conversions strip the blocks automatically (`stripVendorSchemaAugmentation`).
|
|
476
|
+
**Re-check after every core update:** the updater copies upstream files verbatim
|
|
477
|
+
and would bring them back. The template's own `CLAUDE.md` carries the same repair
|
|
478
|
+
note for the project side.
|
|
479
|
+
|
|
480
|
+
|
|
446
481
|
### Problem: `tsc` fails with `new Error('msg', { cause })` error
|
|
447
482
|
|
|
448
483
|
**Cause:** TypeScript target is too old (ES2020 or lower).
|
package/docs/commands.md
CHANGED
|
@@ -1075,6 +1075,15 @@ Installs helper scripts:
|
|
|
1075
1075
|
|
|
1076
1076
|
### `lt fullstack init`
|
|
1077
1077
|
|
|
1078
|
+
**Exit codes.** `0` on success, `1` on any failure — a failed clone, an aborted
|
|
1079
|
+
`pnpm install`, an invalid flag value, an existing target directory. Until 1.43.0
|
|
1080
|
+
every one of those exited `0`, so `lt fullstack init … && echo ok` printed `ok`
|
|
1081
|
+
after a broken scaffold; scripts and CI jobs that check `$?` need no workaround
|
|
1082
|
+
any more. The same contract holds for `lt fullstack add-api` / `add-app`, which
|
|
1083
|
+
`init` delegates to inside an existing workspace. A cancelled interactive prompt
|
|
1084
|
+
is not a failure and still exits `0`.
|
|
1085
|
+
|
|
1086
|
+
|
|
1078
1087
|
Creates a new fullstack workspace with API and frontend.
|
|
1079
1088
|
|
|
1080
1089
|
**Usage:**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "lenne.Tech CLI: lt",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lenne.Tech",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"glob": "13.0.6",
|
|
66
66
|
"gluegun": "5.2.2",
|
|
67
67
|
"js-sha256": "1.0.0",
|
|
68
|
-
"js-yaml": "4.3.
|
|
68
|
+
"js-yaml": "4.3.1",
|
|
69
69
|
"jsdom": "29.1.1",
|
|
70
70
|
"jsonc-parser": "3.3.1",
|
|
71
71
|
"lodash": "4.18.1",
|
|
@@ -104,7 +104,8 @@
|
|
|
104
104
|
"//minimatch-note": "brace-expansion 1.x and 2.x are END OF LINE for GHSA-mh99-v99m-4gvg: the advisory range is <=5.0.7 across ALL majors and upstream patched ONLY 5.0.8+. 1.1.18 and 2.1.4 are the newest releases of their majors and remain vulnerable. Forcing brace-expansion 5.x globally is NOT an option: 5.x is ESM/tshy and exports an object ({ expand, ... }) while 1.x/2.x export the function itself, so minimatch 3.x/5.x would die on `expand is not a function`. The only real fix is to raise each CONSUMER off minimatch 3.x/5.x, which is what the scoped entries below do. Each was verified against the consumer's actual call site before being added. The eslint / @eslint/eslintrc / @eslint/config-array entries were REMOVED in 1.42.0: eslint 10 requests minimatch ^10.2.5 itself, config-array ^10.2.4, and eslintrc is no longer in the tree at all.",
|
|
105
105
|
"minimatch@>=4 <10": "Bounded raise of every minimatch 4.x-9.x install to 10.2.6, whose brace-expansion ^5.0.8 is patched. Covers filelist (gluegun > ejs > jake > filelist, PRODUCTION-reachable, minimatch ^5.0.1), @typescript-eslint/typescript-estree (^9.0.4) and jest-config/jest-runtime/@jest/reporters (^9). Verified safe per consumer: filelist calls only the static helper `minimatch.match(files, pat, opts)`; typescript-estree calls the named `minimatch(filePath, pattern, { dot: true })`; the three jest packages declare minimatch but never require it. Floored at >=4 so the 3.x line is NOT swept in: minimatch 3.x is callable while 9.x/10.x export an object with __esModule but no `default` key, so sweeping it would break any consumer that calls the default export. Drop once these consumers request minimatch >=10 themselves.",
|
|
106
106
|
"babel-plugin-istanbul > test-exclude@<8": "test-exclude 6.0.0 pins minimatch ^3.0.4 and glob ^7. test-exclude 8.0.0 uses minimatch ^10.2.2 + glob ^13 (the glob major this project already ships) and is still CJS with the same `module.exports = TestExclude` class shape, so babel-plugin-istanbul's _interopRequireDefault + `new TestExclude(opts)` keeps working. Drop once babel-plugin-istanbul widens its ^6.0.0 range.",
|
|
107
|
-
"fs-jetpack > minimatch@<10": "Same chain, PRODUCTION-reachable (gluegun > fs-jetpack). Safe: lib/utils/matcher.js does `require('minimatch').Minimatch` and uses only `new Minimatch(pattern, { matchBase, nocomment, nocase, dot })`, `.negate` and `.match()` - all verified against 10.x. Not fixable by upgrading fs-jetpack: gluegun@5.2.2 is the latest release and fs-jetpack@5.1.0 still requests minimatch ^5.1.0 (brace-expansion ^2.0.1, also unpatched)."
|
|
107
|
+
"fs-jetpack > minimatch@<10": "Same chain, PRODUCTION-reachable (gluegun > fs-jetpack). Safe: lib/utils/matcher.js does `require('minimatch').Minimatch` and uses only `new Minimatch(pattern, { matchBase, nocomment, nocase, dot })`, `.negate` and `.match()` - all verified against 10.x. Not fixable by upgrading fs-jetpack: gluegun@5.2.2 is the latest release and fs-jetpack@5.1.0 still requests minimatch ^5.1.0 (brace-expansion ^2.0.1, also unpatched).",
|
|
108
|
+
"@istanbuljs/load-nyc-config": "GHSA-5p4m-2wfm-xmqj (high): quadratic CPU consumption resolving !!omap. Patched only in 4.3.1 - the advisory states the fix was NOT backported to 3.x, so 3.15.0 is the end of its line and there is nothing to raise it to within the major. The one 3.x path is dev-only: ts-jest > @jest/transform > babel-plugin-istanbul > @istanbuljs/load-nyc-config > js-yaml@3.15.0. Cross-major export shape verified before adding (repo policy): load-nyc-config calls `require('js-yaml').load(...)` at index.js:80, and 4.x exports `load` - so the raise is API-compatible. It is also strictly SAFER: 4.x `load` behaves like 3.x `safeLoad`, refusing arbitrary type construction, and the input here is a repo-local .nycrc.yml. Scoped to this ONE consumer rather than raised globally: that is the call site whose shape was verified, and a global raise would silently apply to consumers nobody checked. A top-level `js-yaml@<4.3.1` selector was tried first and npm did not apply it to this nested path at all - the scoped form is what actually resolves. Remove once ts-jest's istanbul chain requests a patched js-yaml itself."
|
|
108
109
|
},
|
|
109
110
|
"overrides": {
|
|
110
111
|
"semver@*": "7.8.5",
|
|
@@ -117,7 +118,10 @@
|
|
|
117
118
|
"babel-plugin-istanbul": {
|
|
118
119
|
"test-exclude@<8": "8.0.0"
|
|
119
120
|
},
|
|
120
|
-
"minimatch@>=4 <10": "10.2.6"
|
|
121
|
+
"minimatch@>=4 <10": "10.2.6",
|
|
122
|
+
"@istanbuljs/load-nyc-config": {
|
|
123
|
+
"js-yaml": "4.3.1"
|
|
124
|
+
}
|
|
121
125
|
},
|
|
122
126
|
"//jest.workerIdleMemoryLimit": "Recycle a ts-jest worker once its heap passes this. Guards against an intermittent `A jest worker process was terminated by another process: signal=SIGSEGV` that kills ONE suite while every other test passes (seen twice in marketplace*, both under `npm run check`, never reproducible on demand - 0 in 9 targeted runs incl. --maxWorkers=16). It is a worker crash, NOT an assertion failure: the signature is `Test suite failed to run` with the remaining count still green. All versions are current and in-range (node 24 / jest 30 / ts-jest 29 / ts 6), so this is a resilience measure against unbounded worker heap growth, not a proven root-cause fix - if it recurs, capture the suite name and re-open.",
|
|
123
127
|
"jest": {
|