@lenne.tech/cli 1.48.0 → 1.49.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.
- package/build/commands/dev/test.js +5 -3
- package/build/commands/fullstack/init.js +12 -3
- package/build/commands/git/squash.js +4 -1
- package/build/extensions/api-mode.js +69 -0
- package/build/extensions/frontend-helper.js +11 -12
- package/build/extensions/server.js +42 -32
- package/build/lib/check-template-sync.js +62 -2
- package/build/lib/config-env-graphql.js +106 -0
- package/build/lib/cross-env.js +94 -0
- package/build/lib/dev-api-launch.js +34 -8
- package/build/lib/dev-package-manager.js +13 -9
- package/build/lib/dev-test-session.js +85 -39
- package/build/lib/fail-run.js +11 -0
- package/build/lib/heal-vendor-migrate-store.js +4 -3
- package/build/lib/migrate-scripts.js +69 -0
- package/build/lib/module-specifiers.js +144 -0
- package/build/lib/spinner-lifetime.js +245 -0
- package/build/templates/check/check.mjs +59 -9
- package/build/templates/check/lt-monorepo-pin.json +4 -4
- package/docs/commands.md +16 -12
- package/package.json +1 -1
- package/build/lib/strip-comments.js +0 -78
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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,
|
|
@@ -74,16 +74,20 @@ function buildCommand(bin, name) {
|
|
|
74
74
|
},
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Manager behind an override like `LT_PM_BIN`.
|
|
79
|
+
*
|
|
80
|
+
* Reads the file NAME, split on both separators and stripped of a Windows launcher
|
|
81
|
+
* extension: there the binary is `C:\\…\\pnpm.cmd`, which neither `endsWith('pnpm')`
|
|
82
|
+
* nor `includes('/pnpm')` matched — the override then resolved to `unknown` and the
|
|
83
|
+
* caller drove the wrong manager.
|
|
84
|
+
*/
|
|
77
85
|
function inferNameFromBin(bin) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return 'yarn';
|
|
84
|
-
}
|
|
85
|
-
if (lower.endsWith('npm') || lower.includes('/npm')) {
|
|
86
|
-
return 'npm';
|
|
86
|
+
var _a;
|
|
87
|
+
const file = (_a = bin.toLowerCase().split(/[\\/]/).pop()) !== null && _a !== void 0 ? _a : '';
|
|
88
|
+
const name = file.replace(/\.(bat|cmd|exe|ps1)$/, '');
|
|
89
|
+
if (name === 'pnpm' || name === 'yarn' || name === 'npm') {
|
|
90
|
+
return name;
|
|
87
91
|
}
|
|
88
92
|
return 'unknown';
|
|
89
93
|
}
|
|
@@ -17,9 +17,11 @@ exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
|
|
|
17
17
|
exports.buildTestAppEnv = buildTestAppEnv;
|
|
18
18
|
exports.hasTestSession = hasTestSession;
|
|
19
19
|
exports.isStackServing = isStackServing;
|
|
20
|
+
exports.migrationFailedError = migrationFailedError;
|
|
20
21
|
exports.resolveTestSession = resolveTestSession;
|
|
21
22
|
exports.runShardedTestSession = runShardedTestSession;
|
|
22
23
|
exports.shardReportDir = shardReportDir;
|
|
24
|
+
exports.startTestApi = startTestApi;
|
|
23
25
|
exports.tearDownAllTestSessions = tearDownAllTestSessions;
|
|
24
26
|
exports.tearDownTestSession = tearDownTestSession;
|
|
25
27
|
exports.testAppEntryCandidates = testAppEntryCandidates;
|
|
@@ -30,8 +32,10 @@ exports.unreachableStackError = unreachableStackError;
|
|
|
30
32
|
* Brings up a SECOND, fully separate stack (own URLs, own internal ports,
|
|
31
33
|
* own Caddy block, own database) that runs PARALLEL to — and never touches —
|
|
32
34
|
* the developer's `lt dev up` session. Used to run the Playwright E2E suite
|
|
33
|
-
* against a
|
|
34
|
-
*
|
|
35
|
+
* against a dedicated database so a developer can keep working in their own
|
|
36
|
+
* environment while tests run, and so a test run never pollutes dev data. That
|
|
37
|
+
* database outlives the run — the CLI never drops it — so the API is migrated
|
|
38
|
+
* before it starts, like every other environment (DEV-3289).
|
|
35
39
|
*
|
|
36
40
|
* Topology (for slug `svl`):
|
|
37
41
|
* - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
|
|
@@ -99,12 +103,13 @@ const TEST_PORT_BASE = 4500;
|
|
|
99
103
|
* `systemSetup.initialAdmin.*` and seeds the admin ONCE on an empty DB (it never
|
|
100
104
|
* overwrites an existing admin), so a set-up system exists before the suite runs.
|
|
101
105
|
* A fresh template project's standard auth E2E specs assume such a system — they
|
|
102
|
-
* do no self-setup — so without this they fail LOCALLY on
|
|
106
|
+
* do no self-setup — so without this they fail LOCALLY on a test DB that has no
|
|
107
|
+
* users yet (its first run, or after a global-setup reset it).
|
|
103
108
|
* These are the exact values the lt-monorepo template CI uses
|
|
104
109
|
* (`.gitlab-ci.yml` / `.github/workflows/test.yml`), keeping CI ↔ local parity.
|
|
105
110
|
*
|
|
106
|
-
* Injected ONLY into the `lt dev test` API process (its DB is
|
|
107
|
-
*
|
|
111
|
+
* Injected ONLY into the `lt dev test` API process (its DB is the dedicated
|
|
112
|
+
* `<…>-test` one), NEVER into `lt dev up` — no surprise admin in the dev DB.
|
|
108
113
|
*/
|
|
109
114
|
exports.TEST_INITIAL_ADMIN_ENV = {
|
|
110
115
|
NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL: 'ci-admin@test.com',
|
|
@@ -276,7 +281,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
276
281
|
log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
|
|
277
282
|
if (apiUrl)
|
|
278
283
|
log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
|
|
279
|
-
|
|
284
|
+
// No claim about resetting here: the CLI never drops this DB, and whether a
|
|
285
|
+
// project's global-setup does is the project's business. The claim used to be
|
|
286
|
+
// printed unconditionally and sent a diagnosis looking for a reset that never
|
|
287
|
+
// happened instead of at the un-migrated DB (DEV-3289).
|
|
288
|
+
log.info(` db: mongodb://127.0.0.1/${dbName}`);
|
|
280
289
|
log.info('');
|
|
281
290
|
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
282
291
|
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
@@ -294,43 +303,23 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
294
303
|
// hands it to the Playwright child as well.
|
|
295
304
|
const appEnv = buildTestAppEnv(devEnv.app.env);
|
|
296
305
|
const pids = {};
|
|
297
|
-
// --- API: compiled (`node dist`) for stability;
|
|
298
|
-
// own
|
|
299
|
-
// first shard produced. Per-component PM detection mirrors `lt dev up`:
|
|
300
|
-
// a monorepo with an npm api and a pnpm app must drive each correctly. ---
|
|
306
|
+
// --- API: compiled (`node dist`) for stability, migrated first; falls back to
|
|
307
|
+
// the project's own start script (see `startTestApi`). ---
|
|
301
308
|
if (layout.apiDir && apiPort) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
306
|
-
build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: layout.apiDir, env: process.env });
|
|
307
|
-
}
|
|
308
|
-
const entry = (0, dev_api_launch_1.findCompiledEntry)(layout.apiDir);
|
|
309
|
-
// Seed a throwaway initial admin into the fresh, isolated test DB so the
|
|
310
|
-
// standard auth E2E specs run against a set-up system — locally exactly like
|
|
311
|
-
// the lt-monorepo template CI. Defaults first so an explicitly inherited
|
|
309
|
+
// Seed a throwaway initial admin into the isolated test DB so the standard
|
|
310
|
+
// auth E2E specs run against a set-up system — locally exactly like the
|
|
311
|
+
// lt-monorepo template CI. Defaults first so an explicitly inherited
|
|
312
312
|
// `NSC__…INITIAL_ADMIN__…` still wins (deliberate override respected). Only
|
|
313
313
|
// reached from `lt dev test` — `lt dev up` never calls bringUpTestSession.
|
|
314
314
|
const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
|
|
328
|
-
apiSpawn = (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
|
|
329
|
-
cwd: layout.apiDir,
|
|
330
|
-
env: apiEnv,
|
|
331
|
-
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
332
|
-
});
|
|
333
|
-
}
|
|
315
|
+
const apiSpawn = yield startTestApi({
|
|
316
|
+
apiDir: layout.apiDir,
|
|
317
|
+
apiEnv,
|
|
318
|
+
dbName,
|
|
319
|
+
log,
|
|
320
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
321
|
+
skipBuild,
|
|
322
|
+
});
|
|
334
323
|
if (apiSpawn)
|
|
335
324
|
pids.api = apiSpawn.pid;
|
|
336
325
|
}
|
|
@@ -515,6 +504,19 @@ function hasTestSession(root) {
|
|
|
515
504
|
function isStackServing(status) {
|
|
516
505
|
return status > 0 && ![502, 503, 504].includes(status);
|
|
517
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* The abort thrown when the test DB cannot be migrated.
|
|
509
|
+
*
|
|
510
|
+
* An abort rather than a warning for the same reason as `unreachableStackError`:
|
|
511
|
+
* a suite run on an un-migrated DB tests a data state no environment has, and
|
|
512
|
+
* whatever it reports is about the test setup, not about the feature. Unlike the
|
|
513
|
+
* API's boot, `migrate:up` runs in the foreground, so its output is already on
|
|
514
|
+
* screen right above this message.
|
|
515
|
+
*/
|
|
516
|
+
function migrationFailedError(dbName, exitCode) {
|
|
517
|
+
return new Error(`migrate:up failed (exit ${String(exitCode)}) against the test DB "${dbName}" — test API not started, ` +
|
|
518
|
+
'because a suite on an un-migrated DB tests a state no environment has. The migration output is above.');
|
|
519
|
+
}
|
|
518
520
|
/** Build the dedicated test identity + test DB name for a project. */
|
|
519
521
|
function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
|
|
520
522
|
const names = testStackNames(shardIndex);
|
|
@@ -599,6 +601,50 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
|
|
|
599
601
|
function shardReportDir(root, shardIndex) {
|
|
600
602
|
return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
|
|
601
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Start the test stack's API: build → apply pending migrations → start the
|
|
606
|
+
* compiled bundle, or fall back to the project's own `start` script when no
|
|
607
|
+
* bundle is available.
|
|
608
|
+
*
|
|
609
|
+
* The compiled start bypasses that `start` script and therefore the
|
|
610
|
+
* `migrate:up` it chains in front of the server. The test DB outlives every
|
|
611
|
+
* run — the CLI never drops it — so without the explicit step here it drifted
|
|
612
|
+
* behind every environment that does migrate (DEV-3289). The migration runs
|
|
613
|
+
* with `apiEnv`, the API's own env, so both resolve the same database, and
|
|
614
|
+
* before the spawn, because the API syncs its indexes against whatever it finds
|
|
615
|
+
* on boot. A failed migration throws `migrationFailedError`.
|
|
616
|
+
*
|
|
617
|
+
* `skipBuild` (sibling shards) reuses the dist the first shard produced but
|
|
618
|
+
* still migrates: every shard has a database of its own. The fallback does not
|
|
619
|
+
* migrate itself — the `start` script it runs owns that, exactly as under
|
|
620
|
+
* `lt dev up`. Per-directory package manager, like `lt dev up`: a monorepo with
|
|
621
|
+
* an npm API and a pnpm App must drive each with its own.
|
|
622
|
+
*/
|
|
623
|
+
function startTestApi(opts) {
|
|
624
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
625
|
+
const { apiDir, apiEnv, dbName, log, logFile, skipBuild } = opts;
|
|
626
|
+
const apiPm = (0, dev_package_manager_1.pickPackageManager)(apiDir);
|
|
627
|
+
let build = 0;
|
|
628
|
+
if (!skipBuild) {
|
|
629
|
+
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
630
|
+
build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: apiDir, env: process.env });
|
|
631
|
+
}
|
|
632
|
+
const entry = (0, dev_api_launch_1.findCompiledEntry)(apiDir);
|
|
633
|
+
if (build === 0 && entry) {
|
|
634
|
+
const migration = yield (0, dev_api_launch_1.applyPendingMigrations)({ apiDir, env: apiEnv, pm: apiPm });
|
|
635
|
+
if (migration.status === 'failed')
|
|
636
|
+
throw migrationFailedError(dbName, migration.exitCode);
|
|
637
|
+
if (migration.status === 'skipped')
|
|
638
|
+
log.info(log.dim(`no migrate:up script — ${dbName} is not migrated`));
|
|
639
|
+
// Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
|
|
640
|
+
// bundles with `Bun.build({ target: 'bun' })` and dies under node with
|
|
641
|
+
// "__require is not a function" (DEV-3208).
|
|
642
|
+
return (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(apiDir), [entry], { cwd: apiDir, env: apiEnv, logFile });
|
|
643
|
+
}
|
|
644
|
+
log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
|
|
645
|
+
return (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), { cwd: apiDir, env: apiEnv, logFile });
|
|
646
|
+
});
|
|
647
|
+
}
|
|
602
648
|
/**
|
|
603
649
|
* Tear down the unsharded test stack AND every sharded stack discovered on disk
|
|
604
650
|
* (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
|
package/build/lib/fail-run.js
CHANGED
|
@@ -20,6 +20,17 @@ exports.failRun = failRun;
|
|
|
20
20
|
* buffered output, including the spinner's own failure message, which is the one
|
|
21
21
|
* line the operator actually needs.
|
|
22
22
|
*
|
|
23
|
+
* That choice carries a precondition worth stating, because it was violated:
|
|
24
|
+
* setting `process.exitCode` only ends the process once **the event loop drains**,
|
|
25
|
+
* and `bin/lt` has no terminal `process.exit()` after `run()`. A running ora
|
|
26
|
+
* spinner holds the loop open on two counts — its `setInterval` for the animation
|
|
27
|
+
* and the `stdin.resume()` its stdin discarder performs. So a command that called
|
|
28
|
+
* `failRun` and returned while a spinner was still spinning did not exit at all:
|
|
29
|
+
* no message, no prompt back, and the terminal left in raw mode. The remedy is to
|
|
30
|
+
* stop the spinner, NOT to switch this helper to `process.exit()` — that would
|
|
31
|
+
* trade a hang for the truncated-output bug this helper exists to avoid.
|
|
32
|
+
* `__tests__/spinner-lifetime.test.ts` enforces the spinner half.
|
|
33
|
+
*
|
|
23
34
|
* **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
|
|
24
35
|
* (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
|
|
25
36
|
* interactive `lt` menu a command is one step of a longer session, and failing
|
|
@@ -90,9 +90,10 @@ const ts = __importStar(require("typescript"));
|
|
|
90
90
|
* Detection runs on the TypeScript AST, not on regex-stripped text. A regex
|
|
91
91
|
* "lexer" has no string/template/regex-literal state, so a `/*` or `//` inside a
|
|
92
92
|
* literal earlier in the file silently erased the guard and triggered the very
|
|
93
|
-
* overwrite this function must avoid.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* overwrite this function must avoid. Scanning is not enough either: a standalone
|
|
94
|
+
* `ts.createScanner` loop desynchronises on the first interpolating template
|
|
95
|
+
* literal (that is what retired `lib/strip-comments.ts`). The AST solves comments
|
|
96
|
+
* AND nesting in one step, and recognises a backtick require for free.
|
|
96
97
|
*
|
|
97
98
|
* ## Recoverability
|
|
98
99
|
*
|
|
@@ -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
|
+
}
|