@lenne.tech/cli
Version:
lenne.Tech CLI: lt
959 lines (958 loc) • 50.7 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FrontendHelper = void 0;
const markdown_table_1 = require("../lib/markdown-table");
/**
* Frontend helper functions for project scaffolding
* Provides reusable methods for setting up Nuxt and Angular frontends
*/
class FrontendHelper {
/**
* Constructor for integration of toolbox
*/
constructor(toolbox) {
this.toolbox = toolbox;
}
/**
* Fix package name in package.json
* Changes the name to "app" which is a valid npm package name for monorepos
*
* @param dest - Directory containing the package.json
*/
fixPackageName(dest) {
return __awaiter(this, void 0, void 0, function* () {
const { patching } = this.toolbox;
yield patching.update(`${dest}/package.json`, (data) => {
data.name = 'app';
return data;
});
});
}
/**
* Patch frontend .env file with project-specific values
* Replaces template placeholders with actual project values
*
* @param dest - Directory containing the .env file
* @param projectName - Project name in kebab-case (e.g., "my-shop")
*/
patchFrontendEnv(dest, projectName) {
const { filesystem } = this.toolbox;
const envPath = `${dest}/.env`;
const envExamplePath = `${dest}/.env.example`;
// Create .env from .env.example if it doesn't exist
if (!filesystem.exists(envPath) && filesystem.exists(envExamplePath)) {
filesystem.copy(envExamplePath, envPath);
}
if (!filesystem.exists(envPath)) {
return;
}
let content = filesystem.read(envPath);
if (!content) {
return;
}
// Replace NUXT_PUBLIC_STORAGE_PREFIX value with project-specific prefix
content = content.replace(/^(NUXT_PUBLIC_STORAGE_PREFIX=).*$/m, `$1${projectName}-local`);
filesystem.write(envPath, content);
}
/**
* Flatten the cloned nuxt-base-starter wrapper layout so the project's
* `projects/app/` directory IS the Nuxt app.
*
* `lenneTech/nuxt-base-starter` ships a wrapper repo: the root
* `package.json` is the `create-nuxt-base` scaffolder (a separate npm
* package — `bin/create-nuxt-base` lives at `index.js`), and the
* actual Nuxt app lives one level deeper at `nuxt-base-template/`.
* Without this flatten, the generated monorepo's `pnpm-workspace.yaml`
* and the README's `cd projects/app && pnpm install && pnpm dev`
* point at the wrapper, not the app, so `pnpm install` resolves the
* wrong dependencies and `pnpm dev` has nothing to run
* (LLM-test 2026-05-03 friction #3 entry 20:30).
*
* Defense-in-depth: only mutate the layout if extraction succeeds.
* If `nuxt-base-template/` is missing or isn't a directory (corrupt
* clone, future repo reshape that drops the wrapper), we return
* `{ flattened: false, reason }` and leave the original tree alone.
* The pre-flatten layout is annoying but functional — better than
* wiping a user's clone over an unexpected layout.
*
* @param dest - The cloned `projects/app/` directory.
* @returns Whether the flatten ran, plus a reason if it didn't.
*/
flattenNuxtBaseTemplate(dest) {
return __awaiter(this, void 0, void 0, function* () {
const { filesystem } = this.toolbox;
const subdir = filesystem.path(dest, 'nuxt-base-template');
if (!filesystem.exists(subdir)) {
return { flattened: false, reason: 'no nuxt-base-template subdirectory' };
}
if (!filesystem.isDirectory(subdir)) {
// Stray file at the path we'd flatten — abort to avoid clobbering
// the user's tree on a corrupt clone.
return { flattened: false, reason: 'nuxt-base-template path exists but is not a directory' };
}
// Stage the template into a sibling directory before touching `dest`,
// so a copy failure leaves the original layout intact.
const parent = filesystem.path(dest, '..');
const stage = filesystem.path(parent, `.nuxt-base-template-staging-${Date.now()}-${process.pid}`);
try {
filesystem.copy(subdir, stage, { overwrite: true });
}
catch (err) {
// Couldn't stage — leave `dest` untouched and bubble the reason up.
filesystem.remove(stage);
return { flattened: false, reason: `failed to stage template: ${err.message}` };
}
try {
// Wipe the cloned root (wrapper package.json, index.js, lock file,
// README, etc.) and replace it with the staged template contents.
// gluegun's `filesystem.remove(dest)` removes the directory, so
// we re-create it before copying back so dotfiles land at the
// right level.
filesystem.remove(dest);
filesystem.dir(dest);
filesystem.copy(stage, dest, { overwrite: true });
}
finally {
filesystem.remove(stage);
}
return { flattened: true };
});
}
/**
* Setup Nuxt frontend
* Handles template setup (link/copy/clone) and optional npm install
*
* @param dest - Destination directory path
* @param options - Setup options
* @returns FrontendSetupResult with success status
*/
setupNuxt(dest_1) {
return __awaiter(this, arguments, void 0, function* (dest, options = {}) {
const { system, templateHelper } = this.toolbox;
const { branch, copyPath, linkPath, skipInstall } = options;
// Use template extension for link/copy/branch operations
if (linkPath || copyPath || branch) {
const result = yield templateHelper.setup(dest, {
branch,
copyPath,
isNuxt: true,
linkPath,
repoUrl: branch ? 'https://github.com/lenneTech/nuxt-base-starter.git' : undefined,
});
if (!result.success) {
return { method: result.method, path: result.path, success: false };
}
// After a clone, flatten the wrapper layout so `projects/app/`
// IS the Nuxt app (the cloned root is the `create-nuxt-base`
// scaffolder, not the app — see flattenNuxtBaseTemplate).
// Skip on link mode: a symlink points at the user's local
// checkout and must not have its template subdir torn out.
if (result.method === 'clone') {
yield this.flattenNuxtBaseTemplate(dest);
}
// Run install if not skipped and not a symlink
if (!skipInstall && result.method !== 'link') {
try {
const { pm } = this.toolbox;
yield system.run(`cd "${dest}" && ${pm.install(pm.detect(dest))}`);
}
catch (err) {
return { method: result.method, path: dest, success: false };
}
}
return { method: result.method, path: result.path, success: true };
}
// Default: use create-nuxt-base
try {
const { pm } = this.toolbox;
yield system.run(pm.exec(`create-nuxt-base@latest "${dest}"`));
// Fix package name - create-nuxt-base uses path as name which is invalid for lerna
yield this.fixPackageName(dest);
return { method: 'npx', path: dest, success: true };
}
catch (err) {
return { method: 'npx', path: dest, success: false };
}
});
}
/**
* Setup Angular frontend
* Handles template setup, npm install, husky removal, git init, and localize
*
* @param dest - Destination directory path
* @param options - Setup options
* @returns FrontendSetupResult with success status
*/
setupAngular(dest_1) {
return __awaiter(this, arguments, void 0, function* (dest, options = {}) {
const { filesystem, patching, system, templateHelper } = this.toolbox;
const { branch, copyPath, gitLink, linkPath, localize = false, skipGitInit = false, skipHuskyRemoval = false, skipInstall = false, } = options;
// Setup template
const result = yield templateHelper.setup(dest, {
branch,
copyPath,
linkPath,
repoUrl: 'https://github.com/lenneTech/ng-base-starter',
});
if (!result.success) {
return { method: result.method, path: result.path, success: false };
}
// Link mode: skip all post-processing
if (result.method === 'link') {
return { method: 'link', path: result.path, success: true };
}
// Install packages
if (!skipInstall) {
try {
const { pm } = this.toolbox;
yield system.run(`cd "${dest}" && ${pm.install(pm.detect(dest))}`);
}
catch (err) {
return { method: result.method, path: dest, success: false };
}
}
// Initialize git
if (!skipGitInit) {
try {
yield system.run(`cd "${dest}" && git init --initial-branch=main`);
if (gitLink) {
yield system.run(`cd "${dest}" && git remote add origin ${gitLink}`);
yield system.run(`cd "${dest}" && git add .`);
yield system.run(`cd "${dest}" && git commit -m "Initial commit"`);
yield system.run(`cd "${dest}" && git push -u origin main`);
}
}
catch (err) {
return { method: result.method, path: dest, success: false };
}
}
// Remove husky
if (!skipHuskyRemoval) {
filesystem.remove(`${dest}/.husky`);
yield patching.update(`${dest}/package.json`, (data) => {
if (data.scripts && typeof data.scripts === 'object') {
delete data.scripts.prepare;
}
if (data.devDependencies && typeof data.devDependencies === 'object') {
delete data.devDependencies.husky;
}
return data;
});
}
// Add localize
if (localize) {
try {
yield system.run(`cd "${dest}" && ng add @angular/localize --skip-confirmation`);
}
catch (_a) {
// Localize failure is not fatal
}
}
// Run init script if exists
if (!skipInstall) {
try {
const { pm: pmHelper } = this.toolbox;
yield system.run(`cd "${dest}" && ${pmHelper.run('init', pmHelper.detect(dest))}`);
}
catch (_b) {
// Init script failure is not fatal
}
}
return { method: result.method, path: dest, success: true };
});
}
// ═══════════════════════════════════════════════════════════════════════
// Frontend Vendor Mode — @lenne.tech/nuxt-extensions
// ═══════════════════════════════════════════════════════════════════════
/**
* Convert an existing npm-mode frontend project to vendor mode.
*
* Validates that the project currently uses @lenne.tech/nuxt-extensions
* as an npm dependency, then delegates to convertAppCloneToVendored.
*/
convertAppToVendorMode(options) {
return __awaiter(this, void 0, void 0, function* () {
const { dest, upstreamBranch, upstreamRepoUrl } = options;
const { isVendoredAppProject } = require('../lib/frontend-framework-detection');
if (isVendoredAppProject(dest)) {
throw new Error('Project is already in vendor mode (app/core/VENDOR.md exists).');
}
const pkg = this.toolbox.filesystem.read(`${dest}/package.json`, 'json');
if (!pkg) {
throw new Error('Cannot read package.json');
}
const allDeps = Object.assign(Object.assign({}, (pkg.dependencies || {})), (pkg.devDependencies || {}));
if (!allDeps['@lenne.tech/nuxt-extensions']) {
throw new Error('@lenne.tech/nuxt-extensions is not in dependencies or devDependencies. ' +
'Is this an npm-mode lenne.tech frontend project?');
}
yield this.convertAppCloneToVendored({
dest,
upstreamBranch,
upstreamRepoUrl,
});
});
}
/**
* Convert an existing vendor-mode frontend project back to npm mode.
*
* Performs the inverse of convertAppCloneToVendored:
* 1. Read baseline version from VENDOR.md
* 2. Rewrite consumer imports from relative paths back to @lenne.tech/nuxt-extensions
* 3. Delete app/core/
* 4. Restore @lenne.tech/nuxt-extensions dependency in package.json
* 5. Rewrite nuxt.config.ts module entry
* 6. Remove vendor-specific scripts and CLAUDE.md marker
*/
convertAppToNpmMode(options) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const { dest, targetVersion } = options;
const { filesystem } = this.toolbox;
const path = require('node:path');
const { isVendoredAppProject } = require('../lib/frontend-framework-detection');
if (!isVendoredAppProject(dest)) {
throw new Error('Project is not in vendor mode (app/core/VENDOR.md not found).');
}
const coreDir = path.join(dest, 'app', 'core');
// ── 1. Determine target version + warn about local patches ──────────
const vendorMd = filesystem.read(path.join(coreDir, 'VENDOR.md')) || '';
let version = targetVersion;
if (!version) {
const match = vendorMd.match(/Baseline-Version[^0-9]*(\d+\.\d+\.\d+\S*)/);
if (match) {
version = match[1];
}
}
if (!version) {
throw new Error('Cannot determine target version. Specify --version or ensure VENDOR.md has a Baseline-Version.');
}
// Warn if VENDOR.md documents local patches that will be lost
const localChangesSection = vendorMd.match(/## Local changes[\s\S]*?(?=## |$)/i);
if (localChangesSection) {
const hasRealPatches = localChangesSection[0].includes('|') &&
!localChangesSection[0].includes('(none, pristine)') &&
/\|\s*\d{4}-/.test(localChangesSection[0]);
if (hasRealPatches) {
const { print } = this.toolbox;
print.warning('');
print.warning('VENDOR.md documents local patches in app/core/ that will be LOST:');
const rows = localChangesSection[0].split('\n').filter((l) => /^\|\s*\d{4}-/.test(l));
for (const row of rows.slice(0, 5)) {
print.info(` ${row.trim()}`);
}
if (rows.length > 5) {
print.info(` ... and ${rows.length - 5} more`);
}
print.warning('Consider running /lt-dev:frontend:contribute-nuxt-extensions-core first to upstream them.');
print.warning('');
}
}
// ── 2. Rewrite consumer imports: relative → @lenne.tech/nuxt-extensions ─
this.rewriteConsumerImportsToNpm(dest);
// ── 3. Delete app/core/ ──────────────────────────────────────────────
if (filesystem.exists(coreDir)) {
filesystem.remove(coreDir);
}
// ── 4. Restore @lenne.tech/nuxt-extensions in package.json ──────────
const pkgPath = path.join(dest, 'package.json');
if (filesystem.exists(pkgPath)) {
const pkg = filesystem.read(pkgPath, 'json');
if (pkg && typeof pkg === 'object') {
if (!pkg.dependencies)
pkg.dependencies = {};
pkg.dependencies['@lenne.tech/nuxt-extensions'] = `^${version}`;
// Remove vendor-specific scripts
if (pkg.scripts && typeof pkg.scripts === 'object') {
const scripts = pkg.scripts;
delete scripts['check:vendor-freshness'];
// Unhook freshness from check/check:fix/check:naf
for (const scriptName of ['check', 'check:fix', 'check:naf']) {
if ((_a = scripts[scriptName]) === null || _a === void 0 ? void 0 : _a.includes('check:vendor-freshness')) {
scripts[scriptName] = scripts[scriptName].replace(/pnpm run check:vendor-freshness && /g, '');
}
}
}
filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
}
}
// ── 5. Rewrite nuxt.config.ts module entry ──────────────────────────
this.rewriteNuxtConfig(dest, 'npm');
// ── 6. Clean CLAUDE.md vendor marker ─────────────────────────────────
const claudeMdPath = path.join(dest, 'CLAUDE.md');
if (filesystem.exists(claudeMdPath)) {
let content = filesystem.read(claudeMdPath) || '';
const markerStart = '<!-- lt-vendor-marker-frontend -->';
const markerEnd = '---';
if (content.includes(markerStart)) {
const startIdx = content.indexOf(markerStart);
const endIdx = content.indexOf(markerEnd, startIdx);
if (endIdx > startIdx) {
content = content.slice(0, startIdx) + content.slice(endIdx + markerEnd.length);
content = content.replace(/^\n+/, '');
filesystem.write(claudeMdPath, content);
}
}
}
// ── Post-conversion verification ─────────────────────────────────────
const stale = this.findStaleFrontendImports(dest, /from\s+['"]\..*\/core['"]/);
if (stale.length > 0) {
const { print } = this.toolbox;
print.warning(`${stale.length} file(s) still contain relative core imports after npm conversion:`);
for (const f of stale.slice(0, 10)) {
print.info(` ${f}`);
}
print.info('These imports must be manually rewritten to @lenne.tech/nuxt-extensions.');
}
});
}
/**
* Core vendoring pipeline for @lenne.tech/nuxt-extensions.
*
* Clones the upstream repo, copies module.ts + runtime/ into app/core/,
* rewrites nuxt.config.ts and explicit consumer imports, merges deps,
* creates VENDOR.md and patches CLAUDE.md.
*/
convertAppCloneToVendored(options) {
return __awaiter(this, void 0, void 0, function* () {
const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nuxt-extensions.git' } = options;
const path = require('node:path');
const { filesystem, system } = this.toolbox;
const coreDir = path.join(dest, 'app', 'core');
// ── 1. Clone @lenne.tech/nuxt-extensions into temp dir ──────────────
const tmpClone = path.join(require('os').tmpdir(), `lt-vendor-nuxt-ext-${Date.now()}`);
const branchArg = upstreamBranch ? `--branch ${upstreamBranch} ` : '';
try {
yield system.run(`git clone --depth 1 ${branchArg}${upstreamRepoUrl} ${tmpClone}`);
}
catch (err) {
const raw = err.message || '';
const hints = [];
if (/Could not resolve host|getaddrinfo|ECONNREFUSED|Network is unreachable/i.test(raw)) {
hints.push('Network issue reaching github.com — check your connection or proxy settings.');
}
if (/Permission denied|authentication failed|publickey|403|401/i.test(raw)) {
hints.push('Authentication issue — the CLI uses an anonymous HTTPS clone; verify GitHub is reachable.');
}
if (upstreamBranch && /Remote branch .* not found|did not match any file\(s\) known to git/i.test(raw)) {
hints.push(`Upstream ref "${upstreamBranch}" does not exist. Check ${upstreamRepoUrl}/tags for valid refs. ` +
'Note: nuxt-extensions tags have NO "v" prefix — use e.g. "1.5.3", not "v1.5.3".');
}
if (/already exists and is not an empty/i.test(raw)) {
hints.push(`Target ${tmpClone} already exists. rm -rf /tmp/lt-vendor-nuxt-ext-* and retry.`);
}
const hintBlock = hints.length > 0 ? `\n Hints:\n - ${hints.join('\n - ')}` : '';
throw new Error(`Failed to clone ${upstreamRepoUrl}${upstreamBranch ? ` (branch/tag: ${upstreamBranch})` : ''}.\n Raw git error: ${raw.trim()}${hintBlock}`);
}
// Snapshot upstream metadata
let upstreamDeps = {};
let upstreamDevDeps = {};
let upstreamPeerDeps = {};
let upstreamVersion = '';
try {
const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
if (upstreamPkg && typeof upstreamPkg === 'object') {
upstreamDeps = upstreamPkg.dependencies || {};
upstreamDevDeps = upstreamPkg.devDependencies || {};
upstreamPeerDeps = upstreamPkg.peerDependencies || {};
upstreamVersion = upstreamPkg.version || '';
}
}
catch (_a) {
// Best-effort
}
let upstreamClaudeMd = '';
try {
const c = filesystem.read(`${tmpClone}/CLAUDE.md`);
if (typeof c === 'string')
upstreamClaudeMd = c;
}
catch (_b) {
// Non-fatal
}
let upstreamCommit = '';
try {
const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
upstreamCommit = (sha || '').trim();
}
catch (_c) {
// Non-fatal
}
try {
// ── 2. Copy source files to app/core/ ─────────────────────────────
if (filesystem.exists(coreDir)) {
filesystem.remove(coreDir);
}
const copies = [
[`${tmpClone}/src/module.ts`, `${coreDir}/module.ts`],
[`${tmpClone}/src/index.ts`, `${coreDir}/index.ts`],
[`${tmpClone}/src/runtime`, `${coreDir}/runtime`],
[`${tmpClone}/LICENSE`, `${coreDir}/LICENSE`],
];
for (const [from, to] of copies) {
if (filesystem.exists(from)) {
filesystem.copy(from, to);
}
}
}
finally {
// Always clean up temp clone
if (filesystem.exists(tmpClone)) {
filesystem.remove(tmpClone);
}
}
// No flatten-fix needed — nuxt-extensions source structure is already
// flat (module.ts + runtime/ at the same level). Unlike the backend
// (nest-server) where src/index.ts and src/core/ must be merged into
// one directory, nuxt-extensions keeps everything directly under src/.
// ── 3. Rewrite consumer explicit imports ─────────────────────────────
//
// Most consumer code uses Nuxt auto-imports (composables, utils,
// components) which the module.ts registers via addImports/addComponent.
// However, a few explicit imports exist:
// - Type imports: `from '@lenne.tech/nuxt-extensions'` (e.g. LtUser, LtUploadItem)
// - Testing imports: `from '@lenne.tech/nuxt-extensions/testing'`
//
// These must be rewritten to relative paths to app/core.
this.rewriteConsumerImportsToVendor(dest);
// ── 4. Rewrite nuxt.config.ts module entry ──────────────────────────
this.rewriteNuxtConfig(dest, 'vendor');
// ── 5. package.json: remove @lenne.tech/nuxt-extensions, merge deps ─
const pkgPath = path.join(dest, 'package.json');
if (filesystem.exists(pkgPath)) {
const pkg = filesystem.read(pkgPath, 'json');
if (pkg && typeof pkg === 'object') {
if (pkg.dependencies && typeof pkg.dependencies === 'object') {
delete pkg.dependencies['@lenne.tech/nuxt-extensions'];
}
if (pkg.devDependencies && typeof pkg.devDependencies === 'object') {
delete pkg.devDependencies['@lenne.tech/nuxt-extensions'];
}
// Merge upstream deps (mainly @nuxt/kit)
if (!pkg.dependencies)
pkg.dependencies = {};
const deps = pkg.dependencies;
for (const [depName, depVersion] of Object.entries(upstreamDeps)) {
if (depName === '@lenne.tech/nuxt-extensions')
continue;
if (!(depName in deps)) {
deps[depName] = depVersion;
}
}
// Promote any upstream devDeps flagged as runtime-needed (via
// vendor-frontend-runtime-deps.json) into dependencies. Currently
// empty for nuxt-extensions but reserved for future additions.
for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
if (this.isFrontendVendorRuntimeDep(depName) && !(depName in deps)) {
deps[depName] = depVersion;
}
}
// Verify peer deps are present (they should already be from the starter)
for (const [depName] of Object.entries(upstreamPeerDeps)) {
if (!(depName in deps) && !(depName in (pkg.devDependencies || {}))) {
const { print } = this.toolbox;
print.warning(`Peer dependency ${depName} is missing — you may need to install it.`);
}
}
// Vendor freshness check script
if (pkg.scripts && typeof pkg.scripts === 'object') {
const scripts = pkg.scripts;
scripts['check:vendor-freshness'] = [
'node -e "',
"var f=require('fs'),h=require('https');",
"try{var c=f.readFileSync('app/core/VENDOR.md','utf8')}catch(e){process.exit(0)}",
'var m=c.match(/Baseline-Version[^0-9]*(\\d+\\.\\d+\\.\\d+)/);',
"if(!m){process.stderr.write(String.fromCharCode(9888)+' vendor-freshness: no baseline\\n');process.exit(0)}",
'var v=m[1];',
"h.get('https://registry.npmjs.org/@lenne.tech/nuxt-extensions/latest',function(r){",
"var d='';r.on('data',function(c){d+=c});r.on('end',function(){",
'try{var l=JSON.parse(d).version;',
"if(v===l)console.log('vendor core up-to-date (v'+v+')');",
"else process.stderr.write('vendor core v'+v+', latest v'+l+'\\n')",
"}catch(e){}})}).on('error',function(){});",
'setTimeout(function(){process.exit(0)},5000)',
'"',
].join('');
// Hook freshness into check scripts
const hookFreshness = (scriptName) => {
const existing = scripts[scriptName];
if (!existing)
return;
if (existing.includes('check:vendor-freshness'))
return;
scripts[scriptName] = `pnpm run check:vendor-freshness && ${existing}`;
};
hookFreshness('check');
hookFreshness('check:fix');
hookFreshness('check:naf');
}
// Sort dependency maps alphabetically so merged-in entries
// (e.g. upstream `@nuxt/kit`) end up in the expected position
// and the generated package.json passes oxfmt/format:check.
const sortObjectKeys = (obj) => {
if (!obj)
return obj;
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
};
pkg.dependencies = sortObjectKeys(pkg.dependencies);
pkg.devDependencies = sortObjectKeys(pkg.devDependencies);
pkg.peerDependencies = sortObjectKeys(pkg.peerDependencies);
// Ensure trailing newline — oxfmt with the starter's .editorconfig
// `insert_final_newline = true` requires it.
filesystem.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
}
}
// ── 6. CLAUDE.md: prepend vendor marker + merge upstream sections ────
const claudeMdPath = path.join(dest, 'CLAUDE.md');
if (filesystem.exists(claudeMdPath)) {
const existing = filesystem.read(claudeMdPath) || '';
const marker = '<!-- lt-vendor-marker-frontend -->';
if (!existing.includes(marker)) {
const vendorBlock = [
marker,
'',
'# Vendor-Mode Notice (Frontend)',
'',
'This frontend project runs in **vendor mode**: the `@lenne.tech/nuxt-extensions`',
'module has been copied directly into `app/core/` as first-class',
'project code. There is **no** `@lenne.tech/nuxt-extensions` npm dependency.',
'',
'- **Read framework code from `app/core/**`** — not from `node_modules/`.',
"- **nuxt.config.ts** references `'./app/core/module'` instead of",
" `'@lenne.tech/nuxt-extensions'`.",
'- **Baseline + patch log** live in `app/core/VENDOR.md`. Log any',
' substantial local change there so the `nuxt-extensions-core-updater`',
' agent can classify it at sync time.',
'- **Update flow:** run `/lt-dev:frontend:update-nuxt-extensions-core`.',
'- **Contribute back:** run `/lt-dev:frontend:contribute-nuxt-extensions-core`.',
'- **Freshness check:** `pnpm run check:vendor-freshness` warns when',
' upstream has a newer release than the baseline.',
'',
'---',
'',
].join('\n');
filesystem.write(claudeMdPath, vendorBlock + existing);
}
}
// Merge upstream CLAUDE.md sections
if (upstreamClaudeMd && filesystem.exists(claudeMdPath)) {
const projectContent = filesystem.read(claudeMdPath) || '';
const upstreamSections = this.parseH2Sections(upstreamClaudeMd);
const projectSections = this.parseH2Sections(projectContent);
const newSections = [];
for (const [heading, body] of upstreamSections) {
if (heading === '__preamble__')
continue;
if (!projectSections.has(heading)) {
newSections.push(`## ${heading}\n\n${body.trim()}`);
}
}
if (newSections.length > 0) {
const separator = projectContent.endsWith('\n') ? '\n' : '\n\n';
filesystem.write(claudeMdPath, `${projectContent}${separator}${newSections.join('\n\n')}\n`);
}
}
// ── 7. VENDOR.md baseline ────────────────────────────────────────────
const vendorMdPath = path.join(coreDir, 'VENDOR.md');
if (!filesystem.exists(vendorMdPath)) {
const today = new Date().toISOString().slice(0, 10);
const versionLine = upstreamVersion
? `- **Baseline-Version:** ${upstreamVersion}`
: '- **Baseline-Version:** (not detected)';
const commitLine = upstreamCommit
? `- **Baseline-Commit:** \`${upstreamCommit}\``
: '- **Baseline-Commit:** (not detected)';
const syncHistoryTo = upstreamVersion
? `${upstreamVersion}${upstreamCommit ? ` (\`${upstreamCommit.slice(0, 10)}\`)` : ''}`
: 'initial import';
filesystem.write(vendorMdPath, [
'# @lenne.tech/nuxt-extensions (vendored)',
'',
'This directory is a curated vendor copy of `@lenne.tech/nuxt-extensions`.',
'It is first-class project code, not a node_modules shadow copy — but it',
'is **not a fork**. The copy exists so Claude Code (and humans) can read',
'framework internals directly. Log substantial local changes in the',
'"Local changes" table below so the `nuxt-extensions-core-updater` agent',
'can classify them at sync time.',
'',
'Unlike the backend (nest-server) vendoring, no flatten-fix is needed —',
'the nuxt-extensions source structure is already flat.',
'',
'## Modification Policy',
'',
'Edit `app/core/` **only** when the change is generally useful to every',
'@lenne.tech/nuxt-extensions consumer:',
'',
'- Bugfixes that apply to every consumer',
'- Broad framework enhancements (new composables, better defaults, SSR fixes)',
'- Security vulnerability fixes',
'- Type/config compatibility fixes every consumer would hit',
'',
'Everything else stays **outside** `app/core/`. Project-specific business',
'rules, customer branding, and proprietary integrations belong in project',
'code (`app/composables/`, `app/components/`, `app/middleware/`, plugin',
'overrides).',
'',
'Generally-useful changes **MUST** be submitted as an upstream PR to',
'https://github.com/lenneTech/nuxt-extensions. Run',
'`/lt-dev:frontend:contribute-nuxt-extensions-core` to prepare it — the',
'agent filters cosmetic commits, categorizes local changes as',
'upstream-candidate vs. project-specific, and writes PR drafts for human',
"review. Letting useful fixes rot in one project's vendor tree is an",
'anti-pattern: they belong upstream so every consumer benefits and the',
'local patch disappears on the next sync.',
'',
'When in doubt, ask before editing `app/core/`.',
'',
'## Baseline',
'',
'- **Upstream-Repo:** https://github.com/lenneTech/nuxt-extensions',
versionLine,
commitLine,
`- **Vendored am:** ${today}`,
'- **Vendored von:** lt CLI (`lt frontend convert-mode --to vendor`)',
'',
'## Sync history',
'',
...(0, markdown_table_1.formatMarkdownTable)(['Date', 'From', 'To', 'Notes'], [[today, '—', syncHistoryTo, 'scaffolded by lt CLI']]),
'',
'## Local changes',
'',
...(0, markdown_table_1.formatMarkdownTable)(['Date', 'Commit', 'Scope', 'Reason', 'Status'], [['—', '—', '(none, pristine)', 'initial vendor', '—']]),
'',
'## Upstream PRs',
'',
...(0, markdown_table_1.formatMarkdownTable)(['PR', 'Title', 'Commits', 'Status'], [['—', '(none yet)', '—', '—']]),
].join('\n'));
}
// ── Post-conversion verification ─────────────────────────────────────
// Only match actual import/from statements, not comments or strings
const staleImports = this.findStaleFrontendImports(dest, /(?:^|\s)(?:import|from)\s+['"][^'"]*@lenne\.tech\/nuxt-extensions/m, 'app/core/');
if (staleImports.length > 0) {
const { print } = this.toolbox;
print.warning(`${staleImports.length} file(s) still contain '@lenne.tech/nuxt-extensions' imports after vendor conversion:`);
for (const f of staleImports.slice(0, 10)) {
print.info(` ${f}`);
}
if (staleImports.length > 10) {
print.info(` ... and ${staleImports.length - 10} more`);
}
print.info('These imports must be manually rewritten to relative paths pointing to app/core.');
}
return { upstreamDeps };
});
}
// ═══════════════════════════════════════════════════════════════════════
// Private vendor helpers
// ═══════════════════════════════════════════════════════════════════════
/**
* Rewrite nuxt.config.ts module entry between npm and vendor mode.
*
* npm→vendor: '@lenne.tech/nuxt-extensions' → './app/core/module'
* vendor→npm: './app/core/module' → '@lenne.tech/nuxt-extensions'
*/
rewriteNuxtConfig(appDir, mode) {
const path = require('node:path');
const { filesystem } = this.toolbox;
const configPath = path.join(appDir, 'nuxt.config.ts');
if (!filesystem.exists(configPath))
return;
let content = filesystem.read(configPath) || '';
if (mode === 'vendor') {
content = content.replace(/['"]@lenne\.tech\/nuxt-extensions['"]/g, "'./app/core/module'");
}
else {
content = content.replace(/['"]\.\/app\/core\/module['"]/g, "'@lenne.tech/nuxt-extensions'");
}
filesystem.write(configPath, content);
}
/**
* Rewrite consumer imports from npm specifiers to relative vendor paths.
*
* Handles both .ts and .vue files via regex replacement.
* Skips files inside app/core/ (the vendored framework itself).
*/
rewriteConsumerImportsToVendor(appDir) {
const path = require('node:path');
const { filesystem } = this.toolbox;
const coreDir = path.join(appDir, 'app', 'core');
const coreDirWithSep = coreDir + path.sep;
const allFiles = this.walkConsumerFiles(appDir);
for (const absFile of allFiles) {
// Skip vendored framework files
if (absFile.startsWith(coreDirWithSep))
continue;
const content = filesystem.read(absFile);
if (!content)
continue;
if (!content.includes('@lenne.tech/nuxt-extensions'))
continue;
const fromDir = path.dirname(absFile);
let relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
if (!relToCore.startsWith('.'))
relToCore = `./${relToCore}`;
let patched = content;
// Testing imports: @lenne.tech/nuxt-extensions/testing → relative/runtime/testing
patched = patched.replace(/from\s+['"]@lenne\.tech\/nuxt-extensions\/testing['"]/g, `from '${relToCore}/runtime/testing'`);
// Main imports: @lenne.tech/nuxt-extensions → relative core
patched = patched.replace(/from\s+['"]@lenne\.tech\/nuxt-extensions['"]/g, `from '${relToCore}'`);
if (patched !== content) {
filesystem.write(absFile, patched);
}
}
}
/**
* Rewrite consumer imports from relative vendor paths back to npm specifiers.
*/
rewriteConsumerImportsToNpm(appDir) {
const path = require('node:path');
const { filesystem } = this.toolbox;
const coreDir = path.join(appDir, 'app', 'core');
const coreDirWithSep = coreDir + path.sep;
const allFiles = this.walkConsumerFiles(appDir);
for (const absFile of allFiles) {
if (absFile.startsWith(coreDirWithSep))
continue;
const content = filesystem.read(absFile);
if (!content)
continue;
// Check if file has any relative import pointing to the core dir
const fromDir = path.dirname(absFile);
const relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
if (!content.includes(relToCore))
continue;
let patched = content;
// Testing imports: relative/runtime/testing → @lenne.tech/nuxt-extensions/testing
const testingPattern = new RegExp(`from\\s+['"]${relToCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/runtime/testing['"]`, 'g');
patched = patched.replace(testingPattern, "from '@lenne.tech/nuxt-extensions/testing'");
// Main imports: relative core → @lenne.tech/nuxt-extensions
const corePattern = new RegExp(`from\\s+['"]${relToCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, 'g');
patched = patched.replace(corePattern, "from '@lenne.tech/nuxt-extensions'");
if (patched !== content) {
filesystem.write(absFile, patched);
}
}
}
/**
* Scan consumer files for stale imports matching a pattern.
*/
findStaleFrontendImports(appDir, needle, skipPathContaining) {
const { filesystem } = this.toolbox;
const allFiles = this.walkConsumerFiles(appDir);
const stale = [];
for (const absFile of allFiles) {
if (skipPathContaining && absFile.includes(skipPathContaining))
continue;
const content = filesystem.read(absFile) || '';
const matches = typeof needle === 'string' ? content.includes(needle) : needle.test(content);
if (matches) {
stale.push(absFile.replace(`${appDir}/`, ''));
}
}
return stale;
}
/**
* Predicate: is a given upstream `devDependencies` key actually a runtime
* dep in disguise that needs to live in `dependencies` after vendoring?
*
* The list of such helpers lives in `src/config/vendor-frontend-runtime-deps.json`
* under the `runtimeHelpers` key. Adding a new helper is a data-only change
* (no CLI release required). If the config file is missing or unreadable,
* the predicate safely returns `false` for everything.
*
* Currently, nuxt-extensions has a minimal dependency graph (only `@nuxt/kit`
* as a direct runtime dep), so this list is typically empty. The mechanism
* exists for future-proofing: if upstream adds a devDep that the framework
* code imports at runtime, add it to the JSON and the next vendor conversion
* will promote it automatically.
*/
isFrontendVendorRuntimeDep(pkgName) {
if (!this._vendorFrontendRuntimeHelpers) {
try {
const path = require('node:path');
const configPath = path.join(__dirname, '..', 'config', 'vendor-frontend-runtime-deps.json');
const raw = this.toolbox.filesystem.read(configPath, 'json');
const list = Array.isArray(raw === null || raw === void 0 ? void 0 : raw.runtimeHelpers) ? raw.runtimeHelpers : [];
this._vendorFrontendRuntimeHelpers = new Set(list.filter((e) => typeof e === 'string'));
}
catch (_a) {
this._vendorFrontendRuntimeHelpers = new Set();
}
}
return this._vendorFrontendRuntimeHelpers.has(pkgName);
}
/**
* Recursively walks `app/` and `tests/` directories under `appDir`,
* returning absolute paths to all `.ts` and `.vue` files.
*
* Shared helper for consumer-import codemods and stale-import scans.
* Uses native `fs.readdirSync` because gluegun's `filesystem.find()`
* returns paths relative to the jetpack cwd, which is unreliable
* when the CLI is invoked from arbitrary working directories.
*/
walkConsumerFiles(appDir) {
const fs = require('node:fs');
const path = require('node:path');
const searchDirs = [path.join(appDir, 'app'), path.join(appDir, 'tests')];
const allFiles = [];
const walk = (dir) => {
try {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fp = path.join(dir, item.name);
if (item.isDirectory()) {
walk(fp);
}
else if (item.isFile() && (fp.endsWith('.ts') || fp.endsWith('.vue'))) {
allFiles.push(fp);
}
}
}
catch (_a) {
// Directory doesn't exist or can't be read
}
};
for (const dir of searchDirs) {
walk(dir);
}
return allFiles;
}
/**
* Parse markdown content into H2 sections for section-level merge.
*/
parseH2Sections(content) {
const sections = new Map();
const lines = content.split('\n');
let currentHeading = '__preamble__';
let currentBody = [];
for (const line of lines) {
const match = /^## (.+)$/.exec(line);
if (match) {
sections.set(currentHeading, currentBody.join('\n'));
currentHeading = match[1].trim();
currentBody = [];
}
else {
currentBody.push(line);
}
}
sections.set(currentHeading, currentBody.join('\n'));
return sections;
}
}
exports.FrontendHelper = FrontendHelper;
/**
* Extend toolbox
*/
exports.default = (toolbox) => {
toolbox.frontendHelper = new FrontendHelper(toolbox);
};