UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

344 lines (343 loc) 14.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.checkGlobalSetupTicketSafe = checkGlobalSetupTicketSafe; exports.clearTicketMarker = clearTicketMarker; exports.defaultTicketBranch = defaultTicketBranch; exports.deriveTicketId = deriveTicketId; exports.dropDatabase = dropDatabase; exports.gitBranchExists = gitBranchExists; exports.gitFetch = gitFetch; exports.gitMainRepoRoot = gitMainRepoRoot; exports.listWorktrees = listWorktrees; exports.pnpmInstall = pnpmInstall; exports.readTicketMarker = readTicketMarker; exports.resolveDevIdentity = resolveDevIdentity; exports.worktreeAdd = worktreeAdd; exports.worktreeDirtyOnlyGenerated = worktreeDirtyOnlyGenerated; exports.worktreePathFor = worktreePathFor; exports.worktreeRemove = worktreeRemove; exports.worktreeSafetyReport = worktreeSafetyReport; exports.writeTicketMarker = writeTicketMarker; /** * Per-ticket parallel dev environments for `lt dev` (used by the `lt ticket` * command group). * * The model: ONE git repo, N git worktrees — one per ticket/feature — each on * its own branch (created fresh from `origin/dev` so tickets are independent), * each running its own `lt dev` stack on a SUFFIXED identity: * * ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost * worktree <parent>/svl-2200/ branch feat/DEV-2200 * DB svl-sports-system-2200 (empty at start, isolated) * * A worktree is "tagged" with its ticket by a `.lt-dev/ticket` marker file the * moment `lt ticket start` creates it. From then on EVERY `lt dev *` command run * inside that worktree (up / down / test / status) reads the marker via * {@link resolveDevIdentity} and operates on the ticket's isolated stack — no * flags needed. So `lt ticket` only has to orchestrate the worktree + marker and * can delegate the actual bring-up/-down to the normal `lt dev` commands. */ const child_process_1 = require("child_process"); const fs_1 = require("fs"); const path_1 = require("path"); const dev_identity_1 = require("./dev-identity"); const dev_project_1 = require("./dev-project"); const dev_state_1 = require("./dev-state"); /** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */ const TICKET_MARKER = 'ticket'; /** * Check whether a project's Playwright `global-setup` (if it wipes a DB) would * ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard` * create (`<base>-<id>-test[-<n>]`). Used by `lt dev doctor` to WARN (never * auto-edit) when a bespoke allow-list is too narrow to reset a ticket's test DB. * * Precise, not heuristic: it extracts the real regex literals from the file and * tests them against a SYNTHETIC ticket test-DB name — so an already-safe * allow-list (e.g. svl's `…-(?:[a-z0-9-]+-)?test…`) is correctly recognised and * a shard-only one (`…-test-\d+`) is correctly flagged. */ function checkGlobalSetupTicketSafe(layout) { var _a, _b; const candidates = [ ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'tests', 'global-setup.ts')] : []), (0, path_1.join)(layout.root, 'tests', 'global-setup.ts'), (0, path_1.join)(layout.root, 'global-setup.ts'), ]; const file = (_a = candidates.find((f) => (0, fs_1.existsSync)(f))) !== null && _a !== void 0 ? _a : null; if (!file) return { file: null, hasDbReset: false, ticketSafe: true }; let content = ''; try { content = (0, fs_1.readFileSync)(file, 'utf8'); } catch (_c) { return { file, hasDbReset: false, ticketSafe: true }; } const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content); if (!hasDbReset) return { file, hasDbReset: false, ticketSafe: true }; // Synthetic per-ticket test DB names derived from the project's dev DB base. const base = (0, dev_project_1.deriveDbName)(layout.apiDir, (0, dev_identity_1.buildIdentity)(layout.root).slug).replace(/-(local|dev)$/i, ''); const samples = [`${base}-tkprobe-test`, `${base}-tkprobe-test-2`]; // Test every regex LITERAL in the file against the samples (char classes kept intact). const literals = (_b = content.match(/\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n[])+\/[a-z]*/gi)) !== null && _b !== void 0 ? _b : []; let ticketSafe = false; for (const lit of literals) { const lastSlash = lit.lastIndexOf('/'); try { const re = new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1)); if (samples.some((s) => re.test(s))) { ticketSafe = true; break; } } catch (_d) { /* not a valid regex literal (e.g. a division) — ignore */ } } return { file, hasDbReset, ticketSafe }; } /** Clear the ticket marker (called on teardown). */ function clearTicketMarker(root) { const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER); if ((0, fs_1.existsSync)(file)) { try { (0, fs_1.rmSync)(file); } catch (_a) { /* best-effort */ } } } /** * Default branch name for a ticket/feature: `feat/<name>` with the human ticket * id preserved (case + number), only sanitised for git-ref safety. * * "DEV-2200" → feat/DEV-2200 * "checkout refactor" → feat/checkout-refactor */ function defaultTicketBranch(name) { const safe = name .trim() .replace(/\s+/g, '-') .replace(/[^\w.\-/]+/g, '') .replace(/^-+|-+$/g, ''); return `feat/${safe}`; } /** * Derive the short env id from a ticket id or free feature name. * * "DEV-2200" → "2200" (ticket pattern → short numeric part) * "ABC-123" → "123" * "checkout-refactor" → "checkout-refactor" (free name → slug) * (asOverride "cof") → "cof" (explicit `--as` wins) * * The id flows into the slug / URLs / DB, so it is always a clean slug. */ function deriveTicketId(name, asOverride) { if (asOverride && asOverride.trim()) return (0, dev_identity_1.slugify)(asOverride); const trimmed = name.trim(); const ticketMatch = trimmed.match(/^[A-Za-z][A-Za-z0-9]*-(\d+)$/); if (ticketMatch) return ticketMatch[1]; return (0, dev_identity_1.slugify)(trimmed); } /** Drop a MongoDB database (best-effort, via `mongosh`). Returns true on success. */ function dropDatabase(dbName, mongoBaseUri = 'mongodb://127.0.0.1:27017') { try { (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'], { stdio: 'ignore', }); return true; } catch (_a) { return false; } } /** True if a local branch with this name already exists. */ function gitBranchExists(repoDir, branch) { try { git(repoDir, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); return true; } catch (_a) { return false; } } /** `git fetch <remote>` (so worktrees branch from the freshest base). */ function gitFetch(repoDir, remote = 'origin') { git(repoDir, ['fetch', remote, '--prune']); } /** * Resolve the MAIN repository root even when invoked from inside a worktree, so * sibling worktrees are always created next to the primary checkout (never * nested inside another worktree). */ function gitMainRepoRoot(cwd) { // `--git-common-dir` is the SHARED `.git` (same for every worktree); its // parent is the main repo root. const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']); return (0, path_1.dirname)(commonDir); } /** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */ function listWorktrees(repoDir) { let out = ''; try { out = git(repoDir, ['worktree', 'list', '--porcelain']); } catch (_a) { return []; } const result = []; let current = null; for (const line of out.split(/\r?\n/)) { if (line.startsWith('worktree ')) { if (current === null || current === void 0 ? void 0 : current.path) result.push(finalizeWorktree(current)); current = { branch: null, path: line.slice('worktree '.length) }; } else if (line.startsWith('branch ') && current) { current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, ''); } } if (current === null || current === void 0 ? void 0 : current.path) result.push(finalizeWorktree(current)); return result; } /** Install dependencies in a freshly-created worktree (pnpm hard-links from the shared store → fast). */ function pnpmInstall(dir) { const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm'; (0, child_process_1.execFileSync)(pnpmBin, ['install'], { cwd: dir, stdio: 'inherit' }); } /** Read the ticket id this worktree is tagged with, or null. */ function readTicketMarker(root) { const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER); if (!(0, fs_1.existsSync)(file)) return null; try { const id = (0, fs_1.readFileSync)(file, 'utf8').trim(); return id || null; } catch (_a) { return null; } } /** * Resolve the dev identity + DB name for a project root, ticket-aware. * * Order of precedence for the ticket id: * 1. an explicit `--ticket <name>` option (raw → {@link deriveTicketId}), * 2. the `.lt-dev/ticket` marker in the worktree (already an id), * 3. none → the plain project identity (base dev stack). * * Used by `lt dev up / down / test / status` so all of them treat a ticket * worktree identically without duplicating the suffix logic. */ function resolveDevIdentity(layout, options) { const base = (0, dev_identity_1.buildIdentity)(layout.root); const baseDb = (0, dev_project_1.deriveDbName)(layout.apiDir, base.slug); const flag = typeof (options === null || options === void 0 ? void 0 : options.ticket) === 'string' && options.ticket.trim() ? deriveTicketId(options.ticket) : null; const ticket = flag !== null && flag !== void 0 ? flag : readTicketMarker(layout.root); if (!ticket) return { dbName: baseDb, identity: base, ticket: null }; return { dbName: (0, dev_project_1.deriveTicketDbName)(baseDb, ticket), identity: (0, dev_identity_1.buildTicketIdentity)(base, ticket), ticket, }; } /** * Add a worktree for a ticket. Creates the branch from `baseRef` when it does * not exist yet, otherwise checks the existing branch out into the worktree. */ function worktreeAdd(repoDir, worktreePath, branch, baseRef) { if (gitBranchExists(repoDir, branch)) { git(repoDir, ['worktree', 'add', worktreePath, branch]); } else { git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]); } } /** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */ const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/; /** * True ONLY when a worktree has uncommitted changes AND every one is a * framework-generated / ephemeral file (`.nuxtrc`, `.nuxt`, `.output`, …). * `nuxt dev` rewrites the tracked `.nuxtrc` on boot, which would otherwise block * `git worktree remove`; this lets `lt ticket stop` auto-clean those safely. */ function worktreeDirtyOnlyGenerated(worktreePath) { let out = ''; try { out = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']); } catch (_a) { return false; } const lines = out.split(/\r?\n/).filter((l) => l.trim()); if (lines.length === 0) return false; // clean → no force needed return lines.every((line) => GENERATED_PATHS.test(porcelainPath(line))); } /** * Compute the sibling worktree path for a ticket: `<parent-of-main-repo>/<slug>-<id>`, * so it sits right next to the primary checkout and matches the URL (`<slug>-<id>.localhost`). */ function worktreePathFor(mainRepoRoot, slug, id) { return (0, path_1.join)((0, path_1.dirname)(mainRepoRoot), `${slug}-${id}`); } /** Remove a worktree (the branch is kept). */ function worktreeRemove(repoDir, worktreePath, force = false) { const args = ['worktree', 'remove', worktreePath]; if (force) args.push('--force'); git(repoDir, args); } /** * Unsaved-work report for a worktree, so `lt ticket stop` can WARN + refuse to * delete it before the user has committed AND pushed: `dirtySource` are * uncommitted NON-generated changes (lost on removal), `unpushed` are commits on * the branch not on any remote (the branch is kept on stop, but local-only). */ function worktreeSafetyReport(worktreePath) { let status = ''; try { status = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']); } catch (_a) { return { dirtySource: [], unpushed: 0 }; } const dirtySource = status .split(/\r?\n/) .filter((l) => l.trim()) .filter((l) => !GENERATED_PATHS.test(porcelainPath(l))); let unpushed = 0; try { unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0; } catch (_b) { /* no remotes / detached HEAD → cannot determine; treat as 0 */ } return { dirtySource, unpushed }; } /** Write the ticket marker that tags a worktree (created by `lt ticket start`). */ function writeTicketMarker(root, id) { const dir = (0, path_1.join)(root, dev_state_1.paths.sessionDir); (0, fs_1.mkdirSync)(dir, { recursive: true }); (0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8'); } function finalizeWorktree(partial) { var _a, _b; const path = (_a = partial.path) !== null && _a !== void 0 ? _a : ''; return { branch: (_b = partial.branch) !== null && _b !== void 0 ? _b : null, path, ticket: path ? readTicketMarker(path) : null }; } /** Run a git command in `cwd`, returning trimmed stdout. Throws on non-zero exit. */ function git(cwd, args) { return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim(); } /** Path from a `git status --porcelain` line ("XY <path>" / "XY <old> -> <new>"). */ function porcelainPath(line) { var _a; return (_a = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()) !== null && _a !== void 0 ? _a : ''; }