@lenne.tech/cli 1.45.0 → 1.47.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.
@@ -3,9 +3,13 @@
3
3
  /**
4
4
  * Postinstall script for lt CLI
5
5
  * Generates static completion files on install/update (no runtime overhead)
6
+ *
7
+ * Must never fail the install: npm runs this through cmd.exe on Windows, so the
8
+ * former `2>/dev/null || true` guard in package.json broke `npm i -g` there
9
+ * instead of protecting it. The guard now lives in this file (see bottom).
6
10
  */
7
11
 
8
- const { execSync } = require('child_process');
12
+ const { execFileSync } = require('child_process');
9
13
  const { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
10
14
  const { homedir } = require('os');
11
15
  const { join } = require('path');
@@ -26,11 +30,17 @@ function addSourceLine(configFile, sourceLine) {
26
30
 
27
31
  /**
28
32
  * Detect user's shell
33
+ *
34
+ * Returns null when no supported shell can be identified. On Windows an unset
35
+ * SHELL means PowerShell or cmd.exe, which read none of the rc files below —
36
+ * appending to a ~/.bashrc nothing sources would only leave clutter behind.
37
+ * Git Bash sets SHELL, so it still gets bash completions.
29
38
  */
30
39
  function detectShell() {
31
40
  const shell = process.env.SHELL || '';
32
41
  if (shell.includes('zsh')) return 'zsh';
33
42
  if (shell.includes('fish')) return 'fish';
43
+ if (!shell && process.platform === 'win32') return null;
34
44
  return 'bash';
35
45
  }
36
46
 
@@ -45,8 +55,13 @@ function generateCompletionFile(shell, completionFile) {
45
55
  mkdirSync(dir, { recursive: true });
46
56
  }
47
57
 
48
- // Generate completion script
49
- const script = execSync(`lt completion ${shell}`, { encoding: 'utf-8' });
58
+ // Run this package's own entry point with the current Node binary instead of
59
+ // resolving `lt` from PATH: during a global install the bin link may not exist
60
+ // yet, and on Windows `lt` is a .cmd shim that cannot be spawned without a shell.
61
+ const script = execFileSync(process.execPath, [join(__dirname, 'lt'), 'completion', shell], {
62
+ encoding: 'utf-8',
63
+ stdio: ['ignore', 'pipe', 'ignore'],
64
+ });
50
65
  writeFileSync(completionFile, script);
51
66
  return true;
52
67
  } catch {
@@ -109,6 +124,10 @@ function main() {
109
124
  }
110
125
 
111
126
  const shell = detectShell();
127
+ if (!shell) {
128
+ return; // No supported shell (e.g. PowerShell) - nothing to install
129
+ }
130
+
112
131
  const paths = getCompletionPaths(shell);
113
132
  const isTTY = process.stdout.isTTY;
114
133
 
@@ -152,5 +171,9 @@ function main() {
152
171
  }
153
172
  }
154
173
 
155
- // Run
156
- main();
174
+ // Run - completions are a convenience, so no failure may abort the install
175
+ try {
176
+ main();
177
+ } catch {
178
+ // Silent fail
179
+ }
@@ -169,7 +169,7 @@ const DoctorCommand = {
169
169
  try {
170
170
  const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
171
171
  const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
172
- const { resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
172
+ const { keptWrapperReason, resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
173
173
  const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
174
174
  if (filesystem.exists(bundledCheck)) {
175
175
  const missing = [];
@@ -183,12 +183,26 @@ const DoctorCommand = {
183
183
  drifted.push(rel);
184
184
  }
185
185
  }
186
- if (missing.length > 0) {
186
+ // A missing module only breaks `check` when the project's wrapper is the
187
+ // canonical one that imports it (or the wrapper itself is missing). An
188
+ // older wrapper does not import the newer modules (e.g. `lib/*.mjs`) and
189
+ // still runs — calling that "cannot start" would be a false alarm.
190
+ const wrapperDrifted = drifted.includes('scripts/check.mjs');
191
+ // A project wrapper that heal refuses to replace (newer release, or the same
192
+ // release with other content) is not drift to fix: pointing at
193
+ // `lt fullstack update` there would recommend a no-op or a downgrade.
194
+ const kept = keptWrapperReason(layout.root, bundledCheck);
195
+ if (kept) {
196
+ line('INFO', colors.cyan, `check wrapper: ${kept}`);
197
+ }
198
+ else if (missing.length > 0 && !wrapperDrifted) {
187
199
  line('ERROR', colors.red, `check wrapper incomplete — missing ${missing.join(', ')}`);
188
200
  line('ERROR', colors.red, ' `pnpm run check` cannot start; run `lt fullstack update` to install it');
189
201
  }
190
202
  else if (drifted.length > 0) {
191
- line('WARN', colors.yellow, `${drifted.join(', ')} differs from the canonical CLI version`);
203
+ const outdated = [...drifted, ...missing.map((rel) => `${rel} (missing)`)];
204
+ const verb = outdated.length === 1 ? 'differs' : 'differ';
205
+ line('WARN', colors.yellow, `${outdated.join(', ')} ${verb} from the canonical CLI version`);
192
206
  line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
193
207
  }
194
208
  else if (filesystem.exists(j(layout.root, 'scripts', 'check.mjs'))) {
@@ -199,6 +213,34 @@ const DoctorCommand = {
199
213
  catch (_a) {
200
214
  /* best-effort diagnostics */
201
215
  }
216
+ // 7.6 oxlint config filename: oxlint only auto-discovers `.oxlintrc.json`.
217
+ // Apps from the template before the rename carry `oxlint.json`, whose
218
+ // rules then silently never apply — lint passes on code the project's
219
+ // own rules forbid.
220
+ if (layout.appDir) {
221
+ // Behaviour-changing fix flags delete console calls once rules load.
222
+ try {
223
+ const { findDangerousFixFlagUsage } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-oxlintrc')));
224
+ const workspaceRoot = layout.root !== layout.appDir ? layout.root : undefined;
225
+ const flagged = findDangerousFixFlagUsage(layout.appDir, workspaceRoot);
226
+ if (flagged.length > 0) {
227
+ line('WARN', colors.yellow, `oxlint --fix-suggestions/--fix-dangerously in ${flagged.join(', ')}`);
228
+ line('WARN', colors.yellow, ' they apply behaviour-changing fixes (e.g. delete console calls); run `lt fullstack update`');
229
+ }
230
+ }
231
+ catch (_b) {
232
+ /* best-effort diagnostics */
233
+ }
234
+ const hasLegacy = filesystem.exists(filesystem.path(layout.appDir, 'oxlint.json'));
235
+ const hasCurrent = filesystem.exists(filesystem.path(layout.appDir, '.oxlintrc.json'));
236
+ if (hasLegacy && !hasCurrent) {
237
+ line('WARN', colors.yellow, 'oxlint config not loaded — oxlint only reads .oxlintrc.json, the app has oxlint.json');
238
+ line('WARN', colors.yellow, ' run `lt fullstack update` to rename it');
239
+ }
240
+ else if (hasLegacy) {
241
+ line('WARN', colors.yellow, 'oxlint.json is ignored next to .oxlintrc.json — merge its rules and delete it');
242
+ }
243
+ }
202
244
  // 8. Slug ↔ path: is this project's slug registered to a DIFFERENT checkout?
203
245
  // Two clones of the same project (same package.json "name") share the
204
246
  // slug → Caddy block / ports / DB and collide. Surface it proactively.
@@ -354,6 +354,9 @@ const UpCommand = {
354
354
  // Build env per process.
355
355
  const devEnv = (0, dev_env_1.buildDevEnv)({
356
356
  apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
357
+ // Lets `buildDevEnv` consult the app's own `.env` for keys a project may have set
358
+ // itself, so `lt dev` never replaces one of its values with a derived fallback.
359
+ appDir: layout.appDir,
357
360
  appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
358
361
  baseEnv: process.env,
359
362
  dbName,
@@ -14,6 +14,7 @@ const dev_patches_1 = require("../../lib/dev-patches");
14
14
  const framework_detection_1 = require("../../lib/framework-detection");
15
15
  const frontend_framework_detection_1 = require("../../lib/frontend-framework-detection");
16
16
  const heal_check_wrapper_1 = require("../../lib/heal-check-wrapper");
17
+ const heal_oxlintrc_1 = require("../../lib/heal-oxlintrc");
17
18
  const heal_vendor_migrate_store_1 = require("../../lib/heal-vendor-migrate-store");
18
19
  const vendor_claude_md_1 = require("../../lib/vendor-claude-md");
19
20
  /**
@@ -213,6 +214,36 @@ const NewCommand = {
213
214
  warning(` Check wrapper NOT updated: ${entry}`);
214
215
  }
215
216
  }
217
+ // ── Self-heal: let oxlint actually load the app's config ───────────────
218
+ //
219
+ // oxlint only auto-discovers `.oxlintrc.json`; the app template shipped
220
+ // `oxlint.json`, so its rules never applied. Runs AFTER the check wrapper
221
+ // heal on purpose: the rename is refused while anything still passes
222
+ // `--fix-suggestions`, which would delete console calls once the config loads.
223
+ if (appDir) {
224
+ const workspaceRoot = isWorkspace ? cwd : undefined;
225
+ // 1. Strip the behaviour-changing fix flags first (the root wrapper was
226
+ // handled above). A file it has to skip keeps blocking the rename below.
227
+ const flags = (0, heal_oxlintrc_1.healDangerousOxlintFixFlags)(appDir, workspaceRoot);
228
+ if (flags.changed.length > 0) {
229
+ info('');
230
+ success(` Removed --fix-suggestions/--fix-dangerously from: ${flags.changed.join(', ')}`);
231
+ }
232
+ for (const entry of flags.skipped) {
233
+ warning(` Fix flag NOT removed: ${entry}`);
234
+ }
235
+ // 2. Rename — the gate inside re-checks every file.
236
+ const oxlintrc = (0, heal_oxlintrc_1.healOxlintrcFilename)(appDir, workspaceRoot);
237
+ if (oxlintrc.action === 'renamed') {
238
+ info('');
239
+ success(` Renamed the oxlint config so oxlint loads it: ${oxlintrc.changed.join(', ')}`);
240
+ info(' Its rules apply from now on — expect new lint findings on the next check.');
241
+ }
242
+ else if (oxlintrc.detail) {
243
+ info('');
244
+ warning(` oxlint config NOT renamed: ${oxlintrc.detail}`);
245
+ }
246
+ }
216
247
  // ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
217
248
  //
218
249
  // Newer templates ship the entry, but pre-existing projects predate it —
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHECK_TEMPLATE_PIN_FILE = exports.CHECK_TEMPLATE_REPOSITORY = void 0;
4
+ exports.readCheckTemplatePin = readCheckTemplatePin;
5
+ exports.syncCheckTemplate = syncCheckTemplate;
6
+ exports.verifyCheckTemplate = verifyCheckTemplate;
7
+ /**
8
+ * Keep `src/templates/check/` a verbatim copy of a PINNED lt-monorepo state.
9
+ *
10
+ * lt-monorepo is the upstream of the check wrapper. The copy here drifted two
11
+ * releases behind once, and because heal treats the bundled copy as canonical,
12
+ * `lt fullstack update` then downgraded freshly created projects. So the
13
+ * template is never edited by hand: `npm run sync:check-template -- --ref <tag|sha>`
14
+ * copies the wrapper plus its transitive imports from that ref and records the
15
+ * commit and a hash per file in a pin file. A Jest test compares the template
16
+ * against that pin without any network access.
17
+ */
18
+ const child_process_1 = require("child_process");
19
+ const crypto_1 = require("crypto");
20
+ const fs_1 = require("fs");
21
+ const os_1 = require("os");
22
+ const path_1 = require("path");
23
+ const heal_check_wrapper_1 = require("./heal-check-wrapper");
24
+ exports.CHECK_TEMPLATE_REPOSITORY = 'lenneTech/lt-monorepo';
25
+ exports.CHECK_TEMPLATE_PIN_FILE = 'lt-monorepo-pin.json';
26
+ const DEFAULT_FROM = `https://github.com/${exports.CHECK_TEMPLATE_REPOSITORY}.git`;
27
+ /** Read a pin file; null when it is missing or unparseable. */
28
+ function readCheckTemplatePin(pinPath) {
29
+ try {
30
+ return JSON.parse((0, fs_1.readFileSync)(pinPath, 'utf8'));
31
+ }
32
+ catch (_a) {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Copy `scripts/check.mjs` and its transitive relative imports from `ref` into
38
+ * the template dir and write the pin. Files the previous pin listed but the new
39
+ * closure no longer contains are removed; nothing else in the dir is touched.
40
+ *
41
+ * Refuses a source without an `@lt-check-wrapper` marker: an unversioned
42
+ * template would switch the downgrade guard in heal off without a trace.
43
+ */
44
+ function syncCheckTemplate(options) {
45
+ var _a, _b;
46
+ const { from = DEFAULT_FROM, ref, templateDir } = options;
47
+ const pinPath = (_a = options.pinPath) !== null && _a !== void 0 ? _a : (0, path_1.join)(templateDir, exports.CHECK_TEMPLATE_PIN_FILE);
48
+ const checkout = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'lt-check-template-'));
49
+ try {
50
+ const git = (...args) => (0, child_process_1.execFileSync)('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
51
+ git('clone', '--quiet', '--no-checkout', from, checkout);
52
+ git('-C', checkout, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref);
53
+ const commit = git('-C', checkout, 'rev-parse', 'HEAD');
54
+ const asset = (0, path_1.join)(checkout, 'scripts', 'check.mjs');
55
+ if (!(0, fs_1.existsSync)(asset)) {
56
+ throw new Error(`${ref}: scripts/check.mjs not found`);
57
+ }
58
+ const version = (0, heal_check_wrapper_1.readWrapperVersion)(asset);
59
+ if (!version) {
60
+ throw new Error(`${ref}: scripts/check.mjs carries no @lt-check-wrapper marker — refusing to pin it`);
61
+ }
62
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
63
+ throw new Error(`${ref}: @lt-check-wrapper marker "${version}" is not a version — refusing to pin it`);
64
+ }
65
+ const previous = readCheckTemplatePin(pinPath);
66
+ const files = {};
67
+ for (const { rel, source } of (0, heal_check_wrapper_1.resolveCopySet)(asset)) {
68
+ const name = rel.replace(/^scripts\//, '');
69
+ const target = (0, path_1.join)(templateDir, name);
70
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
71
+ (0, fs_1.copyFileSync)(source, target);
72
+ files[name] = hashFile(target);
73
+ }
74
+ for (const stale of Object.keys((_b = previous === null || previous === void 0 ? void 0 : previous.files) !== null && _b !== void 0 ? _b : {})) {
75
+ // Names come from the pin file; never let one reach outside the template dir.
76
+ if (!(stale in files) && !stale.split(/[\\/]/).includes('..')) {
77
+ (0, fs_1.rmSync)((0, path_1.join)(templateDir, stale), { force: true });
78
+ }
79
+ }
80
+ const pin = {
81
+ commit,
82
+ files: sortKeys(files),
83
+ ref,
84
+ repository: exports.CHECK_TEMPLATE_REPOSITORY,
85
+ version,
86
+ };
87
+ (0, fs_1.writeFileSync)(pinPath, `${JSON.stringify(pin, null, 2)}\n`);
88
+ return pin;
89
+ }
90
+ finally {
91
+ (0, fs_1.rmSync)(checkout, { force: true, recursive: true });
92
+ }
93
+ }
94
+ /**
95
+ * Differences between the template dir and its pin; empty when they match.
96
+ * Checks both directions: every pinned file with its hash, and every file the
97
+ * wrapper actually imports is pinned (a hand-added import would otherwise slip by).
98
+ */
99
+ function verifyCheckTemplate(templateDir, pin) {
100
+ const problems = [];
101
+ const actual = new Set((0, heal_check_wrapper_1.resolveCopySet)((0, path_1.join)(templateDir, 'check.mjs')).map((c) => c.rel.replace(/^scripts\//, '')));
102
+ for (const [name, hash] of Object.entries(pin.files)) {
103
+ const file = (0, path_1.join)(templateDir, name);
104
+ if (!(0, fs_1.existsSync)(file)) {
105
+ problems.push(`${name}: pinned but missing`);
106
+ }
107
+ else if (hashFile(file) !== hash) {
108
+ problems.push(`${name}: differs from ${exports.CHECK_TEMPLATE_REPOSITORY}@${pin.commit.slice(0, 7)}`);
109
+ }
110
+ }
111
+ for (const name of actual) {
112
+ if (!(name in pin.files)) {
113
+ problems.push(`${name}: imported by the wrapper but not pinned`);
114
+ }
115
+ }
116
+ const version = (0, heal_check_wrapper_1.readWrapperVersion)((0, path_1.join)(templateDir, 'check.mjs'));
117
+ if (version !== pin.version) {
118
+ problems.push(`check.mjs: marker ${version !== null && version !== void 0 ? version : '(none)'} does not match pinned version ${pin.version}`);
119
+ }
120
+ return problems;
121
+ }
122
+ function hashFile(file) {
123
+ return (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(file)).digest('hex');
124
+ }
125
+ function sortKeys(record) {
126
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
127
+ }
@@ -11,10 +11,10 @@ exports.runClaudeCommand = runClaudeCommand;
11
11
  * Claude CLI utilities
12
12
  * Handles detection and execution of Claude CLI commands
13
13
  */
14
- const child_process_1 = require("child_process");
15
14
  const fs_1 = require("fs");
16
15
  const os_1 = require("os");
17
16
  const path_1 = require("path");
17
+ const platform_1 = require("./platform");
18
18
  /**
19
19
  * Path to Claude plugins marketplaces directory
20
20
  */
@@ -31,7 +31,7 @@ exports.CLAUDE_KNOWN_MARKETPLACES_PATH = (0, path_1.join)((0, os_1.homedir)(), '
31
31
  function checkCommandExists(command) {
32
32
  try {
33
33
  const [cmd, ...args] = command.trim().split(/\s+/);
34
- const result = (0, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
34
+ const result = (0, platform_1.spawnCmdSync)(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
35
35
  return result.status === 0;
36
36
  }
37
37
  catch (_a) {
@@ -48,27 +48,19 @@ function checkMarketplaceExists(marketplaceName) {
48
48
  }
49
49
  /**
50
50
  * Find the Claude CLI executable path
51
- * Checks common installation locations and falls back to 'which'
51
+ * Checks common installation locations first, then PATH
52
+ *
53
+ * On Windows the native installer puts `claude.exe` into `%USERPROFILE%\.local\bin`
54
+ * and `npm i -g` a `claude.cmd` shim into `%APPDATA%\npm`. Both are checked
55
+ * directly, because a shell opened before the install does not have them on PATH yet.
52
56
  * @returns Path to Claude CLI or null if not found
53
57
  */
54
- function findClaudeCli() {
55
- const possiblePaths = [(0, path_1.join)((0, os_1.homedir)(), '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude'];
56
- for (const p of possiblePaths) {
57
- if ((0, fs_1.existsSync)(p)) {
58
- return p;
59
- }
60
- }
61
- try {
62
- const result = (0, child_process_1.spawnSync)('which', ['claude'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
63
- const path = (result.stdout || '').trim();
64
- if (result.status === 0 && path && (0, fs_1.existsSync)(path)) {
65
- return path;
66
- }
67
- }
68
- catch (_a) {
69
- // Claude CLI not found in PATH
70
- }
71
- return null;
58
+ function findClaudeCli(options = {}) {
59
+ const { env = process.env, home = (0, os_1.homedir)(), platform = process.platform } = options;
60
+ const candidates = (0, platform_1.isWindows)(platform)
61
+ ? [path_1.win32.join(home, '.local', 'bin', 'claude.exe'), path_1.win32.join((0, platform_1.windowsAppData)(env, home), 'npm', 'claude.cmd')]
62
+ : [path_1.posix.join(home, '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude'];
63
+ return (0, platform_1.findExecutable)('claude', Object.assign(Object.assign({}, options), { candidates, env, platform }));
72
64
  }
73
65
  /**
74
66
  * List the names of all marketplaces known to the Claude CLI.
@@ -99,10 +91,7 @@ function readKnownMarketplaces() {
99
91
  */
100
92
  function runClaudeCommand(cli, args) {
101
93
  try {
102
- const result = (0, child_process_1.spawnSync)(cli, args.split(' '), {
103
- encoding: 'utf-8',
104
- stdio: ['pipe', 'pipe', 'pipe'],
105
- });
94
+ const result = (0, platform_1.spawnCmdSync)(cli, args.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] });
106
95
  return {
107
96
  output: result.stdout + result.stderr,
108
97
  success: result.status === 0,
@@ -6,42 +6,35 @@ exports.runCodexCommand = runCodexCommand;
6
6
  /**
7
7
  * Codex CLI utilities.
8
8
  */
9
- const child_process_1 = require("child_process");
10
- const fs_1 = require("fs");
11
9
  const os_1 = require("os");
12
10
  const path_1 = require("path");
13
- exports.DEFAULT_CODEX_MARKETPLACE_ROOT = process.env.LT_CODEX_MARKETPLACE_ROOT || '/Users/kaihaase/code/lenneTech/codex';
14
- function findCodexCli() {
15
- const possiblePaths = [
16
- (0, path_1.join)((0, os_1.homedir)(), '.local', 'bin', 'codex'),
17
- (0, path_1.join)((0, os_1.homedir)(), '.codex', 'bin', 'codex'),
18
- '/usr/local/bin/codex',
19
- '/opt/homebrew/bin/codex',
20
- '/usr/bin/codex',
21
- ];
22
- for (const path of possiblePaths) {
23
- if ((0, fs_1.existsSync)(path)) {
24
- return path;
25
- }
26
- }
27
- try {
28
- const result = (0, child_process_1.spawnSync)('which', ['codex'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
29
- const path = (result.stdout || '').trim();
30
- if (result.status === 0 && path && (0, fs_1.existsSync)(path)) {
31
- return path;
32
- }
33
- }
34
- catch (_a) {
35
- // Codex CLI not found in PATH.
36
- }
37
- return null;
11
+ const platform_1 = require("./platform");
12
+ /**
13
+ * Codex marketplace checkout used when `lt codex plugins` gets no `--path`.
14
+ * Defaults to the current directory, matching the command's own guidance to run it
15
+ * from a checkout that contains the generated marketplace.
16
+ */
17
+ exports.DEFAULT_CODEX_MARKETPLACE_ROOT = process.env.LT_CODEX_MARKETPLACE_ROOT || process.cwd();
18
+ /**
19
+ * Find the Codex CLI executable path: common installation locations first, then PATH.
20
+ * On Windows `npm i -g @openai/codex` leaves a `codex.cmd` shim in `%APPDATA%\npm`.
21
+ */
22
+ function findCodexCli(options = {}) {
23
+ const { env = process.env, home = (0, os_1.homedir)(), platform = process.platform } = options;
24
+ const candidates = (0, platform_1.isWindows)(platform)
25
+ ? [path_1.win32.join((0, platform_1.windowsAppData)(env, home), 'npm', 'codex.cmd')]
26
+ : [
27
+ path_1.posix.join(home, '.local', 'bin', 'codex'),
28
+ path_1.posix.join(home, '.codex', 'bin', 'codex'),
29
+ '/usr/local/bin/codex',
30
+ '/opt/homebrew/bin/codex',
31
+ '/usr/bin/codex',
32
+ ];
33
+ return (0, platform_1.findExecutable)('codex', Object.assign(Object.assign({}, options), { candidates, env, platform }));
38
34
  }
39
35
  function runCodexCommand(cli, args) {
40
36
  try {
41
- const result = (0, child_process_1.spawnSync)(cli, args, {
42
- encoding: 'utf-8',
43
- stdio: ['pipe', 'pipe', 'pipe'],
44
- });
37
+ const result = (0, platform_1.spawnCmdSync)(cli, args, { stdio: ['pipe', 'pipe', 'pipe'] });
45
38
  return {
46
39
  output: result.stdout + result.stderr,
47
40
  success: result.status === 0,
@@ -16,8 +16,11 @@ exports.writeEnvBridge = writeEnvBridge;
16
16
  * Shell B does not inherit shell A's exports. Reading a file solves
17
17
  * this without polluting global state.
18
18
  *
19
- * The file is gitignored via `.lt-dev/`. It contains only public URLs +
20
- * the local CA path — no secrets. Format: standard dotenv KEY=VALUE.
19
+ * The file is gitignored via `.lt-dev/` and written 0600, because it is no longer
20
+ * URLs only: `NUXT_SESSION_PASSWORD` is a session-sealing key. Usually that is the
21
+ * value `lt dev` derived itself, but when the project exported its own it is THAT
22
+ * one being copied here — so treat the file as credential-bearing, and think twice
23
+ * before adding a key to it. Format: standard dotenv KEY=VALUE.
21
24
  */
22
25
  const fs_1 = require("fs");
23
26
  const os_1 = require("os");
@@ -84,6 +87,11 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
84
87
  'NUXT_PUBLIC_SITE_URL',
85
88
  'NUXT_PUBLIC_STORAGE_PREFIX',
86
89
  'NUXT_PUBLIC_API_PROXY',
90
+ // Not needed by the runner itself, but external suites check it to tell a stack that
91
+ // can log in from one that will 500 on every login, and a suite that seals its own
92
+ // session cookie to skip the login form needs the value (see dev-env.ts). This is the
93
+ // one credential-shaped key in the bridge — it is why the file is written 0600.
94
+ 'NUXT_SESSION_PASSWORD',
87
95
  'NSC__MONGOOSE__URI',
88
96
  'DATABASE_URL',
89
97
  // Legacy aliases — see dev-env.ts for the rationale.
@@ -107,6 +115,9 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
107
115
  if ((0, fs_1.existsSync)(file) && (0, fs_1.readFileSync)(file, 'utf8') === content)
108
116
  return file;
109
117
  (0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
110
- (0, fs_1.writeFileSync)(file, content, 'utf8');
118
+ (0, fs_1.writeFileSync)(file, content, { encoding: 'utf8', mode: 0o600 });
119
+ // `mode` only applies when the file is CREATED, so an existing bridge from an older
120
+ // lt version would keep its 0644. chmod unconditionally to heal those.
121
+ (0, fs_1.chmodSync)(file, 0o600);
111
122
  return file;
112
123
  }