@lenne.tech/cli
Version:
lenne.Tech CLI: lt
344 lines (343 loc) • 14.6 kB
JavaScript
;
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 : '';
}