@lenne.tech/cli
Version:
lenne.Tech CLI: lt
167 lines (166 loc) • 9.33 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 });
const fs_1 = require("fs");
const dev_identity_1 = require("../../lib/dev-identity");
const dev_process_1 = require("../../lib/dev-process");
const dev_project_1 = require("../../lib/dev-project");
const dev_state_1 = require("../../lib/dev-state");
const dev_ticket_1 = require("../../lib/dev-ticket");
/**
* `lt ticket stop [<id>]` — tear a ticket env down + remove its worktree.
*
* 1. `lt dev down` inside the worktree (stops the ticket stack + any test
* stacks, removes the Caddy block — residue-free),
* 2. `git worktree remove` (the BRANCH is kept, so nothing is lost),
* 3. `--drop-db` also drops the ticket's empty dev + test databases.
*
* Run with NO id from INSIDE a ticket worktree to clean up THIS environment
* (the current folder is removed; the process steps out to the main repo first).
*/
const StopCommand = {
alias: ['rm'],
description: 'Stop a ticket env + remove its worktree (branch kept); no id = the current worktree',
name: 'stop',
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
var _a, _b, _c, _d, _e;
const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
let mainRepoRoot;
try {
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
}
catch (_f) {
error('Not inside a git repository.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'ticket stop: not a git repo';
}
// Id from the argument — or, when invoked with NO id from INSIDE a ticket
// worktree, the current worktree's own ticket (so a bare `lt ticket stop`
// cleans up "this" environment and removes this very folder).
const fromMarker = parameters.first == null ? (0, dev_ticket_1.readTicketMarker)(layout.root) : null;
const id = String((_b = (_a = parameters.first) !== null && _a !== void 0 ? _a : fromMarker) !== null && _b !== void 0 ? _b : '').trim();
if (!id) {
error('Usage: lt ticket stop <id> [--drop-db] [--force] — or run with no id from INSIDE a ticket worktree.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'ticket stop: missing id';
}
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
if (!wt) {
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'ticket stop: not found';
}
// SAFETY: never silently delete unsaved work. Warn + REFUSE (unless --force)
// when the worktree has uncommitted changes OR unpushed commits, so the user
// commits + pushes first. (`--force` removes anyway; the branch is kept, so
// committed history survives regardless.)
const safety = (0, dev_ticket_1.worktreeSafetyReport)(wt.path);
if ((safety.dirtySource.length > 0 || safety.unpushed > 0) && parameters.options.force !== true) {
warning('');
warning(`Refusing to remove ticket "${id}" — work is not fully committed + pushed:`);
if (safety.dirtySource.length > 0) {
warning(` • ${safety.dirtySource.length} uncommitted change(s) — would be LOST on removal:`);
safety.dirtySource.slice(0, 12).forEach((l) => info(colors.dim(` ${l}`)));
if (safety.dirtySource.length > 12)
info(colors.dim(` … and ${safety.dirtySource.length - 12} more`));
}
if (safety.unpushed > 0) {
warning(` • ${safety.unpushed} commit(s) on "${(_c = wt.branch) !== null && _c !== void 0 ? _c : '-'}" not pushed to any remote`);
}
info('');
info(colors.dim(' Commit + push first (the branch is kept), or re-run with --force to remove anyway.'));
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'ticket stop: unsaved work (use --force)';
}
// If we are removing the worktree we are standing in, step the process out
// to the main repo first so git can remove the folder cleanly.
const removingCwd = fromMarker !== null || samePath(wt.path, layout.root);
if (removingCwd) {
try {
process.chdir(mainRepoRoot);
}
catch (_g) {
/* best-effort */
}
}
info('');
info(colors.bold(`Stopping ticket "${id}"`));
// 1. Tear the isolated stack down from inside the worktree (marker-aware).
info(colors.dim(' lt dev down …'));
yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'down'], { cwd: wt.path, env: process.env });
// 2. Optionally drop the ticket databases (they are otherwise just left empty).
if (parameters.options.dropDb === true || parameters.options['drop-db'] === true) {
const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
const entry = (0, dev_state_1.loadRegistry)().projects[`${base.slug}-${id}`];
const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
const devDb = (_d = entry === null || entry === void 0 ? void 0 : entry.dbName) !== null && _d !== void 0 ? _d : (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(mainLayout.apiDir, base.slug), id);
const testDb = (0, dev_project_1.deriveTestDbName)(devDb);
for (const db of [devDb, testDb]) {
if ((0, dev_ticket_1.dropDatabase)(db))
info(colors.dim(` dropped db ${db}`));
else
warning(` could not drop db ${db} (mongosh missing or DB not reachable) — drop it manually if needed.`);
}
}
// 3. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
// files are framework-generated (e.g. `nuxt dev` rewrites the tracked
// `.nuxtrc` on boot), which would otherwise block the remove — but NEVER
// discard real source edits (those keep the non-forced remove, which
// errors with a hint so unsaved work is never lost).
const force = parameters.options.force === true || (0, dev_ticket_1.worktreeDirtyOnlyGenerated)(wt.path);
if (force && parameters.options.force !== true) {
info(colors.dim(' (worktree had only generated files dirty, e.g. .nuxtrc — removing)'));
}
try {
(0, dev_ticket_1.worktreeRemove)(mainRepoRoot, wt.path, force);
}
catch (e) {
error(`git worktree remove failed: ${e.message}`);
info(colors.dim(' The worktree has uncommitted SOURCE changes — commit/stash them, or pass --force to discard.'));
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'ticket stop: worktree remove failed';
}
// The whole env is gone now — drop the ticket's registry entry so its slug +
// reserved ports are reclaimed (`lt dev down` only ends the session, keeping
// the entry for a restart; `lt ticket stop` removes the env entirely).
{
const reg = (0, dev_state_1.loadRegistry)();
const ticketSlug = `${(0, dev_identity_1.buildIdentity)(mainRepoRoot).slug}-${id}`;
if (reg.projects[ticketSlug]) {
delete reg.projects[ticketSlug];
(0, dev_state_1.saveRegistry)(reg);
}
}
info('');
success(`Ticket "${id}" stopped — worktree removed, branch "${(_e = wt.branch) !== null && _e !== void 0 ? _e : '-'}" kept.`);
if (removingCwd)
info(colors.dim(` This folder is gone — your shell is still in it. Run: cd ${mainRepoRoot}`));
if (!parameters.options.fromGluegunMenu)
process.exit();
return `ticket stop: ${id}`;
}),
};
/** True if two paths point at the same location (resolving symlinks, e.g. /tmp → /private/tmp). */
function samePath(a, b) {
try {
return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
}
catch (_a) {
return a === b;
}
}
module.exports = StopCommand;