@lenne.tech/cli 1.48.0 → 1.50.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.
Files changed (40) hide show
  1. package/build/commands/dev/doctor.js +27 -10
  2. package/build/commands/dev/down.js +48 -5
  3. package/build/commands/dev/status.js +32 -8
  4. package/build/commands/dev/test.js +5 -3
  5. package/build/commands/dev/up.js +59 -21
  6. package/build/commands/fullstack/init.js +12 -3
  7. package/build/commands/git/squash.js +4 -1
  8. package/build/extensions/api-mode.js +69 -0
  9. package/build/extensions/frontend-helper.js +11 -12
  10. package/build/extensions/server.js +43 -33
  11. package/build/lib/caddy.js +17 -10
  12. package/build/lib/check-template-sync.js +62 -2
  13. package/build/lib/config-env-graphql.js +106 -0
  14. package/build/lib/cross-env.js +94 -0
  15. package/build/lib/dev-api-launch.js +34 -8
  16. package/build/lib/dev-env-bridge.js +7 -0
  17. package/build/lib/dev-env.js +44 -4
  18. package/build/lib/dev-log-tail.js +91 -0
  19. package/build/lib/dev-package-manager.js +13 -9
  20. package/build/lib/dev-patches.js +15 -5
  21. package/build/lib/dev-process.js +362 -127
  22. package/build/lib/dev-project.js +14 -1
  23. package/build/lib/dev-prune.js +15 -5
  24. package/build/lib/dev-service.js +29 -15
  25. package/build/lib/dev-state.js +40 -4
  26. package/build/lib/dev-test-session.js +141 -73
  27. package/build/lib/dev-ticket.js +13 -1
  28. package/build/lib/fail-run.js +11 -0
  29. package/build/lib/heal-vendor-migrate-store.js +4 -3
  30. package/build/lib/hoist-workspace-pnpm-config.js +17 -6
  31. package/build/lib/migrate-scripts.js +69 -0
  32. package/build/lib/module-specifiers.js +144 -0
  33. package/build/lib/platform.js +53 -1
  34. package/build/lib/spinner-lifetime.js +245 -0
  35. package/build/templates/check/check.mjs +59 -9
  36. package/build/templates/check/lt-monorepo-pin.json +4 -4
  37. package/build/templates/nest-server-starter/README.md.ejs +13 -6
  38. package/docs/commands.md +16 -12
  39. package/package.json +4 -1
  40. package/build/lib/strip-comments.js +0 -78
@@ -49,8 +49,10 @@ const path_1 = require("path");
49
49
  const ts = __importStar(require("typescript"));
50
50
  const adopt_upstream_build_allowlist_1 = require("../lib/adopt-upstream-build-allowlist");
51
51
  const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
52
+ const cross_env_1 = require("../lib/cross-env");
52
53
  const markdown_table_1 = require("../lib/markdown-table");
53
- const strip_comments_1 = require("../lib/strip-comments");
54
+ const migrate_scripts_1 = require("../lib/migrate-scripts");
55
+ const module_specifiers_1 = require("../lib/module-specifiers");
54
56
  const vendor_claude_md_1 = require("../lib/vendor-claude-md");
55
57
  /**
56
58
  * Server helper functions
@@ -662,7 +664,7 @@ class Server {
662
664
  if (!experimental) {
663
665
  // Generate README
664
666
  yield template.generate({
665
- props: { description, name },
667
+ props: { description, name, vendor: frameworkMode === 'vendor' },
666
668
  target: `${dest}/README.md`,
667
669
  template: 'nest-server-starter/README.md.ejs',
668
670
  });
@@ -1556,10 +1558,16 @@ class Server {
1556
1558
  scripts['migrate:up'] = `node ./bin/migrate.js up ${migrateArgs}`;
1557
1559
  scripts['migrate:down'] = `node ./bin/migrate.js down ${migrateArgs}`;
1558
1560
  scripts['migrate:list'] = `node ./bin/migrate.js list ${migrateArgs}`;
1559
- scripts['migrate:develop:up'] = `NODE_ENV=develop node ./bin/migrate.js up ${migrateArgs}`;
1560
- scripts['migrate:test:up'] = `NODE_ENV=test node ./bin/migrate.js up ${migrateArgs}`;
1561
- scripts['migrate:preview:up'] = `NODE_ENV=preview node ./bin/migrate.js up ${migrateArgs}`;
1562
- scripts['migrate:prod:up'] = `NODE_ENV=production node ./bin/migrate.js up ${migrateArgs}`;
1561
+ // The deployed scripts run the COMPILED migrations under dist/, not the
1562
+ // sources: a production tree has no ts-node (devDependency), so the
1563
+ // `--compiler ts:…` form dies there. `copy:bin` ships the shim into
1564
+ // dist/bin. See src/lib/migrate-scripts.ts.
1565
+ Object.assign(scripts, (0, migrate_scripts_1.deployedMigrateScripts)('node ./dist/bin/migrate.js'));
1566
+ const guardWarning = (0, migrate_scripts_1.missingBuildGuardWarning)(filesystem, dest);
1567
+ if (guardWarning) {
1568
+ this.toolbox.print.warning(guardWarning);
1569
+ }
1570
+ (0, cross_env_1.ensureCrossEnvDependency)(pkg);
1563
1571
  // Make the production build carry a runnable migration setup.
1564
1572
  //
1565
1573
  // The image has no ts-node, and the docker entrypoint looks for
@@ -1579,7 +1587,8 @@ class Server {
1579
1587
  // deliberate customization survives a re-run (older starters simply
1580
1588
  // lack them → still repaired).
1581
1589
  if (typeof scripts['copy:bin'] !== 'string') {
1582
- scripts['copy:bin'] = 'cpy ./bin ./dist/ || true';
1590
+ // `|| exit 0`, not `|| true`: cmd.exe has no `true` command.
1591
+ scripts['copy:bin'] = 'cpy ./bin ./dist/ || exit 0';
1583
1592
  }
1584
1593
  if (typeof scripts['copy:migrations'] !== 'string') {
1585
1594
  scripts['copy:migrations'] =
@@ -1887,13 +1896,10 @@ class Server {
1887
1896
  // Scan all consumer files for stale bare-specifier imports that the
1888
1897
  // codemod should have rewritten. A single miss causes a compile error,
1889
1898
  // so catching it here with a clear message saves the user debugging time.
1890
- const staleImports = this.findStaleImports(dest, '@lenne.tech/nest-server',
1891
- // Match only real import/export/require specifiers in single/double
1892
- // quotes — NOT comment references like
1893
- // `node_modules/@lenne.tech/nest-server/...` (backticks, no keyword),
1894
- // which the codemod legitimately leaves untouched. A naive substring
1895
- // match flagged config.env.ts's JSDoc path as a false positive.
1896
- /(?:from|import|export|require)\s*\(?\s*['"]@lenne\.tech\/nest-server(?:\/[^'"]*)?['"]/);
1899
+ // A specifier, not a text match: a JSDoc path like
1900
+ // `node_modules/@lenne.tech/nest-server/...` is prose and never reaches the AST,
1901
+ // which is what used to make config.env.ts a false positive.
1902
+ const staleImports = this.findStaleImports(dest, (specifier) => (0, module_specifiers_1.isPackageImport)(specifier, '@lenne.tech/nest-server'));
1897
1903
  if (staleImports.length > 0) {
1898
1904
  const { print } = this.toolbox;
1899
1905
  print.warning(`⚠ ${staleImports.length} file(s) still contain '@lenne.tech/nest-server' imports after vendor conversion:`);
@@ -2417,11 +2423,16 @@ class Server {
2417
2423
  scripts['migrate:up'] = `migrate up ${migrateStore} --compiler ${migrateCompiler}`;
2418
2424
  scripts['migrate:down'] = `migrate down ${migrateStore} --compiler ${migrateCompiler}`;
2419
2425
  scripts['migrate:list'] = `migrate list ${migrateStore} --compiler ${migrateCompiler}`;
2420
- // Env-prefixed migrate scripts
2421
- for (const env of ['develop', 'test', 'preview', 'prod']) {
2422
- const nodeEnv = env === 'prod' ? 'production' : env;
2423
- scripts[`migrate:${env}:up`] = `NODE_ENV=${nodeEnv} migrate up ${migrateStore} --compiler ${migrateCompiler}`;
2424
- }
2426
+ // Deployed scripts: compiled migrations under dist/, no --compiler (no ts-node
2427
+ // in a production tree), via cross-env for cmd.exe. `migrate` is the binary of
2428
+ // @lenne.tech/nest-server, a production dependency in npm mode — the same path
2429
+ // docker-entrypoint.sh uses for this mode.
2430
+ Object.assign(scripts, (0, migrate_scripts_1.deployedMigrateScripts)('migrate'));
2431
+ const guardWarning = (0, migrate_scripts_1.missingBuildGuardWarning)(filesystem, dest);
2432
+ if (guardWarning) {
2433
+ this.toolbox.print.warning(guardWarning);
2434
+ }
2435
+ (0, cross_env_1.ensureCrossEnvDependency)(pkg);
2425
2436
  filesystem.write(`${dest}/package.json`, pkg);
2426
2437
  }
2427
2438
  // ── 5. Remove vendor artifacts ──────────────────────────────────────
@@ -2498,7 +2509,7 @@ class Server {
2498
2509
  // Scan all consumer files for stale relative imports that still resolve
2499
2510
  // to the (now deleted) src/core/ directory. These would be silent
2500
2511
  // compile errors.
2501
- const staleRelativeImports = this.findStaleImports(dest, '../core', /['"]\.\.?\/[^'"]*core['"]|from\s+['"]\.\.?\/[^'"]*core['"]/);
2512
+ const staleRelativeImports = this.findStaleImports(dest, module_specifiers_1.isRelativeCoreImport);
2502
2513
  if (staleRelativeImports.length > 0) {
2503
2514
  const { print } = this.toolbox;
2504
2515
  print.warning(`⚠ ${staleRelativeImports.length} file(s) still contain relative core imports after npm conversion:`);
@@ -2514,11 +2525,10 @@ class Server {
2514
2525
  * rewritten by a mode conversion. Returns a list of file paths that still
2515
2526
  * contain matches.
2516
2527
  *
2517
- * @param dest Project root directory
2518
- * @param needle Literal string to search for (used when no regex provided)
2519
- * @param pattern Optional regex for more flexible matching
2528
+ * @param dest Project root directory
2529
+ * @param matches Predicate on a module SPECIFIER, e.g. `(s) => isPackageImport(s, 'pkg')`
2520
2530
  */
2521
- findStaleImports(dest, needle, pattern) {
2531
+ findStaleImports(dest, matches) {
2522
2532
  // Patterns are relative to `dest` and passed to `glob` rather than
2523
2533
  // `filesystem.find`: jetpack concatenates the resolved base path in front of
2524
2534
  // the pattern, and minimatch treats a backslash inside a PATTERN as an escape,
@@ -2538,15 +2548,15 @@ class Server {
2538
2548
  for (const glob of globs) {
2539
2549
  const files = (0, glob_1.globSync)(glob, { absolute: true, cwd: dest, dot: true, nodir: true });
2540
2550
  for (const file of files) {
2541
- const raw = this.filesystem.read(file) || '';
2542
- // Strip comments before matching. The keyword-anchored pattern is not enough on its own:
2543
- // a docblock that DOCUMENTS the conversion legitimately quotes the very syntax it looks
2544
- // for — nest-server-starter's `tests/unit/bootstrap-diagnostics.spec.ts` contains
2545
- // "rewrites `from '@lenne.tech/nest-server'` to a relative `./core` path", which matched
2546
- // and told the user to rewrite imports that file does not have. A detector that reads
2547
- // comments as code produces false alarms on exactly the files that explain it best.
2548
- const content = (0, strip_comments_1.stripComments)(raw);
2549
- if (pattern ? pattern.test(content) : content.includes(needle)) {
2551
+ // Ask the parser for the file's module specifiers rather than searching its
2552
+ // text. A docblock that DOCUMENTS the conversion quotes the very syntax a text
2553
+ // search looks for — nest-server-starter's `tests/unit/bootstrap-diagnostics.spec.ts`
2554
+ // names `from '@lenne.tech/nest-server'` in prose, and the detector told the user
2555
+ // to rewrite imports that file does not have. Blanking comments first was the old
2556
+ // answer and does not hold (see src/lib/module-specifiers.ts); a comment is not
2557
+ // part of the AST at all.
2558
+ const specifiers = (0, module_specifiers_1.fileImportedSpecifiers)(file, (path) => this.filesystem.read(path) || undefined);
2559
+ if (specifiers.some(matches)) {
2550
2560
  stale.push(file.replace(`${dest}/`, ''));
2551
2561
  }
2552
2562
  }
@@ -36,10 +36,11 @@ exports.writeCaddyfile = writeCaddyfile;
36
36
  * Lifecycle is owned by `lt dev install` (one-time setup) and
37
37
  * `lt dev up`/`lt dev down` (per-project block management).
38
38
  */
39
- const child_process_1 = require("child_process");
40
39
  const fs_1 = require("fs");
41
40
  const os_1 = require("os");
42
41
  const path_1 = require("path");
42
+ const dev_process_1 = require("./dev-process");
43
+ const platform_1 = require("./platform");
43
44
  const CADDYFILE_PATH = process.env.LT_DEV_CADDYFILE || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'Caddyfile');
44
45
  const HEADER = '# Managed by `lt dev`. Per-project blocks are bounded by `# >>> lt-dev:<slug> >>>` markers.';
45
46
  /** Detect whether `caddy` is on PATH. */
@@ -49,16 +50,22 @@ function caddyAvailable() {
49
50
  return result.ok;
50
51
  });
51
52
  }
52
- /** Detect whether the Caddy admin endpoint is reachable (i.e. a daemon is running). */
53
+ /**
54
+ * Detect whether the Caddy admin endpoint is reachable (i.e. a daemon is running).
55
+ *
56
+ * This asked `curl -fsS -o /dev/null`, and on Windows that reported a running
57
+ * Caddy as down: `/dev/null` is an ordinary file path there, so curl completed
58
+ * the request, received the 226-byte answer, failed to WRITE it, and exited 23.
59
+ * The exit code was all this function looked at. Measured on the laptop against a
60
+ * Caddy that was listening on :2019 and answering 200.
61
+ *
62
+ * `127.0.0.1` rather than `localhost` for consistency with the reverse-proxy
63
+ * upstreams (see the note above `renderProjectBlock`) — not because `localhost`
64
+ * was the fault here; it was measured to resolve correctly on that machine.
65
+ */
53
66
  function caddyDaemonRunning() {
54
67
  return __awaiter(this, void 0, void 0, function* () {
55
- return new Promise((resolve) => {
56
- const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', 'http://localhost:2019/config/'], {
57
- stdio: ['ignore', 'ignore', 'ignore'],
58
- });
59
- child.on('error', () => resolve(false));
60
- child.on('close', (code) => resolve(code === 0));
61
- });
68
+ return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 2000)) !== null;
62
69
  });
63
70
  }
64
71
  /** Read the current Caddyfile (or empty string). */
@@ -162,7 +169,7 @@ function writeCaddyfile(content) {
162
169
  function runCaddy(args) {
163
170
  return new Promise((resolve) => {
164
171
  var _a, _b;
165
- const child = (0, child_process_1.spawn)('caddy', args, { stdio: ['ignore', 'pipe', 'pipe'] });
172
+ const child = (0, platform_1.spawnCmd)('caddy', args, { stdio: ['ignore', 'pipe', 'pipe'] });
166
173
  let stdout = '';
167
174
  let stderr = '';
168
175
  let errored = false;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CHECK_TEMPLATE_PIN_FILE = exports.CHECK_TEMPLATE_REPOSITORY = void 0;
4
4
  exports.readCheckTemplatePin = readCheckTemplatePin;
5
5
  exports.syncCheckTemplate = syncCheckTemplate;
6
+ exports.spawnedSiblings = spawnedSiblings;
6
7
  exports.verifyCheckTemplate = verifyCheckTemplate;
7
8
  /**
8
9
  * Keep `src/templates/check/` a verbatim copy of a PINNED lt-monorepo state.
@@ -77,6 +78,14 @@ function syncCheckTemplate(options) {
77
78
  (0, fs_1.rmSync)((0, path_1.join)(templateDir, stale), { force: true });
78
79
  }
79
80
  }
81
+ // Refuse rather than ship silently: whether a spawned sibling belongs in the
82
+ // template or whether lt-monorepo should drop the call is a human decision.
83
+ const spawned = spawnedSiblings(templateDir, Object.keys(files)).filter((name) => !(name in files));
84
+ if (spawned.length > 0) {
85
+ throw new Error(`${ref}: the template starts sibling script(s) it does not ship: ${spawned.join(', ')}. ` +
86
+ 'resolveCopySet only follows imports, so these would be missing in every generated project. ' +
87
+ "Add them to the wrapper's import closure, ship them deliberately, or remove the call upstream.");
88
+ }
80
89
  const pin = {
81
90
  commit,
82
91
  files: sortKeys(files),
@@ -91,10 +100,54 @@ function syncCheckTemplate(options) {
91
100
  (0, fs_1.rmSync)(checkout, { force: true, recursive: true });
92
101
  }
93
102
  }
103
+ /**
104
+ * A sibling script the wrapper SPAWNS rather than imports.
105
+ *
106
+ * `resolveCopySet` follows imports, so a `node scripts/x.mjs` — or a spawn/execFile
107
+ * with such a path — is invisible to it: the file is never shipped, the pin test stays
108
+ * green, and only the generated project breaks. Matched on the path inside a STRING
109
+ * rather than on the call shape, so `spawn('node', ['scripts/x.mjs'])`,
110
+ * `execSync('bash scripts/x.sh')` and a bare `'scripts/x.mjs'` are all covered.
111
+ *
112
+ * Comment LINES are skipped before matching. These files document their own behaviour
113
+ * in backtick-quoted prose, and a backtick is a string delimiter to a regex: the first
114
+ * draft reported `bash scripts/audit.sh` out of a comment on `check.mjs:103` and would
115
+ * have refused every sync from then on. `stripComments` is deliberately NOT used —
116
+ * measured on this very file, it stops blanking at offset 3329, where a regex literal
117
+ * (`/^projects\//`) is followed by a division (`ms / 1000`) and the standalone
118
+ * TypeScript scanner, having no parser context, can no longer tell the two apart.
119
+ */
120
+ const SPAWNED_SIBLING = /['"`](?:[\w./-]+\s+)*(?:\.\/)?scripts\/([\w.-]+\.(?:mjs|cjs|js|sh))/g;
121
+ /**
122
+ * Sibling scripts the template STARTS instead of importing, as `scripts/<name>`.
123
+ * Deduplicated, and the wrapper's own name is never reported.
124
+ */
125
+ function spawnedSiblings(templateDir, files) {
126
+ const found = new Set();
127
+ for (const name of files) {
128
+ const file = (0, path_1.join)(templateDir, name);
129
+ if (!(0, fs_1.existsSync)(file)) {
130
+ continue;
131
+ }
132
+ for (const line of (0, fs_1.readFileSync)(file, 'utf8').split('\n')) {
133
+ const code = line.trimStart();
134
+ if (code.startsWith('//') || code.startsWith('*') || code.startsWith('/*')) {
135
+ continue;
136
+ }
137
+ for (const [, sibling] of code.matchAll(SPAWNED_SIBLING)) {
138
+ if (sibling !== 'check.mjs' && sibling !== name) {
139
+ found.add(sibling);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ return [...found].sort();
145
+ }
94
146
  /**
95
147
  * 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).
148
+ * Checks three directions: every pinned file with its hash, every file the wrapper
149
+ * imports is pinned (a hand-added import would otherwise slip by), and no file is
150
+ * SPAWNED that nothing ships.
98
151
  */
99
152
  function verifyCheckTemplate(templateDir, pin) {
100
153
  const problems = [];
@@ -113,6 +166,13 @@ function verifyCheckTemplate(templateDir, pin) {
113
166
  problems.push(`${name}: imported by the wrapper but not pinned`);
114
167
  }
115
168
  }
169
+ for (const sibling of spawnedSiblings(templateDir, [...Object.keys(pin.files), ...actual])) {
170
+ if (!(sibling in pin.files)) {
171
+ problems.push(`${sibling}: STARTED by the template (\`node scripts/${sibling}\`) but not shipped — ` +
172
+ 'a generated project would call a file it does not have. Decide deliberately: add it to the ' +
173
+ 'template, or have lt-monorepo drop the call.');
174
+ }
175
+ }
116
176
  const version = (0, heal_check_wrapper_1.readWrapperVersion)((0, path_1.join)(templateDir, 'check.mjs'));
117
177
  if (version !== pin.version) {
118
178
  problems.push(`check.mjs: marker ${version !== null && version !== void 0 ? version : '(none)'} does not match pinned version ${pin.version}`);
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.disableGraphQlInEveryEnvBlock = disableGraphQlInEveryEnvBlock;
4
+ exports.envBlocksWithoutGraphQlDisabled = envBlocksWithoutGraphQlDisabled;
5
+ /**
6
+ * Add `graphQl: false` to every environment block that has no `graphQl` property, and
7
+ * replace a `graphQl: { … }` that survived with `false`.
8
+ *
9
+ * Line endings are preserved: a CRLF file stays CRLF.
10
+ */
11
+ function disableGraphQlInEveryEnvBlock(content) {
12
+ var _a, _b, _c;
13
+ const { project, sourceFile } = parse(content);
14
+ const added = [];
15
+ for (const [name, block] of envBlocks(sourceFile)) {
16
+ const property = block.getProperty('graphQl');
17
+ if (!property) {
18
+ block.insertPropertyAssignment(0, { initializer: 'false', name: 'graphQl' });
19
+ added.push(name);
20
+ continue;
21
+ }
22
+ const initializer = (_b = (_a = property).getInitializer) === null || _b === void 0 ? void 0 : _b.call(_a);
23
+ if (initializer && initializer.getText() !== 'false') {
24
+ property.setInitializer('false');
25
+ added.push(name);
26
+ }
27
+ }
28
+ const written = added.length > 0 ? sourceFile.getFullText() : content;
29
+ (_c = project.removeSourceFile) === null || _c === void 0 ? void 0 : _c.call(project, sourceFile);
30
+ return { added, content: restoreLineEndings(content, written) };
31
+ }
32
+ /**
33
+ * Environment blocks that do NOT disable GraphQL — empty when the file is fine.
34
+ * Used as the conversion's final check.
35
+ */
36
+ function envBlocksWithoutGraphQlDisabled(content) {
37
+ var _a, _b, _c;
38
+ const { project, sourceFile } = parse(content);
39
+ const offenders = [];
40
+ for (const [name, block] of envBlocks(sourceFile)) {
41
+ const property = block.getProperty('graphQl');
42
+ const initializer = property && ((_b = (_a = property).getInitializer) === null || _b === void 0 ? void 0 : _b.call(_a));
43
+ if (!initializer || initializer.getText() !== 'false') {
44
+ offenders.push(name);
45
+ }
46
+ }
47
+ (_c = project.removeSourceFile) === null || _c === void 0 ? void 0 : _c.call(project, sourceFile);
48
+ return offenders;
49
+ }
50
+ /**
51
+ * The environment blocks of a `config.env.ts`: the object-literal properties of the
52
+ * exported `config` object, including the ones passed to a `merge(…)` call (the shape
53
+ * the starter uses). Only direct children count — a nested `mongoose: { … }` is not an
54
+ * environment.
55
+ */
56
+ function envBlocks(sourceFile) {
57
+ var _a, _b;
58
+ const { SyntaxKind } = require('ts-morph');
59
+ const roots = [];
60
+ for (const statement of sourceFile.getVariableStatements()) {
61
+ for (const declaration of statement.getDeclarations()) {
62
+ if (declaration.getName() !== 'config') {
63
+ continue;
64
+ }
65
+ const initializer = declaration.getInitializer();
66
+ if (!initializer) {
67
+ continue;
68
+ }
69
+ if (initializer.getKind() === SyntaxKind.ObjectLiteralExpression) {
70
+ roots.push(initializer);
71
+ }
72
+ if (initializer.getKind() === SyntaxKind.CallExpression) {
73
+ for (const argument of initializer.getArguments()) {
74
+ if (argument.getKind() === SyntaxKind.ObjectLiteralExpression) {
75
+ roots.push(argument);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ const blocks = [];
82
+ for (const root of roots) {
83
+ for (const property of root.getProperties()) {
84
+ if (property.getKind() !== SyntaxKind.PropertyAssignment) {
85
+ continue;
86
+ }
87
+ const initializer = (_b = (_a = property).getInitializer) === null || _b === void 0 ? void 0 : _b.call(_a);
88
+ if ((initializer === null || initializer === void 0 ? void 0 : initializer.getKind()) === SyntaxKind.ObjectLiteralExpression) {
89
+ blocks.push([property.getName(), initializer]);
90
+ }
91
+ }
92
+ }
93
+ return blocks;
94
+ }
95
+ function parse(content) {
96
+ const { Project } = require('ts-morph');
97
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
98
+ return { project, sourceFile: project.createSourceFile('config.env.ts', content, { overwrite: true }) };
99
+ }
100
+ /** ts-morph writes LF; a file that came in as CRLF goes back out as CRLF. */
101
+ function restoreLineEndings(original, written) {
102
+ if (!original.includes('\r\n')) {
103
+ return written;
104
+ }
105
+ return written.replace(/\r?\n/g, '\r\n');
106
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ENV_PREFIX = exports.CROSS_ENV_PACKAGE = exports.CROSS_ENV_VERSION = void 0;
4
+ exports.bareEnvPrefixes = bareEnvPrefixes;
5
+ exports.ensureCrossEnvDependency = ensureCrossEnvDependency;
6
+ exports.usesPosixEnvPrefix = usesPosixEnvPrefix;
7
+ exports.withCrossEnv = withCrossEnv;
8
+ /**
9
+ * Environment prefixes in GENERATED package.json scripts.
10
+ *
11
+ * npm and pnpm run scripts through cmd.exe on Windows, which knows no
12
+ * `VAR=value command` form: it reads `NODE_ENV` as the program name and aborts
13
+ * with "'NODE_ENV' is not recognized as an internal or external command"
14
+ * (reported from a Windows laptop, 2026-09-16, on a project generated by lt
15
+ * 1.47.0: `pnpm run start:local` died there).
16
+ *
17
+ * `cross-env` sets the variable in the child process instead, so the same script
18
+ * works in bash, zsh, PowerShell and cmd.exe.
19
+ */
20
+ /** Pinned exactly: an override target or dependency of ours is never a range (repo rule). */
21
+ exports.CROSS_ENV_VERSION = '10.1.0';
22
+ exports.CROSS_ENV_PACKAGE = 'cross-env';
23
+ /**
24
+ * A POSIX env assignment in front of a command — the form cmd.exe cannot run.
25
+ *
26
+ * Deliberately NOT anchored to the start of the script: the mistake that actually
27
+ * happens when converting is an `&&` chain in which only the first link was
28
+ * wrapped (`cross-env NODE_ENV=x a && NODE_ENV=x b`), and an anchored expression
29
+ * reports that as clean.
30
+ */
31
+ exports.ENV_PREFIX = /(^|[\s;&|(])[A-Z_][A-Z0-9_]*=\S/;
32
+ /** `cross-env` plus the assignments it carries — those are the covered ones. */
33
+ const CROSS_ENV_ASSIGNMENTS = /cross-env\s+(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/g;
34
+ /**
35
+ * Bare `VAR=value` prefixes in `script`, i.e. the ones cmd.exe would choke on.
36
+ * Assignments that `cross-env` carries are removed first, so a correctly
37
+ * converted script — including `cross-env A=1 B=2 cmd` — reports nothing.
38
+ */
39
+ function bareEnvPrefixes(script) {
40
+ const rest = script.replace(CROSS_ENV_ASSIGNMENTS, '');
41
+ const found = [];
42
+ for (const match of rest.matchAll(/(?:^|[\s;&|(])([A-Z_][A-Z0-9_]*=\S+)/g)) {
43
+ found.push(match[1]);
44
+ }
45
+ return found;
46
+ }
47
+ /**
48
+ * Make sure a generated project can run the scripts we just wrote.
49
+ *
50
+ * Lands in `dependencies`, NOT `devDependencies`: production trees are installed
51
+ * without dev packages — the starter's Dockerfile uses
52
+ * `pnpm install --frozen-lockfile --prod` and `pnpm deploy --prod` — and the scripts
53
+ * run there (`start:prod`, `dp:prod` → `migrate:prod:up`) are exactly the env-carrying
54
+ * ones. Measured by the starter session on two real `--prod` installs from one
55
+ * lockfile: as a devDependency `node_modules/.bin/cross-env` is missing, as a
56
+ * dependency it is there. (The framework repos keep it in devDependencies: their
57
+ * scripts never reach a customer image, and nest-server is published to npm, where a
58
+ * production dependency would be forced on every consumer.)
59
+ *
60
+ * Only added when the project does not declare it anywhere already — the starters ship
61
+ * it themselves, and a second entry would fight theirs on every update. An existing
62
+ * entry keeps its group and version, devDependencies included.
63
+ *
64
+ * @param pkg Parsed package.json of the generated project; mutated in place.
65
+ * @returns true when the dependency was added.
66
+ */
67
+ function ensureCrossEnvDependency(pkg) {
68
+ const declared = [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies, pkg.optionalDependencies].some((group) => group && typeof group === 'object' && exports.CROSS_ENV_PACKAGE in group);
69
+ if (declared) {
70
+ return false;
71
+ }
72
+ if (!pkg.dependencies || typeof pkg.dependencies !== 'object') {
73
+ pkg.dependencies = {};
74
+ }
75
+ pkg.dependencies[exports.CROSS_ENV_PACKAGE] = exports.CROSS_ENV_VERSION;
76
+ pkg.dependencies = Object.fromEntries(Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)));
77
+ return true;
78
+ }
79
+ /** True when `script` still carries a bare `VAR=value` prefix. */
80
+ function usesPosixEnvPrefix(script) {
81
+ return bareEnvPrefixes(script).length > 0;
82
+ }
83
+ /**
84
+ * Prefix a command with `cross-env VAR=value …`.
85
+ *
86
+ * @param env Variables to set, e.g. `{ NODE_ENV: 'production' }`.
87
+ * @param command The command to run.
88
+ */
89
+ function withCrossEnv(env, command) {
90
+ const assignments = Object.entries(env)
91
+ .map(([name, value]) => `${name}=${value}`)
92
+ .join(' ');
93
+ return `cross-env ${assignments} ${command}`;
94
+ }
@@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.applyPendingMigrations = applyPendingMigrations;
12
13
  exports.findCompiledEntry = findCompiledEntry;
13
14
  exports.isApiCompiledRequested = isApiCompiledRequested;
14
15
  exports.resolveApiRuntime = resolveApiRuntime;
@@ -29,6 +30,33 @@ const node_path_1 = require("node:path");
29
30
  const dev_process_1 = require("./dev-process");
30
31
  /** Candidate compiled entry points, in preference order. Single-sourced so `lt dev test` agrees. */
31
32
  const COMPILED_ENTRIES = ['dist/src/main.js', 'dist/main.js'];
33
+ /**
34
+ * Apply the project's pending migrations (`<pm> run migrate:up`) ahead of an API
35
+ * that is started WITHOUT its own `start` script.
36
+ *
37
+ * lt projects chain the migration into that script (`migrate:up && start:local`),
38
+ * so every path that starts the compiled bundle directly — `lt dev up
39
+ * --api-compiled` and `lt dev test` — drops the migration along with it. On a
40
+ * database that outlives the run, that leaves the API on a state no deployed
41
+ * environment has: a changed unique index never reached the `lt dev test` DB, and
42
+ * the suite failed with a 409 that looked like a bug in the feature (DEV-3289).
43
+ *
44
+ * `env` must be the environment the API itself gets. The migration store resolves
45
+ * its database from the same `config.env.ts` + `NSC__MONGOOSE__URI` merge, so
46
+ * sharing the env is what makes both reach the same database.
47
+ *
48
+ * A project without a `migrate:up` script is `skipped`, never an error — nest-base
49
+ * (Prisma) projects have none. A migration killed by a signal (`null`) is `failed`.
50
+ */
51
+ function applyPendingMigrations(options) {
52
+ return __awaiter(this, void 0, void 0, function* () {
53
+ const { apiDir, env, pm } = options;
54
+ if (!hasScript(apiDir, 'migrate:up'))
55
+ return { status: 'skipped' };
56
+ const exitCode = yield (0, dev_process_1.runChildInherit)(pm.bin, pm.runScript('migrate:up'), { cwd: apiDir, env });
57
+ return exitCode === 0 ? { status: 'applied' } : { exitCode, status: 'failed' };
58
+ });
59
+ }
32
60
  /** Resolve the compiled API entry point in `apiDir`, or `undefined` if none was built. */
33
61
  function findCompiledEntry(apiDir) {
34
62
  return COMPILED_ENTRIES.map((rel) => (0, node_path_1.join)(apiDir, rel)).find((candidate) => (0, node_fs_1.existsSync)(candidate));
@@ -106,14 +134,12 @@ function startCompiledApi(options) {
106
134
  const build = yield (0, dev_process_1.runChildInherit)(pm.bin, pm.runScript('build'), { cwd: apiDir, env });
107
135
  const entry = findCompiledEntry(apiDir);
108
136
  if (build === 0 && entry) {
109
- if (hasScript(apiDir, 'migrate:up')) {
110
- const migrate = yield (0, dev_process_1.runChildInherit)(pm.bin, pm.runScript('migrate:up'), { cwd: apiDir, env });
111
- if (migrate !== 0) {
112
- // Parity with `migrate:up && start:local`: a failed migration must PREVENT the server
113
- // from starting rather than boot it against a half-migrated DB behind a "Started" banner.
114
- log.warn(`migrate:up failed (exit ${String(migrate)}) — API NOT started (would run on an un-migrated DB).`);
115
- return undefined;
116
- }
137
+ const migration = yield applyPendingMigrations({ apiDir, env, pm });
138
+ if (migration.status === 'failed') {
139
+ // Parity with `migrate:up && start:local`: a failed migration must PREVENT the server
140
+ // from starting rather than boot it against a half-migrated DB behind a "Started" banner.
141
+ log.warn(`migrate:up failed (exit ${String(migration.exitCode)}) — API NOT started (would run on an un-migrated DB).`);
142
+ return undefined;
117
143
  }
118
144
  return (0, dev_process_1.spawnDetached)(resolveApiRuntime(apiDir), [entry], {
119
145
  cwd: apiDir,
@@ -97,6 +97,13 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
97
97
  // Legacy aliases — see dev-env.ts for the rationale.
98
98
  'API_URL',
99
99
  'SITE_URL',
100
+ // The loopback addresses. An external runner that RESOLVES a URL from Node —
101
+ // an API helper, a seeding script, a readiness check — needs these: on Windows
102
+ // `*.localhost` does not resolve for Node at all, only for browsers. The
103
+ // public names above stay, because browser-facing values (a Playwright
104
+ // `baseURL`, a link a developer opens) must keep them. See `dev-env.ts#internalUrl`.
105
+ 'LT_DEV_API_INTERNAL_URL',
106
+ 'LT_DEV_APP_INTERNAL_URL',
100
107
  ];
101
108
  for (const key of exported) {
102
109
  const v = devEnv.app.env[key];