@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.
- package/build/commands/dev/dev.js +8 -6
- package/build/commands/dev/doctor.js +41 -7
- package/build/commands/dev/install.js +107 -48
- package/build/commands/dev/status.js +1 -1
- package/build/commands/dev/test.js +1 -1
- package/build/commands/dev/tunnel.js +142 -0
- package/build/commands/dev/uninstall.js +92 -0
- package/build/commands/dev/up.js +1 -1
- package/build/commands/server/create.js +3 -2
- package/build/lib/caddy.js +15 -1
- package/build/lib/cloudflared.js +129 -0
- package/build/lib/dev-env-bridge.js +3 -0
- package/build/lib/dev-env.js +33 -3
- package/build/lib/dev-service.js +414 -0
- package/docs/commands.md +65 -5
- package/package.json +1 -1
|
@@ -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];
|
package/build/lib/dev-env.js
CHANGED
|
@@ -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
|
|
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), {
|
|
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),
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|