@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.
- package/README.md +16 -10
- package/build/commands/{local/local.js → dev/dev.js} +14 -10
- package/build/commands/dev/doctor.js +144 -0
- package/build/commands/dev/down.js +76 -0
- package/build/commands/dev/install.js +172 -0
- package/build/commands/dev/migrate.js +96 -0
- package/build/commands/dev/status.js +153 -0
- package/build/commands/dev/test.js +191 -0
- package/build/commands/dev/tunnel.js +142 -0
- package/build/commands/dev/uninstall.js +92 -0
- package/build/commands/dev/up.js +228 -0
- package/build/commands/fullstack/init.js +36 -2
- package/build/commands/status.js +16 -16
- package/build/lib/caddy.js +183 -0
- package/build/lib/cloudflared.js +129 -0
- package/build/lib/dev-env-bridge.js +112 -0
- package/build/lib/dev-env.js +65 -0
- package/build/lib/dev-identity.js +100 -0
- package/build/lib/dev-migrate-helper.js +81 -0
- package/build/lib/dev-patches.js +190 -0
- package/build/lib/dev-process.js +152 -0
- package/build/lib/{local-project.js → dev-project.js} +23 -38
- package/build/lib/dev-service.js +414 -0
- package/build/lib/dev-state.js +152 -0
- package/docs/commands.md +167 -94
- package/package.json +1 -1
- package/build/commands/local/down.js +0 -71
- package/build/commands/local/init.js +0 -162
- package/build/commands/local/status.js +0 -69
- package/build/commands/local/up.js +0 -148
- package/build/commands/ports/ports.js +0 -118
- package/build/commands/ports/scan.js +0 -131
- package/build/lib/local-patches.js +0 -175
- package/build/lib/port-registry.js +0 -304
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
42
|
-
*
|
|
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, '&').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
|
+
}
|