@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
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BUILD_GUARD_FILE = exports.COMPILED_MIGRATE_ARGS = exports.MIGRATE_ENVIRONMENTS = void 0;
4
+ exports.deployedMigrateScripts = deployedMigrateScripts;
5
+ exports.missingBuildGuardWarning = missingBuildGuardWarning;
6
+ const path_1 = require("path");
7
+ const cross_env_1 = require("./cross-env");
8
+ /** Script suffix → `NODE_ENV` value. `prod` maps to `production`, the name Nest config keys off. */
9
+ exports.MIGRATE_ENVIRONMENTS = [
10
+ ['develop', 'develop'],
11
+ ['test', 'test'],
12
+ ['preview', 'preview'],
13
+ ['prod', 'production'],
14
+ ];
15
+ /** Where the build puts the compiled migrations and the store helper. */
16
+ exports.COMPILED_MIGRATE_ARGS = '--store ./dist/migrations-utils/migrate.js --migrations-dir ./dist/migrations';
17
+ /**
18
+ * Build guard in front of every deployed script — the same wiring nest-server-starter
19
+ * uses (`node scripts/… && <migrate>`), so the chain fails BEFORE migrate starts and
20
+ * `dp:prod` never reaches the server start.
21
+ *
22
+ * It is needed because the failure is otherwise SILENT: measured with nest-server
23
+ * 11.41.2, a missing migrations directory prints "treating as empty" and a missing or
24
+ * empty one both end in "No pending migrations", exit 0. An unbuilt checkout would
25
+ * report a successful migration run that never happened, and the server would then come
26
+ * up against an unmigrated database.
27
+ */
28
+ exports.BUILD_GUARD_FILE = 'require-built-migrations.mjs';
29
+ const BUILD_GUARD = `node scripts/${exports.BUILD_GUARD_FILE} && `;
30
+ /**
31
+ * Build the four deployed migrate scripts.
32
+ *
33
+ * @param invocation How this project starts the migrate CLI: `'migrate'` in npm mode
34
+ * (the binary of `@lenne.tech/nest-server`, a production dependency),
35
+ * `'node ./dist/bin/migrate.js'` in vendor mode (the shim `copy:bin`
36
+ * ships into `dist/`).
37
+ */
38
+ function deployedMigrateScripts(invocation) {
39
+ const scripts = {};
40
+ for (const [scriptEnv, nodeEnv] of exports.MIGRATE_ENVIRONMENTS) {
41
+ scripts[`migrate:${scriptEnv}:up`] =
42
+ BUILD_GUARD + (0, cross_env_1.withCrossEnv)({ NODE_ENV: nodeEnv }, `${invocation} up ${exports.COMPILED_MIGRATE_ARGS}`);
43
+ }
44
+ return scripts;
45
+ }
46
+ /**
47
+ * Warn when the build guard the deployed scripts call is not in the project.
48
+ *
49
+ * The file ships with nest-server-starter, which BOTH modes clone as their base
50
+ * (`server.ts`: "Both npm and vendor mode clone nest-server-starter as the base"), and
51
+ * nothing in the conversion prunes `scripts/` wholesale — only
52
+ * `strip-api-mode-markers.mjs`, `scripts/vendor/` and, in REST mode,
53
+ * `run-spectaql.mjs`. So the CLI does NOT ship its own copy: a second, nearly identical
54
+ * file is exactly the drift we are trying to avoid.
55
+ *
56
+ * What it does instead is fail loudly if the assumption stops holding — e.g. a project
57
+ * built from an older starter. Same treatment as `migrations-utils/mongo-uri.js`:
58
+ * without the file every deployed migrate script dies at the first `&&`.
59
+ *
60
+ * @returns A warning message, or null when the guard is present.
61
+ */
62
+ function missingBuildGuardWarning(filesystem, projectDir) {
63
+ if (filesystem.exists((0, path_1.join)(projectDir, 'scripts', exports.BUILD_GUARD_FILE))) {
64
+ return null;
65
+ }
66
+ return (` ⚠ scripts/${exports.BUILD_GUARD_FILE} is missing — the deployed migrate:<env>:up scripts call it ` +
67
+ 'before migrating and would fail immediately. It ships with nest-server-starter; add it ' +
68
+ '(or update the starter this project was created from).');
69
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.fileImportedSpecifiers = fileImportedSpecifiers;
37
+ exports.importedSpecifiers = importedSpecifiers;
38
+ exports.isPackageImport = isPackageImport;
39
+ exports.isRelativeCoreImport = isRelativeCoreImport;
40
+ /**
41
+ * The module specifiers a file imports — read from the syntax tree, never from text.
42
+ *
43
+ * The detectors that ask "does this file still import X?" used to search the raw source
44
+ * and blank out comments first, because a docblock that DOCUMENTS the conversion quotes
45
+ * the very syntax they look for (`nest-server-starter`'s
46
+ * `tests/unit/bootstrap-diagnostics.spec.ts` names `from '@lenne.tech/nest-server'` in
47
+ * prose, and the detector told the user to rewrite imports that file does not have).
48
+ *
49
+ * That workaround does not hold. `stripComments` drives a standalone `ts.createScanner`
50
+ * in a plain `scan()` loop, and a template literal WITH a substitution cannot be read
51
+ * that way: the scanner hands back `TemplateHead` and needs `reScanTemplateToken()` to
52
+ * continue. Without it every token after the first `` `x${…}` `` is misread, so comments
53
+ * from there on pass through verbatim. Bisected on `src/templates/check/check.mjs`: the
54
+ * first surviving comment appears right after `` `${s.toFixed(1)}s` `` on line 70, and a
55
+ * two-line fixture reproduces it. A plain template without substitution is fine; regex
56
+ * literals and division — the first suspects — are not the cause.
57
+ * Since template literals are everywhere, degradation was present in 23 of 76 real
58
+ * frontend files and 21 of 50 backend files; only the absence of a needle-quoting
59
+ * comment BELOW such a template kept it from producing false alarms.
60
+ *
61
+ * Asking the parser removes the whole class: a comment is not part of the AST, so there
62
+ * is nothing to strip and nothing to get wrong.
63
+ */
64
+ const ts = __importStar(require("typescript"));
65
+ /** `<script>` / `<script setup>` blocks of a Vue SFC, which is not valid TypeScript as a whole. */
66
+ const VUE_SCRIPT_BLOCK = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
67
+ /**
68
+ * Same, for a file on disk. A `.vue` SFC is not valid TypeScript, so its `<script>`
69
+ * blocks are extracted first and parsed individually — the template and style blocks
70
+ * cannot contain imports and are skipped. Everything else is parsed as-is.
71
+ *
72
+ * @returns The specifiers, or `[]` when the file cannot be read.
73
+ */
74
+ function fileImportedSpecifiers(filePath, read) {
75
+ const source = read(filePath);
76
+ if (!source) {
77
+ return [];
78
+ }
79
+ if (!filePath.endsWith('.vue')) {
80
+ return importedSpecifiers(source, filePath);
81
+ }
82
+ const specifiers = [];
83
+ for (const [, block] of source.matchAll(VUE_SCRIPT_BLOCK)) {
84
+ specifiers.push(...importedSpecifiers(block, `${filePath}.ts`));
85
+ }
86
+ return specifiers;
87
+ }
88
+ /**
89
+ * Every module specifier `source` imports, exports from, dynamically imports or
90
+ * requires. Duplicates are preserved; order follows the file.
91
+ *
92
+ * Covers `import x from 'y'`, `import 'y'`, `export … from 'y'`, `export * from 'y'`,
93
+ * `import y = require('y')`, `await import('y')` and `require('y')`.
94
+ *
95
+ * @param source The file's text.
96
+ * @param fileName Only used to pick the parser dialect (`.tsx` vs `.ts`).
97
+ */
98
+ function importedSpecifiers(source, fileName = 'file.ts') {
99
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
100
+ const specifiers = [];
101
+ const literalText = (node) => node && ts.isStringLiteralLike(node) ? node.text : null;
102
+ const visit = (node) => {
103
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
104
+ const text = literalText(node.moduleSpecifier);
105
+ if (text !== null) {
106
+ specifiers.push(text);
107
+ }
108
+ }
109
+ else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
110
+ const text = literalText(node.moduleReference.expression);
111
+ if (text !== null) {
112
+ specifiers.push(text);
113
+ }
114
+ }
115
+ else if (ts.isCallExpression(node)) {
116
+ const callee = node.expression;
117
+ const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
118
+ const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword;
119
+ if (isRequire || isDynamicImport) {
120
+ const text = literalText(node.arguments[0]);
121
+ if (text !== null) {
122
+ specifiers.push(text);
123
+ }
124
+ }
125
+ }
126
+ ts.forEachChild(node, visit);
127
+ };
128
+ visit(sourceFile);
129
+ return specifiers;
130
+ }
131
+ /** True when `specifier` is `pkg` itself or a deep import of it (`pkg/sub/path`). */
132
+ function isPackageImport(specifier, pkg) {
133
+ return specifier === pkg || specifier.startsWith(`${pkg}/`);
134
+ }
135
+ /**
136
+ * True when `specifier` is a relative path whose last segment is `core`, or that leads
137
+ * into a `core` directory — `./core`, `../core`, `../../core/utils`.
138
+ */
139
+ function isRelativeCoreImport(specifier) {
140
+ if (!specifier.startsWith('.')) {
141
+ return false;
142
+ }
143
+ return specifier.split('/').includes('core');
144
+ }
@@ -1,10 +1,44 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.findExecutable = findExecutable;
4
37
  exports.isWindows = isWindows;
38
+ exports.spawnCmd = spawnCmd;
5
39
  exports.spawnCmdSync = spawnCmdSync;
6
40
  exports.windowsAppData = windowsAppData;
7
- const cross_spawn_1 = require("cross-spawn");
41
+ const cross_spawn_1 = __importStar(require("cross-spawn"));
8
42
  const fs_1 = require("fs");
9
43
  const path_1 = require("path");
10
44
  const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
@@ -48,6 +82,24 @@ function findExecutable(name, options = {}) {
48
82
  function isWindows(platform = process.platform) {
49
83
  return platform === 'win32';
50
84
  }
85
+ /**
86
+ * `spawn` that also runs `.cmd`/`.bat` shims on Windows.
87
+ *
88
+ * The asynchronous counterpart of `spawnCmdSync`, with the same delegation and
89
+ * the same argument limit — see there. Needed because every long-running child
90
+ * the CLI starts (`pnpm run dev`, `pnpm start`, a Playwright run) is spawned
91
+ * asynchronously, and `pnpm` is `pnpm.cmd` on Windows: Node refuses to exec a
92
+ * `.cmd` directly (CVE-2024-27980, `EINVAL`), so without this nothing starts
93
+ * there at all.
94
+ *
95
+ * `detached` and `stdio` pass through unchanged, so a caller keeps its process
96
+ * group and its log-file descriptors. On Windows cross-spawn interposes
97
+ * `cmd.exe /c`, which means the reported pid is that of `cmd.exe` — the child
98
+ * tree, not a process group, is what a killer has to walk there.
99
+ */
100
+ function spawnCmd(command, args = [], options = {}) {
101
+ return (0, cross_spawn_1.default)(command, [...args], options);
102
+ }
51
103
  /**
52
104
  * `spawnSync` that also runs `.cmd`/`.bat` shims on Windows.
53
105
  *
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.findSpinnerLeaks = findSpinnerLeaks;
37
+ /**
38
+ * Finds progress spinners that can be left running.
39
+ *
40
+ * gluegun's `spin(text)` is `require('ora')(text).start()`. ora 4.0.2 with the
41
+ * default `discardStdin: true` does three things on start that only a stop undoes:
42
+ * it puts the terminal into raw mode (remembering the value it found), it calls
43
+ * `stdin.resume()` and adds a `data` listener, and it starts a `setInterval` for
44
+ * the animation.
45
+ *
46
+ * A spinner that is never stopped therefore does not merely look untidy — the
47
+ * interval and the resumed stdin keep the Node event loop alive, so the process
48
+ * never exits, and the terminal stays in raw mode. That combination is how
49
+ * `lt fullstack init` could reach the end of its work and simply not return, with
50
+ * a spinner still turning. `failRun` deliberately sets `process.exitCode` instead
51
+ * of calling `process.exit()` (so a failure message is never truncated), which
52
+ * means "the event loop drains" is a precondition the CLI relies on.
53
+ *
54
+ * ## What "stopped" means here
55
+ *
56
+ * This is a dominance approximation, not a full control-flow analysis, and it is
57
+ * deliberately conservative in the direction that avoids false alarms — a guard
58
+ * that flags correct code gets switched off. A statement list definitely stops a
59
+ * spinner when it contains a direct stop call, or an `if` whose BOTH branches
60
+ * stop, or a `try` whose body and `catch` both stop (or whose `finally` stops).
61
+ * Anything else does not count.
62
+ *
63
+ * Two questions are asked per spinner:
64
+ *
65
+ * 1. Can the end of its enclosing block be reached without a stop? That is the
66
+ * silent case — the command falls off the end, exit code 0, spinner running.
67
+ * 2. Is there a `return` after it that no stop dominates? That is the early-exit
68
+ * case — an error path that prints and leaves.
69
+ *
70
+ * Returns inside a nested function are ignored: they leave the callback, not the
71
+ * command.
72
+ */
73
+ const ts = __importStar(require("typescript"));
74
+ /** Methods that stop an ora spinner. `stopAndPersist` is what `succeed`/`fail`/… call. */
75
+ const STOP_METHODS = new Set(['fail', 'info', 'stop', 'stopAndPersist', 'succeed', 'warn']);
76
+ /**
77
+ * Every spinner in `source` that can outlive the code path that started it.
78
+ *
79
+ * @param source The file's text.
80
+ * @param fileName Only used to pick the parser dialect.
81
+ */
82
+ function findSpinnerLeaks(source, fileName = 'file.ts') {
83
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
84
+ const leaks = [];
85
+ const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
86
+ for (const declaration of spinnerDeclarations(sourceFile)) {
87
+ const name = declaration.name.getText(sourceFile);
88
+ const block = enclosingBlock(declaration);
89
+ if (!block) {
90
+ continue;
91
+ }
92
+ const returns = returnsAfter(block, declaration);
93
+ // `return spinner;` hands ownership to the caller, which is then responsible
94
+ // for stopping it — a factory like `logger.ts#spin` is not a leak. One such
95
+ // return exempts the whole declaration, because the spinner's lifetime is no
96
+ // longer decided in this function.
97
+ if (returns.some((statement) => handsOverOwnership(statement, name))) {
98
+ continue;
99
+ }
100
+ const after = statementsAfter(block, declaration);
101
+ if (!after.some((statement) => definitelyStops(statement, name, sourceFile))) {
102
+ leaks.push({
103
+ line: lineOf(declaration),
104
+ reason: after.length === 0 ? 'never-stopped' : 'falls-through',
105
+ spinner: name,
106
+ });
107
+ }
108
+ for (const returnStatement of returns) {
109
+ if (!dominatedByStop(returnStatement, block, declaration, name, sourceFile)) {
110
+ leaks.push({ line: lineOf(returnStatement), reason: 'return-without-stop', spinner: name });
111
+ }
112
+ }
113
+ }
114
+ return leaks.sort((a, b) => a.line - b.line);
115
+ }
116
+ /**
117
+ * True when executing `statement` always stops `name`.
118
+ *
119
+ * Only the shapes the CLI actually uses are recognised. Everything unrecognised
120
+ * answers false, so an unusual construct produces a finding to look at rather
121
+ * than a silent pass.
122
+ */
123
+ function definitelyStops(statement, name, sourceFile) {
124
+ if (ts.isExpressionStatement(statement)) {
125
+ return isStopCall(statement.expression, name, sourceFile);
126
+ }
127
+ if (ts.isBlock(statement)) {
128
+ return statement.statements.some((child) => definitelyStops(child, name, sourceFile));
129
+ }
130
+ if (ts.isIfStatement(statement)) {
131
+ // Only an if/else that stops on BOTH sides stops unconditionally.
132
+ return (!!statement.elseStatement &&
133
+ definitelyStops(statement.thenStatement, name, sourceFile) &&
134
+ definitelyStops(statement.elseStatement, name, sourceFile));
135
+ }
136
+ if (ts.isTryStatement(statement)) {
137
+ if (statement.finallyBlock && definitelyStops(statement.finallyBlock, name, sourceFile)) {
138
+ return true;
139
+ }
140
+ return (definitelyStops(statement.tryBlock, name, sourceFile) &&
141
+ !!statement.catchClause &&
142
+ definitelyStops(statement.catchClause.block, name, sourceFile));
143
+ }
144
+ return false;
145
+ }
146
+ /**
147
+ * True when some stop of `name` is guaranteed to run before `returnStatement`.
148
+ *
149
+ * Walks out from the return towards `block`, and at each level checks the
150
+ * statements that precede the one we came from — a stop in any of them dominates.
151
+ */
152
+ function dominatedByStop(returnStatement, block, declaration, name, sourceFile) {
153
+ let child = returnStatement;
154
+ let parent = returnStatement.parent;
155
+ while (parent) {
156
+ if (ts.isBlock(parent) || ts.isSourceFile(parent)) {
157
+ for (const statement of parent.statements) {
158
+ if (statement === child || statement.getStart(sourceFile) >= child.getStart(sourceFile)) {
159
+ break;
160
+ }
161
+ if (statement.getStart(sourceFile) > declaration.getStart(sourceFile) &&
162
+ definitelyStops(statement, name, sourceFile)) {
163
+ return true;
164
+ }
165
+ }
166
+ }
167
+ if (parent === block) {
168
+ return false;
169
+ }
170
+ child = parent;
171
+ parent = parent.parent;
172
+ }
173
+ return false;
174
+ }
175
+ /** The nearest enclosing block of a declaration — the scope its spinner must not outlive. */
176
+ function enclosingBlock(node) {
177
+ let current = node.parent;
178
+ while (current) {
179
+ if (ts.isBlock(current)) {
180
+ return current;
181
+ }
182
+ current = current.parent;
183
+ }
184
+ return undefined;
185
+ }
186
+ /** True for `return spinner;` — the caller becomes responsible for stopping it. */
187
+ function handsOverOwnership(statement, name) {
188
+ return !!statement.expression && ts.isIdentifier(statement.expression) && statement.expression.text === name;
189
+ }
190
+ /** True for `x.succeed(…)` / `x.fail(…)` / … on the spinner called `name`. */
191
+ function isStopCall(expression, name, sourceFile) {
192
+ if (!ts.isCallExpression(expression) || !ts.isPropertyAccessExpression(expression.expression)) {
193
+ return false;
194
+ }
195
+ const access = expression.expression;
196
+ return access.expression.getText(sourceFile) === name && STOP_METHODS.has(access.name.text);
197
+ }
198
+ /** Every `return` after `declaration` inside `block`, skipping nested functions. */
199
+ function returnsAfter(block, declaration) {
200
+ const found = [];
201
+ const start = declaration.getStart();
202
+ const visit = (node) => {
203
+ if (ts.isFunctionLike(node)) {
204
+ return; // A return there leaves the callback, not the command.
205
+ }
206
+ if (ts.isReturnStatement(node) && node.getStart() > start) {
207
+ found.push(node);
208
+ }
209
+ ts.forEachChild(node, visit);
210
+ };
211
+ for (const statement of block.statements) {
212
+ if (statement.getStart() > start) {
213
+ visit(statement);
214
+ }
215
+ }
216
+ return found;
217
+ }
218
+ /** `const x = spin(…)` declarations, including `toolbox.print.spin(…)`. */
219
+ function spinnerDeclarations(sourceFile) {
220
+ const found = [];
221
+ const visit = (node) => {
222
+ if (ts.isVariableDeclaration(node) &&
223
+ ts.isIdentifier(node.name) &&
224
+ node.initializer &&
225
+ ts.isCallExpression(node.initializer)) {
226
+ const callee = node.initializer.expression;
227
+ const name = ts.isPropertyAccessExpression(callee)
228
+ ? callee.name.text
229
+ : ts.isIdentifier(callee)
230
+ ? callee.text
231
+ : '';
232
+ if (name === 'spin') {
233
+ found.push(node);
234
+ }
235
+ }
236
+ ts.forEachChild(node, visit);
237
+ };
238
+ visit(sourceFile);
239
+ return found;
240
+ }
241
+ /** The statements of `block` that follow `declaration`. */
242
+ function statementsAfter(block, declaration) {
243
+ const start = declaration.getStart();
244
+ return block.statements.filter((statement) => statement.getStart() > start);
245
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @lt-check-wrapper 3.13.0
2
+ // @lt-check-wrapper 3.13.1
3
3
  /**
4
4
  * Quiet, report-driven wrapper around the project `check` pipeline.
5
5
  *
@@ -33,7 +33,7 @@
33
33
  * scripts/check-wrapper-version.test.mjs fails when it drifts from
34
34
  * package.json. In a generated project it keeps the release it came from.
35
35
  */
36
- import { execSync, spawn } from 'node:child_process';
36
+ import { execFileSync, execSync, spawn } from 'node:child_process';
37
37
  import { readFileSync, realpathSync } from 'node:fs';
38
38
  import { dirname, join } from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
@@ -84,6 +84,12 @@ function fmtDuration(ms) {
84
84
  // binds only to the first simple command, so a step written with `;` or a
85
85
  // leading `cd` would be reported as pinned and run unpinned. Both carry the
86
86
  // same value, so they cannot disagree.
87
+ //
88
+ // On Windows only the second level exists: cmd.exe has no `VAR=value cmd`
89
+ // syntax and would run the assignment as a command, so `pinCheckBuildDir`
90
+ // writes no prefix there and `stepEnv` carries the pin alone. That is why
91
+ // `stepEnv` keys on the command rather than on the presence of a prefix —
92
+ // scripts/check-windows.test.mjs holds the two together.
87
93
  const CHECK_BUILD_DIR = '.nuxt-check';
88
94
 
89
95
  // Deliberately narrow: a blanket prefix would override the dirs the package.json
@@ -123,8 +129,15 @@ export const PM_INVOCATION = new RegExp(`${PM_INSTALL.source}|${PM_AUDIT.source}
123
129
  * existing-pin test is NOT anchored to the start of the string, because the
124
130
  * shape the nuxt starter actually ships is `cross-env NUXT_BUILD_DIR=… pnpm …`,
125
131
  * which a `^` anchor does not see.
132
+ *
133
+ * On Windows there is no prefix at all: `VAR=value cmd` is POSIX shell syntax,
134
+ * and cmd.exe reads the assignment as the command name ('NUXT_BUILD_DIR' is not
135
+ * recognized as an internal or external command). `stepEnv` below hands the
136
+ * same value to the child as a real environment variable, which is what every
137
+ * step relies on there.
126
138
  */
127
- export function pinCheckBuildDir(cmd) {
139
+ export function pinCheckBuildDir(cmd, platform = process.platform) {
140
+ if (platform === 'win32') return cmd;
128
141
  if (!PM_INVOCATION.test(cmd) || /(^|\s)NUXT_BUILD_DIR=/.test(cmd)) return cmd;
129
142
  return `NUXT_BUILD_DIR=${CHECK_BUILD_DIR} ${cmd}`;
130
143
  }
@@ -132,13 +145,22 @@ export function pinCheckBuildDir(cmd) {
132
145
  /**
133
146
  * The environment a step needs beyond the inherited one.
134
147
  *
135
- * Mirrors the textual pin so a shell construct the prefix cannot reach (a `;`
136
- * separator, a leading `cd`) still gets the isolated build dir. Only for
137
- * commands the pin applies to — a step that pins itself keeps its own value,
138
- * because the prefix check already declined to touch it.
148
+ * Keyed on the COMMAND, not on the textual pin. Two reasons, and the second is
149
+ * why reading the text was not enough:
150
+ *
151
+ * - a shell construct the prefix cannot reach (a `;` separator, a leading
152
+ * `cd`) would be reported as pinned and run unpinned;
153
+ * - on Windows `pinCheckBuildDir` writes no prefix, so a predicate that looks
154
+ * for one would find nothing and hand the step NO build dir — the pin would
155
+ * silently disappear on exactly the platform that cannot use the prefix.
156
+ *
157
+ * A step that pins itself keeps its own value: the textual prefix wins over the
158
+ * inherited environment, and `cross-env` sets the variable itself.
139
159
  */
140
- function stepEnv(step) {
141
- return /(^|\s)NUXT_BUILD_DIR=/.test(step.cmd) ? { NUXT_BUILD_DIR: CHECK_BUILD_DIR } : null;
160
+ export function stepEnv(step) {
161
+ return PM_INVOCATION.test(step.cmd) || /(^|\s)NUXT_BUILD_DIR=/.test(step.cmd)
162
+ ? { NUXT_BUILD_DIR: CHECK_BUILD_DIR }
163
+ : null;
142
164
  }
143
165
 
144
166
  // ── step classification ────────────────────────────────────────────────────
@@ -407,11 +429,39 @@ const IDLE_TIMEOUT_MS = (() => {
407
429
  // ── command runner ─────────────────────────────────────────────────────────
408
430
  const RUNNING = new Set();
409
431
 
432
+ /**
433
+ * How to kill a process tree on this platform.
434
+ *
435
+ * Windows has neither `pgrep` nor signals: `process.kill(pid, 'SIGTERM')` there ends the one
436
+ * process and orphans its children, and `taskkill /T` alone was measured to leave the tree
437
+ * running ("Die Beendigung dieses Prozesses muss erzwungen werden") with the port still held.
438
+ * `/F` is what actually frees it, so the whole tree is force-killed in one call.
439
+ *
440
+ * The consequence is worth stating, because it is a behaviour difference and not an
441
+ * implementation detail: on Windows there is no graceful stage. The SIGTERM call already
442
+ * terminates, so a child's graceful-shutdown hook does not run — a server gets no chance to
443
+ * close connections or flush. The SIGKILL escalation five seconds later then finds nothing.
444
+ *
445
+ * Split out as a pure function so both branches can be tested from either platform.
446
+ */
447
+ export function killTreePlan(pid, signal, platform = process.platform) {
448
+ return platform === 'win32' ? { args: ['/PID', String(pid), '/T', '/F'], command: 'taskkill' } : { signal };
449
+ }
450
+
410
451
  // Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
411
452
  // fork workers). Killing only the direct child orphans the tree — exactly the
412
453
  // zombie workers a deadlock leaves behind. Children are collected via pgrep
413
454
  // and killed leaves-first.
414
455
  function killTree(child, signal = 'SIGTERM') {
456
+ const plan = killTreePlan(child.pid, signal);
457
+ if (plan.command) {
458
+ try {
459
+ execFileSync(plan.command, plan.args, { stdio: 'ignore' });
460
+ } catch {
461
+ /* already gone, or taskkill refused — nothing further to try */
462
+ }
463
+ return;
464
+ }
415
465
  const pids = [];
416
466
  const collect = (pid) => {
417
467
  pids.push(pid);
@@ -1,13 +1,13 @@
1
1
  {
2
- "commit": "61f8c9f9b3292cca71d9bfdfbf18e11d47a9a4a8",
2
+ "commit": "fcf7c83cffa37b09db88481833e7ccc0dd2fbe0f",
3
3
  "files": {
4
4
  "build-test-gate.mjs": "865bf1b9dd28151f53b9f2dfe4c3c46d29b9303fb1c2f497ce0e3136bcd37d71",
5
- "check.mjs": "783dce99aad1d2faf3d880184e43422c6b8433afcfa50c1f652b2d8ef6b2a1db",
5
+ "check.mjs": "fed669a625b56d907ab2e3693c2e5797a877c460588bd79f89249d88c568c178",
6
6
  "lib/ansi.mjs": "f997ee6e9bf1c7ee54ab612d4040fcc778064ec160eae19bf5c3960a75a6e2ff",
7
7
  "lib/audit-report.mjs": "503825c3342dec1ee5d8d70e5af21b73e798faee320426b2492190cde0801df2",
8
8
  "lib/workspace-packages.mjs": "ccdd133622a88186c924bc8271d35cf1f8357dc26d4a607998ca5e91fce5bf88"
9
9
  },
10
- "ref": "v3.13.0",
10
+ "ref": "v3.13.1",
11
11
  "repository": "lenneTech/lt-monorepo",
12
- "version": "3.13.0"
12
+ "version": "3.13.1"
13
13
  }