@lenne.tech/cli 1.22.0 → 1.24.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,129 @@
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.TRYCLOUDFLARE_URL_PATTERN = void 0;
13
+ exports.buildQuickTunnelArgs = buildQuickTunnelArgs;
14
+ exports.cloudflaredAvailable = cloudflaredAvailable;
15
+ exports.extractTrycloudflareUrl = extractTrycloudflareUrl;
16
+ exports.spawnQuickTunnel = spawnQuickTunnel;
17
+ /**
18
+ * Cloudflare Tunnel integration for `lt dev tunnel`.
19
+ *
20
+ * Quick tunnels (no Cloudflare account, ephemeral URL) are the only
21
+ * mode supported here. Named tunnels would require multi-step Cloudflare
22
+ * setup (auth, DNS routing) which belongs in a separate command and is
23
+ * intentionally out of scope for this lib.
24
+ *
25
+ * The Caddy upstream stays unchanged: cloudflared connects to Caddy's
26
+ * HTTPS endpoint and rewrites the `Host` header to the configured
27
+ * `*.localhost` so Caddy's per-project block matches. Without that
28
+ * rewrite Cloudflare's edge would forward the random `*.trycloudflare.com`
29
+ * hostname which Caddy doesn't know.
30
+ */
31
+ const child_process_1 = require("child_process");
32
+ /**
33
+ * Match the trycloudflare URL anywhere in cloudflared's log output.
34
+ *
35
+ * cloudflared prints it in an ASCII-box on stderr (Linux/macOS) — exported
36
+ * here so tests can assert the exact pattern without spawning the binary.
37
+ */
38
+ exports.TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com/i;
39
+ /**
40
+ * Build the argv list for `cloudflared tunnel --url ...`. Pure helper.
41
+ *
42
+ * Why each flag:
43
+ * --url : the local upstream (Caddy's HTTPS endpoint)
44
+ * --http-host-header: tells cloudflared to rewrite Host before forwarding,
45
+ * so Caddy's vhost match works for the public URL
46
+ * --no-tls-verify : Caddy serves a locally-signed cert that cloudflared
47
+ * cannot validate from outside the local trust store;
48
+ * disabling the check is safe because the upstream
49
+ * hop never leaves localhost
50
+ */
51
+ function buildQuickTunnelArgs(opts) {
52
+ return [
53
+ 'tunnel',
54
+ '--no-autoupdate',
55
+ '--no-tls-verify',
56
+ '--http-host-header',
57
+ opts.hostHeader,
58
+ '--url',
59
+ opts.upstreamUrl,
60
+ ];
61
+ }
62
+ /** Detect whether `cloudflared` is on PATH. */
63
+ function cloudflaredAvailable() {
64
+ return __awaiter(this, void 0, void 0, function* () {
65
+ return new Promise((resolve) => {
66
+ var _a;
67
+ const child = (0, child_process_1.spawn)('cloudflared', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
68
+ let stdout = '';
69
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
70
+ child.on('error', () => resolve({ installed: false }));
71
+ child.on('close', (code) => {
72
+ if (code !== 0)
73
+ return resolve({ installed: false });
74
+ const match = stdout.match(/version\s+(\S+)/i);
75
+ resolve({ binary: 'cloudflared', installed: true, version: match === null || match === void 0 ? void 0 : match[1] });
76
+ });
77
+ });
78
+ });
79
+ }
80
+ /**
81
+ * Extract the trycloudflare URL from a chunk of cloudflared output.
82
+ * Returns the first match (cloudflared logs the URL exactly once).
83
+ */
84
+ function extractTrycloudflareUrl(output) {
85
+ const match = output.match(exports.TRYCLOUDFLARE_URL_PATTERN);
86
+ return match ? match[0] : null;
87
+ }
88
+ /**
89
+ * Spawn a quick tunnel and resolve the public URL once cloudflared logs it.
90
+ *
91
+ * The returned `publicUrl` promise rejects if the child exits before
92
+ * surfacing a URL (timeout: ~30s, then cloudflared usually emits an
93
+ * error message and exits). The caller is expected to keep the
94
+ * process alive (foreground command) and `child.kill()` on Ctrl-C.
95
+ */
96
+ function spawnQuickTunnel(opts) {
97
+ const args = buildQuickTunnelArgs(opts);
98
+ const child = (0, child_process_1.spawn)('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'] });
99
+ const publicUrl = new Promise((resolve, reject) => {
100
+ var _a, _b;
101
+ let buffer = '';
102
+ let settled = false;
103
+ const onChunk = (chunk) => {
104
+ if (settled)
105
+ return;
106
+ buffer += String(chunk);
107
+ const url = extractTrycloudflareUrl(buffer);
108
+ if (url) {
109
+ settled = true;
110
+ resolve(url);
111
+ }
112
+ };
113
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', onChunk);
114
+ (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', onChunk);
115
+ child.on('error', (err) => {
116
+ if (settled)
117
+ return;
118
+ settled = true;
119
+ reject(err);
120
+ });
121
+ child.on('close', (code) => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ reject(new Error(`cloudflared exited (code ${code}) before publishing a tunnel URL.\n${buffer.slice(-500)}`));
126
+ });
127
+ });
128
+ return { child, publicUrl };
129
+ }
@@ -86,6 +86,9 @@ function writeEnvBridge(projectRoot, devEnv, dbName) {
86
86
  'NUXT_PUBLIC_API_PROXY',
87
87
  'NSC__MONGOOSE__URI',
88
88
  'DATABASE_URL',
89
+ // Legacy aliases — see dev-env.ts for the rationale.
90
+ 'API_URL',
91
+ 'SITE_URL',
89
92
  ];
90
93
  for (const key of exported) {
91
94
  const v = devEnv.app.env[key];
@@ -1,6 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildDevEnv = buildDevEnv;
4
+ /**
5
+ * Build environment variables for `lt dev up`.
6
+ *
7
+ * URL-first: API and App processes receive complete URLs (not just ports)
8
+ * so they can configure CORS, BetterAuth trusted origins, OpenAPI servers,
9
+ * Vite proxies and storage prefixes consistently — without ever needing
10
+ * to know which internal port Caddy proxies them to.
11
+ *
12
+ * Cross-wiring protection:
13
+ * - `BASE_URL`/`APP_URL` lock the API to its own App origin (CORS + BetterAuth)
14
+ * - `NUXT_PUBLIC_*` lock the App to its own API
15
+ * - `NUXT_PUBLIC_STORAGE_PREFIX` namespaces localStorage/sessionStorage
16
+ * - `NSC__MONGOOSE__URI` / `DATABASE_URL` namespace the database per project
17
+ *
18
+ * CA trust for SSR fetches:
19
+ * - Both API and App receive `NODE_EXTRA_CA_CERTS` pointing at the
20
+ * Caddy local root CA so server-side fetches between the two
21
+ * subdomains succeed. Without this Nuxt SSR fails with "unable to
22
+ * get local issuer certificate" when the app calls its own API.
23
+ */
24
+ const dev_env_bridge_1 = require("./dev-env-bridge");
4
25
  /**
5
26
  * Build the environment maps for both API and App processes.
6
27
  *
@@ -13,14 +34,23 @@ function buildDevEnv(input) {
13
34
  const appSub = identity.subdomains.app;
14
35
  const apiUrl = apiSub ? `https://${apiSub.hostname}` : '';
15
36
  const appUrl = appSub ? `https://${appSub.hostname}` : '';
16
- const sharedKeys = Object.assign(Object.assign(Object.assign({}, (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {}));
37
+ const caPath = (0, dev_env_bridge_1.detectCaddyRootCa)();
38
+ const sharedKeys = Object.assign(Object.assign(Object.assign(Object.assign({}, (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {})), (caPath ? { NODE_EXTRA_CA_CERTS: caPath } : {}));
17
39
  return {
18
40
  api: {
19
- env: Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), { PORT: String(apiInternalPort) }),
41
+ env: Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
42
+ // Force IPv4 loopback binding so Caddy's `127.0.0.1` upstream
43
+ // (see `caddy.ts#renderProjectBlock`) always matches the
44
+ // listener. Without this, Nuxt + Nest sometimes bind to
45
+ // `[::1]` only, and Caddy gets connection-refused on IPv4.
46
+ HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1', PORT: String(apiInternalPort) }),
20
47
  internalPort: apiInternalPort,
21
48
  },
22
49
  app: {
23
- env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), (apiUrl ? { NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl } : {})), {
50
+ env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
51
+ // See API note above: pin the dev server to IPv4 so Caddy's
52
+ // `127.0.0.1` upstream is unambiguous.
53
+ HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
24
54
  // Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
25
55
  // both subdomains under HTTPS with shared cookie domain, so
26
56
  // same-origin trickery is no longer required.
@@ -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
+ }