@lenne.tech/cli 1.21.0 → 1.22.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.
@@ -1,304 +0,0 @@
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.SLOT_PORT_RANGE_END = exports.SLOT_MAX = exports.SLOT_STEP = exports.SLOT_BASE_API = void 0;
13
- exports.allocatedSlots = allocatedSlots;
14
- exports.allocateSlot = allocateSlot;
15
- exports.checkPortInUse = checkPortInUse;
16
- exports.clearLocalState = clearLocalState;
17
- exports.isPidAlive = isPidAlive;
18
- exports.isValidPid = isValidPid;
19
- exports.listenSnapshot = listenSnapshot;
20
- exports.loadLocalState = loadLocalState;
21
- exports.loadRegistry = loadRegistry;
22
- exports.localStatePath = localStatePath;
23
- exports.portsForSlot = portsForSlot;
24
- exports.projectSlug = projectSlug;
25
- exports.registryPath = registryPath;
26
- exports.saveLocalState = saveLocalState;
27
- exports.saveRegistry = saveRegistry;
28
- exports.slotFromSlug = slotFromSlug;
29
- /**
30
- * Port registry helpers for `lt local` and `lt ports`.
31
- *
32
- * Provides:
33
- * - Deterministic slot allocation from a project slug (hash-based, reproducible across machines)
34
- * - Persistent registry at ~/.lenneTech/ports.json
35
- * - Live port introspection via `lsof` (single-call snapshot for batch checks)
36
- * - Process state tracking under <project>/.lt-local/state.json
37
- */
38
- const child_process_1 = require("child_process");
39
- const fs_1 = require("fs");
40
- const os_1 = require("os");
41
- const path_1 = require("path");
42
- /** Lowest API port: slot 0 maps to 3000/3001, slot 1 to 3010/3011, … */
43
- exports.SLOT_BASE_API = 3000;
44
- /** Distance between two adjacent slots' API ports. */
45
- exports.SLOT_STEP = 10;
46
- /** Number of slots [0..SLOT_MAX). API range = [SLOT_BASE_API, SLOT_BASE_API + SLOT_MAX*SLOT_STEP). */
47
- exports.SLOT_MAX = 90;
48
- /** Highest port (exclusive) covered by the slot range. Useful for live-port sweeps. */
49
- exports.SLOT_PORT_RANGE_END = exports.SLOT_BASE_API + exports.SLOT_MAX * exports.SLOT_STEP;
50
- /** All currently allocated slots in the registry. */
51
- function allocatedSlots(registry) {
52
- return new Set(Object.values(registry.projects).map((p) => p.slot));
53
- }
54
- /**
55
- * Allocate a slot for a project. Returns deterministic slot from slug if free,
56
- * otherwise scans linearly until a free slot is found. Throws if all are taken.
57
- *
58
- * The linear scan loops `i in [1, SLOT_MAX)` (not `<=`) because `i = 0` is the
59
- * preferred slot already checked in the fast path above.
60
- */
61
- function allocateSlot(slug, registry) {
62
- const taken = allocatedSlots(registry);
63
- const preferred = slotFromSlug(slug);
64
- if (!taken.has(preferred)) {
65
- return preferred;
66
- }
67
- for (let i = 1; i < exports.SLOT_MAX; i++) {
68
- const candidate = (preferred + i) % exports.SLOT_MAX;
69
- if (!taken.has(candidate)) {
70
- return candidate;
71
- }
72
- }
73
- throw new Error('No free port slot available (all 90 slots are taken).');
74
- }
75
- /**
76
- * Check via `lsof` whether a single TCP port is currently bound by a LISTEN socket.
77
- *
78
- * For multi-port checks prefer {@link listenSnapshot} — it issues a single `lsof`
79
- * call instead of one per port (~50ms vs ~50ms × N).
80
- *
81
- * Note: `-iTCP:<port>` selects connections by *service port* — both LISTEN
82
- * sockets and remote endpoints. We filter explicitly to LISTEN via
83
- * `-sTCP:LISTEN` AND post-filter the NAME column for `*:<port>` /
84
- * `<addr>:<port>` (LISTEN) so outgoing connections whose remote port is
85
- * `<port>` don't trigger a false positive.
86
- *
87
- * Returns null if lsof is unavailable.
88
- */
89
- function checkPortInUse(port) {
90
- return __awaiter(this, void 0, void 0, function* () {
91
- return new Promise((resolve) => {
92
- var _a;
93
- const child = (0, child_process_1.spawn)('lsof', ['-iTCP', `-sTCP:LISTEN`, '-nP', `-iTCP:${port}`], {
94
- stdio: ['ignore', 'pipe', 'ignore'],
95
- });
96
- let out = '';
97
- (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => (out += chunk.toString()));
98
- child.on('error', () => resolve(null));
99
- child.on('close', () => {
100
- const lines = out.trim().split('\n').slice(1); // skip header
101
- const portRe = new RegExp(`:${port}\\s+\\(LISTEN\\)\\s*$`);
102
- const match = lines.find((l) => portRe.test(l));
103
- if (!match) {
104
- resolve({ inUse: false });
105
- return;
106
- }
107
- const cols = match.split(/\s+/);
108
- resolve({ command: cols[0], inUse: true, pid: Number(cols[1]) });
109
- });
110
- });
111
- });
112
- }
113
- /** Reset state file to an empty record. Called by `lt local down` after stopping processes. */
114
- function clearLocalState(projectPath) {
115
- const path = localStatePath(projectPath);
116
- if ((0, fs_1.existsSync)(path)) {
117
- (0, fs_1.writeFileSync)(path, `${JSON.stringify({ pids: {}, ports: { api: 0, app: 0 }, startedAt: '' }, null, 2)}\n`, 'utf8');
118
- }
119
- }
120
- /**
121
- * Check whether a PID is still alive without sending any signal.
122
- * `process.kill(pid, 0)` performs a permission check; ESRCH means dead.
123
- *
124
- * Refuses non-positive / non-integer PIDs to prevent accidental probes
125
- * of process groups (negative PID) or every user-owned process (PID 0/-1).
126
- */
127
- function isPidAlive(pid) {
128
- if (!Number.isInteger(pid) || pid <= 0)
129
- return false;
130
- try {
131
- process.kill(pid, 0);
132
- return true;
133
- }
134
- catch (e) {
135
- return e.code === 'EPERM';
136
- }
137
- }
138
- /**
139
- * Validate a value parsed from `state.json` as a plausible PID.
140
- *
141
- * Accepts: positive integer in [100, 2^31 - 1] or undefined.
142
- * The lower bound 100 excludes init / kernel / login PIDs that should
143
- * never be the result of a `pnpm start` spawn.
144
- */
145
- function isValidPid(value) {
146
- if (value === undefined)
147
- return true;
148
- if (typeof value !== 'number')
149
- return false;
150
- return Number.isInteger(value) && value >= 100 && value <= 0x7fffffff;
151
- }
152
- /**
153
- * One-shot listener snapshot for an arbitrary set of ports.
154
- *
155
- * Issues a single `lsof -iTCP -sTCP:LISTEN -nP` call and filters in memory.
156
- * ~50ms total regardless of port count, vs ~50ms × N for sequential
157
- * {@link checkPortInUse} calls.
158
- *
159
- * Returns an empty Map if `lsof` is unavailable.
160
- */
161
- function listenSnapshot(ports) {
162
- return __awaiter(this, void 0, void 0, function* () {
163
- return new Promise((resolve) => {
164
- var _a;
165
- const child = (0, child_process_1.spawn)('lsof', ['-iTCP', '-sTCP:LISTEN', '-nP'], { stdio: ['ignore', 'pipe', 'ignore'] });
166
- let out = '';
167
- (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => (out += chunk.toString()));
168
- child.on('error', () => resolve(new Map()));
169
- child.on('close', () => {
170
- const wanted = new Set(ports);
171
- const result = new Map();
172
- const lines = out.trim().split('\n').slice(1);
173
- const re = /:(\d+)\s+\(LISTEN\)\s*$/;
174
- for (const line of lines) {
175
- const m = re.exec(line);
176
- if (!m)
177
- continue;
178
- const port = Number(m[1]);
179
- if (!wanted.has(port))
180
- continue;
181
- if (result.has(port))
182
- continue; // first hit wins (IPv4 before IPv6)
183
- const cols = line.split(/\s+/);
184
- result.set(port, { command: cols[0], pid: Number(cols[1]) });
185
- }
186
- resolve(result);
187
- });
188
- });
189
- });
190
- }
191
- /**
192
- * Load the local state JSON for a project.
193
- *
194
- * Returns null when the file is missing, unreadable, malformed, or contains
195
- * structurally invalid data (see {@link isValidPid}). The schema validation
196
- * here is the authoritative gate: it prevents `process.kill(-pid, …)` in
197
- * `lt local down` from receiving anything but a plausible PID we ourselves
198
- * could have written.
199
- */
200
- function loadLocalState(projectPath) {
201
- const path = localStatePath(projectPath);
202
- if (!(0, fs_1.existsSync)(path))
203
- return null;
204
- try {
205
- const parsed = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
206
- if (!parsed || typeof parsed !== 'object')
207
- return null;
208
- const obj = parsed;
209
- const pids = obj.pids;
210
- const ports = obj.ports;
211
- if (!pids || typeof pids !== 'object' || !ports || typeof ports !== 'object')
212
- return null;
213
- if (!isValidPid(pids.api) || !isValidPid(pids.app))
214
- return null;
215
- if (typeof ports.api !== 'number' || typeof ports.app !== 'number')
216
- return null;
217
- if (typeof obj.startedAt !== 'string')
218
- return null;
219
- return {
220
- pids: { api: pids.api, app: pids.app },
221
- ports: { api: ports.api, app: ports.app },
222
- startedAt: obj.startedAt,
223
- };
224
- }
225
- catch (_a) {
226
- return null;
227
- }
228
- }
229
- /**
230
- * Load registry; returns empty if missing or corrupt.
231
- *
232
- * Prints a warning when a corrupt or schema-incompatible file is encountered
233
- * so the user notices the silent reset rather than discovering stale port
234
- * allocations later.
235
- */
236
- function loadRegistry() {
237
- const path = registryPath();
238
- if (!(0, fs_1.existsSync)(path)) {
239
- return { projects: {}, version: 1 };
240
- }
241
- try {
242
- const raw = (0, fs_1.readFileSync)(path, 'utf8');
243
- const parsed = JSON.parse(raw);
244
- if ((parsed === null || parsed === void 0 ? void 0 : parsed.version) !== 1 || typeof (parsed === null || parsed === void 0 ? void 0 : parsed.projects) !== 'object') {
245
- console.warn(`[lt] ports.json has wrong schema (got version=${parsed === null || parsed === void 0 ? void 0 : parsed.version}); starting with empty registry.`);
246
- return { projects: {}, version: 1 };
247
- }
248
- return parsed;
249
- }
250
- catch (e) {
251
- console.warn(`[lt] ports.json was unreadable (${e.message}); starting with empty registry.`);
252
- return { projects: {}, version: 1 };
253
- }
254
- }
255
- /** Path to the local state file inside a project. */
256
- function localStatePath(projectPath) {
257
- return (0, path_1.join)(projectPath, '.lt-local', 'state.json');
258
- }
259
- /** Convert a slot to its API+App port pair. */
260
- function portsForSlot(slot) {
261
- const api = exports.SLOT_BASE_API + slot * exports.SLOT_STEP;
262
- return { api, app: api + 1 };
263
- }
264
- /** Convert any project path → a stable slug (basename, lowercase, alpha-num + dashes). */
265
- function projectSlug(projectPath) {
266
- const base = projectPath.replace(/\/+$/, '').split('/').pop() || 'project';
267
- return base
268
- .toLowerCase()
269
- .replace(/[^a-z0-9]+/g, '-')
270
- .replace(/^-+|-+$/g, '');
271
- }
272
- /**
273
- * Path to the central registry.
274
- *
275
- * Honors `LT_PORTS_REGISTRY_PATH` for tests / non-default workspaces;
276
- * falls back to `~/.lenneTech/ports.json`.
277
- */
278
- function registryPath() {
279
- return process.env.LT_PORTS_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'ports.json');
280
- }
281
- /** Persist local state to <project>/.lt-local/state.json (creates the parent directory if needed). */
282
- function saveLocalState(projectPath, state) {
283
- const path = localStatePath(projectPath);
284
- (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
285
- (0, fs_1.writeFileSync)(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
286
- }
287
- /** Save registry, creating parent directory if needed. */
288
- function saveRegistry(registry) {
289
- const path = registryPath();
290
- (0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
291
- (0, fs_1.writeFileSync)(path, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
292
- }
293
- /**
294
- * Deterministic slot from project slug — same slug yields the same slot on every machine.
295
- * Uses the lower 32 bits of FNV-1a then modulo SLOT_MAX.
296
- */
297
- function slotFromSlug(slug) {
298
- let hash = 2166136261; // FNV-1a 32-bit offset basis
299
- for (let i = 0; i < slug.length; i++) {
300
- hash ^= slug.charCodeAt(i);
301
- hash = Math.imul(hash, 16777619);
302
- }
303
- return Math.abs(hash | 0) % exports.SLOT_MAX;
304
- }