@lenne.tech/cli
Version:
lenne.Tech CLI: lt
336 lines (335 loc) • 18.6 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 path_1 = require("path");
const caddy_1 = require("../../lib/caddy");
const dev_env_1 = require("../../lib/dev-env");
const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
const dev_patches_1 = require("../../lib/dev-patches");
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");
/**
* Start API + App behind Caddy with project-specific URLs.
*
* Pre-flight:
* - Caddy must be installed and running (otherwise points to install)
* - No existing `lt dev up` session for THIS project
* - Internal upstream ports must be free
*
* Process:
* 1. Resolve layout + identity
* 2. Allocate (or reuse) internal upstream ports
* 3. Upsert Caddy block + reload
* 4. Spawn API + App detached, log into `<root>/.lt-dev/{api,app}.log`
* 5. Persist registry entry + session state
*
* Env-vars injected (see lib/dev-env.ts):
* PORT, BASE_URL, APP_URL, NUXT_API_URL, NUXT_PUBLIC_API_URL,
* NUXT_PUBLIC_SITE_URL, NUXT_PUBLIC_STORAGE_PREFIX,
* NSC__MONGOOSE__URI, NSC__BASE_URL, NSC__APP_URL, DATABASE_URL,
* NUXT_PUBLIC_API_PROXY=false (Caddy makes vite-proxy obsolete).
*/
function formatBytes(bytes) {
if (bytes < 1024)
return `${bytes}B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)}KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
}
function formatRotationNote(label, archivePath, previousSize) {
const size = formatBytes(previousSize);
const huge = previousSize > 100 * 1024 * 1024 ? ' (large — consider fixing noisy warnings)' : '';
return `Rotated previous ${label} log → ${archivePath} (${size})${huge}`;
}
/**
* Print the project's bound URLs (app, api, db) as a small info block.
*
* Used in two places so the user always sees the URLs next to the PIDs —
* once after a successful `up` and once when `up` short-circuits on
* "Already running". Falls back gracefully when only one of api/app is
* present (single-side projects).
*/
function printProjectUrls(info, options) {
if (options.appHostname) {
const arrow = options.appUpstreamPort ? ` → 127.0.0.1:${options.appUpstreamPort}` : '';
info(` app: https://${options.appHostname}${arrow}`);
}
if (options.apiHostname) {
const arrow = options.apiUpstreamPort ? ` → 127.0.0.1:${options.apiUpstreamPort}` : '';
info(` api: https://${options.apiHostname}${arrow}`);
}
if (options.dbName) {
info(` db: mongodb://127.0.0.1/${options.dbName}`);
}
}
const UpCommand = {
alias: ['u'],
description: 'Start API + App behind Caddy',
hidden: false,
name: 'up',
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
if (!layout.apiDir && !layout.appDir) {
error('No API or App project detected at this path. Run `lt dev init` first.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: not a project';
}
// Pre-flight: Caddy
if (!(yield (0, caddy_1.caddyAvailable)())) {
error('caddy is not installed. Run `lt dev install` first.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: caddy missing';
}
if (!(yield (0, caddy_1.caddyDaemonRunning)())) {
error('caddy daemon is not running. Run `lt dev install` to start the lt-dev service.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: caddy daemon down';
}
// Ticket-aware: in a `lt ticket` worktree (tagged by a `.lt-dev/ticket`
// marker) — or with an explicit `--ticket <name>` — the slug / URLs / DB are
// suffixed so the stack is fully isolated from the base dev session and every
// other ticket. Without a ticket this is the plain project identity.
const { dbName, identity, ticket } = (0, dev_ticket_1.resolveDevIdentity)(layout, { ticket: parameters.options.ticket });
// Guard against two checkouts of the SAME project (same package.json "name"
// → same slug → shared URLs / ports / DB / Caddy block). If another checkout
// is already RUNNING under this slug, abort with a clear message — otherwise
// both fight over the same ports and one `lt dev down` unroutes the other.
{
const conflict = (0, dev_state_1.detectSlugConflict)(identity.slug, layout.root);
if (conflict === null || conflict === void 0 ? void 0 : conflict.otherSessionAlive) {
error(`Slug "${identity.slug}" is already in use by another RUNNING checkout:`);
info(colors.dim(` ${conflict.otherPath}`));
info('Two checkouts of the same project share URLs / ports / database and collide.');
info('Stop it there (`lt dev down`), or give THIS checkout a distinct package.json "name".');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: slug in use by another checkout';
}
}
// Sanft auto-migrate sichere Operationen (ohne Code-Modifikation):
// CLAUDE.md-URL-Block einfügen + .gitignore ergänzen.
// Code-Patches (config.env.ts, nuxt.config.ts) bleiben explizit `lt dev init`.
{
// NEVER patch the git-tracked CLAUDE.md for a ticket worktree: it would
// differ per worktree and risk committing ticket-specific URLs. The lt-dev
// plugin hook surfaces the ticket context per prompt instead (from the
// gitignored `.lt-dev/ticket` marker). For the base project we keep the
// committed URL block up to date as before.
if (!ticket) {
const claudeCandidates = [
(0, path_1.join)(layout.root, 'CLAUDE.md'),
...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
];
const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
if (patched.length > 0) {
info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
}
}
// Always keep `.lt-dev/` (state, env bridge, ticket marker) out of git.
if ((0, dev_patches_1.addToGitignore)(layout.root, '.lt-dev/')) {
info(colors.dim('added `.lt-dev/` to .gitignore'));
}
}
// Warnung bei Legacy-Code (hardcoded ports) — kein Auto-Patch.
{
const legacyFiles = [];
if (layout.apiDir) {
const f = (0, dev_project_1.apiNeedsPortPatch)(layout.apiDir);
if (f)
legacyFiles.push(f);
}
if (layout.appDir)
legacyFiles.push(...(0, dev_project_1.appNeedsPortPatch)(layout.appDir));
if (legacyFiles.length > 0) {
warning('Legacy hardcoded ports detected — Caddy will proxy correctly only after running `lt dev init`:');
legacyFiles.forEach((f) => info(colors.dim(` - ${f}`)));
info(colors.dim(' (Continuing — env-aware files will work; legacy files may bind on 3000/3001 and miss Caddy.)'));
}
}
// Already running?
const existingSession = (0, dev_state_1.loadSession)(layout.root);
if (existingSession) {
const apiUp = existingSession.pids.api ? (0, dev_state_1.isPidAlive)(existingSession.pids.api) : false;
const appUp = existingSession.pids.app ? (0, dev_state_1.isPidAlive)(existingSession.pids.app) : false;
if (apiUp || appUp) {
warning(`Already running (api pid ${(_a = existingSession.pids.api) !== null && _a !== void 0 ? _a : '-'}, app pid ${(_b = existingSession.pids.app) !== null && _b !== void 0 ? _b : '-'}).`);
// Surface the bound URLs so the user can copy them out without having
// to look up `lt dev status` separately. Falls back to the in-process
// identity/registry data — both sources stay in sync via saveRegistry.
const existingEntry = (0, dev_state_1.loadRegistry)().projects[identity.slug];
printProjectUrls(info, {
apiHostname: (_c = identity.subdomains.api) === null || _c === void 0 ? void 0 : _c.hostname,
apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
});
info('Run `lt dev down` first.');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: already running';
}
}
// Allocate internal ports (reuse existing if registered), verify they are
// free, AND reserve them in the registry — all ATOMICALLY under a cross-
// process lock, so two simultaneous `lt ticket start` (each → `lt dev up`)
// can never grab the same ports.
let apiPort;
let appPort;
try {
yield (0, dev_state_1.withRegistryLock)(() => __awaiter(void 0, void 0, void 0, function* () {
var _a, _b;
const reg = (0, dev_state_1.loadRegistry)();
const entry = reg.projects[identity.slug];
const taken = (0, dev_state_1.takenInternalPorts)(reg, identity.slug);
apiPort = (_a = entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) !== null && _a !== void 0 ? _a : (layout.apiDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
if (apiPort)
taken.add(apiPort);
appPort = (_b = entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) !== null && _b !== void 0 ? _b : (layout.appDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
for (const p of portsToCheck) {
const r = snap.get(p);
if (r)
throw new Error(`Internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
}
// Reserve immediately so a concurrent `lt dev up` sees these as taken.
const subdomainMap = {};
for (const [k, v] of Object.entries(identity.subdomains))
subdomainMap[k] = v.hostname;
reg.projects[identity.slug] = {
dbName,
internalPorts: { api: apiPort, app: appPort },
lastUsedAt: new Date().toISOString(),
path: layout.root,
subdomains: subdomainMap,
};
(0, dev_state_1.saveRegistry)(reg);
}));
}
catch (e) {
error(e.message);
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: port in use';
}
// Caddy block + reload.
const routes = [];
if (identity.subdomains.api && apiPort)
routes.push({ hostname: identity.subdomains.api.hostname, upstreamPort: apiPort });
if (identity.subdomains.app && appPort)
routes.push({ hostname: identity.subdomains.app.hostname, upstreamPort: appPort });
if (routes.length === 0) {
error('No subdomains to expose (project has neither api nor app).');
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: nothing to expose';
}
(0, caddy_1.upsertProjectBlock)(identity.slug, routes);
const reload = yield (0, caddy_1.reloadCaddy)();
if (!reload.ok) {
error(`caddy reload failed:\n${reload.stderr}`);
if (!parameters.options.fromGluegunMenu)
process.exit(1);
return 'dev up: caddy reload failed';
}
info('');
info(colors.bold(`Starting "${identity.slug}"`) + (ticket ? colors.dim(` (ticket ${ticket})`) : ''));
if (identity.subdomains.app)
info(` app: https://${identity.subdomains.app.hostname} → 127.0.0.1:${appPort}`);
if (identity.subdomains.api)
info(` api: https://${identity.subdomains.api.hostname} → 127.0.0.1:${apiPort}`);
info(` db: mongodb://127.0.0.1/${dbName}`);
info('');
// Build env per process.
const devEnv = (0, dev_env_1.buildDevEnv)({
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
baseEnv: process.env,
dbName,
identity,
});
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
const pids = {};
const rotationNotes = [];
if (layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort) {
const apiResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
cwd: layout.apiDir,
env: devEnv.api.env,
logFile: (0, path_1.join)(layout.root, '.lt-dev', 'api.log'),
});
if (apiResult) {
pids.api = apiResult.pid;
if (apiResult.rotated.rotated && apiResult.rotated.archivePath !== undefined) {
rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_f = apiResult.rotated.previousSize) !== null && _f !== void 0 ? _f : 0));
}
}
}
if (layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort) {
const appResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
cwd: layout.appDir,
env: devEnv.app.env,
logFile: (0, path_1.join)(layout.root, '.lt-dev', 'app.log'),
});
if (appResult) {
pids.app = appResult.pid;
if (appResult.rotated.rotated && appResult.rotated.archivePath !== undefined) {
rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_g = appResult.rotated.previousSize) !== null && _g !== void 0 ? _g : 0));
}
}
}
// Persist the session (PIDs). The registry entry (ports) was already reserved
// atomically before the spawn, above.
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() });
// Write the ENV bridge so external tools (Playwright, IDE test runners,
// custom shell scripts) can pick up the URLs without inheriting our shell.
const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
info(colors.dim(`ENV bridge: ${bridgePath}`));
success(`Started: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
// Echo the bound URLs next to the PIDs as well — the "Starting" block
// prints them before the spawn, but on a long boot log they scroll out
// of view, so repeating them here keeps PID + URL visually grouped.
printProjectUrls(info, {
apiHostname: (_k = identity.subdomains.api) === null || _k === void 0 ? void 0 : _k.hostname,
apiUpstreamPort: apiPort,
appHostname: (_l = identity.subdomains.app) === null || _l === void 0 ? void 0 : _l.hostname,
appUpstreamPort: appPort,
dbName,
});
info(colors.dim('Logs: <root>/.lt-dev/api.log, <root>/.lt-dev/app.log'));
for (const note of rotationNotes)
info(colors.dim(note));
info(colors.dim('Stop with: lt dev down'));
// Best-effort: kill orphaned children if neither spawned (unlikely, but tidy).
if (Object.keys(pids).length === 0) {
warning('Nothing was spawned. Check package.json scripts (`start` for api, `dev` for app).');
if (pids.api)
(0, dev_process_1.killProcessGroup)(pids.api);
if (pids.app)
(0, dev_process_1.killProcessGroup)(pids.app);
}
if (!parameters.options.fromGluegunMenu)
process.exit();
return `dev up: api=${pids.api}, app=${pids.app}`;
}),
};
module.exports = UpCommand;