@lenne.tech/cli 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.checkPortInUse = checkPortInUse;
13
+ exports.killProcessGroup = killProcessGroup;
14
+ exports.listenSnapshot = listenSnapshot;
15
+ exports.spawnDetached = spawnDetached;
16
+ /**
17
+ * Process + port helpers for `lt dev`.
18
+ *
19
+ * - `spawnDetached`: detached child whose stdout/stderr go to a log file.
20
+ * The Claude Code session does NOT block waiting for it, and `lt dev down`
21
+ * can SIGTERM the entire process group via `process.kill(-pid, …)`.
22
+ * - `listenSnapshot` / `checkPortInUse`: thin lsof wrappers used by
23
+ * `lt dev doctor` to detect port collisions.
24
+ */
25
+ const child_process_1 = require("child_process");
26
+ const fs_1 = require("fs");
27
+ const path_1 = require("path");
28
+ const dev_state_1 = require("./dev-state");
29
+ /**
30
+ * Check via `lsof` whether a single TCP port is bound by a LISTEN socket.
31
+ * Returns null if lsof is unavailable.
32
+ */
33
+ function checkPortInUse(port) {
34
+ return __awaiter(this, void 0, void 0, function* () {
35
+ return new Promise((resolve) => {
36
+ var _a;
37
+ const child = (0, child_process_1.spawn)('lsof', ['-iTCP', `-sTCP:LISTEN`, '-nP', `-iTCP:${port}`], {
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ let stdout = '';
41
+ let errored = false;
42
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
43
+ child.on('error', () => (errored = true));
44
+ child.on('close', () => {
45
+ if (errored)
46
+ return resolve(null);
47
+ const lines = stdout.split('\n').filter((l) => l && !l.startsWith('COMMAND'));
48
+ const matching = lines.find((l) => new RegExp(`[: ]${port}\\s.*\\(LISTEN\\)`).test(l) || l.includes(`:${port} (LISTEN)`));
49
+ if (!matching)
50
+ return resolve({ inUse: false });
51
+ const parts = matching.trim().split(/\s+/);
52
+ resolve({ command: parts[0], inUse: true, pid: Number(parts[1]) });
53
+ });
54
+ });
55
+ });
56
+ }
57
+ /** Send SIGTERM to a detached process group; falls back to single-PID kill. */
58
+ function killProcessGroup(pid) {
59
+ if (!(0, dev_state_1.isValidPid)(pid))
60
+ return false;
61
+ try {
62
+ process.kill(-pid, 'SIGTERM');
63
+ return true;
64
+ }
65
+ catch (_a) {
66
+ try {
67
+ process.kill(pid, 'SIGTERM');
68
+ return true;
69
+ }
70
+ catch (_b) {
71
+ return false;
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Multi-port lsof snapshot — single subprocess for N ports.
77
+ * Returns map<port, {command, pid}> for ports that are in use.
78
+ */
79
+ function listenSnapshot(ports) {
80
+ return __awaiter(this, void 0, void 0, function* () {
81
+ const result = new Map();
82
+ if (ports.length === 0)
83
+ return result;
84
+ return new Promise((resolve) => {
85
+ var _a;
86
+ const portArgs = ports.flatMap((p) => ['-iTCP', `-iTCP:${p}`]);
87
+ const child = (0, child_process_1.spawn)('lsof', ['-sTCP:LISTEN', '-nP', ...portArgs], { stdio: ['ignore', 'pipe', 'pipe'] });
88
+ let stdout = '';
89
+ let errored = false;
90
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
91
+ child.on('error', () => (errored = true));
92
+ child.on('close', () => {
93
+ if (errored)
94
+ return resolve(result);
95
+ for (const line of stdout.split('\n')) {
96
+ if (!line || line.startsWith('COMMAND'))
97
+ continue;
98
+ const parts = line.trim().split(/\s+/);
99
+ if (parts.length < 9)
100
+ continue;
101
+ const command = parts[0];
102
+ const pid = Number(parts[1]);
103
+ const name = parts[8];
104
+ const portMatch = name.match(/:(\d+)$/);
105
+ if (!portMatch)
106
+ continue;
107
+ const port = Number(portMatch[1]);
108
+ if (ports.includes(port) && /\(LISTEN\)/.test(line)) {
109
+ result.set(port, { command, pid });
110
+ }
111
+ }
112
+ resolve(result);
113
+ });
114
+ });
115
+ });
116
+ }
117
+ /**
118
+ * Spawn a detached child whose stdio is redirected to a log file.
119
+ *
120
+ * The parent's copy of the log file descriptor is closed in `finally`
121
+ * — the child has already inherited its own fd before `spawn` returns,
122
+ * so closing prevents fd leaks and avoids racing-write artifacts on
123
+ * filesystems where O_APPEND is not atomic.
124
+ *
125
+ * Returns the child PID, or undefined if spawn failed.
126
+ */
127
+ function spawnDetached(cmd, args, opts) {
128
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true });
129
+ const out = (0, fs_1.openSync)(opts.logFile, 'a');
130
+ let child;
131
+ try {
132
+ child = (0, child_process_1.spawn)(cmd, args, {
133
+ cwd: opts.cwd,
134
+ detached: true,
135
+ env: opts.env,
136
+ stdio: ['ignore', out, out],
137
+ });
138
+ child.unref();
139
+ return child.pid;
140
+ }
141
+ catch (_a) {
142
+ return undefined;
143
+ }
144
+ finally {
145
+ try {
146
+ (0, fs_1.closeSync)(out);
147
+ }
148
+ catch (_b) {
149
+ /* fd already inherited by child; ignore */
150
+ }
151
+ }
152
+ }
@@ -2,27 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apiNeedsPortPatch = apiNeedsPortPatch;
4
4
  exports.appNeedsPortPatch = appNeedsPortPatch;
5
+ exports.deriveDbName = deriveDbName;
5
6
  exports.resolveLayout = resolveLayout;
6
7
  const fs_1 = require("fs");
7
8
  const path_1 = require("path");
8
9
  const workspace_integration_1 = require("./workspace-integration");
9
10
  /**
10
- * Detect whether the API project still has the legacy hardcoded
11
- * `port: 3000`. Returns the absolute file path if a patch is needed,
12
- * null if already env-aware (or no file).
11
+ * Detect whether the API project still has the legacy hardcoded `port: 3000`.
12
+ * Returns the file path if a patch is needed, null otherwise.
13
13
  */
14
14
  function apiNeedsPortPatch(apiDir) {
15
15
  const file = (0, path_1.join)(apiDir, 'src', 'config.env.ts');
16
16
  if (!(0, fs_1.existsSync)(file))
17
17
  return null;
18
18
  const content = (0, fs_1.readFileSync)(file, 'utf8');
19
- // Match `port: 3000,` exactly (not yet env-wrapped).
20
19
  return /port:\s*3000\s*,/.test(content) ? file : null;
21
20
  }
22
21
  /**
23
22
  * Detect whether the App project still has hardcoded `port: 3001` or a
24
23
  * hardcoded vite-proxy `target: 'http://localhost:3000'`. Returns an
25
- * array of absolute file paths that need patching.
24
+ * array of file paths that need patching.
26
25
  */
27
26
  function appNeedsPortPatch(appDir) {
28
27
  const candidates = [(0, path_1.join)(appDir, 'nuxt.config.ts'), (0, path_1.join)(appDir, 'playwright.config.ts')];
@@ -37,40 +36,43 @@ function appNeedsPortPatch(appDir) {
37
36
  /host:\s*'http:\/\/localhost:3001'/.test(c));
38
37
  });
39
38
  }
39
+ /** Read `dbName` from the API config (defaults to `<slug>-local`). */
40
+ function deriveDbName(apiDir, slug) {
41
+ if (apiDir) {
42
+ const cfg = (0, path_1.join)(apiDir, 'src', 'config.env.ts');
43
+ if ((0, fs_1.existsSync)(cfg)) {
44
+ const content = (0, fs_1.readFileSync)(cfg, 'utf8');
45
+ const match = content.match(/dbName:\s*['"`]([^'"`]+)['"`]/);
46
+ if (match)
47
+ return match[1];
48
+ }
49
+ }
50
+ return `${slug}-local`;
51
+ }
40
52
  /**
41
- * Resolve the project layout starting from `cwd`. Walks up to find a
42
- * monorepo workspace if cwd is inside `projects/api/` or `projects/app/`.
53
+ * Resolve layout starting from `cwd`. Walks up to find a workspace if
54
+ * cwd is inside `projects/api/` or `projects/app/`.
43
55
  */
44
56
  function resolveLayout(cwd, filesystem) {
45
- // Inside a sub-project? → walk to workspace root.
46
57
  const subContext = (0, workspace_integration_1.detectSubProjectContext)(cwd, filesystem);
47
- if (subContext) {
58
+ if (subContext)
48
59
  return monorepoLayout(subContext.workspaceRoot);
49
- }
50
- // Workspace root directly?
51
60
  const layout = (0, workspace_integration_1.detectWorkspaceLayout)(cwd, filesystem);
52
- if (layout.hasWorkspace) {
61
+ if (layout.hasWorkspace)
53
62
  return monorepoLayout(layout.workspaceDir);
54
- }
55
- // Walk up to find a workspace.
56
63
  const workspaceRoot = (0, workspace_integration_1.findWorkspaceRoot)(cwd, filesystem);
57
- if (workspaceRoot) {
64
+ if (workspaceRoot)
58
65
  return monorepoLayout(workspaceRoot);
59
- }
60
- // Fall back to standalone — figure out if it's API or App.
66
+ // Standalone project — figure out if it's API or App.
61
67
  const isApi = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'src', 'config.env.ts')) || (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nest-cli.json'));
62
68
  const isApp = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nuxt.config.ts'));
63
69
  return {
64
70
  apiDir: isApi ? cwd : null,
65
71
  appDir: isApp ? cwd : null,
66
- name: readPackageName(cwd) || basename(cwd),
67
72
  root: cwd,
68
73
  workspace: false,
69
74
  };
70
75
  }
71
- function basename(p) {
72
- return p.replace(/\/+$/, '').split('/').pop() || 'project';
73
- }
74
76
  /** Build the layout for an lt-monorepo workspace root. */
75
77
  function monorepoLayout(workspaceRoot) {
76
78
  const apiDir = (0, path_1.join)(workspaceRoot, 'projects', 'api');
@@ -78,24 +80,7 @@ function monorepoLayout(workspaceRoot) {
78
80
  return {
79
81
  apiDir: (0, fs_1.existsSync)(apiDir) ? apiDir : null,
80
82
  appDir: (0, fs_1.existsSync)(appDir) ? appDir : null,
81
- name: readPackageName(workspaceRoot) || basename(workspaceRoot),
82
83
  root: workspaceRoot,
83
84
  workspace: true,
84
85
  };
85
86
  }
86
- /** Read the `name` field from `package.json`, scrubbed to just the bare name (no scope). */
87
- function readPackageName(dir) {
88
- const pkgPath = (0, path_1.join)(dir, 'package.json');
89
- if (!(0, fs_1.existsSync)(pkgPath))
90
- return null;
91
- try {
92
- const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8'));
93
- if (!pkg.name)
94
- return null;
95
- // Strip npm scope: @lenne.tech/foo → foo
96
- return pkg.name.includes('/') ? pkg.name.split('/').pop() : pkg.name;
97
- }
98
- catch (_a) {
99
- return null;
100
- }
101
- }
@@ -0,0 +1,414 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.SERVICE_LABEL = void 0;
13
+ exports.getServicePaths = getServicePaths;
14
+ exports.getServiceStatus = getServiceStatus;
15
+ exports.installService = installService;
16
+ exports.platformSupported = platformSupported;
17
+ exports.renderLaunchAgentPlist = renderLaunchAgentPlist;
18
+ exports.renderSystemdUnit = renderSystemdUnit;
19
+ exports.resolveCaddyBin = resolveCaddyBin;
20
+ exports.setShellRunner = setShellRunner;
21
+ exports.uninstallService = uninstallService;
22
+ exports.waitForServiceReady = waitForServiceReady;
23
+ /**
24
+ * Service lifecycle for the dedicated `lt-dev` Caddy daemon.
25
+ *
26
+ * Why a dedicated service:
27
+ * `brew services start caddy` is fragile because the Homebrew plist
28
+ * hardcodes `--config /opt/homebrew/etc/Caddyfile` (or the equivalent
29
+ * Intel/Linux paths). When lt-dev keeps its Caddyfile at
30
+ * `~/.lenneTech/Caddyfile`, Caddy crashes in an endless relaunch
31
+ * loop, port 2019 never opens, and `sudo caddy trust` fails with
32
+ * "connection refused" — which is what blocked the first real
33
+ * install attempt. Owning the service definition removes that
34
+ * coupling entirely.
35
+ *
36
+ * Platforms:
37
+ * - macOS (Darwin): per-user LaunchAgent under
38
+ * `~/Library/LaunchAgents/tech.lenne.lt-dev-caddy.plist`,
39
+ * bootstrapped via `launchctl bootstrap gui/<uid> <plist>`.
40
+ * - Linux: systemd-user unit at
41
+ * `~/.config/systemd/user/lt-dev-caddy.service`, controlled via
42
+ * `systemctl --user`.
43
+ * - Anything else (Windows, BSDs without systemd-user): explicitly
44
+ * unsupported — the caller surfaces a clear message.
45
+ *
46
+ * Tests inject a `ShellRunner` to mock `launchctl` / `systemctl`
47
+ * without touching the real OS. Render functions stay pure.
48
+ */
49
+ const child_process_1 = require("child_process");
50
+ const fs_1 = require("fs");
51
+ const os_1 = require("os");
52
+ const path_1 = require("path");
53
+ const caddy_1 = require("./caddy");
54
+ /**
55
+ * Resolve the user's home directory in a test-overridable way.
56
+ *
57
+ * `os.homedir()` on macOS goes through `getpwuid()` and **ignores**
58
+ * `process.env.HOME`, which makes it impossible to redirect file-system
59
+ * side effects in tests. Honouring `HOME` first (then falling back to
60
+ * `homedir()`) keeps real-world behaviour identical while letting tests
61
+ * scope writes to a temp directory by setting `process.env.HOME` in
62
+ * `beforeEach`.
63
+ */
64
+ function userHome() {
65
+ return process.env.HOME || (0, os_1.homedir)();
66
+ }
67
+ /** Reverse-DNS label for the service. Keep stable — used in launchctl + systemd. */
68
+ exports.SERVICE_LABEL = 'tech.lenne.lt-dev-caddy';
69
+ let activeRunner = defaultShellRunner;
70
+ /** Compute the file-system locations for the service. Pure. */
71
+ function getServicePaths(home = userHome(), plat = platformSupported()) {
72
+ const logFile = (0, path_1.join)(home, '.lenneTech', 'caddy.log');
73
+ const errFile = (0, path_1.join)(home, '.lenneTech', 'caddy.err.log');
74
+ if (plat === 'darwin') {
75
+ return {
76
+ errFile,
77
+ label: exports.SERVICE_LABEL,
78
+ logFile,
79
+ platform: 'darwin',
80
+ unitFile: (0, path_1.join)(home, 'Library', 'LaunchAgents', `${exports.SERVICE_LABEL}.plist`),
81
+ };
82
+ }
83
+ if (plat === 'linux') {
84
+ return {
85
+ errFile,
86
+ label: exports.SERVICE_LABEL,
87
+ logFile,
88
+ platform: 'linux',
89
+ unitFile: (0, path_1.join)(home, '.config', 'systemd', 'user', 'lt-dev-caddy.service'),
90
+ };
91
+ }
92
+ return { errFile, label: exports.SERVICE_LABEL, logFile, platform: 'unsupported', unitFile: '' };
93
+ }
94
+ /** Current service state — installed/loaded/reachable. */
95
+ function getServiceStatus() {
96
+ return __awaiter(this, void 0, void 0, function* () {
97
+ const paths = getServicePaths();
98
+ const installed = paths.platform !== 'unsupported' && (0, fs_1.existsSync)(paths.unitFile);
99
+ let loaded = false;
100
+ let pid;
101
+ if (paths.platform === 'darwin' && installed) {
102
+ const uid = (0, os_1.userInfo)().uid;
103
+ const res = yield activeRunner('launchctl', ['print', `gui/${uid}/${paths.label}`]);
104
+ loaded = res.ok;
105
+ const pidMatch = res.stdout.match(/pid\s*=\s*(\d+)/);
106
+ if (pidMatch)
107
+ pid = Number(pidMatch[1]);
108
+ }
109
+ else if (paths.platform === 'linux' && installed) {
110
+ const res = yield activeRunner('systemctl', ['--user', 'is-active', `${paths.label}.service`]);
111
+ loaded = res.ok && res.stdout.trim() === 'active';
112
+ if (loaded) {
113
+ const pidRes = yield activeRunner('systemctl', ['--user', 'show', `${paths.label}.service`, '-p', 'MainPID']);
114
+ const m = pidRes.stdout.match(/MainPID=(\d+)/);
115
+ if (m && m[1] !== '0')
116
+ pid = Number(m[1]);
117
+ }
118
+ }
119
+ const daemonReachable = yield pingCaddyAdmin();
120
+ return { daemonReachable, installed, loaded, pid, platform: paths.platform, unitFile: paths.unitFile };
121
+ });
122
+ }
123
+ /**
124
+ * Install (or update) and start the service.
125
+ *
126
+ * Idempotent:
127
+ * - rewrites the unit file only when its content changes
128
+ * - bootstraps the service only when not already loaded
129
+ * - on content change: bootout + bootstrap (= reload)
130
+ */
131
+ function installService() {
132
+ return __awaiter(this, arguments, void 0, function* (opts = {}) {
133
+ const plat = platformSupported();
134
+ if (plat === 'unsupported') {
135
+ return {
136
+ bootstrapped: false,
137
+ created: false,
138
+ message: 'Service management is only supported on macOS and Linux.',
139
+ ok: false,
140
+ };
141
+ }
142
+ const caddyBin = opts.caddyBin || (yield resolveCaddyBin());
143
+ if (!caddyBin) {
144
+ return {
145
+ bootstrapped: false,
146
+ created: false,
147
+ message: 'caddy not found on PATH. Install with `brew install caddy` (macOS) or your package manager (Linux).',
148
+ ok: false,
149
+ };
150
+ }
151
+ const paths = getServicePaths();
152
+ const cfg = {
153
+ caddyBin,
154
+ caddyfile: caddy_1.paths.caddyfile,
155
+ errFile: paths.errFile,
156
+ homeDir: userHome(),
157
+ label: paths.label,
158
+ logFile: paths.logFile,
159
+ };
160
+ const desired = plat === 'darwin' ? renderLaunchAgentPlist(cfg) : renderSystemdUnit(cfg);
161
+ const existed = (0, fs_1.existsSync)(paths.unitFile);
162
+ const current = existed ? (0, fs_1.readFileSync)(paths.unitFile, 'utf8') : '';
163
+ const changed = current !== desired;
164
+ if (changed) {
165
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(paths.unitFile), { recursive: true });
166
+ // Make sure log targets exist + are writable BEFORE the daemon
167
+ // tries to open them — otherwise launchd silently keeps restarting.
168
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(paths.logFile), { recursive: true });
169
+ touchFile(paths.logFile);
170
+ touchFile(paths.errFile);
171
+ (0, fs_1.writeFileSync)(paths.unitFile, desired, 'utf8');
172
+ }
173
+ if (plat === 'darwin')
174
+ return installDarwin(paths, changed, existed);
175
+ return installLinux(paths, changed, existed);
176
+ });
177
+ }
178
+ /** Detect the supported platform. */
179
+ function platformSupported() {
180
+ const p = (0, os_1.platform)();
181
+ if (p === 'darwin')
182
+ return 'darwin';
183
+ if (p === 'linux')
184
+ return 'linux';
185
+ return 'unsupported';
186
+ }
187
+ /**
188
+ * Render the macOS LaunchAgent plist.
189
+ *
190
+ * Critical env keys:
191
+ * - HOME: caddy stores its local CA in `$HOME/Library/Application
192
+ * Support/Caddy/` — without HOME caddy falls back to
193
+ * launchd's empty default and fails to persist CA state.
194
+ * - PATH: caddy shells out to `security` (Keychain) during
195
+ * `caddy trust`; a minimal launchd PATH cannot find it.
196
+ */
197
+ function renderLaunchAgentPlist(cfg) {
198
+ const programArgs = [cfg.caddyBin, 'run', '--config', cfg.caddyfile, '--adapter', 'caddyfile'];
199
+ const programArgsXml = programArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
200
+ const pathValue = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
201
+ return `<?xml version="1.0" encoding="UTF-8"?>
202
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
203
+ <plist version="1.0">
204
+ <dict>
205
+ <key>Label</key>
206
+ <string>${escapeXml(cfg.label)}</string>
207
+ <key>ProgramArguments</key>
208
+ <array>
209
+ ${programArgsXml}
210
+ </array>
211
+ <key>EnvironmentVariables</key>
212
+ <dict>
213
+ <key>HOME</key>
214
+ <string>${escapeXml(cfg.homeDir)}</string>
215
+ <key>PATH</key>
216
+ <string>${pathValue}</string>
217
+ </dict>
218
+ <key>RunAtLoad</key>
219
+ <true/>
220
+ <key>KeepAlive</key>
221
+ <true/>
222
+ <key>WorkingDirectory</key>
223
+ <string>${escapeXml(cfg.homeDir)}</string>
224
+ <key>StandardOutPath</key>
225
+ <string>${escapeXml(cfg.logFile)}</string>
226
+ <key>StandardErrorPath</key>
227
+ <string>${escapeXml(cfg.errFile)}</string>
228
+ </dict>
229
+ </plist>
230
+ `;
231
+ }
232
+ /** Render the systemd-user unit file. */
233
+ function renderSystemdUnit(cfg) {
234
+ return `[Unit]
235
+ Description=lt-dev Caddy reverse proxy (HTTPS for *.localhost)
236
+ Documentation=https://github.com/lenneTech/cli
237
+ After=network.target
238
+
239
+ [Service]
240
+ Type=simple
241
+ Environment=HOME=${cfg.homeDir}
242
+ ExecStart=${cfg.caddyBin} run --config ${cfg.caddyfile} --adapter caddyfile
243
+ Restart=on-failure
244
+ RestartSec=5s
245
+ StandardOutput=append:${cfg.logFile}
246
+ StandardError=append:${cfg.errFile}
247
+
248
+ [Install]
249
+ WantedBy=default.target
250
+ `;
251
+ }
252
+ /** Resolve the absolute path of `caddy` via `which` so launchd has a guaranteed path. */
253
+ function resolveCaddyBin() {
254
+ return __awaiter(this, void 0, void 0, function* () {
255
+ const r = yield activeRunner('which', ['caddy']);
256
+ if (!r.ok)
257
+ return undefined;
258
+ const line = r.stdout.split('\n').find((s) => s.trim().length > 0);
259
+ return line ? line.trim() : undefined;
260
+ });
261
+ }
262
+ /** Inject a custom runner (tests). Pass `null` to reset to the real spawner. */
263
+ function setShellRunner(runner) {
264
+ activeRunner = runner !== null && runner !== void 0 ? runner : defaultShellRunner;
265
+ }
266
+ /** Stop the service and remove the unit file. */
267
+ function uninstallService() {
268
+ return __awaiter(this, void 0, void 0, function* () {
269
+ const plat = platformSupported();
270
+ if (plat === 'unsupported') {
271
+ return { bootedOut: false, message: 'Nothing to uninstall on this platform.', ok: true, removed: [] };
272
+ }
273
+ const paths = getServicePaths();
274
+ let bootedOut = false;
275
+ if (plat === 'darwin') {
276
+ const uid = (0, os_1.userInfo)().uid;
277
+ const target = `gui/${uid}/${paths.label}`;
278
+ const printRes = yield activeRunner('launchctl', ['print', target]);
279
+ if (printRes.ok) {
280
+ const result = yield activeRunner('launchctl', ['bootout', target]);
281
+ bootedOut = result.ok;
282
+ // bootout returns non-zero with "Operation now in progress" on some
283
+ // macOS versions even when the service is unloaded — tolerate it.
284
+ if (!result.ok && /no such process|not loaded|service is not loaded/i.test(result.stderr))
285
+ bootedOut = true;
286
+ }
287
+ }
288
+ else {
289
+ yield activeRunner('systemctl', ['--user', 'stop', `${paths.label}.service`]);
290
+ const dis = yield activeRunner('systemctl', ['--user', 'disable', `${paths.label}.service`]);
291
+ bootedOut = dis.ok;
292
+ }
293
+ const removed = [];
294
+ if ((0, fs_1.existsSync)(paths.unitFile)) {
295
+ (0, fs_1.unlinkSync)(paths.unitFile);
296
+ removed.push(paths.unitFile);
297
+ }
298
+ return {
299
+ bootedOut,
300
+ message: removed.length > 0 ? `Service uninstalled (${removed.length} file(s) removed).` : 'Service was not installed.',
301
+ ok: true,
302
+ removed,
303
+ };
304
+ });
305
+ }
306
+ /** Wait until Caddy's admin API responds, or until timeout. Returns true on success. */
307
+ function waitForServiceReady() {
308
+ return __awaiter(this, arguments, void 0, function* (timeoutMs = 5000) {
309
+ const start = Date.now();
310
+ while (Date.now() - start < timeoutMs) {
311
+ if (yield pingCaddyAdmin())
312
+ return true;
313
+ yield sleep(150);
314
+ }
315
+ return false;
316
+ });
317
+ }
318
+ function defaultShellRunner(cmd, args) {
319
+ return new Promise((resolve) => {
320
+ var _a, _b;
321
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
322
+ let stdout = '';
323
+ let stderr = '';
324
+ let errored = false;
325
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
326
+ (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (b) => (stderr += String(b)));
327
+ child.on('error', () => (errored = true));
328
+ child.on('close', (code) => {
329
+ if (errored)
330
+ resolve({ code: null, ok: false, stderr: stderr || 'command not found', stdout });
331
+ else
332
+ resolve({ code, ok: code === 0, stderr, stdout });
333
+ });
334
+ });
335
+ }
336
+ function escapeXml(s) {
337
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
338
+ }
339
+ function installDarwin(paths, changed, existed) {
340
+ return __awaiter(this, void 0, void 0, function* () {
341
+ const uid = (0, os_1.userInfo)().uid;
342
+ const target = `gui/${uid}/${paths.label}`;
343
+ const print = yield activeRunner('launchctl', ['print', target]);
344
+ const alreadyLoaded = print.ok;
345
+ if (alreadyLoaded && changed) {
346
+ // bootout returns "no such process" with a non-zero exit on macOS 14+
347
+ // even on success; we re-check via `print` to confirm.
348
+ yield activeRunner('launchctl', ['bootout', target]);
349
+ }
350
+ let bootstrapped = alreadyLoaded && !changed;
351
+ if (!bootstrapped) {
352
+ const result = yield activeRunner('launchctl', ['bootstrap', `gui/${uid}`, paths.unitFile]);
353
+ bootstrapped = result.ok;
354
+ if (!bootstrapped) {
355
+ return {
356
+ bootstrapped: false,
357
+ created: changed,
358
+ message: `launchctl bootstrap failed: ${result.stderr.trim() || result.stdout.trim() || 'unknown error'}`,
359
+ ok: false,
360
+ };
361
+ }
362
+ }
363
+ return {
364
+ bootstrapped,
365
+ created: changed,
366
+ message: existed && !changed ? 'Service already installed and up to date.' : 'Service installed.',
367
+ ok: true,
368
+ };
369
+ });
370
+ }
371
+ function installLinux(paths, changed, existed) {
372
+ return __awaiter(this, void 0, void 0, function* () {
373
+ const reload = yield activeRunner('systemctl', ['--user', 'daemon-reload']);
374
+ if (!reload.ok) {
375
+ return {
376
+ bootstrapped: false,
377
+ created: changed,
378
+ message: `systemctl daemon-reload failed: ${reload.stderr.trim() || 'unknown error'}`,
379
+ ok: false,
380
+ };
381
+ }
382
+ const enableRes = yield activeRunner('systemctl', ['--user', 'enable', '--now', `${paths.label}.service`]);
383
+ if (!enableRes.ok) {
384
+ return {
385
+ bootstrapped: false,
386
+ created: changed,
387
+ message: `systemctl --user enable --now failed: ${enableRes.stderr.trim() || 'unknown error'}`,
388
+ ok: false,
389
+ };
390
+ }
391
+ return {
392
+ bootstrapped: true,
393
+ created: changed,
394
+ message: existed && !changed ? 'Service already installed and up to date.' : 'Service installed.',
395
+ ok: true,
396
+ };
397
+ });
398
+ }
399
+ function pingCaddyAdmin() {
400
+ return new Promise((resolve) => {
401
+ const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', '--max-time', '1', 'http://127.0.0.1:2019/config/'], {
402
+ stdio: ['ignore', 'ignore', 'ignore'],
403
+ });
404
+ child.on('error', () => resolve(false));
405
+ child.on('close', (code) => resolve(code === 0));
406
+ });
407
+ }
408
+ function sleep(ms) {
409
+ return new Promise((r) => setTimeout(r, ms));
410
+ }
411
+ function touchFile(p) {
412
+ if (!(0, fs_1.existsSync)(p))
413
+ (0, fs_1.writeFileSync)(p, '', 'utf8');
414
+ }