@lenne.tech/cli 1.47.0 → 1.48.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.
@@ -55,8 +55,17 @@ const NewCommand = {
55
55
  else {
56
56
  info('No local changes to discard.');
57
57
  }
58
- // Show commits that would be lost
59
- const localCommits = yield run(`git log origin/${branch}..HEAD --oneline 2>/dev/null || echo ""`);
58
+ // Show commits that would be lost (none listed when the upstream ref is unknown locally).
59
+ // `2>/dev/null || echo ""` used to swallow the failure, but cmd.exe on Windows has no
60
+ // /dev/null: the redirect failed first and `echo ""` then reported success, so the preview
61
+ // silently claimed there was nothing ahead. try/catch behaves the same on every platform.
62
+ let localCommits = '';
63
+ try {
64
+ localCommits = yield run(`git log origin/${branch}..HEAD --oneline`);
65
+ }
66
+ catch (_d) {
67
+ // ignore - no upstream ref, so nothing ahead to report
68
+ }
60
69
  if (localCommits === null || localCommits === void 0 ? void 0 : localCommits.trim()) {
61
70
  info('');
62
71
  info('Local commits that would be lost:');
@@ -54,7 +54,17 @@ const NewCommand = {
54
54
  warning('DRY-RUN MODE - No changes will be made');
55
55
  info('');
56
56
  // Show commits that would be rebased
57
- const commitsToRebase = yield run(`git log ${baseBranchPreview}..HEAD --oneline 2>/dev/null || echo ""`);
57
+ // try/catch, not `2>/dev/null || echo ""`: on Windows that `echo` succeeds
58
+ // and the git log never runs, so a dry-run would report zero commits to
59
+ // rebase for a branch that has some — a preview that quietly understates
60
+ // what the real command will do.
61
+ let commitsToRebase = '';
62
+ try {
63
+ commitsToRebase = yield run(`git log ${baseBranchPreview}..HEAD --oneline`);
64
+ }
65
+ catch (_k) {
66
+ // Unknown base ref — nothing to preview, which the check below handles.
67
+ }
58
68
  if (commitsToRebase === null || commitsToRebase === void 0 ? void 0 : commitsToRebase.trim()) {
59
69
  const commitCount = commitsToRebase.trim().split('\n').length;
60
70
  info(`Would rebase ${commitCount} commit(s) from branch "${branch}" onto "${baseBranchPreview}":`);
@@ -10,6 +10,24 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  const path_1 = require("path");
13
+ const git_env_1 = require("../../lib/git-env");
14
+ /**
15
+ * Environment that keeps the `git ls-remote` below non-interactive.
16
+ *
17
+ * This used to be a POSIX prefix on the command string
18
+ * (`GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-…}" git …`).
19
+ * `system.run` shells out through `cmd.exe` on Windows, which reads
20
+ * `VAR=value cmd` as a command name, not as an assignment. Paired with the
21
+ * trailing `|| true` the call then "succeeded" with empty output, so every
22
+ * `lt git reset` on Windows aborted with "No remote branch … found!" — the
23
+ * ls-remote had never run. Handing the variables to the child as its
24
+ * environment works on every platform.
25
+ *
26
+ * The `||` keeps the semantics of the shell's `:-` default: a caller who
27
+ * configured ssh deliberately (a user with a custom agent, or a test harness
28
+ * pinning the behaviour) still wins. See `git.ts#gitInstalled` for why the
29
+ * assignment must never be unconditional.
30
+ */
13
31
  /**
14
32
  * Reset current branch
15
33
  */
@@ -46,7 +64,13 @@ const NewCommand = {
46
64
  return;
47
65
  }
48
66
  // Check remote (use short SSH timeout so ls-remote doesn't hang in offline environments)
49
- const remoteBranch = yield system.run(`GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git ls-remote --heads origin ${branch} 2>/dev/null || true`);
67
+ let remoteBranch = '';
68
+ try {
69
+ remoteBranch = yield system.run(`git ls-remote --heads origin ${branch}`, { env: (0, git_env_1.nonInteractiveGitEnv)() });
70
+ }
71
+ catch (_d) {
72
+ // ignore - unreachable remote is reported as "no remote branch" below
73
+ }
50
74
  if (!remoteBranch) {
51
75
  error(`No remote branch ${branch} found!`);
52
76
  return;
@@ -68,8 +92,14 @@ const NewCommand = {
68
92
  else {
69
93
  info('No local changes to discard.');
70
94
  }
71
- // Show commits that would be lost
72
- const localCommits = yield system.run(`git log origin/${branch}..HEAD --oneline 2>/dev/null || echo ""`);
95
+ // Show commits that would be lost (none listed when the upstream ref is unknown locally)
96
+ let localCommits = '';
97
+ try {
98
+ localCommits = yield system.run(`git log origin/${branch}..HEAD --oneline`);
99
+ }
100
+ catch (_e) {
101
+ // ignore - no upstream ref, so nothing ahead to report
102
+ }
73
103
  if (localCommits === null || localCommits === void 0 ? void 0 : localCommits.trim()) {
74
104
  info('');
75
105
  info('Local commits that would be lost:');
@@ -109,7 +139,9 @@ const NewCommand = {
109
139
  const projectDir = (0, path_1.dirname)(pkgPath);
110
140
  const detectedPm = toolbox.pm.detect(projectDir);
111
141
  const installSpin = spin(`Install packages using ${detectedPm}`);
112
- yield system.run(`cd ${projectDir} && ${toolbox.pm.install(detectedPm)}`);
142
+ // `cwd` instead of `cd <dir> &&`: cmd.exe's `cd` does not switch drives,
143
+ // and it needs no quoting for paths with spaces.
144
+ yield system.run(toolbox.pm.install(detectedPm), { cwd: projectDir });
113
145
  installSpin.succeed();
114
146
  }
115
147
  // Success info
@@ -10,6 +10,22 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  const path_1 = require("path");
13
+ const git_env_1 = require("../../lib/git-env");
14
+ /**
15
+ * Environment that keeps the `git` calls below non-interactive.
16
+ *
17
+ * This used to be a POSIX prefix on the command string
18
+ * (`GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-…}" git …`).
19
+ * `system.run` shells out through `cmd.exe` on Windows, which does not read
20
+ * `VAR=value cmd` as an assignment but as a command name — so every `lt git
21
+ * update` failed there with "'GIT_TERMINAL_PROMPT' is not recognized". Handing
22
+ * the variables to the child as its environment works on every platform.
23
+ *
24
+ * The `||` keeps the semantics of the shell's `:-` default: a caller who
25
+ * configured ssh deliberately (a user with a custom agent, or a test harness
26
+ * pinning the behaviour) still wins. See `git.ts#gitInstalled` for why the
27
+ * assignment must never be unconditional.
28
+ */
13
29
  /**
14
30
  * Update branch
15
31
  */
@@ -47,10 +63,22 @@ const NewCommand = {
47
63
  info('');
48
64
  info(`Current branch: ${branch}`);
49
65
  info('');
50
- // Fetch to see incoming changes (use short SSH timeout so it doesn't hang offline)
51
- yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true');
52
- // Check for incoming commits
53
- const incomingCommits = yield run(`git log ${branch}..origin/${branch} --oneline 2>/dev/null || echo ""`);
66
+ // Fetch to see incoming changes (use short SSH timeout so it doesn't hang offline).
67
+ // Best effort: offline or without credentials the preview falls back to the local refs.
68
+ try {
69
+ yield run('git fetch', { env: (0, git_env_1.nonInteractiveGitEnv)() });
70
+ }
71
+ catch (_d) {
72
+ // ignore - a failed fetch only makes the preview less current
73
+ }
74
+ // Check for incoming commits (none yet when the upstream ref is unknown locally)
75
+ let incomingCommits = '';
76
+ try {
77
+ incomingCommits = yield run(`git log ${branch}..origin/${branch} --oneline`);
78
+ }
79
+ catch (_e) {
80
+ // ignore - no upstream ref, so nothing incoming to report
81
+ }
54
82
  const commits = (incomingCommits === null || incomingCommits === void 0 ? void 0 : incomingCommits.trim().split('\n').filter((c) => c)) || [];
55
83
  if (commits.length > 0) {
56
84
  info(`Incoming commits (${commits.length}):`);
@@ -78,7 +106,13 @@ const NewCommand = {
78
106
  const timer = startTimer();
79
107
  // Update
80
108
  const updateSpin = spin(`Update branch ${branch}`);
81
- yield run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true && GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git pull --rebase');
109
+ try {
110
+ yield run('git fetch', { env: (0, git_env_1.nonInteractiveGitEnv)() });
111
+ }
112
+ catch (_f) {
113
+ // ignore - `git pull --rebase` below fetches again and reports the real failure
114
+ }
115
+ yield run('git pull --rebase', { env: (0, git_env_1.nonInteractiveGitEnv)() });
82
116
  updateSpin.succeed();
83
117
  // Install packages (unless skipped) with correctly detected package manager (supports monorepo lockfiles)
84
118
  if (!skipInstall) {
@@ -87,7 +121,9 @@ const NewCommand = {
87
121
  const projectDir = (0, path_1.dirname)(pkgPath);
88
122
  const detectedPm = toolbox.pm.detect(projectDir);
89
123
  const installSpin = spin(`Install packages using ${detectedPm}`);
90
- yield run(`cd ${projectDir} && ${toolbox.pm.install(detectedPm)}`);
124
+ // `cwd` instead of `cd <dir> &&`: cmd.exe's `cd` does not switch drives,
125
+ // and it needs no quoting for paths with spaces.
126
+ yield run(toolbox.pm.install(detectedPm), { cwd: projectDir });
91
127
  installSpin.succeed();
92
128
  }
93
129
  }
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ApiMode = void 0;
13
+ const glob_1 = require("glob");
13
14
  const path_1 = require("path");
14
15
  /**
15
16
  * API Mode processing for nest-server-starter template
@@ -114,10 +115,7 @@ class ApiMode {
114
115
  // 1. Delete files matching filePatterns
115
116
  if (modeConfig.filePatterns) {
116
117
  for (const pattern of modeConfig.filePatterns) {
117
- const matches = this.filesystem.find(projectPath, {
118
- matching: pattern,
119
- });
120
- for (const file of matches) {
118
+ for (const file of this.globFiles(projectPath, pattern)) {
121
119
  this.filesystem.remove(file);
122
120
  }
123
121
  }
@@ -169,6 +167,29 @@ class ApiMode {
169
167
  this.cleanOrphanImports(projectPath);
170
168
  });
171
169
  }
170
+ /**
171
+ * Resolve a POSIX-style glob pattern against `baseDir` and return the
172
+ * matching files as absolute, OS-native paths.
173
+ *
174
+ * Deliberately NOT `filesystem.find()`: gluegun/fs-jetpack builds its
175
+ * matcher by string-concatenating the *resolved* base path in front of
176
+ * the pattern (fs-jetpack/lib/utils/matcher.js). On Windows that base
177
+ * path is backslash-separated, and minimatch reads a backslash inside a
178
+ * pattern as an escape character — so a base of `C:\project\src`
179
+ * collapses to the literal `C:projectsrc` and nothing ever matches.
180
+ * Effect: on Windows no file was deleted and no region was stripped,
181
+ * silently. `glob` keeps pattern and base directory apart and
182
+ * normalises separators itself, so it behaves the same on every
183
+ * platform.
184
+ */
185
+ globFiles(baseDir, pattern) {
186
+ return (0, glob_1.globSync)(pattern, {
187
+ absolute: true,
188
+ cwd: baseDir,
189
+ dot: true,
190
+ nodir: true,
191
+ });
192
+ }
172
193
  /**
173
194
  * Strip region markers from all .ts files
174
195
  *
@@ -181,7 +202,7 @@ class ApiMode {
181
202
  if (!this.filesystem.exists(dir)) {
182
203
  continue;
183
204
  }
184
- const files = this.filesystem.find(dir, { matching: '**/*.ts' });
205
+ const files = this.globFiles(dir, '**/*.ts');
185
206
  for (const file of files) {
186
207
  const content = this.filesystem.read(file);
187
208
  if (!content) {
@@ -196,7 +217,10 @@ class ApiMode {
196
217
  // graphql` block with an explicit `graphQl: false,` so GraphQL
197
218
  // is cleanly disabled.
198
219
  let processed;
199
- if (removeMarker === 'graphql' && file.endsWith('/config.env.ts')) {
220
+ // `basename` instead of `endsWith('/config.env.ts')`: on Windows the
221
+ // path separator is a backslash, so the POSIX-only suffix check never
222
+ // matched and config.env.ts was processed like any other file.
223
+ if (removeMarker === 'graphql' && (0, path_1.basename)(file) === 'config.env.ts') {
200
224
  processed = this.replaceGraphqlRegionsWithDisabled(content, keepMarker);
201
225
  }
202
226
  else {
@@ -299,7 +323,7 @@ class ApiMode {
299
323
  if (!this.filesystem.exists(dir)) {
300
324
  continue;
301
325
  }
302
- const files = this.filesystem.find(dir, { matching: '**/*.ts' });
326
+ const files = this.globFiles(dir, '**/*.ts');
303
327
  for (const file of files) {
304
328
  const content = this.filesystem.read(file);
305
329
  if (!content) {
@@ -444,7 +468,7 @@ class ApiMode {
444
468
  if (!this.filesystem.exists(dir)) {
445
469
  continue;
446
470
  }
447
- const files = this.filesystem.find(dir, { matching: '**/*.ts' });
471
+ const files = this.globFiles(dir, '**/*.ts');
448
472
  for (const file of files) {
449
473
  const content = this.filesystem.read(file);
450
474
  if (!content) {
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.Git = void 0;
13
+ const git_env_1 = require("../lib/git-env");
13
14
  /**
14
15
  * Git functions
15
16
  */
@@ -119,8 +120,17 @@ class Git {
119
120
  const result = [];
120
121
  // Toolbox features
121
122
  const { system } = this.toolbox;
122
- // Get branches (use short SSH timeout so fetch doesn't hang in offline environments)
123
- const branches = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null; git show-branch --list');
123
+ // Refresh first, but never let a failed fetch cost us the branch list: the
124
+ // two commands used to be chained with `;`, which cmd.exe does not honour as
125
+ // a separator at all, so on Windows this ran neither reliably. They are two
126
+ // calls now — the fetch is best-effort, the listing is the actual result.
127
+ try {
128
+ yield system.run('git fetch', { env: (0, git_env_1.nonInteractiveGitEnv)() });
129
+ }
130
+ catch (_a) {
131
+ // Offline or no remote — `git show-branch` still lists what is local.
132
+ }
133
+ const branches = yield system.run('git show-branch --list');
124
134
  branches.split('\n').forEach((item) => {
125
135
  const matches = item.match(/\[(.*?)]/);
126
136
  if (matches) {
@@ -197,27 +207,6 @@ class Git {
197
207
  /**
198
208
  * Check if git is installed (cached for performance)
199
209
  */
200
- /**
201
- * Why the git calls in this repo spell `GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-…}"` (shell default)
202
- * rather than assigning it outright.
203
- *
204
- * These commands do a best-effort `git fetch` to see whether the branch is
205
- * behind. They set `BatchMode=yes` so ssh fails instead of PROMPTING — but an
206
- * agent that stalls is not a prompt. On a 1Password-backed machine the agent is
207
- * reachable and every signature needs an interactive approval; unattended it
208
- * waits and then reports `communication with agent failed`. Measured: **61 s per
209
- * fetch**, so `lt git update --dry-run` took 62 s and `lt git create --dry-run`
210
- * 123 s (two fetches). `ConnectTimeout` does not bound it — that covers the TCP
211
- * connect, not the agent.
212
- *
213
- * Waiting for a human to approve a key is legitimate for an interactive command,
214
- * so the default is unchanged. What was wrong is that the assignment was
215
- * UNCONDITIONAL: it overrode a caller who had deliberately configured ssh,
216
- * including a test harness trying to make the behaviour deterministic. The
217
- * `:-` default respects an existing value and keeps the old behaviour when there
218
- * is none. `IdentityAgent=none` in the caller's env then drops the same fetch to
219
- * ~1 s with a clean `Permission denied (publickey)`.
220
- */
221
210
  gitInstalled() {
222
211
  return __awaiter(this, void 0, void 0, function* () {
223
212
  // Return cached result if available
@@ -272,8 +261,17 @@ class Git {
272
261
  if (opts.spin) {
273
262
  searchSpin = spin(opts.spinText);
274
263
  }
275
- // Update infos (use short SSH timeout so fetch doesn't hang in offline environments)
276
- const fetch = yield system.run('GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o ConnectTimeout=5 -o BatchMode=yes}" git fetch 2>/dev/null || true');
264
+ // Update infos. Best-effort, but expressed as try/catch rather than
265
+ // `2>/dev/null || true`: on Windows `true` resolves to Git-for-Windows'
266
+ // `true.exe`, so the pipeline succeeded with empty output and the fetch
267
+ // never ran — a silent no-op that read as a successful refresh.
268
+ let fetch = '';
269
+ try {
270
+ fetch = yield system.run('git fetch', { env: (0, git_env_1.nonInteractiveGitEnv)() });
271
+ }
272
+ catch (_b) {
273
+ // Offline or no remote — the branch search below still works on local refs.
274
+ }
277
275
  if (fetch.length && !fetch.startsWith('remote')) {
278
276
  info(`Could not update infos ${fetch.length}`);
279
277
  }
@@ -44,6 +44,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.Server = void 0;
46
46
  const crypto = __importStar(require("crypto"));
47
+ const glob_1 = require("glob");
47
48
  const path_1 = require("path");
48
49
  const ts = __importStar(require("typescript"));
49
50
  const adopt_upstream_build_allowlist_1 = require("../lib/adopt-upstream-build-allowlist");
@@ -1232,13 +1233,15 @@ class Server {
1232
1233
  // only as type annotations — any runtime `new Request(...)` calls refer
1233
1234
  // to the global Fetch API Request, not the express one.
1234
1235
  const expressImportRegex = /^import\s+\{([^}]*)\}\s+from\s+['"]express['"]\s*;?\s*$/gm;
1235
- const vendoredTsFiles = filesystem.find(coreDir, {
1236
- matching: '**/*.ts',
1237
- recursive: true,
1238
- }) || [];
1239
- for (const filePath of vendoredTsFiles) {
1240
- // filesystem.find returns paths relative to jetpack cwd — use absolute resolution
1241
- const absPath = filePath.startsWith('/') ? filePath : require('node:path').resolve(filePath);
1236
+ // `glob`, not `filesystem.find`: jetpack builds its matcher by concatenating the
1237
+ // RESOLVED base path in front of the pattern, and minimatch reads a backslash
1238
+ // inside a PATTERN as an escape. On Windows `C:\…\core` + `**/*.ts` therefore
1239
+ // collapses to a literal that matches nothing, so this loop silently patched
1240
+ // no files at all. (minimatch 3 normalised the pattern's separators; the repo
1241
+ // now resolves the hoisted minimatch 10, which does not — see package.json.)
1242
+ // `glob` keeps base and pattern apart and returns absolute OS-native paths.
1243
+ const vendoredTsFiles = (0, glob_1.globSync)('**/*.ts', { absolute: true, cwd: coreDir, dot: true, nodir: true });
1244
+ for (const absPath of vendoredTsFiles) {
1242
1245
  if (!filesystem.exists(absPath))
1243
1246
  continue;
1244
1247
  const content = filesystem.read(absPath) || '';
@@ -2516,20 +2519,24 @@ class Server {
2516
2519
  * @param pattern Optional regex for more flexible matching
2517
2520
  */
2518
2521
  findStaleImports(dest, needle, pattern) {
2522
+ // Patterns are relative to `dest` and passed to `glob` rather than
2523
+ // `filesystem.find`: jetpack concatenates the resolved base path in front of
2524
+ // the pattern, and minimatch treats a backslash inside a PATTERN as an escape,
2525
+ // so on Windows every slash-bearing pattern below matched nothing. This
2526
+ // function would then report "no stale imports" for a project that has them —
2527
+ // a false clean bill of health on the one check meant to catch a broken
2528
+ // conversion. See the same fix at the vendored-express patch above.
2519
2529
  const globs = [
2520
- `${dest}/src/server/**/*.ts`,
2521
- `${dest}/src/main.ts`,
2522
- `${dest}/src/config.env.ts`,
2523
- `${dest}/tests/**/*.ts`,
2524
- `${dest}/migrations/**/*.ts`,
2525
- `${dest}/scripts/**/*.ts`,
2530
+ 'src/server/**/*.ts',
2531
+ 'src/main.ts',
2532
+ 'src/config.env.ts',
2533
+ 'tests/**/*.ts',
2534
+ 'migrations/**/*.ts',
2535
+ 'scripts/**/*.ts',
2526
2536
  ];
2527
2537
  const stale = [];
2528
2538
  for (const glob of globs) {
2529
- const files = this.filesystem.find(dest, {
2530
- matching: glob.replace(`${dest}/`, ''),
2531
- recursive: true,
2532
- }) || [];
2539
+ const files = (0, glob_1.globSync)(glob, { absolute: true, cwd: dest, dot: true, nodir: true });
2533
2540
  for (const file of files) {
2534
2541
  const raw = this.filesystem.read(file) || '';
2535
2542
  // Strip comments before matching. The keyword-anchored pattern is not enough on its own:
@@ -11,6 +11,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.findCompiledEntry = findCompiledEntry;
13
13
  exports.isApiCompiledRequested = isApiCompiledRequested;
14
+ exports.resolveApiRuntime = resolveApiRuntime;
14
15
  exports.startCompiledApi = startCompiledApi;
15
16
  /**
16
17
  * Launch strategy for the API process under `lt dev up`.
@@ -47,6 +48,50 @@ function isApiCompiledRequested(options = {}) {
47
48
  const affirmative = (value) => value === true || ['1', 'true', 'yes'].includes(String(value).toLowerCase());
48
49
  return affirmative(options.apiCompiled) || affirmative(options['api-compiled']);
49
50
  }
51
+ /**
52
+ * Which runtime to start the API's COMPILED bundle with.
53
+ *
54
+ * A bundle is only portable across runtimes by accident. `nest-base` builds with
55
+ * `Bun.build({ target: 'bun' })`, whose output calls `__require` — a Bun-only
56
+ * shim. Started under node it dies in milliseconds with "__require is not a
57
+ * function" (DEV-3208), and because the caller used to only WARN when the API
58
+ * never answered, the whole Playwright suite then ran against a dead API: every
59
+ * data-dependent spec passed trivially or skipped, so a run that checked nothing
60
+ * looked exactly like one that checked everything.
61
+ *
62
+ * Detected from the project rather than configured, because the project already
63
+ * states it three times over. Any one signal is enough:
64
+ * - a `bun.lock` / `bun.lockb` IN THIS DIRECTORY (not a nested package's),
65
+ * - `engines.bun` in its package.json,
66
+ * - a `build` / `start` script that invokes `bun` as the COMMAND.
67
+ *
68
+ * The script check matches `bun` as a whole word at a command position, never as
69
+ * a substring: `rollup --bundle` and `node -r bunyan …` are Node projects, and a
70
+ * loose `includes('bun')` would switch them to a runtime they never installed.
71
+ *
72
+ * Defaults to `node` whenever nothing says otherwise, including an unreadable or
73
+ * missing package.json — the historical behaviour, and the safe one: guessing
74
+ * `bun` for a project that does not have it installed turns a working start into
75
+ * an exit-127.
76
+ *
77
+ * This deliberately does NOT live in `dev-package-manager`: that module resolves
78
+ * which PACKAGE MANAGER drives a directory (`pnpm run build`), a separate axis
79
+ * from which runtime executes an already-built bundle. A Bun project whose
80
+ * scripts pnpm happens to run still needs `bun dist/main.js`.
81
+ */
82
+ function resolveApiRuntime(apiDir) {
83
+ var _a, _b, _c;
84
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(apiDir, 'bun.lock')) || (0, node_fs_1.existsSync)((0, node_path_1.join)(apiDir, 'bun.lockb')))
85
+ return 'bun';
86
+ const pkg = readPackageJson(apiDir);
87
+ if (!pkg)
88
+ return 'node';
89
+ if ((_a = pkg.engines) === null || _a === void 0 ? void 0 : _a.bun)
90
+ return 'bun';
91
+ // `bun` as the command itself, or after a shell separator (`&&`, `;`, `|`).
92
+ const invokesBun = /(?:^|[&|;]\s*)bun\b/;
93
+ return [(_b = pkg.scripts) === null || _b === void 0 ? void 0 : _b.build, (_c = pkg.scripts) === null || _c === void 0 ? void 0 : _c.start].some((script) => script && invokesBun.test(script)) ? 'bun' : 'node';
94
+ }
50
95
  /**
51
96
  * Build the API and start it compiled (`node dist/src/main.js`). Applies pending
52
97
  * migrations first for parity with the ts-node path it replaces (`<pm> run start`
@@ -70,7 +115,11 @@ function startCompiledApi(options) {
70
115
  return undefined;
71
116
  }
72
117
  }
73
- return (0, dev_process_1.spawnDetached)('node', [entry], { cwd: apiDir, env: Object.assign(Object.assign({}, env), { NODE_ENV: 'local' }), logFile });
118
+ return (0, dev_process_1.spawnDetached)(resolveApiRuntime(apiDir), [entry], {
119
+ cwd: apiDir,
120
+ env: Object.assign(Object.assign({}, env), { NODE_ENV: 'local' }),
121
+ logFile,
122
+ });
74
123
  }
75
124
  log.warn(`compiled API unavailable (build exit ${String(build)}) — falling back to \`${pm.bin} start\` (ts-node).`);
76
125
  return (0, dev_process_1.spawnDetached)(pm.bin, pm.runScript('start'), { cwd: apiDir, env, logFile });
@@ -78,12 +127,15 @@ function startCompiledApi(options) {
78
127
  }
79
128
  /** True when `package.json` in `apiDir` defines a script named `name`. */
80
129
  function hasScript(apiDir, name) {
81
- var _a;
130
+ var _a, _b;
131
+ return typeof ((_b = (_a = readPackageJson(apiDir)) === null || _a === void 0 ? void 0 : _a.scripts) === null || _b === void 0 ? void 0 : _b[name]) === 'string';
132
+ }
133
+ /** The parsed `package.json` in `apiDir`, or `undefined` when it is missing or unparsable. */
134
+ function readPackageJson(apiDir) {
82
135
  try {
83
- const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(apiDir, 'package.json'), 'utf8'));
84
- return typeof ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a[name]) === 'string';
136
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(apiDir, 'package.json'), 'utf8'));
85
137
  }
86
- catch (_b) {
87
- return false;
138
+ catch (_a) {
139
+ return undefined;
88
140
  }
89
141
  }
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.checkPortInUse = checkPortInUse;
13
+ exports.detachedSpawnCommand = detachedSpawnCommand;
13
14
  exports.killProcessGroup = killProcessGroup;
14
15
  exports.listenSnapshot = listenSnapshot;
15
16
  exports.rotateLogFile = rotateLogFile;
@@ -59,6 +60,40 @@ function checkPortInUse(port) {
59
60
  });
60
61
  });
61
62
  }
63
+ /**
64
+ * How a detached child is actually launched on this platform.
65
+ *
66
+ * POSIX raises the soft file-descriptor limit before exec-ing the real command.
67
+ * macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
68
+ * by the terminal that runs `lt dev up` and therefore by these detached children
69
+ * — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
70
+ * The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
71
+ * monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
72
+ * that force a manual `lt dev up`. Hence `sh -c "ulimit …; exec …"`:
73
+ * - `exec` replaces the shell IN PLACE → the recorded PID and the detached
74
+ * process group are still the real process (PID tracking + group-kill in
75
+ * `terminateProcessGroup` keep working).
76
+ * - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
77
+ * - the cascade tries a high limit first, falling back on machines with a
78
+ * lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
79
+ *
80
+ * Windows gets the command directly. There is no `/bin/sh` to run the wrapper —
81
+ * spawning it fails outright — and no RLIMIT_NOFILE for `ulimit` to raise, so
82
+ * the wrapper has nothing to offer there even in principle.
83
+ *
84
+ * Note for the POSIX path: because `spawn('/bin/sh', …)` almost always succeeds,
85
+ * a bogus `cmd` does not surface as `pid === undefined` — the inner `exec` fails
86
+ * (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
87
+ * `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
88
+ * deliberately don't watch for that here: a detached, unref'd child's 127 exit is
89
+ * racy to observe, and callers must be self-correcting against real crashes anyway.
90
+ */
91
+ function detachedSpawnCommand(cmd, args, platform = process.platform) {
92
+ if (platform === 'win32')
93
+ return { args, command: cmd };
94
+ const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
95
+ return { args: ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], command: '/bin/sh' };
96
+ }
62
97
  /** Send SIGTERM to a detached process group; falls back to single-PID kill. */
63
98
  function killProcessGroup(pid) {
64
99
  if (!(0, dev_state_1.isValidPid)(pid))
@@ -215,6 +250,8 @@ function runChildToFile(cmd, args, opts) {
215
250
  * so closing prevents fd leaks and avoids racing-write artifacts on
216
251
  * filesystems where O_APPEND is not atomic.
217
252
  *
253
+ * See `detachedSpawnCommand` for how the child is launched per platform.
254
+ *
218
255
  * Returns the child PID, or undefined if spawn failed.
219
256
  */
220
257
  function spawnDetached(cmd, args, opts) {
@@ -223,32 +260,22 @@ function spawnDetached(cmd, args, opts) {
223
260
  const out = (0, fs_1.openSync)(opts.logFile, 'a');
224
261
  let child;
225
262
  try {
226
- // Raise the soft file-descriptor limit before exec-ing the real command.
227
- // macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
228
- // by the terminal that runs `lt dev up` and therefore by these detached children
229
- // — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
230
- // The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
231
- // monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
232
- // that force a manual `lt dev up`. We wrap the command in `sh -c "ulimit …; exec …"`:
233
- // - `exec` replaces the shell IN PLACE → the recorded PID and the detached
234
- // process group are still the real process (PID tracking + group-kill in
235
- // `terminateProcessGroup` keep working).
236
- // - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
237
- // - the cascade tries a high limit first, falling back on machines with a
238
- // lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
239
- // Note: because the outer `spawn('/bin/sh', …)` almost always succeeds, a bogus
240
- // `cmd` no longer surfaces as `pid === undefined` here — the inner `exec` fails
241
- // (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
242
- // `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
243
- // deliberately don't watch for that here: a detached, unref'd child's 127 exit is
244
- // racy to observe, and callers must be self-correcting against real crashes anyway.
245
- const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
246
- child = (0, child_process_1.spawn)('/bin/sh', ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], {
263
+ const spawned = detachedSpawnCommand(cmd, args);
264
+ child = (0, child_process_1.spawn)(spawned.command, spawned.args, {
247
265
  cwd: opts.cwd,
248
266
  detached: true,
249
267
  env: opts.env,
250
268
  stdio: ['ignore', out, out],
251
269
  });
270
+ // spawn reports "could not start this at all" (missing executable, bad cwd)
271
+ // through an ASYNCHRONOUS 'error' event — the try/catch around spawn() never
272
+ // sees it. With no listener, Node escalates an unhandled 'error' to a process
273
+ // crash, so one unstartable child would take the whole CLI down with it. That
274
+ // is not hypothetical: on Windows `/bin/sh` does not exist, and this line is
275
+ // what turned that into "Test suite failed to run" instead of one red test.
276
+ // Nothing to do but swallow it — the child is detached and its log file is
277
+ // already closed below; callers are self-correcting against a dead PID.
278
+ child.on('error', () => undefined);
252
279
  child.unref();
253
280
  if (child.pid === undefined)
254
281
  return undefined;
@@ -330,7 +357,15 @@ function terminateProcessGroup(pid_1) {
330
357
  *
331
358
  * Resolves `true` on the first matching response, `false` on timeout. Never rejects.
332
359
  */
333
- function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) {
360
+ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600,
361
+ /**
362
+ * Optional early exit: return `true` once there is nothing left to wait FOR —
363
+ * typically "the process I am waiting on has exited". Without it a server that
364
+ * crashes on boot is indistinguishable from one that is still starting, and the
365
+ * caller burns the entire timeout (120s for the `lt dev test` API) before
366
+ * learning something that was decided in the first 300ms.
367
+ */
368
+ abort = () => false) {
334
369
  const start = Date.now();
335
370
  return new Promise((resolve) => {
336
371
  const tick = () => {
@@ -340,20 +375,21 @@ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status
340
375
  });
341
376
  let status = '';
342
377
  (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
378
+ const retry = () => {
379
+ // Order matters: a LAST probe already ran above, so a server that came up
380
+ // just before dying is still reported ready. Only then does `abort` end it.
381
+ if (Date.now() - start > timeoutMs || abort())
382
+ return resolve(false);
383
+ setTimeout(tick, 500);
384
+ };
343
385
  child.on('close', () => {
344
386
  const code = Number(status.trim());
345
387
  // `000` (curl could not connect) parses to 0 → never "ready".
346
388
  if (Number.isFinite(code) && code > 0 && ready(code))
347
389
  return resolve(true);
348
- if (Date.now() - start > timeoutMs)
349
- return resolve(false);
350
- setTimeout(tick, 500);
351
- });
352
- child.on('error', () => {
353
- if (Date.now() - start > timeoutMs)
354
- return resolve(false);
355
- setTimeout(tick, 500);
390
+ retry();
356
391
  });
392
+ child.on('error', retry);
357
393
  };
358
394
  tick();
359
395
  });
@@ -204,10 +204,21 @@ function loadSession(root, sessionFile = SESSION_FILE) {
204
204
  }
205
205
  return null;
206
206
  }
207
- /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
207
+ /**
208
+ * True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var).
209
+ *
210
+ * `.native` rather than plain `realpathSync`, because on Windows the JS
211
+ * implementation follows symlinks but keeps 8.3 short components: a path spelled
212
+ * `C:\Users\RUNNER~1\…` and the same directory spelled `C:\Users\runneradmin\…`
213
+ * stay different strings. Both spellings occur in practice — `os.tmpdir()` hands
214
+ * out the short form while git prints the long one — so a registry entry written
215
+ * under one and a cwd under the other would read as a DIFFERENT checkout. The
216
+ * native variant asks the OS for the final name and collapses both.
217
+ * No behavioural difference on POSIX.
218
+ */
208
219
  function sameRealPath(a, b) {
209
220
  try {
210
- return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
221
+ return fs_1.realpathSync.native(a) === fs_1.realpathSync.native(b);
211
222
  }
212
223
  catch (_a) {
213
224
  return a === b;
@@ -16,12 +16,14 @@ exports.bringUpTestSession = bringUpTestSession;
16
16
  exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
17
  exports.buildTestAppEnv = buildTestAppEnv;
18
18
  exports.hasTestSession = hasTestSession;
19
+ exports.isStackServing = isStackServing;
19
20
  exports.resolveTestSession = resolveTestSession;
20
21
  exports.runShardedTestSession = runShardedTestSession;
21
22
  exports.shardReportDir = shardReportDir;
22
23
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
23
24
  exports.tearDownTestSession = tearDownTestSession;
24
25
  exports.testAppEntryCandidates = testAppEntryCandidates;
26
+ exports.unreachableStackError = unreachableStackError;
25
27
  /**
26
28
  * Ephemeral, isolated test session for `lt dev test`.
27
29
  *
@@ -35,12 +37,16 @@ exports.testAppEntryCandidates = testAppEntryCandidates;
35
37
  * - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
36
38
  * - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
37
39
  *
38
- * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
39
- * ts-node intermittently dies mid-run) and the App as the production Nitro output
40
- * (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
41
- * falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
42
- * found. bringUp waits for a real 2xx on the API `/meta` before returning so the
43
- * suite never starts against a not-yet-serving API.
40
+ * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (from `dist`,
41
+ * because ts-node intermittently dies mid-run) and the App as the production Nitro
42
+ * output (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile).
43
+ * Each falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build
44
+ * output is found. The API's runtime follows the project (`resolveApiRuntime`):
45
+ * a Bun-built bundle is started with `bun`, everything else with `node`.
46
+ *
47
+ * bringUp waits for BOTH halves to answer and THROWS when one never does, so the
48
+ * suite can never run against a dead stack (DEV-3208) — see `isStackServing` for
49
+ * what counts as answering.
44
50
  *
45
51
  * Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
46
52
  * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
@@ -308,7 +314,10 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
308
314
  const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
309
315
  let apiSpawn;
310
316
  if (build === 0 && entry) {
311
- apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
317
+ // Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
318
+ // bundles with `Bun.build({ target: 'bun' })` and dies under node with
319
+ // "__require is not a function" (DEV-3208).
320
+ apiSpawn = (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(layout.apiDir), [entry], {
312
321
  cwd: layout.apiDir,
313
322
  env: apiEnv,
314
323
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
@@ -387,26 +396,43 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
387
396
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
388
397
  // ENV bridge for external tooling (kept separate from the dev `.env`).
389
398
  (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
390
- // Wait for the test App to answer (best-effort).
399
+ // THIS stack's log paths. Resolved BEFORE the readiness waits below, because
400
+ // the abort messages point at them: the log is the only place a crash-on-boot
401
+ // is recorded, and an abort that withholds it just relocates the guesswork.
402
+ // `apiLogPath` is also handed to the caller so the auth E2E specs (via
403
+ // NEST_SERVER_LOG) read the exact isolated log — correct per shard.
404
+ const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
405
+ const appLogPath = layout.appDir ? (0, path_1.join)(layout.root, '.lt-dev', names.appLog) : undefined;
406
+ // Both waits below ABORT rather than warn (DEV-3208). A warning let Playwright
407
+ // run the whole suite against a dead stack: every data-dependent spec passed
408
+ // trivially or skipped, so a run that verified NOTHING was indistinguishable
409
+ // from one that verified everything — worse than a red run, because nobody
410
+ // goes looking. `isStackServing` decides what counts as up, and the liveness
411
+ // guard turns a crash-on-boot into an abort in the same 300ms it took to
412
+ // happen, instead of after the full timeout.
413
+ //
414
+ // `gone` is deliberately true for an ABSENT pid too: reaching here means the
415
+ // spawn was attempted, so no pid means it failed outright and there is nothing
416
+ // left to wait for. Being wrong here costs nothing — the timeout still bounds
417
+ // the wait, so this only ever makes the abort arrive sooner.
418
+ const gone = (pid) => pid === undefined || !(0, dev_state_1.isPidAlive)(pid);
419
+ // Wait for the test App to answer.
391
420
  if (appUrl) {
392
421
  log.info(log.dim(`Waiting for ${appUrl} …`));
393
- yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
422
+ const appReady = yield (0, dev_process_1.waitForHttp)(appUrl, 90000, isStackServing, () => gone(pids.app));
423
+ if (!appReady)
424
+ throw unreachableStackError('App', appUrl, appLogPath);
394
425
  }
395
- // Wait for the test API to actually SERVE (real 2xx on /meta) before handing
396
- // off to Playwright. Previously bringUp only waited for the App, so a compiled
397
- // API still connecting to Mongo made the first specs skip via the suite's
398
- // `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
399
- // required: Caddy answers 502 while its upstream is still booting, which the
400
- // default (lenient) predicate would accept as "up".
426
+ // Wait for the test API to actually SERVE before handing off to Playwright.
427
+ // Previously bringUp only waited for the App, so a compiled API still
428
+ // connecting to its database made the first specs skip via the suite's
429
+ // `ensureApiReachableOrSkip` guard (the API-readiness race).
401
430
  if (apiUrl) {
402
431
  log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
403
- const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
432
+ const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
404
433
  if (!apiReady)
405
- log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
434
+ throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
406
435
  }
407
- // Expose THIS stack's API log path so the caller can point the auth E2E specs
408
- // (via NEST_SERVER_LOG) at the exact isolated log — correct per shard.
409
- const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
410
436
  return { apiLogPath, apiUrl, appEnv, appUrl, dbName, pids, testIdentity };
411
437
  });
412
438
  }
@@ -467,6 +493,28 @@ function buildTestAppEnv(appEnv) {
467
493
  function hasTestSession(root) {
468
494
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
469
495
  }
496
+ /**
497
+ * Whether an HTTP status proves the component ITSELF answered.
498
+ *
499
+ * Readiness here means liveness, not health: the question is "did my process get
500
+ * far enough to serve HTTP", not "did it like this particular request". So a 404
501
+ * and a 500 both count as ready — they came FROM the upstream — while Caddy's
502
+ * gateway errors do not, because Caddy returns those for the entire window in
503
+ * which its upstream is not yet listening.
504
+ *
505
+ * The 404 case is the load-bearing one. The API probe targets `/meta`, an
506
+ * endpoint `@lenne.tech/nest-server` provides and `nest-base` does not: a healthy
507
+ * nest-base API answers 404 there (verified against a live one). The previous
508
+ * strict-2xx predicate was harmless while a failed wait only logged a warning,
509
+ * but this module now ABORTS on it — under strict 2xx that would block
510
+ * `lt dev test` for every nest-base project on a perfectly healthy API, which is
511
+ * worse than the false-green it replaces. Probing a list of per-framework paths
512
+ * would work too, but it needs updating for every new template; "something other
513
+ * than the proxy answered" needs no such list.
514
+ */
515
+ function isStackServing(status) {
516
+ return status > 0 && ![502, 503, 504].includes(status);
517
+ }
470
518
  /** Build the dedicated test identity + test DB name for a project. */
471
519
  function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
472
520
  const names = testStackNames(shardIndex);
@@ -661,6 +709,23 @@ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
661
709
  function testAppEntryCandidates() {
662
710
  return [`${exports.TEST_NITRO_OUTPUT_DIR}/server/index.mjs`, '.output/server/index.mjs'];
663
711
  }
712
+ /**
713
+ * The abort thrown when a test-stack component never becomes reachable.
714
+ *
715
+ * Phrased as an abort, not a diagnosis, because the cause lives in the component's
716
+ * own log and nowhere else — the process is detached, so its stderr never reaches
717
+ * this terminal. Naming the log file is therefore the whole value of the message:
718
+ * an abort that only says "did not come up" moves the guesswork one step later
719
+ * instead of ending it.
720
+ *
721
+ * `lt dev test` catches this, reports it, tears the stack down and exits non-zero,
722
+ * so Playwright never starts (DEV-3208).
723
+ */
724
+ function unreachableStackError(component, url, logFile) {
725
+ const where = logFile ? ` Why it never came up: ${logFile}` : '';
726
+ return new Error(`Test ${component} never became reachable at ${url} — aborting before Playwright runs, ` +
727
+ `because a suite run against a dead ${component} passes trivially and proves nothing.${where}`);
728
+ }
664
729
  /**
665
730
  * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
666
731
  * given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nonInteractiveGitEnv = nonInteractiveGitEnv;
4
+ /**
5
+ * The environment git subprocesses run with so they never block on a prompt.
6
+ *
7
+ * Two variables, one rule each:
8
+ *
9
+ * `GIT_TERMINAL_PROMPT=0` stops git asking for credentials on a dead terminal.
10
+ * A prompt in a detached or CI child is not a question anyone can answer — it is
11
+ * a hang that looks like a slow network.
12
+ *
13
+ * `GIT_SSH_COMMAND` only supplies a DEFAULT, and the distinction is expensive.
14
+ * `BatchMode=yes` makes ssh fail rather than PROMPT — but an agent that stalls is
15
+ * not a prompt. On a 1Password-backed machine the agent is reachable and every
16
+ * signature needs interactive approval; unattended it waits, then reports
17
+ * `communication with agent failed`. Measured at 61s per fetch, which made
18
+ * `lt git update --dry-run` take 62s and `lt git create --dry-run` 123s (two
19
+ * fetches). `ConnectTimeout` does not bound that — it covers the TCP connect,
20
+ * not the agent.
21
+ *
22
+ * Waiting for a human to approve a key is legitimate for an interactive command,
23
+ * so the default itself is unchanged. What was wrong was assigning it
24
+ * UNCONDITIONALLY: that overrode anyone who had configured ssh deliberately,
25
+ * including a test harness trying to make the behaviour deterministic. A
26
+ * caller's own value therefore always wins (`IdentityAgent=none` in their env
27
+ * drops the same fetch to ~1s with a clean `Permission denied (publickey)`); the
28
+ * fallback only keeps an unconfigured machine from hanging.
29
+ *
30
+ * This exists as an environment object rather than a shell prefix because
31
+ * `VAR=value git …` is not assignment syntax to cmd.exe — it reads the whole
32
+ * thing as a command name and fails with "'GIT_TERMINAL_PROMPT' is not
33
+ * recognized". Handing the child an env works on every platform, and it is the
34
+ * only spelling that does. Keep it here rather than inline at each call site:
35
+ * this is the single place the default is defined, which is what lets a test
36
+ * assert that nobody assigns GIT_SSH_COMMAND unconditionally anywhere else.
37
+ *
38
+ * One caveat if you ever read a variable back off the returned object: spreading
39
+ * `process.env` produces a PLAIN object, which on Windows loses the
40
+ * case-insensitive lookup the real `process.env` proxy provides. The value is
41
+ * intact under its original spelling (`Path`, not `PATH`), and handing the whole
42
+ * object to `spawn` works — but `result.PATH` is undefined there.
43
+ */
44
+ function nonInteractiveGitEnv(base = process.env) {
45
+ return Object.assign(Object.assign({}, base), { GIT_SSH_COMMAND: base.GIT_SSH_COMMAND || 'ssh -o ConnectTimeout=5 -o BatchMode=yes', GIT_TERMINAL_PROMPT: '0' });
46
+ }
@@ -38,8 +38,11 @@ const LINT_STAGED_FILES = [
38
38
  */
39
39
  function findDangerousFixFlagUsage(appDir, workspaceRoot) {
40
40
  const base = workspaceRoot !== null && workspaceRoot !== void 0 ? workspaceRoot : appDir;
41
- const files = [...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []), ...fixFlagFiles(appDir, workspaceRoot)];
42
- return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => (0, path_1.relative)(base, file) || file);
41
+ const files = [
42
+ ...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []),
43
+ ...fixFlagFiles(appDir, workspaceRoot),
44
+ ];
45
+ return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => repoRelative(base, file));
43
46
  }
44
47
  /**
45
48
  * Remove `--fix-suggestions` and `--fix-dangerously` from the project's lint commands
@@ -57,7 +60,7 @@ function healDangerousOxlintFixFlags(appDir, workspaceRoot) {
57
60
  if (!usesDangerousFixFlag(file)) {
58
61
  continue;
59
62
  }
60
- const rel = (0, path_1.relative)(base, file) || file;
63
+ const rel = repoRelative(base, file);
61
64
  const dir = (0, path_1.join)(file, '..');
62
65
  const name = (0, path_1.relative)(dir, file);
63
66
  if (isSymlink(file)) {
@@ -203,6 +206,20 @@ function isTracked(dir, relPath) {
203
206
  return false;
204
207
  }
205
208
  }
209
+ /**
210
+ * `file` relative to `base`, always with forward slashes.
211
+ *
212
+ * These strings are reported to the user next to git facts ("not tracked by
213
+ * git", "uncommitted changes") and name files inside the repository, which git
214
+ * itself always spells with `/`. `relative()` returns the OS-native form, so on
215
+ * Windows the report would say `projects\app\package.json` while `git status`
216
+ * says `projects/app/package.json` for the same file. Falls back to the
217
+ * absolute path when there is no relative form (`base` IS the file).
218
+ */
219
+ function repoRelative(base, file) {
220
+ const rel = (0, path_1.relative)(base, file);
221
+ return rel ? rel.split(path_1.sep).join('/') : file;
222
+ }
206
223
  function usesDangerousFixFlag(file) {
207
224
  try {
208
225
  // Follows a symlink on purpose: a linked config still feeds the lint run, so it
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stripAugmentationBlocks = stripAugmentationBlocks;
4
4
  exports.stripVendorSchemaAugmentation = stripVendorSchemaAugmentation;
5
+ const glob_1 = require("glob");
5
6
  const fs_utils_1 = require("./fs-utils");
6
7
  /**
7
8
  * Drop `declare module '<nuxt|@nuxt>/schema' { … }` blocks that only augment
@@ -118,7 +119,6 @@ function stripAugmentationBlocks(source, onSkip) {
118
119
  * is still there and only the transform knows it.
119
120
  */
120
121
  function stripVendorSchemaAugmentation(options) {
121
- var _a;
122
122
  const { coreDir, filesystem } = options;
123
123
  if (!filesystem.isDirectory(coreDir))
124
124
  return { touched: [], warnings: [] };
@@ -128,7 +128,18 @@ function stripVendorSchemaAugmentation(options) {
128
128
  return { touched: [], warnings: [] };
129
129
  const touched = [];
130
130
  const warnings = [];
131
- for (const file of (_a = filesystem.find(coreDir, { matching: '**/*.ts' })) !== null && _a !== void 0 ? _a : []) {
131
+ // `glob`, not `filesystem.find`: gluegun's find is fs-jetpack, which builds its
132
+ // matcher by concatenating the resolved absolute base path in front of the glob
133
+ // (`fs-jetpack/lib/utils/matcher.js` → `convertPatternToAbsolutePath`). On Windows
134
+ // that base is backslash-separated and minimatch reads a backslash inside a
135
+ // PATTERN as an escape, so `D:\…\core/**/*.ts` collapses to `D:coresrc/**/*.ts`
136
+ // and matches nothing — minimatch normalises separators on the file side only.
137
+ // The strip then reported success while the TS2310 augmentation stayed in every
138
+ // Windows-converted project. `glob` keeps pattern and base directory apart and
139
+ // normalises separators itself. Same fix as `api-mode.ts`'s `globFiles`. glob does
140
+ // not follow symlinked directories, unlike jetpack — which is the direction the
141
+ // guard above already wants: nothing inside a linked checkout gets rewritten.
142
+ for (const file of (0, glob_1.globSync)('**/*.ts', { absolute: true, cwd: coreDir, dot: true, nodir: true })) {
132
143
  const content = filesystem.read(file);
133
144
  if (!content || !content.includes('PublicRuntimeConfig'))
134
145
  continue;
@@ -33,6 +33,7 @@ exports.healVendorClaudeMd = healVendorClaudeMd;
33
33
  exports.insertVendorBlockIfMissing = insertVendorBlockIfMissing;
34
34
  exports.removeVendorBlock = removeVendorBlock;
35
35
  exports.upsertVendorBlock = upsertVendorBlock;
36
+ const path_1 = require("path");
36
37
  exports.BACKEND_VENDOR_MARKER = '<!-- lt-vendor-marker -->';
37
38
  exports.FRONTEND_VENDOR_MARKER = '<!-- lt-vendor-marker-frontend -->';
38
39
  exports.ROOT_VENDOR_MARKER = '<!-- lt-vendor-marker-root -->';
@@ -182,15 +183,20 @@ function healVendorClaudeMd(fs, state) {
182
183
  changed.push(path);
183
184
  }
184
185
  };
186
+ // `join`, not string concatenation with `/`: the returned paths are reported
187
+ // to the user and compared by callers, so they have to be the same spelling
188
+ // the rest of the CLI produces. Concatenating `/` onto a Windows directory
189
+ // yields `C:\ws\projects\api/CLAUDE.md` — it opens fine, but it matches
190
+ // nothing and reads like a bug in the output.
185
191
  if (state.apiDir) {
186
- apply(joinPath(state.apiDir, 'CLAUDE.md'), exports.BACKEND_VENDOR_MARKER, state.backendVendor ? buildBackendVendorBlock() : null);
192
+ apply((0, path_1.join)(state.apiDir, 'CLAUDE.md'), exports.BACKEND_VENDOR_MARKER, state.backendVendor ? buildBackendVendorBlock() : null);
187
193
  }
188
194
  if (state.appDir) {
189
- apply(joinPath(state.appDir, 'CLAUDE.md'), exports.FRONTEND_VENDOR_MARKER, state.frontendVendor ? buildFrontendVendorBlock() : null);
195
+ apply((0, path_1.join)(state.appDir, 'CLAUDE.md'), exports.FRONTEND_VENDOR_MARKER, state.frontendVendor ? buildFrontendVendorBlock() : null);
190
196
  }
191
197
  if (state.workspaceRoot) {
192
198
  const anyVendor = state.backendVendor || state.frontendVendor;
193
- apply(joinPath(state.workspaceRoot, 'CLAUDE.md'), exports.ROOT_VENDOR_MARKER, anyVendor ? buildRootVendorBlock({ backend: state.backendVendor, frontend: state.frontendVendor }) : null);
199
+ apply((0, path_1.join)(state.workspaceRoot, 'CLAUDE.md'), exports.ROOT_VENDOR_MARKER, anyVendor ? buildRootVendorBlock({ backend: state.backendVendor, frontend: state.frontendVendor }) : null);
194
200
  }
195
201
  return changed;
196
202
  }
@@ -242,6 +248,3 @@ function blockRegex(marker) {
242
248
  function escapeRegExp(value) {
243
249
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
244
250
  }
245
- function joinPath(dir, file) {
246
- return dir.endsWith('/') ? `${dir}${file}` : `${dir}/${file}`;
247
- }
@@ -81,6 +81,7 @@ exports.runStandaloneWorkspaceGate = runStandaloneWorkspaceGate;
81
81
  exports.shouldProceedAsStandalone = shouldProceedAsStandalone;
82
82
  exports.writeApiConfig = writeApiConfig;
83
83
  const yaml = __importStar(require("js-yaml"));
84
+ const path_1 = require("path");
84
85
  const ensure_root_dockerignore_1 = require("./ensure-root-dockerignore");
85
86
  const hoist_workspace_pnpm_config_1 = require("./hoist-workspace-pnpm-config");
86
87
  const remove_nested_lockfiles_1 = require("./remove-nested-lockfiles");
@@ -104,11 +105,20 @@ function detectSubProjectContext(startDir, filesystem) {
104
105
  const appDir = filesystem.path(root, 'projects', 'app');
105
106
  // Resolve absolute prefixes for a clean startsWith compare. We use
106
107
  // the gluegun-resolved paths because all callers go through it.
108
+ //
109
+ // The prefix MUST carry the OS separator, not a literal `/`:
110
+ // `filesystem.path` resolves through Node's `path`, so on Windows both
111
+ // sides come back with backslashes and an `${apiDir}/` prefix can never
112
+ // match. Every cwd below `projects\api\` then read as "not a
113
+ // sub-project", and the standalone gate in `runStandaloneWorkspaceGate`
114
+ // waved the user through instead of pointing them at the workspace root
115
+ // — the exact case (cloning a sibling tree INSIDE an existing
116
+ // sub-project) the check exists to prevent.
107
117
  const startAbs = filesystem.path(startDir);
108
- if (startAbs === apiDir || startAbs.startsWith(`${apiDir}/`)) {
118
+ if (startAbs === apiDir || startAbs.startsWith(apiDir + path_1.sep)) {
109
119
  return { kind: 'api', subProjectDir: apiDir, workspaceRoot: root };
110
120
  }
111
- if (startAbs === appDir || startAbs.startsWith(`${appDir}/`)) {
121
+ if (startAbs === appDir || startAbs.startsWith(appDir + path_1.sep)) {
112
122
  return { kind: 'app', subProjectDir: appDir, workspaceRoot: root };
113
123
  }
114
124
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",