@lenne.tech/cli 1.10.0 → 1.11.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.
@@ -965,13 +965,28 @@ class Server {
965
965
  // Best-effort — if we can't read upstream pkg, the starter's own
966
966
  // deps should still cover most of the framework's needs.
967
967
  }
968
+ // Snapshot the upstream CLAUDE.md for section-merge into projects/api/CLAUDE.md.
969
+ // The nest-server CLAUDE.md contains framework-specific instructions that
970
+ // Claude Code needs to work correctly with the vendored source (API conventions,
971
+ // UnifiedField usage, CrudService patterns, etc.). We capture it before the
972
+ // temp clone is deleted and merge it after the vendor-marker block.
973
+ let upstreamClaudeMd = '';
974
+ try {
975
+ const claudeMdContent = filesystem.read(`${tmpClone}/CLAUDE.md`);
976
+ if (typeof claudeMdContent === 'string') {
977
+ upstreamClaudeMd = claudeMdContent;
978
+ }
979
+ }
980
+ catch (_b) {
981
+ // Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
982
+ }
968
983
  // Snapshot the upstream commit SHA for traceability in VENDOR.md.
969
984
  let upstreamCommit = '';
970
985
  try {
971
986
  const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
972
987
  upstreamCommit = (sha || '').trim();
973
988
  }
974
- catch (_b) {
989
+ catch (_c) {
975
990
  // Non-fatal — VENDOR.md will just show an empty SHA.
976
991
  }
977
992
  try {
@@ -1002,13 +1017,39 @@ class Server {
1002
1017
  }
1003
1018
  // Copy bin/migrate.js so the project has a working migrate CLI
1004
1019
  // independent of node_modules/@lenne.tech/nest-server.
1020
+ // Overwrite existing file if present (convert-mode on existing project).
1005
1021
  if (filesystem.exists(`${tmpClone}/bin/migrate.js`)) {
1022
+ if (filesystem.exists(`${dest}/bin/migrate.js`)) {
1023
+ filesystem.remove(`${dest}/bin/migrate.js`);
1024
+ }
1006
1025
  filesystem.copy(`${tmpClone}/bin/migrate.js`, `${dest}/bin/migrate.js`);
1007
1026
  }
1008
1027
  // Copy migration-guides for vendor-sync agent reference (optional
1009
1028
  // but useful — small overhead, big value for the updater agent).
1029
+ // Preserve any project-specific guides by merging instead of overwriting.
1010
1030
  if (filesystem.exists(`${tmpClone}/migration-guides`)) {
1011
- filesystem.copy(`${tmpClone}/migration-guides`, `${dest}/migration-guides`);
1031
+ if (filesystem.exists(`${dest}/migration-guides`)) {
1032
+ // Project already has migration-guides (maybe custom ones) — merge
1033
+ // upstream files into the existing directory without removing locals.
1034
+ const upstreamGuides = filesystem.find(`${tmpClone}/migration-guides`, {
1035
+ matching: '*.md',
1036
+ recursive: false,
1037
+ }) || [];
1038
+ for (const guide of upstreamGuides) {
1039
+ const basename = require('node:path').basename(guide);
1040
+ const source = `${tmpClone}/migration-guides/${basename}`;
1041
+ const target = `${dest}/migration-guides/${basename}`;
1042
+ if (filesystem.exists(target)) {
1043
+ filesystem.remove(target);
1044
+ }
1045
+ if (filesystem.exists(source)) {
1046
+ filesystem.copy(source, target);
1047
+ }
1048
+ }
1049
+ }
1050
+ else {
1051
+ filesystem.copy(`${tmpClone}/migration-guides`, `${dest}/migration-guides`);
1052
+ }
1012
1053
  }
1013
1054
  }
1014
1055
  finally {
@@ -1089,6 +1130,43 @@ class Server {
1089
1130
  }
1090
1131
  }
1091
1132
  }
1133
+ // Edge: express exports Request/Response as TYPE-ONLY. In npm-mode this
1134
+ // was not a problem because the framework shipped as pre-compiled dist/
1135
+ // and type imports were erased before runtime. In vendor-mode, vitest/vite
1136
+ // evaluates the TypeScript source directly and chokes with:
1137
+ // "The requested module 'express' does not provide an export named 'Response'"
1138
+ // Fix: convert `import { Request, Response } from 'express'` (and variants
1139
+ // with NextFunction etc.) to type-only imports wherever they appear in the
1140
+ // vendored core. This is safe because the core code uses Request/Response
1141
+ // only as type annotations — any runtime `new Request(...)` calls refer
1142
+ // to the global Fetch API Request, not the express one.
1143
+ const expressImportRegex = /^import\s+\{([^}]*)\}\s+from\s+['"]express['"]\s*;?\s*$/gm;
1144
+ const vendoredTsFiles = filesystem.find(coreDir, {
1145
+ matching: '**/*.ts',
1146
+ recursive: true,
1147
+ }) || [];
1148
+ for (const filePath of vendoredTsFiles) {
1149
+ // filesystem.find returns paths relative to jetpack cwd — use absolute resolution
1150
+ const absPath = filePath.startsWith('/') ? filePath : require('node:path').resolve(filePath);
1151
+ if (!filesystem.exists(absPath))
1152
+ continue;
1153
+ const content = filesystem.read(absPath) || '';
1154
+ if (!content.includes("from 'express'") && !content.includes('from "express"'))
1155
+ continue;
1156
+ const patched = content.replace(expressImportRegex, (_match, names) => {
1157
+ const cleanNames = names
1158
+ .split(',')
1159
+ .map((n) => n.trim())
1160
+ .filter((n) => n.length > 0 && n !== 'type')
1161
+ // Strip any pre-existing 'type ' prefix on individual names
1162
+ .map((n) => n.replace(/^type\s+/, ''))
1163
+ .join(', ');
1164
+ return `import type { ${cleanNames} } from 'express';`;
1165
+ });
1166
+ if (patched !== content) {
1167
+ filesystem.write(absPath, patched);
1168
+ }
1169
+ }
1092
1170
  // ── 4. Rewrite consumer imports: '@lenne.tech/nest-server' → relative ─
1093
1171
  //
1094
1172
  // Every .ts file in the starter's src/server/, src/main.ts, tests/,
@@ -1180,7 +1258,7 @@ class Server {
1180
1258
  }
1181
1259
  }
1182
1260
  }
1183
- catch (_c) {
1261
+ catch (_d) {
1184
1262
  // skip unreadable file
1185
1263
  }
1186
1264
  }
@@ -1208,55 +1286,16 @@ class Server {
1208
1286
  ');',
1209
1287
  '',
1210
1288
  ].join('\n'));
1211
- // ── 4b. Copy vendor maintenance scripts ──────────────────────────────
1212
- //
1213
- // These scripts are consumed by the `nest-server-core-updater` and
1214
- // `nest-server-core-contributor` Claude Code agents and by the
1215
- // `check`/`check:fix`/`check:naf` pipelines below. They live at
1216
- // `scripts/vendor/` in every vendored project:
1217
- //
1218
- // check-vendor-freshness.mjs — non-blocking warning that the
1219
- // vendored core is behind the latest upstream release. Hooked
1220
- // into `check` / `check:fix` / `check:naf`.
1221
- //
1222
- // sync-from-upstream.ts — produces a structured diff (upstream
1223
- // delta vs. baseline, local changes, conflict map) that the
1224
- // updater agent parses when pulling a new upstream version.
1289
+ // ── 4b. (removed) ─────────────────────────────────────────────────────
1225
1290
  //
1226
- // propose-upstream-pr.ts scans local commits that touched
1227
- // src/core/ and emits per-commit patch files the contributor
1228
- // agent uses to open upstream PRs.
1229
- //
1230
- // Templates live at `src/templates/vendor-scripts/` in the CLI and
1231
- // are copied into the new project's `scripts/vendor/`.
1232
- const vendorScriptsSrc = path.join(__dirname, '..', 'templates', 'vendor-scripts');
1233
- const vendorScriptsDest = `${dest}/scripts/vendor`;
1234
- if (filesystem.exists(vendorScriptsSrc)) {
1235
- if (!filesystem.exists(vendorScriptsDest)) {
1236
- filesystem.dir(vendorScriptsDest);
1237
- }
1238
- const vendorScriptFiles = filesystem.find(vendorScriptsSrc, { matching: '*' });
1239
- for (const src of vendorScriptFiles || []) {
1240
- const base = path.basename(src);
1241
- filesystem.copy(src, `${vendorScriptsDest}/${base}`, { overwrite: true });
1242
- }
1243
- }
1244
- // ── 4c. .gitignore: ignore transient vendor script outputs ──────────
1291
+ // Previous versions copied three maintenance scripts into
1292
+ // `scripts/vendor/` (check-vendor-freshness.mjs, sync-from-upstream.ts,
1293
+ // propose-upstream-pr.ts). The sync + propose scripts duplicated what
1294
+ // the lt-dev Claude Code agents (nest-server-core-updater and
1295
+ // nest-server-core-contributor) do natively they were dead weight.
1245
1296
  //
1246
- // sync-from-upstream.ts writes diffs to scripts/vendor/sync-results/
1247
- // and propose-upstream-pr.ts writes patches to
1248
- // scripts/vendor/upstream-candidates/. Both are throw-away analysis
1249
- // artifacts and must not be committed.
1250
- const gitignorePath = `${dest}/.gitignore`;
1251
- if (filesystem.exists(gitignorePath)) {
1252
- const raw = filesystem.read(gitignorePath) || '';
1253
- const entries = ['scripts/vendor/sync-results/', 'scripts/vendor/upstream-candidates/'];
1254
- const missing = entries.filter((e) => !raw.includes(e));
1255
- if (missing.length > 0) {
1256
- const block = `\n# Vendor-sync / upstream-contribute output (transient analysis artifacts)\n${missing.join('\n')}\n`;
1257
- filesystem.write(gitignorePath, raw.trimEnd() + block);
1258
- }
1259
- }
1297
+ // The freshness check is now an inline one-liner in package.json
1298
+ // (see step 5 below). VENDOR.md is the sole vendor-specific file.
1260
1299
  // ── 5. package.json: remove @lenne.tech/nest-server, add migrate/bin ─
1261
1300
  //
1262
1301
  // Delete the framework dep — it's no longer needed since src/core/
@@ -1333,10 +1372,8 @@ class Server {
1333
1372
  // existing migrate:* scripts are already correct for npm mode; we
1334
1373
  // need them pointing at the local bin + local ts-compiler.
1335
1374
  //
1336
- // Also wire the vendor maintenance scripts (check-vendor-freshness,
1337
- // vendor:sync, vendor:propose-upstream) and hook the freshness
1338
- // check into `check` / `check:fix` / `check:naf` as a non-blocking
1339
- // first step.
1375
+ // Wire the inline vendor-freshness check and hook it into
1376
+ // `check` / `check:fix` / `check:naf` as a non-blocking first step.
1340
1377
  if (pkg.scripts && typeof pkg.scripts === 'object') {
1341
1378
  const scripts = pkg.scripts;
1342
1379
  const migrateArgs = '--store ./migrations-utils/migrate.js --migrations-dir ./migrations --compiler ts:./migrations-utils/ts-compiler.js';
@@ -1349,11 +1386,26 @@ class Server {
1349
1386
  scripts['migrate:test:up'] = `NODE_ENV=test node ./bin/migrate.js up ${migrateArgs}`;
1350
1387
  scripts['migrate:preview:up'] = `NODE_ENV=preview node ./bin/migrate.js up ${migrateArgs}`;
1351
1388
  scripts['migrate:prod:up'] = `NODE_ENV=production node ./bin/migrate.js up ${migrateArgs}`;
1352
- // Vendor maintenance scripts
1353
- scripts['check:vendor-freshness'] = 'node scripts/vendor/check-vendor-freshness.mjs';
1354
- scripts['vendor:sync'] = 'node -r ./migrations-utils/ts-compiler scripts/vendor/sync-from-upstream.ts';
1355
- scripts['vendor:propose-upstream'] =
1356
- 'node -r ./migrations-utils/ts-compiler scripts/vendor/propose-upstream-pr.ts';
1389
+ // Vendor freshness check: reads VENDOR.md baseline version and
1390
+ // compares against npm registry. Non-blocking (always exits 0).
1391
+ // Uses a short inline script — no external file needed.
1392
+ // The heredoc-style approach avoids quote-escaping nightmares.
1393
+ scripts['check:vendor-freshness'] = [
1394
+ 'node -e "',
1395
+ "var f=require('fs'),h=require('https');",
1396
+ "try{var c=f.readFileSync('src/core/VENDOR.md','utf8')}catch(e){process.exit(0)}",
1397
+ 'var m=c.match(/Baseline-Version[^0-9]*(\\d+\\.\\d+\\.\\d+)/);',
1398
+ 'if(!m){process.stderr.write(String.fromCharCode(9888)+\' vendor-freshness: no baseline\\n\');process.exit(0)}',
1399
+ 'var v=m[1];',
1400
+ "h.get('https://registry.npmjs.org/@lenne.tech/nest-server/latest',function(r){",
1401
+ "var d='';r.on('data',function(c){d+=c});r.on('end',function(){",
1402
+ "try{var l=JSON.parse(d).version;",
1403
+ "if(v===l)console.log('vendor core up-to-date (v'+v+')');",
1404
+ "else process.stderr.write('vendor core v'+v+', latest v'+l+'\\n')",
1405
+ '}catch(e){}})}).on(\'error\',function(){});',
1406
+ 'setTimeout(function(){process.exit(0)},5000)',
1407
+ '"',
1408
+ ].join('');
1357
1409
  // Hook vendor-freshness as the first step of check / check:fix /
1358
1410
  // check:naf. Non-blocking (exit 0 even on mismatch), so it just
1359
1411
  // surfaces the warning at the top of the log.
@@ -1363,9 +1415,6 @@ class Server {
1363
1415
  return;
1364
1416
  if (existing.includes('check:vendor-freshness'))
1365
1417
  return;
1366
- // Inject the freshness check right after the initial `pnpm install`
1367
- // or at the very beginning of the chain. Looks for a leading
1368
- // `pnpm install && ` and inserts after it; otherwise prepends.
1369
1418
  const installPrefix = 'pnpm install && ';
1370
1419
  if (existing.startsWith(installPrefix)) {
1371
1420
  scripts[scriptName] = `${installPrefix}pnpm run check:vendor-freshness && ${existing.slice(installPrefix.length)}`;
@@ -1423,9 +1472,8 @@ class Server {
1423
1472
  //
1424
1473
  // Replace it with a small informational stub that points the user at
1425
1474
  // the canonical vendor-update path (the `nest-server-core-updater`
1426
- // Claude Code agent / `pnpm run vendor:sync` if the project has that
1427
- // script). Keeps `pnpm run update` from dead-exiting with a confusing
1428
- // message.
1475
+ // Claude Code agent). Keeps `pnpm run update` from dead-exiting with
1476
+ // a confusing message.
1429
1477
  const syncPackagesPath = `${dest}/extras/sync-packages.mjs`;
1430
1478
  if (filesystem.exists(syncPackagesPath)) {
1431
1479
  filesystem.write(syncPackagesPath, [
@@ -1444,8 +1492,7 @@ class Server {
1444
1492
  ' * This project runs in VENDOR mode: the framework core/ tree is',
1445
1493
  ' * copied directly into src/core/ and there is no framework npm',
1446
1494
  ' * dep to sync. To update the vendored core, use the',
1447
- ' * `nest-server-core-updater` Claude Code agent or run the',
1448
- ' * project-local vendor:sync script once it has been added.',
1495
+ ' * `nest-server-core-updater` Claude Code agent.',
1449
1496
  ' */',
1450
1497
  '',
1451
1498
  "console.warn('');",
@@ -1519,6 +1566,33 @@ class Server {
1519
1566
  filesystem.write(apiClaudeMdPath, vendorBlock + existing);
1520
1567
  }
1521
1568
  }
1569
+ // ── 9c. Merge nest-server CLAUDE.md sections into project CLAUDE.md ──
1570
+ //
1571
+ // The nest-server CLAUDE.md contains framework-specific instructions for
1572
+ // Claude Code (API conventions, UnifiedField usage, CrudService patterns,
1573
+ // etc.). We merge its H2 sections into the project's CLAUDE.md so that
1574
+ // downstream agents (backend-dev, code-reviewer, nest-server-updater)
1575
+ // have accurate framework knowledge out of the box.
1576
+ //
1577
+ // Merge strategy (matches /lt-dev:fullstack:sync-claude-md):
1578
+ // - Section in upstream but NOT in project → ADD at end
1579
+ // - Section in BOTH → KEEP project version (may have customizations)
1580
+ // - Section only in project → KEEP (project-specific content)
1581
+ if (upstreamClaudeMd && filesystem.exists(apiClaudeMdPath)) {
1582
+ const projectContent = filesystem.read(apiClaudeMdPath) || '';
1583
+ const upstreamSections = this.parseH2Sections(upstreamClaudeMd);
1584
+ const projectSections = this.parseH2Sections(projectContent);
1585
+ const newSections = [];
1586
+ for (const [heading, body] of upstreamSections) {
1587
+ if (!projectSections.has(heading)) {
1588
+ newSections.push(`## ${heading}\n\n${body.trim()}`);
1589
+ }
1590
+ }
1591
+ if (newSections.length > 0) {
1592
+ const separator = projectContent.endsWith('\n') ? '\n' : '\n\n';
1593
+ filesystem.write(apiClaudeMdPath, `${projectContent}${separator}${newSections.join('\n\n')}\n`);
1594
+ }
1595
+ }
1522
1596
  // ── 10. VENDOR.md baseline ───────────────────────────────────────────
1523
1597
  //
1524
1598
  // Record the exact upstream version + commit SHA we vendored from, so
@@ -1529,7 +1603,7 @@ class Server {
1529
1603
  const today = new Date().toISOString().slice(0, 10);
1530
1604
  const versionLine = upstreamVersion
1531
1605
  ? `- **Baseline-Version:** ${upstreamVersion}`
1532
- : '- **Baseline-Version:** (not detected — run `vendor:sync` to record)';
1606
+ : '- **Baseline-Version:** (not detected — run `/lt-dev:backend:update-nest-server-core` to record)';
1533
1607
  const commitLine = upstreamCommit
1534
1608
  ? `- **Baseline-Commit:** \`${upstreamCommit}\``
1535
1609
  : '- **Baseline-Commit:** (not detected)';
@@ -1580,6 +1654,23 @@ class Server {
1580
1654
  '',
1581
1655
  ].join('\n'));
1582
1656
  }
1657
+ // ── Post-conversion verification ──────────────────────────────────────
1658
+ //
1659
+ // Scan all consumer files for stale bare-specifier imports that the
1660
+ // codemod should have rewritten. A single miss causes a compile error,
1661
+ // so catching it here with a clear message saves the user debugging time.
1662
+ const staleImports = this.findStaleImports(dest, '@lenne.tech/nest-server');
1663
+ if (staleImports.length > 0) {
1664
+ const { print } = this.toolbox;
1665
+ print.warning(`⚠ ${staleImports.length} file(s) still contain '@lenne.tech/nest-server' imports after vendor conversion:`);
1666
+ for (const f of staleImports.slice(0, 10)) {
1667
+ print.info(` ${f}`);
1668
+ }
1669
+ if (staleImports.length > 10) {
1670
+ print.info(` ... and ${staleImports.length - 10} more`);
1671
+ }
1672
+ print.info('These imports must be manually rewritten to relative paths pointing to src/core.');
1673
+ }
1583
1674
  return { upstreamDeps, upstreamDevDeps };
1584
1675
  });
1585
1676
  }
@@ -1858,6 +1949,30 @@ class Server {
1858
1949
  }
1859
1950
  this.filesystem.write(claudeMdPath, content);
1860
1951
  }
1952
+ /**
1953
+ * Parse a markdown file into a Map of H2 sections.
1954
+ * Key = heading text (without `## `), Value = body text after the heading.
1955
+ * Content before the first H2 heading is stored under key `__preamble__`.
1956
+ */
1957
+ parseH2Sections(content) {
1958
+ const sections = new Map();
1959
+ const lines = content.split('\n');
1960
+ let currentHeading = '__preamble__';
1961
+ let currentBody = [];
1962
+ for (const line of lines) {
1963
+ const match = /^## (.+)$/.exec(line);
1964
+ if (match) {
1965
+ sections.set(currentHeading, currentBody.join('\n'));
1966
+ currentHeading = match[1].trim();
1967
+ currentBody = [];
1968
+ }
1969
+ else {
1970
+ currentBody.push(line);
1971
+ }
1972
+ }
1973
+ sections.set(currentHeading, currentBody.join('\n'));
1974
+ return sections;
1975
+ }
1861
1976
  /**
1862
1977
  * Replace secret or private keys in string (e.g. for config files)
1863
1978
  */
@@ -1882,6 +1997,338 @@ class Server {
1882
1997
  return `'${secretMap.get(placeholder)}'`;
1883
1998
  });
1884
1999
  }
2000
+ // ═══════════════════════════════════════════════════════════════════════
2001
+ // Public mode-conversion API (called by `lt server convert-mode`)
2002
+ // ═══════════════════════════════════════════════════════════════════════
2003
+ /**
2004
+ * Convert an existing npm-mode API project to vendor mode.
2005
+ *
2006
+ * This is a wrapper around {@link convertCloneToVendored} for use on
2007
+ * projects that were **already created** (not during `lt fullstack init`).
2008
+ * The method detects the currently installed `@lenne.tech/nest-server`
2009
+ * version and vendors from that tag unless `upstreamBranch` overrides it.
2010
+ */
2011
+ convertToVendorMode(options) {
2012
+ return __awaiter(this, void 0, void 0, function* () {
2013
+ const { dest, upstreamBranch, upstreamRepoUrl } = options;
2014
+ const { isVendoredProject } = require('../lib/framework-detection');
2015
+ if (isVendoredProject(dest)) {
2016
+ throw new Error('Project is already in vendor mode (src/core/VENDOR.md exists).');
2017
+ }
2018
+ // Verify @lenne.tech/nest-server is currently a dependency
2019
+ const pkg = this.filesystem.read(`${dest}/package.json`, 'json');
2020
+ if (!pkg) {
2021
+ throw new Error('Cannot read package.json');
2022
+ }
2023
+ const allDeps = Object.assign(Object.assign({}, (pkg.dependencies || {})), (pkg.devDependencies || {}));
2024
+ if (!allDeps['@lenne.tech/nest-server']) {
2025
+ throw new Error('@lenne.tech/nest-server is not in dependencies or devDependencies. ' +
2026
+ 'Is this an npm-mode lenne.tech API project?');
2027
+ }
2028
+ yield this.convertCloneToVendored({
2029
+ dest,
2030
+ upstreamBranch,
2031
+ upstreamRepoUrl,
2032
+ });
2033
+ });
2034
+ }
2035
+ /**
2036
+ * Convert an existing vendor-mode API project back to npm mode.
2037
+ *
2038
+ * Performs the inverse of {@link convertCloneToVendored}:
2039
+ * 1. Read baseline version from VENDOR.md
2040
+ * 2. Delete `src/core/` (the vendored framework)
2041
+ * 3. Rewrite all consumer imports from relative paths back to `@lenne.tech/nest-server`
2042
+ * 4. Restore `@lenne.tech/nest-server` dependency in package.json
2043
+ * 5. Restore migrate scripts to npm paths
2044
+ * 6. Remove vendor-specific scripts and artifacts
2045
+ * 7. Clean up CLAUDE.md vendor marker
2046
+ * 8. Restore tsconfig to npm-mode defaults
2047
+ */
2048
+ convertToNpmMode(options) {
2049
+ return __awaiter(this, void 0, void 0, function* () {
2050
+ const { dest, targetVersion } = options;
2051
+ const path = require('path');
2052
+ const { Project, SyntaxKind } = require('ts-morph');
2053
+ const { isVendoredProject } = require('../lib/framework-detection');
2054
+ if (!isVendoredProject(dest)) {
2055
+ throw new Error('Project is not in vendor mode (src/core/VENDOR.md not found).');
2056
+ }
2057
+ const filesystem = this.filesystem;
2058
+ const srcDir = `${dest}/src`;
2059
+ const coreDir = `${srcDir}/core`;
2060
+ // ── 1. Determine target version + warn about local patches ──────────
2061
+ const vendorMd = filesystem.read(`${coreDir}/VENDOR.md`) || '';
2062
+ let version = targetVersion;
2063
+ if (!version) {
2064
+ const match = vendorMd.match(/Baseline-Version:\*{0,2}\s+(\d+\.\d+\.\d+\S*)/);
2065
+ if (match) {
2066
+ version = match[1];
2067
+ }
2068
+ }
2069
+ if (!version) {
2070
+ throw new Error('Cannot determine target version. Specify --version or ensure VENDOR.md has a Baseline-Version.');
2071
+ }
2072
+ // Warn if VENDOR.md documents local patches that will be lost
2073
+ const localChangesSection = vendorMd.match(/## Local changes[\s\S]*?(?=## |$)/i);
2074
+ if (localChangesSection) {
2075
+ const hasRealPatches = localChangesSection[0].includes('|') &&
2076
+ !localChangesSection[0].includes('(none, pristine)') &&
2077
+ /\|\s*\d{4}-/.test(localChangesSection[0]);
2078
+ if (hasRealPatches) {
2079
+ const { print } = this.toolbox;
2080
+ print.warning('');
2081
+ print.warning('⚠ VENDOR.md documents local patches in src/core/ that will be LOST:');
2082
+ // Extract non-header table rows
2083
+ const rows = localChangesSection[0]
2084
+ .split('\n')
2085
+ .filter((l) => /^\|\s*\d{4}-/.test(l));
2086
+ for (const row of rows.slice(0, 5)) {
2087
+ print.info(` ${row.trim()}`);
2088
+ }
2089
+ if (rows.length > 5) {
2090
+ print.info(` ... and ${rows.length - 5} more`);
2091
+ }
2092
+ print.warning('Consider running /lt-dev:backend:contribute-nest-server-core first to upstream them.');
2093
+ print.warning('');
2094
+ }
2095
+ }
2096
+ // ── 2. Rewrite consumer imports: relative → @lenne.tech/nest-server ─
2097
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
2098
+ const globs = [
2099
+ `${srcDir}/server/**/*.ts`,
2100
+ `${srcDir}/main.ts`,
2101
+ `${srcDir}/config.env.ts`,
2102
+ `${dest}/tests/**/*.ts`,
2103
+ `${dest}/migrations/**/*.ts`,
2104
+ `${dest}/migrations-utils/*.ts`,
2105
+ `${dest}/scripts/**/*.ts`,
2106
+ ];
2107
+ for (const glob of globs) {
2108
+ project.addSourceFilesAtPaths(glob);
2109
+ }
2110
+ const targetSpecifier = '@lenne.tech/nest-server';
2111
+ const coreAbsPath = path.resolve(coreDir);
2112
+ for (const sourceFile of project.getSourceFiles()) {
2113
+ let modified = false;
2114
+ // Static imports + re-exports
2115
+ for (const decl of [
2116
+ ...sourceFile.getImportDeclarations(),
2117
+ ...sourceFile.getExportDeclarations(),
2118
+ ]) {
2119
+ const spec = decl.getModuleSpecifierValue();
2120
+ if (!spec)
2121
+ continue;
2122
+ // Check if this import resolves to src/core (the vendored framework)
2123
+ if (this.isVendoredCoreImport(spec, sourceFile.getFilePath(), coreAbsPath)) {
2124
+ decl.setModuleSpecifier(targetSpecifier);
2125
+ modified = true;
2126
+ }
2127
+ }
2128
+ // Dynamic imports + CJS require
2129
+ sourceFile.forEachDescendant((node) => {
2130
+ if (node.getKind() === SyntaxKind.CallExpression) {
2131
+ const expr = node.getExpression().getText();
2132
+ if (expr === 'require' || expr === 'import') {
2133
+ const args = node.getArguments();
2134
+ if (args.length > 0) {
2135
+ const argText = args[0].getText().replace(/['"]/g, '');
2136
+ if (this.isVendoredCoreImport(argText, sourceFile.getFilePath(), coreAbsPath)) {
2137
+ args[0].replaceWithText(`'${targetSpecifier}'`);
2138
+ modified = true;
2139
+ }
2140
+ }
2141
+ }
2142
+ }
2143
+ });
2144
+ if (modified) {
2145
+ sourceFile.saveSync();
2146
+ }
2147
+ }
2148
+ // Also rewrite .js files in migrations-utils/
2149
+ const jsFiles = filesystem.find(`${dest}/migrations-utils`, { matching: '*.js' }) || [];
2150
+ for (const jsFile of jsFiles) {
2151
+ const content = filesystem.read(jsFile) || '';
2152
+ // Replace any relative require/import that points to src/core
2153
+ const replaced = content.replace(/require\(['"]([^'"]*\/core(?:\/index)?)['"]\)/g, `require('${targetSpecifier}')`);
2154
+ if (replaced !== content) {
2155
+ filesystem.write(jsFile, replaced);
2156
+ }
2157
+ }
2158
+ // ── 3. Delete src/core/ (vendored framework) ────────────────────────
2159
+ filesystem.remove(coreDir);
2160
+ // ── 4. Restore @lenne.tech/nest-server dep + clean package.json ─────
2161
+ const pkg = filesystem.read(`${dest}/package.json`, 'json');
2162
+ if (pkg) {
2163
+ // Add @lenne.tech/nest-server as dependency
2164
+ pkg.dependencies = pkg.dependencies || {};
2165
+ pkg.dependencies['@lenne.tech/nest-server'] = version;
2166
+ // Remove vendor-specific deps that are transitive via nest-server
2167
+ // (they'll come back via node_modules when nest-server is installed)
2168
+ // We only remove deps that are ALSO in nest-server's package.json.
2169
+ // For safety, we don't remove any dep the consumer might use directly.
2170
+ // Remove vendor-specific scripts
2171
+ const scripts = pkg.scripts || {};
2172
+ delete scripts['check:vendor-freshness'];
2173
+ delete scripts['vendor:sync'];
2174
+ delete scripts['vendor:propose-upstream'];
2175
+ // Unhook vendor-freshness from check / check:fix / check:naf
2176
+ for (const key of ['check', 'check:fix', 'check:naf']) {
2177
+ if (scripts[key] && typeof scripts[key] === 'string') {
2178
+ scripts[key] = scripts[key].replace(/pnpm run check:vendor-freshness && /g, '');
2179
+ }
2180
+ }
2181
+ // Restore migrate scripts to npm-mode paths
2182
+ const migrateCompiler = 'ts:./node_modules/@lenne.tech/nest-server/dist/core/modules/migrate/helpers/ts-compiler.js';
2183
+ const migrateStore = '--store ./migrations-utils/migrate.js --migrations-dir ./migrations';
2184
+ const migrateTemplate = './node_modules/@lenne.tech/nest-server/dist/core/modules/migrate/templates/migration-project.template.ts';
2185
+ scripts['migrate:create'] = `f() { migrate create "$1" --template-file ${migrateTemplate} --migrations-dir ./migrations --compiler ${migrateCompiler}; }; f`;
2186
+ scripts['migrate:up'] = `migrate up ${migrateStore} --compiler ${migrateCompiler}`;
2187
+ scripts['migrate:down'] = `migrate down ${migrateStore} --compiler ${migrateCompiler}`;
2188
+ scripts['migrate:list'] = `migrate list ${migrateStore} --compiler ${migrateCompiler}`;
2189
+ // Env-prefixed migrate scripts
2190
+ for (const env of ['develop', 'test', 'preview', 'prod']) {
2191
+ const nodeEnv = env === 'prod' ? 'production' : env;
2192
+ scripts[`migrate:${env}:up`] = `NODE_ENV=${nodeEnv} migrate up ${migrateStore} --compiler ${migrateCompiler}`;
2193
+ }
2194
+ filesystem.write(`${dest}/package.json`, pkg);
2195
+ }
2196
+ // ── 5. Remove vendor artifacts ──────────────────────────────────────
2197
+ if (filesystem.exists(`${dest}/scripts/vendor`)) {
2198
+ filesystem.remove(`${dest}/scripts/vendor`);
2199
+ }
2200
+ if (filesystem.exists(`${dest}/bin/migrate.js`)) {
2201
+ filesystem.remove(`${dest}/bin/migrate.js`);
2202
+ // Remove bin/ dir if empty
2203
+ const binContents = filesystem.list(`${dest}/bin`) || [];
2204
+ if (binContents.length === 0) {
2205
+ filesystem.remove(`${dest}/bin`);
2206
+ }
2207
+ }
2208
+ if (filesystem.exists(`${dest}/migration-guides`)) {
2209
+ filesystem.remove(`${dest}/migration-guides`);
2210
+ }
2211
+ // Remove migrations-utils/ts-compiler.js (vendor-mode bootstrap)
2212
+ const tsCompilerPath = `${dest}/migrations-utils/ts-compiler.js`;
2213
+ if (filesystem.exists(tsCompilerPath)) {
2214
+ const content = filesystem.read(tsCompilerPath) || '';
2215
+ // Only remove if it's the vendor-mode bootstrap (contains ts-node reference)
2216
+ if (content.includes('ts-node') || content.includes('tsconfig-paths')) {
2217
+ filesystem.remove(tsCompilerPath);
2218
+ }
2219
+ }
2220
+ // Restore extras/sync-packages.mjs if it was replaced with a stub
2221
+ const syncPkgPath = `${dest}/extras/sync-packages.mjs`;
2222
+ if (filesystem.exists(syncPkgPath)) {
2223
+ const content = filesystem.read(syncPkgPath) || '';
2224
+ if (content.includes('vendor mode') || content.includes('vendor:sync')) {
2225
+ // It's the vendor stub — remove it. The user can re-fetch from starter.
2226
+ filesystem.remove(syncPkgPath);
2227
+ }
2228
+ }
2229
+ // ── 6. Clean CLAUDE.md vendor marker ────────────────────────────────
2230
+ const claudeMdPath = `${dest}/CLAUDE.md`;
2231
+ if (filesystem.exists(claudeMdPath)) {
2232
+ let content = filesystem.read(claudeMdPath) || '';
2233
+ const marker = '<!-- lt-vendor-marker -->';
2234
+ if (content.includes(marker)) {
2235
+ // Remove everything from marker to the first `---` separator (end of vendor block)
2236
+ content = content.replace(new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?---\\s*\\n?`, ''), '');
2237
+ // Remove leading whitespace/newlines
2238
+ content = content.replace(/^\n+/, '');
2239
+ filesystem.write(claudeMdPath, content);
2240
+ }
2241
+ }
2242
+ // ── 7. Restore tsconfig excludes ────────────────────────────────────
2243
+ // tsconfig files use JSONC (comments + trailing commas), so we cannot
2244
+ // use `filesystem.read(path, 'json')` which calls strict `JSON.parse`.
2245
+ // Instead we do a regex-based removal of vendor-specific exclude entries.
2246
+ for (const tsconfigName of ['tsconfig.json', 'tsconfig.build.json']) {
2247
+ const tsconfigPath = `${dest}/${tsconfigName}`;
2248
+ if (!filesystem.exists(tsconfigPath))
2249
+ continue;
2250
+ let raw = filesystem.read(tsconfigPath) || '';
2251
+ // Remove vendor-specific exclude entries (the string literal + optional trailing comma)
2252
+ raw = raw.replace(/,?\s*"src\/core\/modules\/migrate\/templates\/\*\*\/\*\.template\.ts"/g, '');
2253
+ raw = raw.replace(/,?\s*"src\/core\/test\/\*\*\/\*\.ts"/g, '');
2254
+ // Clean up potential double commas or trailing commas before ]
2255
+ raw = raw.replace(/,\s*,/g, ',');
2256
+ raw = raw.replace(/,\s*]/g, ']');
2257
+ filesystem.write(tsconfigPath, raw);
2258
+ }
2259
+ // ── 8. Remove .gitignore vendor entries ──────────────────────────────
2260
+ const gitignorePath = `${dest}/.gitignore`;
2261
+ if (filesystem.exists(gitignorePath)) {
2262
+ let content = filesystem.read(gitignorePath) || '';
2263
+ content = content
2264
+ .split('\n')
2265
+ .filter((line) => !line.includes('scripts/vendor/sync-results') &&
2266
+ !line.includes('scripts/vendor/upstream-candidates'))
2267
+ .join('\n');
2268
+ filesystem.write(gitignorePath, content);
2269
+ }
2270
+ // ── Post-conversion verification ──────────────────────────────────────
2271
+ //
2272
+ // Scan all consumer files for stale relative imports that still resolve
2273
+ // to the (now deleted) src/core/ directory. These would be silent
2274
+ // compile errors.
2275
+ const staleRelativeImports = this.findStaleImports(dest, '../core', /['"]\.\.?\/[^'"]*core['"]|from\s+['"]\.\.?\/[^'"]*core['"]/);
2276
+ if (staleRelativeImports.length > 0) {
2277
+ const { print } = this.toolbox;
2278
+ print.warning(`⚠ ${staleRelativeImports.length} file(s) still contain relative core imports after npm conversion:`);
2279
+ for (const f of staleRelativeImports.slice(0, 10)) {
2280
+ print.info(` ${f}`);
2281
+ }
2282
+ print.info('These imports must be manually rewritten to \'@lenne.tech/nest-server\'.');
2283
+ }
2284
+ });
2285
+ }
2286
+ /**
2287
+ * Scans consumer source files for import specifiers that should have been
2288
+ * rewritten by a mode conversion. Returns a list of file paths that still
2289
+ * contain matches.
2290
+ *
2291
+ * @param dest Project root directory
2292
+ * @param needle Literal string to search for (used when no regex provided)
2293
+ * @param pattern Optional regex for more flexible matching
2294
+ */
2295
+ findStaleImports(dest, needle, pattern) {
2296
+ const globs = [
2297
+ `${dest}/src/server/**/*.ts`,
2298
+ `${dest}/src/main.ts`,
2299
+ `${dest}/src/config.env.ts`,
2300
+ `${dest}/tests/**/*.ts`,
2301
+ `${dest}/migrations/**/*.ts`,
2302
+ `${dest}/scripts/**/*.ts`,
2303
+ ];
2304
+ const stale = [];
2305
+ for (const glob of globs) {
2306
+ const files = this.filesystem.find(dest, {
2307
+ matching: glob.replace(`${dest}/`, ''),
2308
+ recursive: true,
2309
+ }) || [];
2310
+ for (const file of files) {
2311
+ const content = this.filesystem.read(file) || '';
2312
+ if (pattern ? pattern.test(content) : content.includes(needle)) {
2313
+ stale.push(file.replace(`${dest}/`, ''));
2314
+ }
2315
+ }
2316
+ }
2317
+ return stale;
2318
+ }
2319
+ /**
2320
+ * Checks whether an import specifier resolves to the vendored core directory.
2321
+ * Used during vendor→npm import rewriting.
2322
+ */
2323
+ isVendoredCoreImport(specifier, fromFilePath, coreAbsPath) {
2324
+ if (!specifier.startsWith('.'))
2325
+ return false;
2326
+ const path = require('path');
2327
+ const resolved = path.resolve(path.dirname(fromFilePath), specifier);
2328
+ // Match if the resolved path IS the core dir or is inside it
2329
+ // (e.g., '../../../core' resolves to src/core, '../../../core/index' resolves to src/core/index)
2330
+ return resolved === coreAbsPath || resolved.startsWith(coreAbsPath + path.sep);
2331
+ }
1885
2332
  }
1886
2333
  exports.Server = Server;
1887
2334
  /**