UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

959 lines (958 loc) 50.7 kB
"use strict"; 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); };