UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

415 lines (412 loc) 17.1 kB
"use strict"; 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 }); exports.SERVICE_LABEL = void 0; exports.getServicePaths = getServicePaths; exports.getServiceStatus = getServiceStatus; exports.installService = installService; exports.platformSupported = platformSupported; exports.renderLaunchAgentPlist = renderLaunchAgentPlist; exports.renderSystemdUnit = renderSystemdUnit; exports.resolveCaddyBin = resolveCaddyBin; exports.setShellRunner = setShellRunner; exports.uninstallService = uninstallService; exports.waitForServiceReady = waitForServiceReady; /** * Service lifecycle for the dedicated `lt-dev` Caddy daemon. * * Why a dedicated service: * `brew services start caddy` is fragile because the Homebrew plist * hardcodes `--config /opt/homebrew/etc/Caddyfile` (or the equivalent * Intel/Linux paths). When lt-dev keeps its Caddyfile at * `~/.lenneTech/Caddyfile`, Caddy crashes in an endless relaunch * loop, port 2019 never opens, and `sudo caddy trust` fails with * "connection refused" — which is what blocked the first real * install attempt. Owning the service definition removes that * coupling entirely. * * Platforms: * - macOS (Darwin): per-user LaunchAgent under * `~/Library/LaunchAgents/tech.lenne.lt-dev-caddy.plist`, * bootstrapped via `launchctl bootstrap gui/<uid> <plist>`. * - Linux: systemd-user unit at * `~/.config/systemd/user/lt-dev-caddy.service`, controlled via * `systemctl --user`. * - Anything else (Windows, BSDs without systemd-user): explicitly * unsupported — the caller surfaces a clear message. * * Tests inject a `ShellRunner` to mock `launchctl` / `systemctl` * without touching the real OS. Render functions stay pure. */ const child_process_1 = require("child_process"); const fs_1 = require("fs"); const os_1 = require("os"); const path_1 = require("path"); const caddy_1 = require("./caddy"); /** * Resolve the user's home directory in a test-overridable way. * * `os.homedir()` on macOS goes through `getpwuid()` and **ignores** * `process.env.HOME`, which makes it impossible to redirect file-system * side effects in tests. Honouring `HOME` first (then falling back to * `homedir()`) keeps real-world behaviour identical while letting tests * scope writes to a temp directory by setting `process.env.HOME` in * `beforeEach`. */ function userHome() { return process.env.HOME || (0, os_1.homedir)(); } /** Reverse-DNS label for the service. Keep stable — used in launchctl + systemd. */ exports.SERVICE_LABEL = 'tech.lenne.lt-dev-caddy'; let activeRunner = defaultShellRunner; /** Compute the file-system locations for the service. Pure. */ function getServicePaths(home = userHome(), plat = platformSupported()) { const logFile = (0, path_1.join)(home, '.lenneTech', 'caddy.log'); const errFile = (0, path_1.join)(home, '.lenneTech', 'caddy.err.log'); if (plat === 'darwin') { return { errFile, label: exports.SERVICE_LABEL, logFile, platform: 'darwin', unitFile: (0, path_1.join)(home, 'Library', 'LaunchAgents', `${exports.SERVICE_LABEL}.plist`), }; } if (plat === 'linux') { return { errFile, label: exports.SERVICE_LABEL, logFile, platform: 'linux', unitFile: (0, path_1.join)(home, '.config', 'systemd', 'user', 'lt-dev-caddy.service'), }; } return { errFile, label: exports.SERVICE_LABEL, logFile, platform: 'unsupported', unitFile: '' }; } /** Current service state — installed/loaded/reachable. */ function getServiceStatus() { return __awaiter(this, void 0, void 0, function* () { const paths = getServicePaths(); const installed = paths.platform !== 'unsupported' && (0, fs_1.existsSync)(paths.unitFile); let loaded = false; let pid; if (paths.platform === 'darwin' && installed) { const uid = (0, os_1.userInfo)().uid; const res = yield activeRunner('launchctl', ['print', `gui/${uid}/${paths.label}`]); loaded = res.ok; const pidMatch = res.stdout.match(/pid\s*=\s*(\d+)/); if (pidMatch) pid = Number(pidMatch[1]); } else if (paths.platform === 'linux' && installed) { const res = yield activeRunner('systemctl', ['--user', 'is-active', `${paths.label}.service`]); loaded = res.ok && res.stdout.trim() === 'active'; if (loaded) { const pidRes = yield activeRunner('systemctl', ['--user', 'show', `${paths.label}.service`, '-p', 'MainPID']); const m = pidRes.stdout.match(/MainPID=(\d+)/); if (m && m[1] !== '0') pid = Number(m[1]); } } const daemonReachable = yield pingCaddyAdmin(); return { daemonReachable, installed, loaded, pid, platform: paths.platform, unitFile: paths.unitFile }; }); } /** * Install (or update) and start the service. * * Idempotent: * - rewrites the unit file only when its content changes * - bootstraps the service only when not already loaded * - on content change: bootout + bootstrap (= reload) */ function installService() { return __awaiter(this, arguments, void 0, function* (opts = {}) { const plat = platformSupported(); if (plat === 'unsupported') { return { bootstrapped: false, created: false, message: 'Service management is only supported on macOS and Linux.', ok: false, }; } const caddyBin = opts.caddyBin || (yield resolveCaddyBin()); if (!caddyBin) { return { bootstrapped: false, created: false, message: 'caddy not found on PATH. Install with `brew install caddy` (macOS) or your package manager (Linux).', ok: false, }; } const paths = getServicePaths(); const cfg = { caddyBin, caddyfile: caddy_1.paths.caddyfile, errFile: paths.errFile, homeDir: userHome(), label: paths.label, logFile: paths.logFile, }; const desired = plat === 'darwin' ? renderLaunchAgentPlist(cfg) : renderSystemdUnit(cfg); const existed = (0, fs_1.existsSync)(paths.unitFile); const current = existed ? (0, fs_1.readFileSync)(paths.unitFile, 'utf8') : ''; const changed = current !== desired; if (changed) { (0, fs_1.mkdirSync)((0, path_1.dirname)(paths.unitFile), { recursive: true }); // Make sure log targets exist + are writable BEFORE the daemon // tries to open them — otherwise launchd silently keeps restarting. (0, fs_1.mkdirSync)((0, path_1.dirname)(paths.logFile), { recursive: true }); touchFile(paths.logFile); touchFile(paths.errFile); (0, fs_1.writeFileSync)(paths.unitFile, desired, 'utf8'); } if (plat === 'darwin') return installDarwin(paths, changed, existed); return installLinux(paths, changed, existed); }); } /** Detect the supported platform. */ function platformSupported() { const p = (0, os_1.platform)(); if (p === 'darwin') return 'darwin'; if (p === 'linux') return 'linux'; return 'unsupported'; } /** * Render the macOS LaunchAgent plist. * * Critical env keys: * - HOME: caddy stores its local CA in `$HOME/Library/Application * Support/Caddy/` — without HOME caddy falls back to * launchd's empty default and fails to persist CA state. * - PATH: caddy shells out to `security` (Keychain) during * `caddy trust`; a minimal launchd PATH cannot find it. */ function renderLaunchAgentPlist(cfg) { const programArgs = [cfg.caddyBin, 'run', '--config', cfg.caddyfile, '--adapter', 'caddyfile']; const programArgsXml = programArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join('\n'); const pathValue = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin'; return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>${escapeXml(cfg.label)}</string> <key>ProgramArguments</key> <array> ${programArgsXml} </array> <key>EnvironmentVariables</key> <dict> <key>HOME</key> <string>${escapeXml(cfg.homeDir)}</string> <key>PATH</key> <string>${pathValue}</string> </dict> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>WorkingDirectory</key> <string>${escapeXml(cfg.homeDir)}</string> <key>StandardOutPath</key> <string>${escapeXml(cfg.logFile)}</string> <key>StandardErrorPath</key> <string>${escapeXml(cfg.errFile)}</string> </dict> </plist> `; } /** Render the systemd-user unit file. */ function renderSystemdUnit(cfg) { return `[Unit] Description=lt-dev Caddy reverse proxy (HTTPS for *.localhost) Documentation=https://github.com/lenneTech/cli After=network.target [Service] Type=simple Environment=HOME=${cfg.homeDir} ExecStart=${cfg.caddyBin} run --config ${cfg.caddyfile} --adapter caddyfile Restart=on-failure RestartSec=5s StandardOutput=append:${cfg.logFile} StandardError=append:${cfg.errFile} [Install] WantedBy=default.target `; } /** Resolve the absolute path of `caddy` via `which` so launchd has a guaranteed path. */ function resolveCaddyBin() { return __awaiter(this, void 0, void 0, function* () { const r = yield activeRunner('which', ['caddy']); if (!r.ok) return undefined; const line = r.stdout.split('\n').find((s) => s.trim().length > 0); return line ? line.trim() : undefined; }); } /** Inject a custom runner (tests). Pass `null` to reset to the real spawner. */ function setShellRunner(runner) { activeRunner = runner !== null && runner !== void 0 ? runner : defaultShellRunner; } /** Stop the service and remove the unit file. */ function uninstallService() { return __awaiter(this, void 0, void 0, function* () { const plat = platformSupported(); if (plat === 'unsupported') { return { bootedOut: false, message: 'Nothing to uninstall on this platform.', ok: true, removed: [] }; } const paths = getServicePaths(); let bootedOut = false; if (plat === 'darwin') { const uid = (0, os_1.userInfo)().uid; const target = `gui/${uid}/${paths.label}`; const printRes = yield activeRunner('launchctl', ['print', target]); if (printRes.ok) { const result = yield activeRunner('launchctl', ['bootout', target]); bootedOut = result.ok; // bootout returns non-zero with "Operation now in progress" on some // macOS versions even when the service is unloaded — tolerate it. if (!result.ok && /no such process|not loaded|service is not loaded/i.test(result.stderr)) bootedOut = true; } } else { yield activeRunner('systemctl', ['--user', 'stop', `${paths.label}.service`]); const dis = yield activeRunner('systemctl', ['--user', 'disable', `${paths.label}.service`]); bootedOut = dis.ok; } const removed = []; if ((0, fs_1.existsSync)(paths.unitFile)) { (0, fs_1.unlinkSync)(paths.unitFile); removed.push(paths.unitFile); } return { bootedOut, message: removed.length > 0 ? `Service uninstalled (${removed.length} file(s) removed).` : 'Service was not installed.', ok: true, removed, }; }); } /** Wait until Caddy's admin API responds, or until timeout. Returns true on success. */ function waitForServiceReady() { return __awaiter(this, arguments, void 0, function* (timeoutMs = 5000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (yield pingCaddyAdmin()) return true; yield sleep(150); } return false; }); } function defaultShellRunner(cmd, args) { return new Promise((resolve) => { var _a, _b; const child = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; let errored = false; (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b))); (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (b) => (stderr += String(b))); child.on('error', () => (errored = true)); child.on('close', (code) => { if (errored) resolve({ code: null, ok: false, stderr: stderr || 'command not found', stdout }); else resolve({ code, ok: code === 0, stderr, stdout }); }); }); } function escapeXml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function installDarwin(paths, changed, existed) { return __awaiter(this, void 0, void 0, function* () { const uid = (0, os_1.userInfo)().uid; const target = `gui/${uid}/${paths.label}`; const print = yield activeRunner('launchctl', ['print', target]); const alreadyLoaded = print.ok; if (alreadyLoaded && changed) { // bootout returns "no such process" with a non-zero exit on macOS 14+ // even on success; we re-check via `print` to confirm. yield activeRunner('launchctl', ['bootout', target]); } let bootstrapped = alreadyLoaded && !changed; if (!bootstrapped) { const result = yield activeRunner('launchctl', ['bootstrap', `gui/${uid}`, paths.unitFile]); bootstrapped = result.ok; if (!bootstrapped) { return { bootstrapped: false, created: changed, message: `launchctl bootstrap failed: ${result.stderr.trim() || result.stdout.trim() || 'unknown error'}`, ok: false, }; } } return { bootstrapped, created: changed, message: existed && !changed ? 'Service already installed and up to date.' : 'Service installed.', ok: true, }; }); } function installLinux(paths, changed, existed) { return __awaiter(this, void 0, void 0, function* () { const reload = yield activeRunner('systemctl', ['--user', 'daemon-reload']); if (!reload.ok) { return { bootstrapped: false, created: changed, message: `systemctl daemon-reload failed: ${reload.stderr.trim() || 'unknown error'}`, ok: false, }; } const enableRes = yield activeRunner('systemctl', ['--user', 'enable', '--now', `${paths.label}.service`]); if (!enableRes.ok) { return { bootstrapped: false, created: changed, message: `systemctl --user enable --now failed: ${enableRes.stderr.trim() || 'unknown error'}`, ok: false, }; } return { bootstrapped: true, created: changed, message: existed && !changed ? 'Service already installed and up to date.' : 'Service installed.', ok: true, }; }); } function pingCaddyAdmin() { return new Promise((resolve) => { const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', '--max-time', '1', 'http://127.0.0.1:2019/config/'], { stdio: ['ignore', 'ignore', 'ignore'], }); child.on('error', () => resolve(false)); child.on('close', (code) => resolve(code === 0)); }); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function touchFile(p) { if (!(0, fs_1.existsSync)(p)) (0, fs_1.writeFileSync)(p, '', 'utf8'); }