@lenne.tech/cli
Version:
lenne.Tech CLI: lt
304 lines (303 loc) • 15.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.addToGitignore = addToGitignore;
exports.autoPatch = autoPatch;
exports.patchApiConfig = patchApiConfig;
exports.patchClaudeMd = patchClaudeMd;
exports.patchNuxtConfig = patchNuxtConfig;
exports.patchPlaywrightConfig = patchPlaywrightConfig;
/**
* Idempotent patches applied by `lt dev init`.
*
* Goal: take a project that still has hardcoded `localhost:3000`
* defaults and make it env-aware so it can be served behind Caddy
* under `https://<slug>.localhost`.
*
* Each patch is a regex-based replace that matches only the legacy
* form. Already-patched files are no-ops.
*/
const fs_1 = require("fs");
/** Append entry to .gitignore if not already present. */
function addToGitignore(root, entry) {
const path = `${root}/.gitignore`;
let content = '';
if ((0, fs_1.existsSync)(path))
content = (0, fs_1.readFileSync)(path, 'utf8');
const lines = content.split(/\r?\n/);
if (lines.some((l) => l.trim() === entry || l.trim() === entry.replace(/\/$/, '')))
return false;
const ensured = `${(content.endsWith('\n') || content.length === 0 ? content : `${content}\n`) + entry}\n`;
(0, fs_1.writeFileSync)(path, ensured, 'utf8');
return true;
}
/** Run the appropriate patch based on filename. */
function autoPatch(file) {
if (file.endsWith('config.env.ts'))
return patchApiConfig(file);
if (file.endsWith('nuxt.config.ts'))
return patchNuxtConfig(file);
if (file.endsWith('playwright.config.ts'))
return patchPlaywrightConfig(file);
return { file, patched: false, replacements: 0 };
}
/**
* API: make the server listen port honour `process.env.PORT` (injected by
* `lt dev up` for its Caddy upstream). Handles two patterns found in
* nest-server `config.env.ts` files:
*
* - the legacy literal `port: 3000,` (e.g. in `deployedConfig()`)
* - the offers-pattern `port: process.env.NSC__PORT ? parseInt(process.env.NSC__PORT, 10) : 3000`
* found in `localConfig()` — `lt dev` runs the API in local mode, so this
* line MUST be patched too or the API ignores the assigned port.
*
* Idempotent — lines that already read `process.env.PORT` are left untouched.
*/
function patchApiConfig(file) {
if (!(0, fs_1.existsSync)(file))
return { file, patched: false, replacements: 0 };
const before = (0, fs_1.readFileSync)(file, 'utf8');
let count = 0;
let after = before.replace(/^(\s*)port:\s*3000\s*,$/gm, (_m, indent) => {
count++;
return `${indent}port: Number(process.env.PORT) || 3000,`;
});
// localConfig() keeps the NSC__PORT operator override; PORT (lt dev) wins.
after = after.replace(/^(\s*)port:\s*process\.env\.NSC__PORT\s*\?[^\n]*:\s*3000\s*,$/gm, (_m, indent) => {
count++;
return `${indent}port: Number(process.env.PORT) || (process.env.NSC__PORT ? parseInt(process.env.NSC__PORT, 10) : 3000),`;
});
if (count === 0)
return { file, patched: false, replacements: 0 };
(0, fs_1.writeFileSync)(file, after, 'utf8');
return { file, patched: true, replacements: count };
}
/**
* Inject a "Local Development (lt dev)" block with the project's
* concrete URLs into CLAUDE.md. Idempotent — re-running with the same
* URLs is a no-op; re-running with different URLs replaces the block
* in place.
*/
function patchClaudeMd(file, options) {
const { dbName, identity } = options;
const startMarker = '<!-- lt-dev:url-block:start -->';
const endMarker = '<!-- lt-dev:url-block:end -->';
const apiSub = identity.subdomains.api;
const appSub = identity.subdomains.app;
const lines = [
startMarker,
'',
'## Local Development (lt dev)',
'',
`This project is registered with \`lt dev\` (slug: \`${identity.slug}\`). Use these commands to run alongside other lt projects without cross-wiring or port collisions:`,
'',
'```bash',
'lt dev up # Start API + App behind Caddy with project-specific URLs',
'lt dev down # Stop the detached processes + remove Caddy block',
'lt dev status # Show running PIDs + bound URLs',
'lt dev test # Ensure up + run the E2E suite with project URLs injected',
'lt dev doctor # Diagnose Caddy/CA/DNS/port issues',
'```',
'',
'**Start and test local apps via `lt dev`** — never `pnpm dev` / `pnpm start` / a bare `playwright test` directly; those bind the framework default ports (3000/3001) and collide with parallel projects.',
'',
'**Active URLs for THIS project:**',
'',
];
if (appSub)
lines.push(`- App: \`https://${appSub.hostname}\``);
if (apiSub)
lines.push(`- API: \`https://${apiSub.hostname}\``);
if (dbName)
lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
lines.push('');
lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
lines.push('');
lines.push(endMarker);
const block = lines.join('\n');
if (!(0, fs_1.existsSync)(file))
return { file, patched: false, replacements: 0 };
const content = (0, fs_1.readFileSync)(file, 'utf8');
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
let next;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
const before = content.slice(0, startIdx);
const after = content.slice(endIdx + endMarker.length);
next = before + block + after;
}
else {
const sep = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
next = `${content}${sep}${block}\n`;
}
if (next === content)
return { file, patched: false, replacements: 0 };
(0, fs_1.writeFileSync)(file, next, 'utf8');
return { file, patched: true, replacements: 1 };
}
/** App: hardcoded port + vite-proxy target → env-aware. */
function patchNuxtConfig(file) {
if (!(0, fs_1.existsSync)(file))
return { file, patched: false, replacements: 0 };
const before = (0, fs_1.readFileSync)(file, 'utf8');
let count = 0;
let after = before.replace(/^(\s*)port:\s*3001\s*,$/gm, (_m, indent) => {
count++;
return `${indent}port: Number(process.env.PORT) || 3001,`;
});
after = after.replace(/target:\s*'http:\/\/localhost:3000'/g, () => {
count++;
return `target: process.env.NUXT_API_URL || 'http://localhost:3000'`;
});
if (count === 0)
return { file, patched: false, replacements: 0 };
(0, fs_1.writeFileSync)(file, after, 'utf8');
return { file, patched: true, replacements: count };
}
/**
* Playwright: hardcoded baseURL/host/url → env-aware, plus a top-of-file
* dotenv-load of `.lt-dev/.env` so external test runners (CLI, IDE, VS
* Code Playwright Extension) automatically pick up `lt dev up`'s URLs
* and the local Caddy CA — without requiring the parent shell to inherit
* any env.
*
* Patches applied (each idempotent):
* 1. Top-of-file: `if (existsSync('.lt-dev/.env')) loadEnv(...)` block,
* bracketed by `// >>> lt-dev:bridge >>>` markers.
* 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
* `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
* 3. `webServer` wrapped in an `LT_DEV_ACTIVE` guard so Playwright reuses
* the App already served by `lt dev` / `lt dev test` instead of spawning
* its own (which would bind the wrong port and miss the isolated stack).
* 4. `ignoreHTTPSErrors: true` so Playwright's Chromium accepts the lt dev
* Caddy self-signed cert (required for `lt dev test` over HTTPS).
* 5. Shard-aware timeouts gated on `LT_DEV_TEST_SHARDS` — a `SHARDED` const
* plus relaxed `timeout` / `expect` / `navigationTimeout` / `actionTimeout`
* under sharded load only, so serial + CI keep their tight, fast-failing
* defaults. Each sub-patch is a graceful no-op on a non-standard config.
* 6. `slowMo: 10` → `0` (pointless per-action delay, multiplied across shards).
*/
function patchPlaywrightConfig(file) {
if (!(0, fs_1.existsSync)(file))
return { file, patched: false, replacements: 0 };
const before = (0, fs_1.readFileSync)(file, 'utf8');
let count = 0;
let after = before;
// 1. URL-Patches.
for (const key of ['baseURL', 'host', 'url']) {
after = after.replace(new RegExp(`${key}:\\s*'http://localhost:3001'`, 'g'), () => {
count++;
return `${key}: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`;
});
}
// 2. Top-of-file dotenv bridge — inject it, or replace an outdated block.
// The loader walks UP from cwd to find `.lt-dev/.env`: that file lives
// at the repo root, while playwright.config.ts (and the process cwd of
// a direct `playwright test` run) usually sit in `projects/app`. The
// original cwd-only resolve missed it, so direct runs fell back to
// `localhost:3001` and could collide with a parallel project.
const bridgeStart = '// >>> lt-dev:bridge >>>';
const bridgeEnd = '// <<< lt-dev:bridge <<<';
const bridgeBlock = [
bridgeStart,
'// Auto-load <root>/.lt-dev/.env when `lt dev up` is active so',
'// external test runners (CLI, IDE, VS Code Playwright Extension)',
'// pick up project URLs + Caddy CA without inheriting the parent shell.',
'// Searches upward from cwd because `.lt-dev/` sits at the repo root',
'// while playwright.config.ts (and cwd) usually sit in projects/app.',
"import { existsSync as __ltDevExists, readFileSync as __ltDevRead } from 'node:fs';",
"import { dirname as __ltDevDirname, resolve as __ltDevResolve } from 'node:path';",
"let __ltDevEnvFile = '';",
'for (let __ltDevDir = process.cwd(), __i = 0; __i < 6; __i++) {',
" const __candidate = __ltDevResolve(__ltDevDir, '.lt-dev/.env');",
' if (__ltDevExists(__candidate)) { __ltDevEnvFile = __candidate; break; }',
' const __parent = __ltDevDirname(__ltDevDir);',
' if (__parent === __ltDevDir) break;',
' __ltDevDir = __parent;',
'}',
'if (__ltDevEnvFile) {',
' for (const __ln of __ltDevRead(__ltDevEnvFile, "utf8").split(/\\r?\\n/)) {',
' const __m = __ln.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);',
' if (__m && process.env[__m[1]] === undefined) process.env[__m[1]] = __m[2];',
' }',
'}',
bridgeEnd,
].join('\n');
const bridgeStartIdx = after.indexOf(bridgeStart);
const bridgeEndIdx = after.indexOf(bridgeEnd);
if (bridgeStartIdx === -1) {
after = `${bridgeBlock}\n${after}`;
count++;
}
else if (bridgeEndIdx !== -1) {
const rebuilt = after.slice(0, bridgeStartIdx) + bridgeBlock + after.slice(bridgeEndIdx + bridgeEnd.length);
if (rebuilt !== after) {
after = rebuilt;
count++;
}
}
// 3. Wrap `webServer` in an `LT_DEV_ACTIVE` guard so Playwright does NOT
// start/manage its own server when the App is already served by
// `lt dev` / `lt dev test` (both export LT_DEV_ACTIVE + the App URL).
// The original array/object's closing `]`/`}` becomes the ternary's
// false branch, so no bracket-matching is needed. Idempotent.
if (!/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(after)) {
after = after.replace(/webServer:\s*([[{])/, (_match, open) => {
count++;
return `webServer: process.env.LT_DEV_ACTIVE ? undefined : ${open}`;
});
}
// 4. ignoreHTTPSErrors — accept the `lt dev` Caddy self-signed cert on
// `https://*.localhost` (Playwright's bundled Chromium uses its own trust
// store, so NODE_EXTRA_CA_CERTS alone is not enough). No-op in CI (http).
// Without this, `lt dev test` fails with ERR_CERT_AUTHORITY_INVALID.
if (!/ignoreHTTPSErrors/.test(after)) {
after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
count++;
return `${whole}\n${indent} ignoreHTTPSErrors: true,`;
});
}
// 5. Shard-aware timeouts — `lt dev test --shard N` runs N built stacks +
// N Chromium concurrently; the CPU saturates and SSR slows 2-3x. Relax
// timeouts ONLY under that load (the CLI exports LT_DEV_TEST_SHARDS), so
// serial + CI keep their tight values and fast-failure feedback. Each
// sub-patch is idempotent + a graceful no-op on non-standard configs.
if (!/const SHARDED\b/.test(after) && /export default defineConfig/.test(after)) {
const shardConst = '// `lt dev test --shard N` saturates the CPU (N built SSR servers + N Chromium),\n' +
'// slowing every navigation. Relax timeouts ONLY under that load — the CLI sets\n' +
'// LT_DEV_TEST_SHARDS — so serial + CI keep their tight, fast-failing defaults.\n' +
"const SHARDED = Number(process.env.LT_DEV_TEST_SHARDS || '0') > 1;\n\n";
after = after.replace(/(export default defineConfig)/, `${shardConst}$1`);
count++;
}
// 5a. per-test timeout (`isWindows ? A : B` form) → add the sharded branch.
if (/timeout:\s*isWindows\s*\?/.test(after) && !/timeout:\s*isWindows\s*\?[^,\n]*SHARDED/.test(after)) {
after = after.replace(/timeout:\s*isWindows\s*\?\s*([0-9_]+)\s*:\s*([0-9_]+|undefined)/, (_m, a, b) => {
count++;
return `timeout: isWindows ? ${a} : SHARDED ? 180_000 : ${b}`;
});
}
// 5b. expect.timeout (only when an `expect: { timeout: N }` already exists).
if (/expect:\s*\{\s*timeout:\s*[0-9_]+\s*\}/.test(after) && !/expect:\s*\{\s*timeout:\s*SHARDED/.test(after)) {
after = after.replace(/expect:\s*\{\s*timeout:\s*([0-9_]+)\s*\}/, (_m, t) => {
count++;
return `expect: { timeout: SHARDED ? 30_000 : ${t} }`;
});
}
// 5c. navigation/action ceilings under shard (inject into `use` if absent).
if (!/navigationTimeout/.test(after)) {
after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
count++;
return `${whole}\n${indent} actionTimeout: SHARDED ? 30_000 : undefined,\n${indent} navigationTimeout: SHARDED ? 60_000 : undefined,`;
});
}
// 6. slowMo: 10 → 0 — an artificial per-action delay, pointless and multiplied
// across N concurrent sharded browsers.
after = after.replace(/slowMo:\s*10\b/, () => {
count++;
return 'slowMo: 0';
});
if (count === 0)
return { file, patched: false, replacements: 0 };
(0, fs_1.writeFileSync)(file, after, 'utf8');
return { file, patched: true, replacements: count };
}