@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.
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.addToGitignore = addToGitignore;
4
+ exports.autoPatch = autoPatch;
5
+ exports.patchApiConfig = patchApiConfig;
6
+ exports.patchClaudeMd = patchClaudeMd;
7
+ exports.patchNuxtConfig = patchNuxtConfig;
8
+ exports.patchPlaywrightConfig = patchPlaywrightConfig;
9
+ /**
10
+ * Idempotent patches applied by `lt dev migrate`.
11
+ *
12
+ * Goal: take a project that still has hardcoded `localhost:3000`
13
+ * defaults and make it env-aware so it can be served behind Caddy
14
+ * under `https://<slug>.localhost`.
15
+ *
16
+ * Each patch is a regex-based replace that matches only the legacy
17
+ * form. Already-patched files are no-ops.
18
+ */
19
+ const fs_1 = require("fs");
20
+ /** Append entry to .gitignore if not already present. */
21
+ function addToGitignore(root, entry) {
22
+ const path = `${root}/.gitignore`;
23
+ let content = '';
24
+ if ((0, fs_1.existsSync)(path))
25
+ content = (0, fs_1.readFileSync)(path, 'utf8');
26
+ const lines = content.split(/\r?\n/);
27
+ if (lines.some((l) => l.trim() === entry || l.trim() === entry.replace(/\/$/, '')))
28
+ return false;
29
+ const ensured = `${(content.endsWith('\n') || content.length === 0 ? content : `${content}\n`) + entry}\n`;
30
+ (0, fs_1.writeFileSync)(path, ensured, 'utf8');
31
+ return true;
32
+ }
33
+ /** Run the appropriate patch based on filename. */
34
+ function autoPatch(file) {
35
+ if (file.endsWith('config.env.ts'))
36
+ return patchApiConfig(file);
37
+ if (file.endsWith('nuxt.config.ts'))
38
+ return patchNuxtConfig(file);
39
+ if (file.endsWith('playwright.config.ts'))
40
+ return patchPlaywrightConfig(file);
41
+ return { file, patched: false, replacements: 0 };
42
+ }
43
+ /** API: `port: 3000,` → `port: Number(process.env.PORT) || 3000,`. */
44
+ function patchApiConfig(file) {
45
+ if (!(0, fs_1.existsSync)(file))
46
+ return { file, patched: false, replacements: 0 };
47
+ const before = (0, fs_1.readFileSync)(file, 'utf8');
48
+ let count = 0;
49
+ const after = before.replace(/^(\s*)port:\s*3000\s*,$/gm, (_m, indent) => {
50
+ count++;
51
+ return `${indent}port: Number(process.env.PORT) || 3000,`;
52
+ });
53
+ if (count === 0)
54
+ return { file, patched: false, replacements: 0 };
55
+ (0, fs_1.writeFileSync)(file, after, 'utf8');
56
+ return { file, patched: true, replacements: count };
57
+ }
58
+ /**
59
+ * Inject a "Local Development (lt dev)" block with the project's
60
+ * concrete URLs into CLAUDE.md. Idempotent — re-running with the same
61
+ * URLs is a no-op; re-running with different URLs replaces the block
62
+ * in place.
63
+ */
64
+ function patchClaudeMd(file, options) {
65
+ const { dbName, identity } = options;
66
+ const startMarker = '<!-- lt-dev:url-block:start -->';
67
+ const endMarker = '<!-- lt-dev:url-block:end -->';
68
+ const apiSub = identity.subdomains.api;
69
+ const appSub = identity.subdomains.app;
70
+ const lines = [
71
+ startMarker,
72
+ '## Local Development (lt dev)',
73
+ '',
74
+ `This project is registered with \`lt dev\` (slug: \`${identity.slug}\`). Use these commands to run alongside other lt projects without cross-wiring or port collisions:`,
75
+ '',
76
+ '```bash',
77
+ 'lt dev up # Start API + App behind Caddy with project-specific URLs',
78
+ 'lt dev down # Stop the detached processes + remove Caddy block',
79
+ 'lt dev status # Show running PIDs + bound URLs',
80
+ 'lt dev doctor # Diagnose Caddy/CA/DNS/port issues',
81
+ '```',
82
+ '',
83
+ '**Active URLs for THIS project:**',
84
+ '',
85
+ ];
86
+ if (appSub)
87
+ lines.push(`- App: \`https://${appSub.hostname}\``);
88
+ if (apiSub)
89
+ lines.push(`- API: \`https://${apiSub.hostname}\``);
90
+ if (dbName)
91
+ lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
92
+ lines.push('');
93
+ lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
94
+ lines.push('');
95
+ lines.push(endMarker);
96
+ const block = lines.join('\n');
97
+ if (!(0, fs_1.existsSync)(file))
98
+ return { file, patched: false, replacements: 0 };
99
+ const content = (0, fs_1.readFileSync)(file, 'utf8');
100
+ const startIdx = content.indexOf(startMarker);
101
+ const endIdx = content.indexOf(endMarker);
102
+ let next;
103
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
104
+ const before = content.slice(0, startIdx);
105
+ const after = content.slice(endIdx + endMarker.length);
106
+ next = before + block + after;
107
+ }
108
+ else {
109
+ const sep = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
110
+ next = `${content}${sep}${block}\n`;
111
+ }
112
+ if (next === content)
113
+ return { file, patched: false, replacements: 0 };
114
+ (0, fs_1.writeFileSync)(file, next, 'utf8');
115
+ return { file, patched: true, replacements: 1 };
116
+ }
117
+ /** App: hardcoded port + vite-proxy target → env-aware. */
118
+ function patchNuxtConfig(file) {
119
+ if (!(0, fs_1.existsSync)(file))
120
+ return { file, patched: false, replacements: 0 };
121
+ const before = (0, fs_1.readFileSync)(file, 'utf8');
122
+ let count = 0;
123
+ let after = before.replace(/^(\s*)port:\s*3001\s*,$/gm, (_m, indent) => {
124
+ count++;
125
+ return `${indent}port: Number(process.env.PORT) || 3001,`;
126
+ });
127
+ after = after.replace(/target:\s*'http:\/\/localhost:3000'/g, () => {
128
+ count++;
129
+ return `target: process.env.NUXT_API_URL || 'http://localhost:3000'`;
130
+ });
131
+ if (count === 0)
132
+ return { file, patched: false, replacements: 0 };
133
+ (0, fs_1.writeFileSync)(file, after, 'utf8');
134
+ return { file, patched: true, replacements: count };
135
+ }
136
+ /**
137
+ * Playwright: hardcoded baseURL/host/url → env-aware, plus a top-of-file
138
+ * dotenv-load of `.lt-dev/.env` so external test runners (CLI, IDE, VS
139
+ * Code Playwright Extension) automatically pick up `lt dev up`'s URLs
140
+ * and the local Caddy CA — without requiring the parent shell to inherit
141
+ * any env.
142
+ *
143
+ * Patches applied (each idempotent):
144
+ * 1. Top-of-file: `if (existsSync('.lt-dev/.env')) loadEnv(...)` block,
145
+ * bracketed by `// >>> lt-dev:bridge >>>` markers.
146
+ * 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
147
+ * `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
148
+ */
149
+ function patchPlaywrightConfig(file) {
150
+ if (!(0, fs_1.existsSync)(file))
151
+ return { file, patched: false, replacements: 0 };
152
+ const before = (0, fs_1.readFileSync)(file, 'utf8');
153
+ let count = 0;
154
+ let after = before;
155
+ // 1. URL-Patches.
156
+ for (const key of ['baseURL', 'host', 'url']) {
157
+ after = after.replace(new RegExp(`${key}:\\s*'http://localhost:3001'`, 'g'), () => {
158
+ count++;
159
+ return `${key}: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`;
160
+ });
161
+ }
162
+ // 2. Top-of-file dotenv bridge — only inject if not already present.
163
+ const bridgeStart = '// >>> lt-dev:bridge >>>';
164
+ const bridgeEnd = '// <<< lt-dev:bridge <<<';
165
+ if (!after.includes(bridgeStart)) {
166
+ const bridgeBlock = [
167
+ bridgeStart,
168
+ '// Auto-load <root>/.lt-dev/.env when `lt dev up` is active so',
169
+ '// external test runners (CLI, IDE, VS Code Playwright Extension)',
170
+ '// pick up project URLs + Caddy CA without inheriting the parent shell.',
171
+ "import { existsSync as __ltDevExists, readFileSync as __ltDevRead } from 'node:fs';",
172
+ "import { resolve as __ltDevResolve } from 'node:path';",
173
+ "const __ltDevEnvFile = __ltDevResolve(process.cwd(), '.lt-dev/.env');",
174
+ 'if (__ltDevExists(__ltDevEnvFile)) {',
175
+ ' for (const __ln of __ltDevRead(__ltDevEnvFile, "utf8").split(/\\r?\\n/)) {',
176
+ ' const __m = __ln.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);',
177
+ ' if (__m && process.env[__m[1]] === undefined) process.env[__m[1]] = __m[2];',
178
+ ' }',
179
+ '}',
180
+ bridgeEnd,
181
+ '',
182
+ ].join('\n');
183
+ after = bridgeBlock + after;
184
+ count++;
185
+ }
186
+ if (count === 0)
187
+ return { file, patched: false, replacements: 0 };
188
+ (0, fs_1.writeFileSync)(file, after, 'utf8');
189
+ return { file, patched: true, replacements: count };
190
+ }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.checkPortInUse = checkPortInUse;
13
+ exports.killProcessGroup = killProcessGroup;
14
+ exports.listenSnapshot = listenSnapshot;
15
+ exports.spawnDetached = spawnDetached;
16
+ /**
17
+ * Process + port helpers for `lt dev`.
18
+ *
19
+ * - `spawnDetached`: detached child whose stdout/stderr go to a log file.
20
+ * The Claude Code session does NOT block waiting for it, and `lt dev down`
21
+ * can SIGTERM the entire process group via `process.kill(-pid, …)`.
22
+ * - `listenSnapshot` / `checkPortInUse`: thin lsof wrappers used by
23
+ * `lt dev doctor` to detect port collisions.
24
+ */
25
+ const child_process_1 = require("child_process");
26
+ const fs_1 = require("fs");
27
+ const path_1 = require("path");
28
+ const dev_state_1 = require("./dev-state");
29
+ /**
30
+ * Check via `lsof` whether a single TCP port is bound by a LISTEN socket.
31
+ * Returns null if lsof is unavailable.
32
+ */
33
+ function checkPortInUse(port) {
34
+ return __awaiter(this, void 0, void 0, function* () {
35
+ return new Promise((resolve) => {
36
+ var _a;
37
+ const child = (0, child_process_1.spawn)('lsof', ['-iTCP', `-sTCP:LISTEN`, '-nP', `-iTCP:${port}`], {
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ let stdout = '';
41
+ let errored = false;
42
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
43
+ child.on('error', () => (errored = true));
44
+ child.on('close', () => {
45
+ if (errored)
46
+ return resolve(null);
47
+ const lines = stdout.split('\n').filter((l) => l && !l.startsWith('COMMAND'));
48
+ const matching = lines.find((l) => new RegExp(`[: ]${port}\\s.*\\(LISTEN\\)`).test(l) || l.includes(`:${port} (LISTEN)`));
49
+ if (!matching)
50
+ return resolve({ inUse: false });
51
+ const parts = matching.trim().split(/\s+/);
52
+ resolve({ command: parts[0], inUse: true, pid: Number(parts[1]) });
53
+ });
54
+ });
55
+ });
56
+ }
57
+ /** Send SIGTERM to a detached process group; falls back to single-PID kill. */
58
+ function killProcessGroup(pid) {
59
+ if (!(0, dev_state_1.isValidPid)(pid))
60
+ return false;
61
+ try {
62
+ process.kill(-pid, 'SIGTERM');
63
+ return true;
64
+ }
65
+ catch (_a) {
66
+ try {
67
+ process.kill(pid, 'SIGTERM');
68
+ return true;
69
+ }
70
+ catch (_b) {
71
+ return false;
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Multi-port lsof snapshot — single subprocess for N ports.
77
+ * Returns map<port, {command, pid}> for ports that are in use.
78
+ */
79
+ function listenSnapshot(ports) {
80
+ return __awaiter(this, void 0, void 0, function* () {
81
+ const result = new Map();
82
+ if (ports.length === 0)
83
+ return result;
84
+ return new Promise((resolve) => {
85
+ var _a;
86
+ const portArgs = ports.flatMap((p) => ['-iTCP', `-iTCP:${p}`]);
87
+ const child = (0, child_process_1.spawn)('lsof', ['-sTCP:LISTEN', '-nP', ...portArgs], { stdio: ['ignore', 'pipe', 'pipe'] });
88
+ let stdout = '';
89
+ let errored = false;
90
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
91
+ child.on('error', () => (errored = true));
92
+ child.on('close', () => {
93
+ if (errored)
94
+ return resolve(result);
95
+ for (const line of stdout.split('\n')) {
96
+ if (!line || line.startsWith('COMMAND'))
97
+ continue;
98
+ const parts = line.trim().split(/\s+/);
99
+ if (parts.length < 9)
100
+ continue;
101
+ const command = parts[0];
102
+ const pid = Number(parts[1]);
103
+ const name = parts[8];
104
+ const portMatch = name.match(/:(\d+)$/);
105
+ if (!portMatch)
106
+ continue;
107
+ const port = Number(portMatch[1]);
108
+ if (ports.includes(port) && /\(LISTEN\)/.test(line)) {
109
+ result.set(port, { command, pid });
110
+ }
111
+ }
112
+ resolve(result);
113
+ });
114
+ });
115
+ });
116
+ }
117
+ /**
118
+ * Spawn a detached child whose stdio is redirected to a log file.
119
+ *
120
+ * The parent's copy of the log file descriptor is closed in `finally`
121
+ * — the child has already inherited its own fd before `spawn` returns,
122
+ * so closing prevents fd leaks and avoids racing-write artifacts on
123
+ * filesystems where O_APPEND is not atomic.
124
+ *
125
+ * Returns the child PID, or undefined if spawn failed.
126
+ */
127
+ function spawnDetached(cmd, args, opts) {
128
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true });
129
+ const out = (0, fs_1.openSync)(opts.logFile, 'a');
130
+ let child;
131
+ try {
132
+ child = (0, child_process_1.spawn)(cmd, args, {
133
+ cwd: opts.cwd,
134
+ detached: true,
135
+ env: opts.env,
136
+ stdio: ['ignore', out, out],
137
+ });
138
+ child.unref();
139
+ return child.pid;
140
+ }
141
+ catch (_a) {
142
+ return undefined;
143
+ }
144
+ finally {
145
+ try {
146
+ (0, fs_1.closeSync)(out);
147
+ }
148
+ catch (_b) {
149
+ /* fd already inherited by child; ignore */
150
+ }
151
+ }
152
+ }
@@ -2,27 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apiNeedsPortPatch = apiNeedsPortPatch;
4
4
  exports.appNeedsPortPatch = appNeedsPortPatch;
5
+ exports.deriveDbName = deriveDbName;
5
6
  exports.resolveLayout = resolveLayout;
6
7
  const fs_1 = require("fs");
7
8
  const path_1 = require("path");
8
9
  const workspace_integration_1 = require("./workspace-integration");
9
10
  /**
10
- * Detect whether the API project still has the legacy hardcoded
11
- * `port: 3000`. Returns the absolute file path if a patch is needed,
12
- * null if already env-aware (or no file).
11
+ * Detect whether the API project still has the legacy hardcoded `port: 3000`.
12
+ * Returns the file path if a patch is needed, null otherwise.
13
13
  */
14
14
  function apiNeedsPortPatch(apiDir) {
15
15
  const file = (0, path_1.join)(apiDir, 'src', 'config.env.ts');
16
16
  if (!(0, fs_1.existsSync)(file))
17
17
  return null;
18
18
  const content = (0, fs_1.readFileSync)(file, 'utf8');
19
- // Match `port: 3000,` exactly (not yet env-wrapped).
20
19
  return /port:\s*3000\s*,/.test(content) ? file : null;
21
20
  }
22
21
  /**
23
22
  * Detect whether the App project still has hardcoded `port: 3001` or a
24
23
  * hardcoded vite-proxy `target: 'http://localhost:3000'`. Returns an
25
- * array of absolute file paths that need patching.
24
+ * array of file paths that need patching.
26
25
  */
27
26
  function appNeedsPortPatch(appDir) {
28
27
  const candidates = [(0, path_1.join)(appDir, 'nuxt.config.ts'), (0, path_1.join)(appDir, 'playwright.config.ts')];
@@ -37,40 +36,43 @@ function appNeedsPortPatch(appDir) {
37
36
  /host:\s*'http:\/\/localhost:3001'/.test(c));
38
37
  });
39
38
  }
39
+ /** Read `dbName` from the API config (defaults to `<slug>-local`). */
40
+ function deriveDbName(apiDir, slug) {
41
+ if (apiDir) {
42
+ const cfg = (0, path_1.join)(apiDir, 'src', 'config.env.ts');
43
+ if ((0, fs_1.existsSync)(cfg)) {
44
+ const content = (0, fs_1.readFileSync)(cfg, 'utf8');
45
+ const match = content.match(/dbName:\s*['"`]([^'"`]+)['"`]/);
46
+ if (match)
47
+ return match[1];
48
+ }
49
+ }
50
+ return `${slug}-local`;
51
+ }
40
52
  /**
41
- * Resolve the project layout starting from `cwd`. Walks up to find a
42
- * monorepo workspace if cwd is inside `projects/api/` or `projects/app/`.
53
+ * Resolve layout starting from `cwd`. Walks up to find a workspace if
54
+ * cwd is inside `projects/api/` or `projects/app/`.
43
55
  */
44
56
  function resolveLayout(cwd, filesystem) {
45
- // Inside a sub-project? → walk to workspace root.
46
57
  const subContext = (0, workspace_integration_1.detectSubProjectContext)(cwd, filesystem);
47
- if (subContext) {
58
+ if (subContext)
48
59
  return monorepoLayout(subContext.workspaceRoot);
49
- }
50
- // Workspace root directly?
51
60
  const layout = (0, workspace_integration_1.detectWorkspaceLayout)(cwd, filesystem);
52
- if (layout.hasWorkspace) {
61
+ if (layout.hasWorkspace)
53
62
  return monorepoLayout(layout.workspaceDir);
54
- }
55
- // Walk up to find a workspace.
56
63
  const workspaceRoot = (0, workspace_integration_1.findWorkspaceRoot)(cwd, filesystem);
57
- if (workspaceRoot) {
64
+ if (workspaceRoot)
58
65
  return monorepoLayout(workspaceRoot);
59
- }
60
- // Fall back to standalone — figure out if it's API or App.
66
+ // Standalone project — figure out if it's API or App.
61
67
  const isApi = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'src', 'config.env.ts')) || (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nest-cli.json'));
62
68
  const isApp = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nuxt.config.ts'));
63
69
  return {
64
70
  apiDir: isApi ? cwd : null,
65
71
  appDir: isApp ? cwd : null,
66
- name: readPackageName(cwd) || basename(cwd),
67
72
  root: cwd,
68
73
  workspace: false,
69
74
  };
70
75
  }
71
- function basename(p) {
72
- return p.replace(/\/+$/, '').split('/').pop() || 'project';
73
- }
74
76
  /** Build the layout for an lt-monorepo workspace root. */
75
77
  function monorepoLayout(workspaceRoot) {
76
78
  const apiDir = (0, path_1.join)(workspaceRoot, 'projects', 'api');
@@ -78,24 +80,7 @@ function monorepoLayout(workspaceRoot) {
78
80
  return {
79
81
  apiDir: (0, fs_1.existsSync)(apiDir) ? apiDir : null,
80
82
  appDir: (0, fs_1.existsSync)(appDir) ? appDir : null,
81
- name: readPackageName(workspaceRoot) || basename(workspaceRoot),
82
83
  root: workspaceRoot,
83
84
  workspace: true,
84
85
  };
85
86
  }
86
- /** Read the `name` field from `package.json`, scrubbed to just the bare name (no scope). */
87
- function readPackageName(dir) {
88
- const pkgPath = (0, path_1.join)(dir, 'package.json');
89
- if (!(0, fs_1.existsSync)(pkgPath))
90
- return null;
91
- try {
92
- const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8'));
93
- if (!pkg.name)
94
- return null;
95
- // Strip npm scope: @lenne.tech/foo → foo
96
- return pkg.name.includes('/') ? pkg.name.split('/').pop() : pkg.name;
97
- }
98
- catch (_a) {
99
- return null;
100
- }
101
- }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.paths = void 0;
4
+ exports.allocateInternalPort = allocateInternalPort;
5
+ exports.clearSession = clearSession;
6
+ exports.isPidAlive = isPidAlive;
7
+ exports.isValidPid = isValidPid;
8
+ exports.loadRegistry = loadRegistry;
9
+ exports.loadSession = loadSession;
10
+ exports.saveRegistry = saveRegistry;
11
+ exports.saveSession = saveSession;
12
+ exports.takenInternalPorts = takenInternalPorts;
13
+ /**
14
+ * State persistence for `lt dev`.
15
+ *
16
+ * Two stores:
17
+ * - Central registry at `~/.lenneTech/projects.json` — index of all
18
+ * known projects, used by `lt dev status --all`, the Claude Code
19
+ * plugin hook, and conflict detection.
20
+ * - Per-project state at `<root>/.lt-dev/state.json` — PIDs of the
21
+ * currently running `lt dev up` session.
22
+ *
23
+ * Both files are JSON, atomically written, and schema-versioned.
24
+ */
25
+ const fs_1 = require("fs");
26
+ const os_1 = require("os");
27
+ const path_1 = require("path");
28
+ const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
29
+ const SESSION_DIR = '.lt-dev';
30
+ const SESSION_FILE = 'state.json';
31
+ /**
32
+ * Allocate a free internal port for a Caddy upstream.
33
+ *
34
+ * Strategy: try sequential ports starting from `start`, skipping any
35
+ * that are already in use. The range 4000-4999 is conventional for
36
+ * lt dev internal ports — well above the deprecated 3000/3001 range
37
+ * and safely below most reserved/system ranges.
38
+ */
39
+ function allocateInternalPort(start, taken) {
40
+ for (let p = start; p < start + 1000; p++) {
41
+ if (!taken.has(p))
42
+ return p;
43
+ }
44
+ throw new Error(`No free internal port in range [${start}, ${start + 1000})`);
45
+ }
46
+ /** Remove session state file (called by `lt dev down`). */
47
+ function clearSession(root) {
48
+ const file = (0, path_1.join)(root, SESSION_DIR, SESSION_FILE);
49
+ if ((0, fs_1.existsSync)(file)) {
50
+ try {
51
+ (0, fs_1.rmSync)(file);
52
+ }
53
+ catch (_a) {
54
+ /* best-effort */
55
+ }
56
+ }
57
+ }
58
+ /** Check whether a process with the given PID is currently alive. */
59
+ function isPidAlive(pid) {
60
+ if (!isValidPid(pid))
61
+ return false;
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ }
66
+ catch (_a) {
67
+ return false;
68
+ }
69
+ }
70
+ /** Validate a PID — positive integer, within plausible range. */
71
+ function isValidPid(pid) {
72
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pid < 4194304;
73
+ }
74
+ /** Load the central registry; returns an empty one if missing or unreadable. */
75
+ function loadRegistry() {
76
+ if (!(0, fs_1.existsSync)(REGISTRY_PATH))
77
+ return { projects: {}, version: 1 };
78
+ try {
79
+ const parsed = JSON.parse((0, fs_1.readFileSync)(REGISTRY_PATH, 'utf8'));
80
+ if (parsed && typeof parsed === 'object' && parsed.version === 1 && typeof parsed.projects === 'object') {
81
+ return parsed;
82
+ }
83
+ }
84
+ catch (_a) {
85
+ /* fall through */
86
+ }
87
+ return { projects: {}, version: 1 };
88
+ }
89
+ /** Load session state for a project root. */
90
+ function loadSession(root) {
91
+ const file = (0, path_1.join)(root, SESSION_DIR, SESSION_FILE);
92
+ if (!(0, fs_1.existsSync)(file))
93
+ return null;
94
+ try {
95
+ const parsed = JSON.parse((0, fs_1.readFileSync)(file, 'utf8'));
96
+ if (parsed &&
97
+ typeof parsed === 'object' &&
98
+ typeof parsed.pids === 'object' &&
99
+ typeof parsed.startedAt === 'string') {
100
+ // Validate PIDs
101
+ const pids = {};
102
+ if (isValidPid(parsed.pids.api))
103
+ pids.api = parsed.pids.api;
104
+ if (isValidPid(parsed.pids.app))
105
+ pids.app = parsed.pids.app;
106
+ return { pids, startedAt: parsed.startedAt };
107
+ }
108
+ }
109
+ catch (_a) {
110
+ /* fall through */
111
+ }
112
+ return null;
113
+ }
114
+ /** Atomically persist the registry. */
115
+ function saveRegistry(reg) {
116
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(REGISTRY_PATH), { recursive: true });
117
+ const tmp = `${REGISTRY_PATH}.tmp`;
118
+ (0, fs_1.writeFileSync)(tmp, JSON.stringify(reg, null, 2), 'utf8');
119
+ // rename is atomic on POSIX
120
+ (0, fs_1.writeFileSync)(REGISTRY_PATH, (0, fs_1.readFileSync)(tmp, 'utf8'), 'utf8');
121
+ try {
122
+ (0, fs_1.rmSync)(tmp);
123
+ }
124
+ catch (_a) {
125
+ /* best-effort */
126
+ }
127
+ }
128
+ /** Persist session state for a project root. */
129
+ function saveSession(root, state) {
130
+ const dir = (0, path_1.join)(root, SESSION_DIR);
131
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
132
+ (0, fs_1.writeFileSync)((0, path_1.join)(dir, SESSION_FILE), JSON.stringify(state, null, 2), 'utf8');
133
+ }
134
+ /** Collect all internal ports already claimed across the registry. */
135
+ function takenInternalPorts(reg, excludeSlug) {
136
+ const ports = new Set();
137
+ for (const [slug, entry] of Object.entries(reg.projects)) {
138
+ if (slug === excludeSlug)
139
+ continue;
140
+ if (entry.internalPorts.api)
141
+ ports.add(entry.internalPorts.api);
142
+ if (entry.internalPorts.app)
143
+ ports.add(entry.internalPorts.app);
144
+ }
145
+ return ports;
146
+ }
147
+ /** Path constants exported for tests + status displays. */
148
+ exports.paths = {
149
+ registry: REGISTRY_PATH,
150
+ sessionDir: SESSION_DIR,
151
+ sessionFile: SESSION_FILE,
152
+ };