@lenne.tech/cli 1.10.0 → 1.11.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/README.md +5 -3
- package/build/commands/config/validate.js +2 -0
- package/build/commands/frontend/convert-mode.js +198 -0
- package/build/commands/fullstack/convert-mode.js +368 -0
- package/build/commands/fullstack/init.js +44 -2
- package/build/commands/fullstack/update.js +49 -1
- package/build/commands/server/convert-mode.js +197 -0
- package/build/commands/status.js +81 -2
- package/build/config/vendor-frontend-runtime-deps.json +4 -0
- package/build/extensions/frontend-helper.js +652 -0
- package/build/extensions/server.js +515 -68
- package/build/lib/frontend-framework-detection.js +129 -0
- package/docs/LT-ECOSYSTEM-GUIDE.md +973 -0
- package/docs/VENDOR-MODE-WORKFLOW.md +471 -0
- package/docs/commands.md +196 -0
- package/docs/lt.config.md +9 -7
- package/package.json +2 -1
- package/build/templates/vendor-scripts/check-vendor-freshness.mjs +0 -131
- package/build/templates/vendor-scripts/propose-upstream-pr.ts +0 -269
- package/build/templates/vendor-scripts/sync-from-upstream.ts +0 -250
|
@@ -197,6 +197,658 @@ class FrontendHelper {
|
|
|
197
197
|
return { method: result.method, path: dest, success: true };
|
|
198
198
|
});
|
|
199
199
|
}
|
|
200
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
201
|
+
// Frontend Vendor Mode — @lenne.tech/nuxt-extensions
|
|
202
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
203
|
+
/**
|
|
204
|
+
* Convert an existing npm-mode frontend project to vendor mode.
|
|
205
|
+
*
|
|
206
|
+
* Validates that the project currently uses @lenne.tech/nuxt-extensions
|
|
207
|
+
* as an npm dependency, then delegates to convertAppCloneToVendored.
|
|
208
|
+
*/
|
|
209
|
+
convertAppToVendorMode(options) {
|
|
210
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
211
|
+
const { dest, upstreamBranch, upstreamRepoUrl } = options;
|
|
212
|
+
const { isVendoredAppProject } = require('../lib/frontend-framework-detection');
|
|
213
|
+
if (isVendoredAppProject(dest)) {
|
|
214
|
+
throw new Error('Project is already in vendor mode (app/core/VENDOR.md exists).');
|
|
215
|
+
}
|
|
216
|
+
const pkg = this.toolbox.filesystem.read(`${dest}/package.json`, 'json');
|
|
217
|
+
if (!pkg) {
|
|
218
|
+
throw new Error('Cannot read package.json');
|
|
219
|
+
}
|
|
220
|
+
const allDeps = Object.assign(Object.assign({}, (pkg.dependencies || {})), (pkg.devDependencies || {}));
|
|
221
|
+
if (!allDeps['@lenne.tech/nuxt-extensions']) {
|
|
222
|
+
throw new Error('@lenne.tech/nuxt-extensions is not in dependencies or devDependencies. ' +
|
|
223
|
+
'Is this an npm-mode lenne.tech frontend project?');
|
|
224
|
+
}
|
|
225
|
+
yield this.convertAppCloneToVendored({
|
|
226
|
+
dest,
|
|
227
|
+
upstreamBranch,
|
|
228
|
+
upstreamRepoUrl,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Convert an existing vendor-mode frontend project back to npm mode.
|
|
234
|
+
*
|
|
235
|
+
* Performs the inverse of convertAppCloneToVendored:
|
|
236
|
+
* 1. Read baseline version from VENDOR.md
|
|
237
|
+
* 2. Rewrite consumer imports from relative paths back to @lenne.tech/nuxt-extensions
|
|
238
|
+
* 3. Delete app/core/
|
|
239
|
+
* 4. Restore @lenne.tech/nuxt-extensions dependency in package.json
|
|
240
|
+
* 5. Rewrite nuxt.config.ts module entry
|
|
241
|
+
* 6. Remove vendor-specific scripts and CLAUDE.md marker
|
|
242
|
+
*/
|
|
243
|
+
convertAppToNpmMode(options) {
|
|
244
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
245
|
+
var _a;
|
|
246
|
+
const { dest, targetVersion } = options;
|
|
247
|
+
const { filesystem } = this.toolbox;
|
|
248
|
+
const path = require('node:path');
|
|
249
|
+
const { isVendoredAppProject } = require('../lib/frontend-framework-detection');
|
|
250
|
+
if (!isVendoredAppProject(dest)) {
|
|
251
|
+
throw new Error('Project is not in vendor mode (app/core/VENDOR.md not found).');
|
|
252
|
+
}
|
|
253
|
+
const coreDir = path.join(dest, 'app', 'core');
|
|
254
|
+
// ── 1. Determine target version + warn about local patches ──────────
|
|
255
|
+
const vendorMd = filesystem.read(path.join(coreDir, 'VENDOR.md')) || '';
|
|
256
|
+
let version = targetVersion;
|
|
257
|
+
if (!version) {
|
|
258
|
+
const match = vendorMd.match(/Baseline-Version[^0-9]*(\d+\.\d+\.\d+\S*)/);
|
|
259
|
+
if (match) {
|
|
260
|
+
version = match[1];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!version) {
|
|
264
|
+
throw new Error('Cannot determine target version. Specify --version or ensure VENDOR.md has a Baseline-Version.');
|
|
265
|
+
}
|
|
266
|
+
// Warn if VENDOR.md documents local patches that will be lost
|
|
267
|
+
const localChangesSection = vendorMd.match(/## Local changes[\s\S]*?(?=## |$)/i);
|
|
268
|
+
if (localChangesSection) {
|
|
269
|
+
const hasRealPatches = localChangesSection[0].includes('|') &&
|
|
270
|
+
!localChangesSection[0].includes('(none, pristine)') &&
|
|
271
|
+
/\|\s*\d{4}-/.test(localChangesSection[0]);
|
|
272
|
+
if (hasRealPatches) {
|
|
273
|
+
const { print } = this.toolbox;
|
|
274
|
+
print.warning('');
|
|
275
|
+
print.warning('VENDOR.md documents local patches in app/core/ that will be LOST:');
|
|
276
|
+
const rows = localChangesSection[0]
|
|
277
|
+
.split('\n')
|
|
278
|
+
.filter((l) => /^\|\s*\d{4}-/.test(l));
|
|
279
|
+
for (const row of rows.slice(0, 5)) {
|
|
280
|
+
print.info(` ${row.trim()}`);
|
|
281
|
+
}
|
|
282
|
+
if (rows.length > 5) {
|
|
283
|
+
print.info(` ... and ${rows.length - 5} more`);
|
|
284
|
+
}
|
|
285
|
+
print.warning('Consider running /lt-dev:frontend:contribute-nuxt-extensions-core first to upstream them.');
|
|
286
|
+
print.warning('');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ── 2. Rewrite consumer imports: relative → @lenne.tech/nuxt-extensions ─
|
|
290
|
+
this.rewriteConsumerImportsToNpm(dest);
|
|
291
|
+
// ── 3. Delete app/core/ ──────────────────────────────────────────────
|
|
292
|
+
if (filesystem.exists(coreDir)) {
|
|
293
|
+
filesystem.remove(coreDir);
|
|
294
|
+
}
|
|
295
|
+
// ── 4. Restore @lenne.tech/nuxt-extensions in package.json ──────────
|
|
296
|
+
const pkgPath = path.join(dest, 'package.json');
|
|
297
|
+
if (filesystem.exists(pkgPath)) {
|
|
298
|
+
const pkg = filesystem.read(pkgPath, 'json');
|
|
299
|
+
if (pkg && typeof pkg === 'object') {
|
|
300
|
+
if (!pkg.dependencies)
|
|
301
|
+
pkg.dependencies = {};
|
|
302
|
+
pkg.dependencies['@lenne.tech/nuxt-extensions'] = `^${version}`;
|
|
303
|
+
// Remove vendor-specific scripts
|
|
304
|
+
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
305
|
+
const scripts = pkg.scripts;
|
|
306
|
+
delete scripts['check:vendor-freshness'];
|
|
307
|
+
// Unhook freshness from check/check:fix/check:naf
|
|
308
|
+
for (const scriptName of ['check', 'check:fix', 'check:naf']) {
|
|
309
|
+
if ((_a = scripts[scriptName]) === null || _a === void 0 ? void 0 : _a.includes('check:vendor-freshness')) {
|
|
310
|
+
scripts[scriptName] = scripts[scriptName]
|
|
311
|
+
.replace(/pnpm run check:vendor-freshness && /g, '');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// ── 5. Rewrite nuxt.config.ts module entry ──────────────────────────
|
|
319
|
+
this.rewriteNuxtConfig(dest, 'npm');
|
|
320
|
+
// ── 6. Clean CLAUDE.md vendor marker ─────────────────────────────────
|
|
321
|
+
const claudeMdPath = path.join(dest, 'CLAUDE.md');
|
|
322
|
+
if (filesystem.exists(claudeMdPath)) {
|
|
323
|
+
let content = filesystem.read(claudeMdPath) || '';
|
|
324
|
+
const markerStart = '<!-- lt-vendor-marker-frontend -->';
|
|
325
|
+
const markerEnd = '---';
|
|
326
|
+
if (content.includes(markerStart)) {
|
|
327
|
+
const startIdx = content.indexOf(markerStart);
|
|
328
|
+
const endIdx = content.indexOf(markerEnd, startIdx);
|
|
329
|
+
if (endIdx > startIdx) {
|
|
330
|
+
content = content.slice(0, startIdx) + content.slice(endIdx + markerEnd.length);
|
|
331
|
+
content = content.replace(/^\n+/, '');
|
|
332
|
+
filesystem.write(claudeMdPath, content);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── Post-conversion verification ─────────────────────────────────────
|
|
337
|
+
const stale = this.findStaleFrontendImports(dest, /from\s+['"]\..*\/core['"]/);
|
|
338
|
+
if (stale.length > 0) {
|
|
339
|
+
const { print } = this.toolbox;
|
|
340
|
+
print.warning(`${stale.length} file(s) still contain relative core imports after npm conversion:`);
|
|
341
|
+
for (const f of stale.slice(0, 10)) {
|
|
342
|
+
print.info(` ${f}`);
|
|
343
|
+
}
|
|
344
|
+
print.info('These imports must be manually rewritten to @lenne.tech/nuxt-extensions.');
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Core vendoring pipeline for @lenne.tech/nuxt-extensions.
|
|
350
|
+
*
|
|
351
|
+
* Clones the upstream repo, copies module.ts + runtime/ into app/core/,
|
|
352
|
+
* rewrites nuxt.config.ts and explicit consumer imports, merges deps,
|
|
353
|
+
* creates VENDOR.md and patches CLAUDE.md.
|
|
354
|
+
*/
|
|
355
|
+
convertAppCloneToVendored(options) {
|
|
356
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
357
|
+
const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nuxt-extensions.git', } = options;
|
|
358
|
+
const path = require('node:path');
|
|
359
|
+
const { filesystem, system } = this.toolbox;
|
|
360
|
+
const coreDir = path.join(dest, 'app', 'core');
|
|
361
|
+
// ── 1. Clone @lenne.tech/nuxt-extensions into temp dir ──────────────
|
|
362
|
+
const tmpClone = path.join(require('os').tmpdir(), `lt-vendor-nuxt-ext-${Date.now()}`);
|
|
363
|
+
const branchArg = upstreamBranch ? `--branch ${upstreamBranch} ` : '';
|
|
364
|
+
try {
|
|
365
|
+
yield system.run(`git clone --depth 1 ${branchArg}${upstreamRepoUrl} ${tmpClone}`);
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
const raw = err.message || '';
|
|
369
|
+
const hints = [];
|
|
370
|
+
if (/Could not resolve host|getaddrinfo|ECONNREFUSED|Network is unreachable/i.test(raw)) {
|
|
371
|
+
hints.push('Network issue reaching github.com — check your connection or proxy settings.');
|
|
372
|
+
}
|
|
373
|
+
if (/Permission denied|authentication failed|publickey|403|401/i.test(raw)) {
|
|
374
|
+
hints.push('Authentication issue — the CLI uses an anonymous HTTPS clone; verify GitHub is reachable.');
|
|
375
|
+
}
|
|
376
|
+
if (upstreamBranch && /Remote branch .* not found|did not match any file\(s\) known to git/i.test(raw)) {
|
|
377
|
+
hints.push(`Upstream ref "${upstreamBranch}" does not exist. Check ${upstreamRepoUrl}/tags for valid refs. ` +
|
|
378
|
+
'Note: nuxt-extensions tags have NO "v" prefix — use e.g. "1.5.3", not "v1.5.3".');
|
|
379
|
+
}
|
|
380
|
+
if (/already exists and is not an empty/i.test(raw)) {
|
|
381
|
+
hints.push(`Target ${tmpClone} already exists. rm -rf /tmp/lt-vendor-nuxt-ext-* and retry.`);
|
|
382
|
+
}
|
|
383
|
+
const hintBlock = hints.length > 0 ? `\n Hints:\n - ${hints.join('\n - ')}` : '';
|
|
384
|
+
throw new Error(`Failed to clone ${upstreamRepoUrl}${upstreamBranch ? ` (branch/tag: ${upstreamBranch})` : ''}.\n Raw git error: ${raw.trim()}${hintBlock}`);
|
|
385
|
+
}
|
|
386
|
+
// Snapshot upstream metadata
|
|
387
|
+
let upstreamDeps = {};
|
|
388
|
+
let upstreamDevDeps = {};
|
|
389
|
+
let upstreamPeerDeps = {};
|
|
390
|
+
let upstreamVersion = '';
|
|
391
|
+
try {
|
|
392
|
+
const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
|
|
393
|
+
if (upstreamPkg && typeof upstreamPkg === 'object') {
|
|
394
|
+
upstreamDeps = upstreamPkg.dependencies || {};
|
|
395
|
+
upstreamDevDeps = upstreamPkg.devDependencies || {};
|
|
396
|
+
upstreamPeerDeps = upstreamPkg.peerDependencies || {};
|
|
397
|
+
upstreamVersion = upstreamPkg.version || '';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (_a) {
|
|
401
|
+
// Best-effort
|
|
402
|
+
}
|
|
403
|
+
let upstreamClaudeMd = '';
|
|
404
|
+
try {
|
|
405
|
+
const c = filesystem.read(`${tmpClone}/CLAUDE.md`);
|
|
406
|
+
if (typeof c === 'string')
|
|
407
|
+
upstreamClaudeMd = c;
|
|
408
|
+
}
|
|
409
|
+
catch (_b) {
|
|
410
|
+
// Non-fatal
|
|
411
|
+
}
|
|
412
|
+
let upstreamCommit = '';
|
|
413
|
+
try {
|
|
414
|
+
const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
|
|
415
|
+
upstreamCommit = (sha || '').trim();
|
|
416
|
+
}
|
|
417
|
+
catch (_c) {
|
|
418
|
+
// Non-fatal
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
// ── 2. Copy source files to app/core/ ─────────────────────────────
|
|
422
|
+
if (filesystem.exists(coreDir)) {
|
|
423
|
+
filesystem.remove(coreDir);
|
|
424
|
+
}
|
|
425
|
+
const copies = [
|
|
426
|
+
[`${tmpClone}/src/module.ts`, `${coreDir}/module.ts`],
|
|
427
|
+
[`${tmpClone}/src/index.ts`, `${coreDir}/index.ts`],
|
|
428
|
+
[`${tmpClone}/src/runtime`, `${coreDir}/runtime`],
|
|
429
|
+
[`${tmpClone}/LICENSE`, `${coreDir}/LICENSE`],
|
|
430
|
+
];
|
|
431
|
+
for (const [from, to] of copies) {
|
|
432
|
+
if (filesystem.exists(from)) {
|
|
433
|
+
filesystem.copy(from, to);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
// Always clean up temp clone
|
|
439
|
+
if (filesystem.exists(tmpClone)) {
|
|
440
|
+
filesystem.remove(tmpClone);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// No flatten-fix needed — nuxt-extensions source structure is already
|
|
444
|
+
// flat (module.ts + runtime/ at the same level). Unlike the backend
|
|
445
|
+
// (nest-server) where src/index.ts and src/core/ must be merged into
|
|
446
|
+
// one directory, nuxt-extensions keeps everything directly under src/.
|
|
447
|
+
// ── 3. Rewrite consumer explicit imports ─────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// Most consumer code uses Nuxt auto-imports (composables, utils,
|
|
450
|
+
// components) which the module.ts registers via addImports/addComponent.
|
|
451
|
+
// However, a few explicit imports exist:
|
|
452
|
+
// - Type imports: `from '@lenne.tech/nuxt-extensions'` (e.g. LtUser, LtUploadItem)
|
|
453
|
+
// - Testing imports: `from '@lenne.tech/nuxt-extensions/testing'`
|
|
454
|
+
//
|
|
455
|
+
// These must be rewritten to relative paths to app/core.
|
|
456
|
+
this.rewriteConsumerImportsToVendor(dest);
|
|
457
|
+
// ── 4. Rewrite nuxt.config.ts module entry ──────────────────────────
|
|
458
|
+
this.rewriteNuxtConfig(dest, 'vendor');
|
|
459
|
+
// ── 5. package.json: remove @lenne.tech/nuxt-extensions, merge deps ─
|
|
460
|
+
const pkgPath = path.join(dest, 'package.json');
|
|
461
|
+
if (filesystem.exists(pkgPath)) {
|
|
462
|
+
const pkg = filesystem.read(pkgPath, 'json');
|
|
463
|
+
if (pkg && typeof pkg === 'object') {
|
|
464
|
+
if (pkg.dependencies && typeof pkg.dependencies === 'object') {
|
|
465
|
+
delete pkg.dependencies['@lenne.tech/nuxt-extensions'];
|
|
466
|
+
}
|
|
467
|
+
if (pkg.devDependencies && typeof pkg.devDependencies === 'object') {
|
|
468
|
+
delete pkg.devDependencies['@lenne.tech/nuxt-extensions'];
|
|
469
|
+
}
|
|
470
|
+
// Merge upstream deps (mainly @nuxt/kit)
|
|
471
|
+
if (!pkg.dependencies)
|
|
472
|
+
pkg.dependencies = {};
|
|
473
|
+
const deps = pkg.dependencies;
|
|
474
|
+
for (const [depName, depVersion] of Object.entries(upstreamDeps)) {
|
|
475
|
+
if (depName === '@lenne.tech/nuxt-extensions')
|
|
476
|
+
continue;
|
|
477
|
+
if (!(depName in deps)) {
|
|
478
|
+
deps[depName] = depVersion;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Promote any upstream devDeps flagged as runtime-needed (via
|
|
482
|
+
// vendor-frontend-runtime-deps.json) into dependencies. Currently
|
|
483
|
+
// empty for nuxt-extensions but reserved for future additions.
|
|
484
|
+
for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
|
|
485
|
+
if (this.isFrontendVendorRuntimeDep(depName) && !(depName in deps)) {
|
|
486
|
+
deps[depName] = depVersion;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Verify peer deps are present (they should already be from the starter)
|
|
490
|
+
for (const [depName] of Object.entries(upstreamPeerDeps)) {
|
|
491
|
+
if (!(depName in deps) && !(depName in (pkg.devDependencies || {}))) {
|
|
492
|
+
const { print } = this.toolbox;
|
|
493
|
+
print.warning(`Peer dependency ${depName} is missing — you may need to install it.`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Vendor freshness check script
|
|
497
|
+
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
498
|
+
const scripts = pkg.scripts;
|
|
499
|
+
scripts['check:vendor-freshness'] = [
|
|
500
|
+
'node -e "',
|
|
501
|
+
"var f=require('fs'),h=require('https');",
|
|
502
|
+
"try{var c=f.readFileSync('app/core/VENDOR.md','utf8')}catch(e){process.exit(0)}",
|
|
503
|
+
'var m=c.match(/Baseline-Version[^0-9]*(\\d+\\.\\d+\\.\\d+)/);',
|
|
504
|
+
'if(!m){process.stderr.write(String.fromCharCode(9888)+\' vendor-freshness: no baseline\\n\');process.exit(0)}',
|
|
505
|
+
'var v=m[1];',
|
|
506
|
+
"h.get('https://registry.npmjs.org/@lenne.tech/nuxt-extensions/latest',function(r){",
|
|
507
|
+
"var d='';r.on('data',function(c){d+=c});r.on('end',function(){",
|
|
508
|
+
"try{var l=JSON.parse(d).version;",
|
|
509
|
+
"if(v===l)console.log('vendor core up-to-date (v'+v+')');",
|
|
510
|
+
"else process.stderr.write('vendor core v'+v+', latest v'+l+'\\n')",
|
|
511
|
+
'}catch(e){}})}).on(\'error\',function(){});',
|
|
512
|
+
'setTimeout(function(){process.exit(0)},5000)',
|
|
513
|
+
'"',
|
|
514
|
+
].join('');
|
|
515
|
+
// Hook freshness into check scripts
|
|
516
|
+
const hookFreshness = (scriptName) => {
|
|
517
|
+
const existing = scripts[scriptName];
|
|
518
|
+
if (!existing)
|
|
519
|
+
return;
|
|
520
|
+
if (existing.includes('check:vendor-freshness'))
|
|
521
|
+
return;
|
|
522
|
+
scripts[scriptName] = `pnpm run check:vendor-freshness && ${existing}`;
|
|
523
|
+
};
|
|
524
|
+
hookFreshness('check');
|
|
525
|
+
hookFreshness('check:fix');
|
|
526
|
+
hookFreshness('check:naf');
|
|
527
|
+
}
|
|
528
|
+
filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// ── 6. CLAUDE.md: prepend vendor marker + merge upstream sections ────
|
|
532
|
+
const claudeMdPath = path.join(dest, 'CLAUDE.md');
|
|
533
|
+
if (filesystem.exists(claudeMdPath)) {
|
|
534
|
+
const existing = filesystem.read(claudeMdPath) || '';
|
|
535
|
+
const marker = '<!-- lt-vendor-marker-frontend -->';
|
|
536
|
+
if (!existing.includes(marker)) {
|
|
537
|
+
const vendorBlock = [
|
|
538
|
+
marker,
|
|
539
|
+
'',
|
|
540
|
+
'# Vendor-Mode Notice (Frontend)',
|
|
541
|
+
'',
|
|
542
|
+
'This frontend project runs in **vendor mode**: the `@lenne.tech/nuxt-extensions`',
|
|
543
|
+
'module has been copied directly into `app/core/` as first-class',
|
|
544
|
+
'project code. There is **no** `@lenne.tech/nuxt-extensions` npm dependency.',
|
|
545
|
+
'',
|
|
546
|
+
'- **Read framework code from `app/core/**`** — not from `node_modules/`.',
|
|
547
|
+
'- **nuxt.config.ts** references `\'./app/core/module\'` instead of',
|
|
548
|
+
' `\'@lenne.tech/nuxt-extensions\'`.',
|
|
549
|
+
'- **Baseline + patch log** live in `app/core/VENDOR.md`. Log any',
|
|
550
|
+
' substantial local change there so the `nuxt-extensions-core-updater`',
|
|
551
|
+
' agent can classify it at sync time.',
|
|
552
|
+
'- **Update flow:** run `/lt-dev:frontend:update-nuxt-extensions-core`.',
|
|
553
|
+
'- **Contribute back:** run `/lt-dev:frontend:contribute-nuxt-extensions-core`.',
|
|
554
|
+
'- **Freshness check:** `pnpm run check:vendor-freshness` warns when',
|
|
555
|
+
' upstream has a newer release than the baseline.',
|
|
556
|
+
'',
|
|
557
|
+
'---',
|
|
558
|
+
'',
|
|
559
|
+
].join('\n');
|
|
560
|
+
filesystem.write(claudeMdPath, vendorBlock + existing);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Merge upstream CLAUDE.md sections
|
|
564
|
+
if (upstreamClaudeMd && filesystem.exists(claudeMdPath)) {
|
|
565
|
+
const projectContent = filesystem.read(claudeMdPath) || '';
|
|
566
|
+
const upstreamSections = this.parseH2Sections(upstreamClaudeMd);
|
|
567
|
+
const projectSections = this.parseH2Sections(projectContent);
|
|
568
|
+
const newSections = [];
|
|
569
|
+
for (const [heading, body] of upstreamSections) {
|
|
570
|
+
if (heading === '__preamble__')
|
|
571
|
+
continue;
|
|
572
|
+
if (!projectSections.has(heading)) {
|
|
573
|
+
newSections.push(`## ${heading}\n\n${body.trim()}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (newSections.length > 0) {
|
|
577
|
+
const separator = projectContent.endsWith('\n') ? '\n' : '\n\n';
|
|
578
|
+
filesystem.write(claudeMdPath, `${projectContent}${separator}${newSections.join('\n\n')}\n`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// ── 7. VENDOR.md baseline ────────────────────────────────────────────
|
|
582
|
+
const vendorMdPath = path.join(coreDir, 'VENDOR.md');
|
|
583
|
+
if (!filesystem.exists(vendorMdPath)) {
|
|
584
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
585
|
+
const versionLine = upstreamVersion
|
|
586
|
+
? `- **Baseline-Version:** ${upstreamVersion}`
|
|
587
|
+
: '- **Baseline-Version:** (not detected)';
|
|
588
|
+
const commitLine = upstreamCommit
|
|
589
|
+
? `- **Baseline-Commit:** \`${upstreamCommit}\``
|
|
590
|
+
: '- **Baseline-Commit:** (not detected)';
|
|
591
|
+
const syncHistoryTo = upstreamVersion
|
|
592
|
+
? `${upstreamVersion}${upstreamCommit ? ` (\`${upstreamCommit.slice(0, 10)}\`)` : ''}`
|
|
593
|
+
: 'initial import';
|
|
594
|
+
filesystem.write(vendorMdPath, [
|
|
595
|
+
'# @lenne.tech/nuxt-extensions (vendored)',
|
|
596
|
+
'',
|
|
597
|
+
'This directory is a curated vendor copy of `@lenne.tech/nuxt-extensions`.',
|
|
598
|
+
'It is first-class project code, not a node_modules shadow copy.',
|
|
599
|
+
'Edit freely; log substantial changes in the "Local changes" table below',
|
|
600
|
+
'so the `nuxt-extensions-core-updater` agent can classify them at sync time.',
|
|
601
|
+
'',
|
|
602
|
+
'Unlike the backend (nest-server) vendoring, no flatten-fix is needed —',
|
|
603
|
+
'the nuxt-extensions source structure is already flat.',
|
|
604
|
+
'',
|
|
605
|
+
'## Baseline',
|
|
606
|
+
'',
|
|
607
|
+
'- **Upstream-Repo:** https://github.com/lenneTech/nuxt-extensions',
|
|
608
|
+
versionLine,
|
|
609
|
+
commitLine,
|
|
610
|
+
`- **Vendored am:** ${today}`,
|
|
611
|
+
'- **Vendored von:** lt CLI (`lt frontend convert-mode --to vendor`)',
|
|
612
|
+
'',
|
|
613
|
+
'## Sync history',
|
|
614
|
+
'',
|
|
615
|
+
'| Date | From | To | Notes |',
|
|
616
|
+
'| ---- | ---- | -- | ----- |',
|
|
617
|
+
`| ${today} | — | ${syncHistoryTo} | scaffolded by lt CLI |`,
|
|
618
|
+
'',
|
|
619
|
+
'## Local changes',
|
|
620
|
+
'',
|
|
621
|
+
'| Date | Commit | Scope | Reason | Status |',
|
|
622
|
+
'| ---- | ------ | ----- | ------ | ------ |',
|
|
623
|
+
'| — | — | (none, pristine) | initial vendor | — |',
|
|
624
|
+
'',
|
|
625
|
+
'## Upstream PRs',
|
|
626
|
+
'',
|
|
627
|
+
'| PR | Title | Commits | Status |',
|
|
628
|
+
'| -- | ----- | ------- | ------ |',
|
|
629
|
+
'| — | (none yet) | — | — |',
|
|
630
|
+
'',
|
|
631
|
+
].join('\n'));
|
|
632
|
+
}
|
|
633
|
+
// ── Post-conversion verification ─────────────────────────────────────
|
|
634
|
+
// Only match actual import/from statements, not comments or strings
|
|
635
|
+
const staleImports = this.findStaleFrontendImports(dest, /(?:^|\s)(?:import|from)\s+['"][^'"]*@lenne\.tech\/nuxt-extensions/m, 'app/core/');
|
|
636
|
+
if (staleImports.length > 0) {
|
|
637
|
+
const { print } = this.toolbox;
|
|
638
|
+
print.warning(`${staleImports.length} file(s) still contain '@lenne.tech/nuxt-extensions' imports after vendor conversion:`);
|
|
639
|
+
for (const f of staleImports.slice(0, 10)) {
|
|
640
|
+
print.info(` ${f}`);
|
|
641
|
+
}
|
|
642
|
+
if (staleImports.length > 10) {
|
|
643
|
+
print.info(` ... and ${staleImports.length - 10} more`);
|
|
644
|
+
}
|
|
645
|
+
print.info('These imports must be manually rewritten to relative paths pointing to app/core.');
|
|
646
|
+
}
|
|
647
|
+
return { upstreamDeps };
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
651
|
+
// Private vendor helpers
|
|
652
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
653
|
+
/**
|
|
654
|
+
* Rewrite nuxt.config.ts module entry between npm and vendor mode.
|
|
655
|
+
*
|
|
656
|
+
* npm→vendor: '@lenne.tech/nuxt-extensions' → './app/core/module'
|
|
657
|
+
* vendor→npm: './app/core/module' → '@lenne.tech/nuxt-extensions'
|
|
658
|
+
*/
|
|
659
|
+
rewriteNuxtConfig(appDir, mode) {
|
|
660
|
+
const path = require('node:path');
|
|
661
|
+
const { filesystem } = this.toolbox;
|
|
662
|
+
const configPath = path.join(appDir, 'nuxt.config.ts');
|
|
663
|
+
if (!filesystem.exists(configPath))
|
|
664
|
+
return;
|
|
665
|
+
let content = filesystem.read(configPath) || '';
|
|
666
|
+
if (mode === 'vendor') {
|
|
667
|
+
content = content.replace(/['"]@lenne\.tech\/nuxt-extensions['"]/g, "'./app/core/module'");
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
content = content.replace(/['"]\.\/app\/core\/module['"]/g, "'@lenne.tech/nuxt-extensions'");
|
|
671
|
+
}
|
|
672
|
+
filesystem.write(configPath, content);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Rewrite consumer imports from npm specifiers to relative vendor paths.
|
|
676
|
+
*
|
|
677
|
+
* Handles both .ts and .vue files via regex replacement.
|
|
678
|
+
* Skips files inside app/core/ (the vendored framework itself).
|
|
679
|
+
*/
|
|
680
|
+
rewriteConsumerImportsToVendor(appDir) {
|
|
681
|
+
const path = require('node:path');
|
|
682
|
+
const { filesystem } = this.toolbox;
|
|
683
|
+
const coreDir = path.join(appDir, 'app', 'core');
|
|
684
|
+
const coreDirWithSep = coreDir + path.sep;
|
|
685
|
+
const allFiles = this.walkConsumerFiles(appDir);
|
|
686
|
+
for (const absFile of allFiles) {
|
|
687
|
+
// Skip vendored framework files
|
|
688
|
+
if (absFile.startsWith(coreDirWithSep))
|
|
689
|
+
continue;
|
|
690
|
+
const content = filesystem.read(absFile);
|
|
691
|
+
if (!content)
|
|
692
|
+
continue;
|
|
693
|
+
if (!content.includes('@lenne.tech/nuxt-extensions'))
|
|
694
|
+
continue;
|
|
695
|
+
const fromDir = path.dirname(absFile);
|
|
696
|
+
let relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
|
|
697
|
+
if (!relToCore.startsWith('.'))
|
|
698
|
+
relToCore = `./${relToCore}`;
|
|
699
|
+
let patched = content;
|
|
700
|
+
// Testing imports: @lenne.tech/nuxt-extensions/testing → relative/runtime/testing
|
|
701
|
+
patched = patched.replace(/from\s+['"]@lenne\.tech\/nuxt-extensions\/testing['"]/g, `from '${relToCore}/runtime/testing'`);
|
|
702
|
+
// Main imports: @lenne.tech/nuxt-extensions → relative core
|
|
703
|
+
patched = patched.replace(/from\s+['"]@lenne\.tech\/nuxt-extensions['"]/g, `from '${relToCore}'`);
|
|
704
|
+
if (patched !== content) {
|
|
705
|
+
filesystem.write(absFile, patched);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Rewrite consumer imports from relative vendor paths back to npm specifiers.
|
|
711
|
+
*/
|
|
712
|
+
rewriteConsumerImportsToNpm(appDir) {
|
|
713
|
+
const path = require('node:path');
|
|
714
|
+
const { filesystem } = this.toolbox;
|
|
715
|
+
const coreDir = path.join(appDir, 'app', 'core');
|
|
716
|
+
const coreDirWithSep = coreDir + path.sep;
|
|
717
|
+
const allFiles = this.walkConsumerFiles(appDir);
|
|
718
|
+
for (const absFile of allFiles) {
|
|
719
|
+
if (absFile.startsWith(coreDirWithSep))
|
|
720
|
+
continue;
|
|
721
|
+
const content = filesystem.read(absFile);
|
|
722
|
+
if (!content)
|
|
723
|
+
continue;
|
|
724
|
+
// Check if file has any relative import pointing to the core dir
|
|
725
|
+
const fromDir = path.dirname(absFile);
|
|
726
|
+
const relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
|
|
727
|
+
if (!content.includes(relToCore))
|
|
728
|
+
continue;
|
|
729
|
+
let patched = content;
|
|
730
|
+
// Testing imports: relative/runtime/testing → @lenne.tech/nuxt-extensions/testing
|
|
731
|
+
const testingPattern = new RegExp(`from\\s+['"]${relToCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/runtime/testing['"]`, 'g');
|
|
732
|
+
patched = patched.replace(testingPattern, "from '@lenne.tech/nuxt-extensions/testing'");
|
|
733
|
+
// Main imports: relative core → @lenne.tech/nuxt-extensions
|
|
734
|
+
const corePattern = new RegExp(`from\\s+['"]${relToCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, 'g');
|
|
735
|
+
patched = patched.replace(corePattern, "from '@lenne.tech/nuxt-extensions'");
|
|
736
|
+
if (patched !== content) {
|
|
737
|
+
filesystem.write(absFile, patched);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Scan consumer files for stale imports matching a pattern.
|
|
743
|
+
*/
|
|
744
|
+
findStaleFrontendImports(appDir, needle, skipPathContaining) {
|
|
745
|
+
const { filesystem } = this.toolbox;
|
|
746
|
+
const allFiles = this.walkConsumerFiles(appDir);
|
|
747
|
+
const stale = [];
|
|
748
|
+
for (const absFile of allFiles) {
|
|
749
|
+
if (skipPathContaining && absFile.includes(skipPathContaining))
|
|
750
|
+
continue;
|
|
751
|
+
const content = filesystem.read(absFile) || '';
|
|
752
|
+
const matches = typeof needle === 'string'
|
|
753
|
+
? content.includes(needle)
|
|
754
|
+
: needle.test(content);
|
|
755
|
+
if (matches) {
|
|
756
|
+
stale.push(absFile.replace(`${appDir}/`, ''));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return stale;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Predicate: is a given upstream `devDependencies` key actually a runtime
|
|
763
|
+
* dep in disguise that needs to live in `dependencies` after vendoring?
|
|
764
|
+
*
|
|
765
|
+
* The list of such helpers lives in `src/config/vendor-frontend-runtime-deps.json`
|
|
766
|
+
* under the `runtimeHelpers` key. Adding a new helper is a data-only change
|
|
767
|
+
* (no CLI release required). If the config file is missing or unreadable,
|
|
768
|
+
* the predicate safely returns `false` for everything.
|
|
769
|
+
*
|
|
770
|
+
* Currently, nuxt-extensions has a minimal dependency graph (only `@nuxt/kit`
|
|
771
|
+
* as a direct runtime dep), so this list is typically empty. The mechanism
|
|
772
|
+
* exists for future-proofing: if upstream adds a devDep that the framework
|
|
773
|
+
* code imports at runtime, add it to the JSON and the next vendor conversion
|
|
774
|
+
* will promote it automatically.
|
|
775
|
+
*/
|
|
776
|
+
isFrontendVendorRuntimeDep(pkgName) {
|
|
777
|
+
if (!this._vendorFrontendRuntimeHelpers) {
|
|
778
|
+
try {
|
|
779
|
+
const path = require('node:path');
|
|
780
|
+
const configPath = path.join(__dirname, '..', 'config', 'vendor-frontend-runtime-deps.json');
|
|
781
|
+
const raw = this.toolbox.filesystem.read(configPath, 'json');
|
|
782
|
+
const list = Array.isArray(raw === null || raw === void 0 ? void 0 : raw.runtimeHelpers) ? raw.runtimeHelpers : [];
|
|
783
|
+
this._vendorFrontendRuntimeHelpers = new Set(list.filter((e) => typeof e === 'string'));
|
|
784
|
+
}
|
|
785
|
+
catch (_a) {
|
|
786
|
+
this._vendorFrontendRuntimeHelpers = new Set();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return this._vendorFrontendRuntimeHelpers.has(pkgName);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Recursively walks `app/` and `tests/` directories under `appDir`,
|
|
793
|
+
* returning absolute paths to all `.ts` and `.vue` files.
|
|
794
|
+
*
|
|
795
|
+
* Shared helper for consumer-import codemods and stale-import scans.
|
|
796
|
+
* Uses native `fs.readdirSync` because gluegun's `filesystem.find()`
|
|
797
|
+
* returns paths relative to the jetpack cwd, which is unreliable
|
|
798
|
+
* when the CLI is invoked from arbitrary working directories.
|
|
799
|
+
*/
|
|
800
|
+
walkConsumerFiles(appDir) {
|
|
801
|
+
const fs = require('node:fs');
|
|
802
|
+
const path = require('node:path');
|
|
803
|
+
const searchDirs = [
|
|
804
|
+
path.join(appDir, 'app'),
|
|
805
|
+
path.join(appDir, 'tests'),
|
|
806
|
+
];
|
|
807
|
+
const allFiles = [];
|
|
808
|
+
const walk = (dir) => {
|
|
809
|
+
try {
|
|
810
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
811
|
+
for (const item of items) {
|
|
812
|
+
const fp = path.join(dir, item.name);
|
|
813
|
+
if (item.isDirectory()) {
|
|
814
|
+
walk(fp);
|
|
815
|
+
}
|
|
816
|
+
else if (item.isFile() && (fp.endsWith('.ts') || fp.endsWith('.vue'))) {
|
|
817
|
+
allFiles.push(fp);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
catch (_a) {
|
|
822
|
+
// Directory doesn't exist or can't be read
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
for (const dir of searchDirs) {
|
|
826
|
+
walk(dir);
|
|
827
|
+
}
|
|
828
|
+
return allFiles;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Parse markdown content into H2 sections for section-level merge.
|
|
832
|
+
*/
|
|
833
|
+
parseH2Sections(content) {
|
|
834
|
+
const sections = new Map();
|
|
835
|
+
const lines = content.split('\n');
|
|
836
|
+
let currentHeading = '__preamble__';
|
|
837
|
+
let currentBody = [];
|
|
838
|
+
for (const line of lines) {
|
|
839
|
+
const match = /^## (.+)$/.exec(line);
|
|
840
|
+
if (match) {
|
|
841
|
+
sections.set(currentHeading, currentBody.join('\n'));
|
|
842
|
+
currentHeading = match[1].trim();
|
|
843
|
+
currentBody = [];
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
currentBody.push(line);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
sections.set(currentHeading, currentBody.join('\n'));
|
|
850
|
+
return sections;
|
|
851
|
+
}
|
|
200
852
|
}
|
|
201
853
|
exports.FrontendHelper = FrontendHelper;
|
|
202
854
|
/**
|