@lenne.tech/cli 1.41.3 → 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.
@@ -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
  /**
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXCLUDED_FROM_PROFILE = exports.MEMORY_PROFILE = void 0;
4
+ exports.buildMergedValue = buildMergedValue;
5
+ exports.detectVariants = detectVariants;
6
+ exports.diffProfile = diffProfile;
7
+ exports.formatChange = formatChange;
8
+ exports.isEnablingFlagSet = isEnablingFlagSet;
9
+ exports.isPreventingFlagSet = isPreventingFlagSet;
10
+ exports.selectVariants = selectVariants;
11
+ exports.settingsPathFor = settingsPathFor;
12
+ exports.tuneSettingsFile = tuneSettingsFile;
13
+ const fs_1 = require("fs");
14
+ const os_1 = require("os");
15
+ const path_1 = require("path");
16
+ /**
17
+ * The tuning profile.
18
+ *
19
+ * Every value here was verified against the shipped extension manifests
20
+ * rather than copied from a blog post — three commonly recommended keys are
21
+ * deliberately ABSENT, see `EXCLUDED_FROM_PROFILE`.
22
+ *
23
+ * The exclude globs use `.nuxt*` / `.output*`, not `.nuxt` / `.output`. Both the
24
+ * framework and this CLI spawn SIBLING build directories — `.nuxt-check` for the
25
+ * check chain, `.nuxt-test` / `.output-test` for `lt dev test` — and a glob
26
+ * segment matches whole path segments, so `**\/.nuxt/**` does not cover
27
+ * `.nuxt-test`. A `.output-test` tree measures 37-294 MB; leaving it watched and
28
+ * indexed would undo the very saving this profile exists to make.
29
+ */
30
+ exports.MEMORY_PROFILE = {
31
+ 'files.watcherExclude': {
32
+ reason: 'Stops the file watcher from following build output and dependencies (CPU + memory per window).',
33
+ value: {
34
+ '**/.git/objects/**': true,
35
+ '**/.git/subtree-cache/**': true,
36
+ '**/.nuxt*/**': true,
37
+ '**/.output*/**': true,
38
+ '**/dist/**': true,
39
+ '**/node_modules/**': true,
40
+ },
41
+ },
42
+ 'search.exclude': {
43
+ reason: 'Keeps full-text search from indexing generated trees.',
44
+ value: {
45
+ '**/.nuxt*/**': true,
46
+ '**/.output*/**': true,
47
+ '**/dist/**': true,
48
+ '**/node_modules/**': true,
49
+ },
50
+ },
51
+ 'typescript.disableAutomaticTypeAcquisition': {
52
+ reason: 'Skips the @types download/scan pass — pointless in projects that declare their own types.',
53
+ value: true,
54
+ },
55
+ 'typescript.preferences.includePackageJsonAutoImports': {
56
+ reason: 'Auto-import no longer scans every package.json in the workspace — the single biggest monorepo win.',
57
+ value: 'off',
58
+ },
59
+ 'typescript.tsserver.maxTsServerMemory': {
60
+ reason: 'Lowers the per-server heap ceiling from the 3072 MB default; multiplies across every open root.',
61
+ value: 2048,
62
+ },
63
+ };
64
+ /**
65
+ * Keys intentionally NOT in the profile, with the measurement or manifest
66
+ * check that ruled them out. Surfaced by `lt dev vscode --explain` so the
67
+ * reasoning survives beyond the session that produced it.
68
+ */
69
+ exports.EXCLUDED_FROM_PROFILE = [
70
+ {
71
+ key: 'typescript.tsserver.useSyntaxServer: "never"',
72
+ why: 'Would drop the 2nd server per root, but those measured only ~1.7 GB across 16 roots while costing editor responsiveness.',
73
+ },
74
+ {
75
+ key: 'vue.server.hybridMode',
76
+ why: 'Does not exist in Volar 3.x — hybrid mode is unconditional there. Setting it is a silent no-op.',
77
+ },
78
+ {
79
+ key: 'typescript.tsserver.maxTsServerMemory: 4096+',
80
+ why: 'v8 pointer compression caps the heap near 4 GB, so values above it are ignored (microsoft/vscode#127105).',
81
+ },
82
+ ];
83
+ /**
84
+ * Merge semantics per value type.
85
+ *
86
+ * A plain value is replaced. An object value (the exclude maps) is merged with
87
+ * the user's existing entries winning, so tuning never silently drops a
88
+ * project-specific exclusion someone added by hand.
89
+ *
90
+ * A non-object `before` under an object-valued key (an array, say) is NOT
91
+ * mergeable and is replaced — VS Code's exclude settings are objects, so such a
92
+ * value is already being ignored by VS Code itself. That is the one case where
93
+ * the "never drops" promise above does not hold, and `--revert` cannot restore
94
+ * it either; the `.bak` is the recovery path.
95
+ */
96
+ function buildMergedValue(before, desired) {
97
+ if (!isPlainObject(desired)) {
98
+ return desired;
99
+ }
100
+ if (!isPlainObject(before)) {
101
+ return Object.assign({}, desired);
102
+ }
103
+ return Object.assign(Object.assign({}, desired), before);
104
+ }
105
+ /** Every known variant, flagged by whether its settings file exists. */
106
+ function detectVariants(platform = process.platform, home = (0, os_1.homedir)()) {
107
+ const known = [
108
+ { dir: 'Code', id: 'code', label: 'VS Code' },
109
+ { dir: 'Code - Insiders', id: 'insiders', label: 'VS Code Insiders' },
110
+ { dir: 'Cursor', id: 'cursor', label: 'Cursor' },
111
+ { dir: 'VSCodium', id: 'vscodium', label: 'VSCodium' },
112
+ ];
113
+ return known.map((k) => {
114
+ const settingsPath = settingsPathFor(k.dir, platform, home);
115
+ return { id: k.id, installed: (0, fs_1.existsSync)(settingsPath), label: k.label, settingsPath };
116
+ });
117
+ }
118
+ /**
119
+ * Compare the profile against a parsed settings object.
120
+ *
121
+ * Object-valued keys (the two exclude maps) are treated as SETS of entries in
122
+ * both directions: applying merges (the user's entries win), and reverting
123
+ * subtracts only the entries this profile contributes. Deleting the whole key on
124
+ * revert would take the user's hand-maintained exclusions with it — an undo that
125
+ * destroys data the tool never added is worse than no undo at all.
126
+ *
127
+ * Scalar keys are removed outright on revert, which restores VS Code's own
128
+ * default. An explicit pre-existing scalar (`maxTsServerMemory: 3072`, say) is
129
+ * not restored — recovering that is what the `.bak` is for.
130
+ */
131
+ function diffProfile(current, remove = false) {
132
+ return Object.entries(exports.MEMORY_PROFILE).map(([key, entry]) => {
133
+ const before = current[key];
134
+ const after = remove ? buildRevertedValue(before, entry.value) : buildMergedValue(before, entry.value);
135
+ let action;
136
+ if (remove) {
137
+ action = before === undefined || sameJson(before, after) ? 'unchanged' : 'removed';
138
+ }
139
+ else if (before === undefined) {
140
+ action = 'added';
141
+ }
142
+ else {
143
+ action = sameJson(before, after) ? 'unchanged' : 'changed';
144
+ }
145
+ return { action, after, before, key };
146
+ });
147
+ }
148
+ /**
149
+ * One `key: before → after` line, coloured by what happens to it.
150
+ *
151
+ * Lives here rather than in the command so it can be unit-tested; it takes the
152
+ * colour helpers as an argument and is otherwise pure.
153
+ */
154
+ function formatChange(change, colors) {
155
+ const short = (v) => {
156
+ if (v === undefined) {
157
+ return '—';
158
+ }
159
+ const s = JSON.stringify(v);
160
+ return s.length > 52 ? `${s.slice(0, 49)}…` : s;
161
+ };
162
+ const tag = {
163
+ added: colors.green('+'),
164
+ changed: colors.yellow('~'),
165
+ removed: colors.red('-'),
166
+ unchanged: colors.dim('='),
167
+ }[change.action];
168
+ if (change.action === 'unchanged') {
169
+ return `${tag} ${colors.dim(change.key)} ${colors.dim(short(change.before))}`;
170
+ }
171
+ return `${tag} ${change.key}: ${colors.dim(short(change.before))} → ${short(change.after)}`;
172
+ }
173
+ /**
174
+ * Whether an ENABLING flag (`--revert`, `--explain`) is set.
175
+ *
176
+ * gluegun declares no booleans to yargs-parser, so `--revert=true` arrives as
177
+ * the STRING `'true'`. For a flag that turns something ON, a parse quirk that
178
+ * reads as "not set" fails CLOSED, which is the safe direction.
179
+ */
180
+ function isEnablingFlagSet(value) {
181
+ return value === true || value === 'true';
182
+ }
183
+ /**
184
+ * Whether a PREVENTING flag (`--dry-run`) is set.
185
+ *
186
+ * Deliberately NOT `=== true || === 'true'`. That idiom is exactly backwards for
187
+ * a flag whose job is to STOP a write: `--dry-run=1` parses to the number `1`
188
+ * and `--dry-run=yes` to a string, both of which would read as "not set" and let
189
+ * the write proceed — the failure mode this repo already paid for once with
190
+ * `--keep-db` (see CLAUDE.md). Presence is intent; only an explicit negation
191
+ * proceeds.
192
+ */
193
+ function isPreventingFlagSet(options, ...names) {
194
+ return names.some((name) => {
195
+ if (!(name in options)) {
196
+ return false;
197
+ }
198
+ const v = options[name];
199
+ return v !== false && v !== 'false' && v !== 0 && v !== '0';
200
+ });
201
+ }
202
+ /**
203
+ * Split the detected variants into what to act on, given an optional `--variant`.
204
+ *
205
+ * `unknownFilter` is reported separately from "nothing installed": a bare
206
+ * `--variant` parses to boolean `true` and matches no id, and conflating that
207
+ * with "no editor found" told users VS Code was missing while it was installed.
208
+ */
209
+ function selectVariants(all, filter) {
210
+ const installed = all.filter((v) => v.installed);
211
+ if (filter === undefined || filter === null) {
212
+ return { targets: installed };
213
+ }
214
+ const wanted = String(filter);
215
+ if (!all.some((v) => v.id === wanted)) {
216
+ return { targets: [], unknownFilter: wanted };
217
+ }
218
+ return { targets: installed.filter((v) => v.id === wanted) };
219
+ }
220
+ /**
221
+ * Absolute path to a variant's user-settings file, per platform.
222
+ *
223
+ * All VS Code forks reuse the upstream layout, only the application support
224
+ * directory name differs. The joiner is selected per platform rather than taken
225
+ * from the ambient `path`, so the Windows branch produces real Windows paths
226
+ * (and stays assertable) when called from a test on macOS or Linux.
227
+ */
228
+ function settingsPathFor(dirName, platform = process.platform, home = (0, os_1.homedir)()) {
229
+ if (platform === 'win32') {
230
+ const appData = process.env.APPDATA || path_1.win32.join(home, 'AppData', 'Roaming');
231
+ return path_1.win32.join(appData, dirName, 'User', 'settings.json');
232
+ }
233
+ if (platform === 'darwin') {
234
+ return path_1.posix.join(home, 'Library', 'Application Support', dirName, 'User', 'settings.json');
235
+ }
236
+ return path_1.posix.join(home, '.config', dirName, 'User', 'settings.json');
237
+ }
238
+ /**
239
+ * Apply (or revert) the profile on one settings file.
240
+ *
241
+ * JSONC-safe: the file is edited through `jsonc-parser`, which preserves
242
+ * comments, key order and the user's formatting. A naive
243
+ * `JSON.parse` → `JSON.stringify` round-trip would silently delete every
244
+ * comment in a file people hand-maintain.
245
+ *
246
+ * `jsonc-parser` is required lazily: gluegun eagerly loads every command module
247
+ * on every `lt` invocation, and this is its only consumer, so a top-level import
248
+ * would put the load cost on `lt --version` too. Matches how the repo already
249
+ * treats `open`, `js-yaml`, `playwright-core` and `ts-morph`.
250
+ *
251
+ * Never writes when nothing would change, so re-running is a true no-op. Every
252
+ * failure is returned as `error` rather than thrown — the caller renders it per
253
+ * installation and carries on with the others.
254
+ */
255
+ function tuneSettingsFile(settingsPath, options = {}) {
256
+ var _a;
257
+ const { dryRun = false, remove = false } = options;
258
+ if (!(0, fs_1.existsSync)(settingsPath)) {
259
+ return { changes: [], error: `settings file not found: ${settingsPath}`, settingsPath, written: false };
260
+ }
261
+ // Never write THROUGH a symlink — the target may be any file the user's
262
+ // account can reach, and we were asked to tune settings, not to overwrite it.
263
+ if (isSymbolicLink(settingsPath)) {
264
+ return { changes: [], error: `refusing to write through a symlink: ${settingsPath}`, settingsPath, written: false };
265
+ }
266
+ const { applyEdits, modify, parse, printParseErrorCode } = require('jsonc-parser');
267
+ let raw;
268
+ try {
269
+ raw = (0, fs_1.readFileSync)(settingsPath, 'utf8');
270
+ }
271
+ catch (e) {
272
+ return { changes: [], error: `cannot read ${settingsPath}: ${errText(e)}`, settingsPath, written: false };
273
+ }
274
+ const errors = [];
275
+ const current = ((_a = parse(raw, errors, { allowTrailingComma: true })) !== null && _a !== void 0 ? _a : {});
276
+ // Refuse to touch a file we cannot read reliably — writing into a
277
+ // malformed settings.json risks destroying the user's configuration.
278
+ if (errors.length > 0) {
279
+ const first = errors[0];
280
+ return {
281
+ changes: [],
282
+ error: `cannot parse ${settingsPath}: ${printParseErrorCode(first.error)} at offset ${first.offset}`,
283
+ settingsPath,
284
+ written: false,
285
+ };
286
+ }
287
+ const changes = diffProfile(current, remove);
288
+ const effective = changes.filter((c) => c.action !== 'unchanged');
289
+ if (effective.length === 0 || dryRun) {
290
+ return { changes, settingsPath, written: false };
291
+ }
292
+ let content = raw;
293
+ for (const change of effective) {
294
+ // `after === undefined` deletes the key; an exclude map emptied by the
295
+ // revert subtraction lands here too.
296
+ const edits = modify(content, [change.key], change.after, {
297
+ formattingOptions: { insertSpaces: true, tabSize: 4 },
298
+ });
299
+ content = applyEdits(content, edits);
300
+ }
301
+ // Keep the FIRST backup. It is the only record of what the file looked like
302
+ // before this tool ever touched it — overwriting it on a later run (notably on
303
+ // `--revert`, which would then back up the *tuned* file) destroys exactly the
304
+ // state a user reaching for the backup wants to get back to.
305
+ const backupPath = `${settingsPath}.bak`;
306
+ try {
307
+ if (!(0, fs_1.existsSync)(backupPath)) {
308
+ (0, fs_1.copyFileSync)(settingsPath, backupPath);
309
+ }
310
+ (0, fs_1.writeFileSync)(settingsPath, content, 'utf8');
311
+ }
312
+ catch (e) {
313
+ return { changes, error: `cannot write ${settingsPath}: ${errText(e)}`, settingsPath, written: false };
314
+ }
315
+ return { backupPath, changes, settingsPath, written: true };
316
+ }
317
+ /**
318
+ * The value a key should hold after `--revert`.
319
+ *
320
+ * For an object-valued profile entry: the user's map minus our own entries, or
321
+ * `undefined` when nothing of theirs remains. For anything else: `undefined`
322
+ * (delete the key).
323
+ */
324
+ function buildRevertedValue(before, desired) {
325
+ if (!isPlainObject(desired) || !isPlainObject(before)) {
326
+ return undefined;
327
+ }
328
+ const kept = Object.fromEntries(Object.entries(before).filter(([k]) => !(k in desired)));
329
+ return Object.keys(kept).length > 0 ? kept : undefined;
330
+ }
331
+ /** Message text of an unknown thrown value. */
332
+ function errText(e) {
333
+ return e instanceof Error ? e.message : String(e);
334
+ }
335
+ /** Narrow to a non-null, non-array object. */
336
+ function isPlainObject(value) {
337
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
338
+ }
339
+ /** True when `path` is a symlink (never follows it). */
340
+ function isSymbolicLink(path) {
341
+ try {
342
+ return (0, fs_1.lstatSync)(path).isSymbolicLink();
343
+ }
344
+ catch (_a) {
345
+ return false;
346
+ }
347
+ }
348
+ /** Structural equality via JSON, sufficient for the plain values in the profile. */
349
+ function sameJson(a, b) {
350
+ return JSON.stringify(a) === JSON.stringify(b);
351
+ }
@@ -153,7 +153,7 @@ All code generators are **mode-aware**: In vendor mode they use relative paths t
153
153
  | Command | Purpose |
154
154
  |---------|---------|
155
155
  | `lt fullstack init` | Create fullstack monorepo (see above) |
156
- | `lt fullstack update` | Show mode-specific update instructions for backend AND frontend |
156
+ | `lt fullstack update` | Show mode-specific update instructions **and self-heal generated project scaffolding** (see `docs/commands.md`) |
157
157
  | **`lt fullstack convert-mode`** | **Convert backend AND frontend between npm and vendor mode in a single command** |
158
158
 
159
159
  **Fullstack mode conversion in one command:**
@@ -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).