@lenne.tech/cli 1.9.6 → 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.
Files changed (38) hide show
  1. package/README.md +88 -3
  2. package/build/commands/config/validate.js +2 -0
  3. package/build/commands/frontend/convert-mode.js +198 -0
  4. package/build/commands/fullstack/convert-mode.js +368 -0
  5. package/build/commands/fullstack/init.js +150 -4
  6. package/build/commands/fullstack/update.js +177 -0
  7. package/build/commands/server/add-property.js +29 -2
  8. package/build/commands/server/convert-mode.js +197 -0
  9. package/build/commands/server/create.js +41 -3
  10. package/build/commands/server/module.js +58 -25
  11. package/build/commands/server/object.js +26 -5
  12. package/build/commands/server/permissions.js +20 -6
  13. package/build/commands/server/test.js +7 -1
  14. package/build/commands/status.js +94 -3
  15. package/build/config/vendor-frontend-runtime-deps.json +4 -0
  16. package/build/config/vendor-runtime-deps.json +9 -0
  17. package/build/extensions/api-mode.js +19 -3
  18. package/build/extensions/frontend-helper.js +652 -0
  19. package/build/extensions/server.js +1475 -3
  20. package/build/lib/framework-detection.js +167 -0
  21. package/build/lib/frontend-framework-detection.js +129 -0
  22. package/build/templates/nest-server-module/inputs/template-create.input.ts.ejs +1 -1
  23. package/build/templates/nest-server-module/inputs/template.input.ts.ejs +1 -1
  24. package/build/templates/nest-server-module/outputs/template-fac-result.output.ts.ejs +1 -1
  25. package/build/templates/nest-server-module/template.controller.ts.ejs +1 -1
  26. package/build/templates/nest-server-module/template.model.ts.ejs +1 -1
  27. package/build/templates/nest-server-module/template.module.ts.ejs +1 -1
  28. package/build/templates/nest-server-module/template.resolver.ts.ejs +1 -1
  29. package/build/templates/nest-server-module/template.service.ts.ejs +1 -1
  30. package/build/templates/nest-server-object/template-create.input.ts.ejs +1 -1
  31. package/build/templates/nest-server-object/template.input.ts.ejs +1 -1
  32. package/build/templates/nest-server-object/template.object.ts.ejs +1 -1
  33. package/build/templates/nest-server-tests/tests.e2e-spec.ts.ejs +1 -1
  34. package/docs/LT-ECOSYSTEM-GUIDE.md +973 -0
  35. package/docs/VENDOR-MODE-WORKFLOW.md +471 -0
  36. package/docs/commands.md +196 -0
  37. package/docs/lt.config.md +9 -7
  38. package/package.json +17 -8
@@ -255,7 +255,25 @@ class Server {
255
255
  }
256
256
  useDefineForClassFieldsActivated() {
257
257
  var _a;
258
- const tsConfigPath = this.filesystem.resolve('tsconfig.json');
258
+ // Walk UP from the current working directory to find the nearest
259
+ // tsconfig.json. gluegun's `filesystem.resolve('tsconfig.json')` resolves
260
+ // relative to cwd without checking existence, so it breaks when `lt
261
+ // server module` is invoked from inside `src/` (where no tsconfig lives)
262
+ // — it would then silently return `false`, causing the generator to
263
+ // emit class fields without the `override` modifier that the project's
264
+ // `noImplicitOverride` rule requires.
265
+ const path = require('path');
266
+ let current = this.filesystem.cwd();
267
+ const root = path.parse(current).root;
268
+ let tsConfigPath = null;
269
+ while (current && current !== root) {
270
+ const candidate = path.join(current, 'tsconfig.json');
271
+ if (this.filesystem.exists(candidate)) {
272
+ tsConfigPath = candidate;
273
+ break;
274
+ }
275
+ current = path.dirname(current);
276
+ }
259
277
  if (tsConfigPath) {
260
278
  const readConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
261
279
  if (!readConfig.error) {
@@ -614,7 +632,7 @@ class Server {
614
632
  setupServer(dest, options) {
615
633
  return __awaiter(this, void 0, void 0, function* () {
616
634
  const { apiMode: apiModeHelper, patching, system, template, templateHelper } = this.toolbox;
617
- const { apiMode, author = '', branch, copyPath, description = '', linkPath, name, projectDir, skipInstall = false, skipPatching = false, } = options;
635
+ const { apiMode, author = '', branch, copyPath, description = '', frameworkMode = 'npm', frameworkUpstreamBranch, linkPath, name, projectDir, skipInstall = false, skipPatching = false, } = options;
618
636
  // Setup template
619
637
  const result = yield templateHelper.setup(dest, {
620
638
  branch,
@@ -677,6 +695,27 @@ class Server {
677
695
  this.filesystem.remove(`${dest}/.yalc`);
678
696
  this.filesystem.remove(`${dest}/yalc.lock`);
679
697
  }
698
+ // Vendor-mode transformation — identical to setupServerForFullstack, so
699
+ // a standalone `lt server create --framework-mode vendor` produces the
700
+ // same project layout as `lt fullstack init --framework-mode vendor`.
701
+ // Essentials list is captured BEFORE processApiMode deletes the
702
+ // manifest (same dance as in setupServerForFullstack).
703
+ let standaloneVendorUpstreamDeps = {};
704
+ let standaloneVendorCoreEssentials = [];
705
+ if (frameworkMode === 'vendor') {
706
+ try {
707
+ const converted = yield this.convertCloneToVendored({
708
+ dest,
709
+ projectName: name,
710
+ upstreamBranch: frameworkUpstreamBranch,
711
+ });
712
+ standaloneVendorUpstreamDeps = converted.upstreamDeps;
713
+ }
714
+ catch (err) {
715
+ return { method: result.method, path: dest, success: false };
716
+ }
717
+ standaloneVendorCoreEssentials = this.readApiModeGraphqlEssentials(dest);
718
+ }
680
719
  // Process API mode (before install so package.json is correct)
681
720
  if (apiMode) {
682
721
  try {
@@ -686,6 +725,19 @@ class Server {
686
725
  return { method: result.method, path: dest, success: false };
687
726
  }
688
727
  }
728
+ // Restore core essentials after processApiMode stripped them (vendor + REST only).
729
+ if (frameworkMode === 'vendor' && apiMode === 'Rest') {
730
+ try {
731
+ this.restoreVendorCoreEssentials({
732
+ dest,
733
+ essentials: standaloneVendorCoreEssentials,
734
+ upstreamDeps: standaloneVendorUpstreamDeps,
735
+ });
736
+ }
737
+ catch (_a) {
738
+ // Non-fatal.
739
+ }
740
+ }
689
741
  // Patch CLAUDE.md with API mode info
690
742
  this.patchClaudeMdApiMode(dest, apiMode);
691
743
  // Install packages
@@ -711,7 +763,21 @@ class Server {
711
763
  setupServerForFullstack(dest, options) {
712
764
  return __awaiter(this, void 0, void 0, function* () {
713
765
  const { apiMode: apiModeHelper, templateHelper } = this.toolbox;
714
- const { apiMode, branch, copyPath, linkPath, name, projectDir } = options;
766
+ const { apiMode, branch, copyPath, frameworkMode = 'npm', frameworkUpstreamBranch, linkPath, name, projectDir, } = options;
767
+ // Both npm and vendor mode clone nest-server-starter as the base. The
768
+ // starter ships the minimal consumer conventions a project needs
769
+ // (src/server/common/models/persistence.model.ts, src/server/modules/user/,
770
+ // file/, meta/, tests/, migrations/, env files, etc.).
771
+ //
772
+ // In vendor mode we additionally clone @lenne.tech/nest-server to
773
+ // obtain the framework `core/` tree, copy it into the project at
774
+ // src/core/ (with the flatten-fix), remove the `@lenne.tech/nest-server`
775
+ // npm dependency, merge its transitive deps into the project
776
+ // package.json, and run a codemod that rewrites every
777
+ // `from '@lenne.tech/nest-server'` import to a relative path pointing
778
+ // at the vendored core.
779
+ //
780
+ // See convertCloneToVendored() below for the exact transformation.
715
781
  // Setup template
716
782
  const result = yield templateHelper.setup(dest, {
717
783
  branch,
@@ -749,6 +815,38 @@ class Server {
749
815
  this.filesystem.remove(`${dest}/.yalc`);
750
816
  this.filesystem.remove(`${dest}/yalc.lock`);
751
817
  }
818
+ // Vendor-mode transformation: strip framework-internal content and wire
819
+ // the remaining files so they behave like a project that consumed the
820
+ // framework's core/ directory directly. Idempotent; safe to skip in
821
+ // npm mode.
822
+ //
823
+ // We capture the framework package.json snapshot from the temp clone so
824
+ // the post-apiMode step can restore upstream-declared core essentials
825
+ // without hard-coding package lists.
826
+ let vendorUpstreamDeps = {};
827
+ let vendorCoreEssentials = [];
828
+ if (frameworkMode === 'vendor') {
829
+ try {
830
+ const converted = yield this.convertCloneToVendored({
831
+ dest,
832
+ projectName: name,
833
+ upstreamBranch: frameworkUpstreamBranch,
834
+ });
835
+ vendorUpstreamDeps = converted.upstreamDeps;
836
+ }
837
+ catch (err) {
838
+ return { method: result.method, path: dest, success: false };
839
+ }
840
+ // Read the graphql-only package list from the starter's
841
+ // api-mode.manifest.json BEFORE processApiMode runs and deletes it.
842
+ // These are exactly the packages processApiMode will strip in REST
843
+ // mode — and in vendor mode they must come back afterwards, because
844
+ // src/core/** still imports them even when the consumer project is
845
+ // REST-only (e.g. PubSub in core-auth.module.ts, GraphQLUpload in
846
+ // core-file.service.ts). List is dynamic; new additions surface
847
+ // automatically.
848
+ vendorCoreEssentials = this.readApiModeGraphqlEssentials(dest);
849
+ }
752
850
  // Process API mode (before install which happens at monorepo level)
753
851
  if (apiMode) {
754
852
  try {
@@ -758,11 +856,1029 @@ class Server {
758
856
  return { method: result.method, path: dest, success: false };
759
857
  }
760
858
  }
859
+ // In vendor mode + REST, re-add the graphql essentials that
860
+ // processApiMode just stripped. Both and GraphQL keep all packages
861
+ // by construction and don't need restoration.
862
+ if (frameworkMode === 'vendor' && apiMode === 'Rest') {
863
+ try {
864
+ this.restoreVendorCoreEssentials({
865
+ dest,
866
+ essentials: vendorCoreEssentials,
867
+ upstreamDeps: vendorUpstreamDeps,
868
+ });
869
+ }
870
+ catch (err) {
871
+ // Non-fatal — install may still succeed if the core never imports
872
+ // the restored packages at the current version.
873
+ }
874
+ }
761
875
  // Patch CLAUDE.md with API mode info
762
876
  this.patchClaudeMdApiMode(dest, apiMode);
763
877
  return { method: result.method, path: dest, success: true };
764
878
  });
765
879
  }
880
+ /**
881
+ * Converts a freshly cloned `nest-server-starter` working tree into a
882
+ * vendored-mode consumer project.
883
+ *
884
+ * The starter ships all consumer conventions a project needs (a
885
+ * working `src/server/` with `common/models/persistence.model.ts`,
886
+ * `modules/user/`, `modules/file/`, `modules/meta/`, sample tests,
887
+ * migrations, env files). In npm mode it relies on the
888
+ * `@lenne.tech/nest-server` npm dependency to provide the framework
889
+ * source via `node_modules/@lenne.tech/nest-server/dist/**`.
890
+ *
891
+ * In vendor mode we additionally clone `@lenne.tech/nest-server` to
892
+ * /tmp, copy its framework kernel (`src/core/`, `src/index.ts`,
893
+ * `src/core.module.ts`, `src/test/`, `src/templates/`, `src/types/`,
894
+ * `LICENSE`, `bin/migrate.js`) into the project at `src/core/`
895
+ * applying the flatten-fix, remove `@lenne.tech/nest-server` from the
896
+ * project's `package.json`, merge the framework's transitive deps into
897
+ * the project's own deps, and run an AST-based codemod that rewrites
898
+ * every `from '@lenne.tech/nest-server'` import in consumer code
899
+ * (src/server, src/main.ts, tests/, migrations/, scripts/) to a
900
+ * relative path pointing at the vendored `src/core/`.
901
+ *
902
+ * The resulting tree matches the layout produced by the imo vendoring
903
+ * pilot, so the `nest-server-core-updater` and
904
+ * `nest-server-core-contributor` agents work without any further
905
+ * post-processing.
906
+ *
907
+ * Idempotent — running twice is a no-op.
908
+ */
909
+ convertCloneToVendored(options) {
910
+ return __awaiter(this, void 0, void 0, function* () {
911
+ const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nest-server.git' } = options;
912
+ const filesystem = this.filesystem;
913
+ const { system } = this.toolbox;
914
+ const os = require('os');
915
+ const path = require('path');
916
+ const { Project, SyntaxKind } = require('ts-morph');
917
+ const srcDir = `${dest}/src`;
918
+ const coreDir = `${srcDir}/core`;
919
+ // ── 1. Clone @lenne.tech/nest-server into a temp directory ───────────
920
+ //
921
+ // We clone the framework repo shallowly to get the `src/core/` tree,
922
+ // `bin/migrate.js`, and associated meta files. The clone lives in a
923
+ // throw-away tmp dir that gets cleaned up at the end.
924
+ const tmpClone = path.join(os.tmpdir(), `lt-vendor-nest-server-${Date.now()}`);
925
+ const branchArg = upstreamBranch ? `--branch ${upstreamBranch} ` : '';
926
+ try {
927
+ yield system.run(`git clone --depth 1 ${branchArg}${upstreamRepoUrl} ${tmpClone}`);
928
+ }
929
+ catch (err) {
930
+ // Clone failures usually boil down to one of four causes — network,
931
+ // auth, unknown ref, or a pre-existing tmp dir. Give the user a
932
+ // pointed error message rather than the raw `git clone` stderr.
933
+ const raw = err.message || '';
934
+ const hints = [];
935
+ if (/Could not resolve host|getaddrinfo|ECONNREFUSED|Network is unreachable/i.test(raw)) {
936
+ hints.push('Network issue reaching github.com — check your connection or proxy settings.');
937
+ }
938
+ if (/Permission denied|authentication failed|publickey|403|401/i.test(raw)) {
939
+ hints.push('Authentication issue — the CLI uses an anonymous HTTPS clone; verify GitHub is reachable.');
940
+ }
941
+ if (upstreamBranch && /Remote branch .* not found|did not match any file\(s\) known to git/i.test(raw)) {
942
+ hints.push(`Upstream ref "${upstreamBranch}" does not exist. Check ${upstreamRepoUrl}/tags or /branches for valid refs. ` +
943
+ 'Note: nest-server tags have NO "v" prefix — use e.g. "11.24.1", not "v11.24.1".');
944
+ }
945
+ if (/already exists and is not an empty/i.test(raw)) {
946
+ hints.push(`Target directory ${tmpClone} already exists. This usually indicates a stale previous run — rm -rf /tmp/lt-vendor-nest-server-* and retry.`);
947
+ }
948
+ const hintBlock = hints.length > 0 ? `\n Hints:\n - ${hints.join('\n - ')}` : '';
949
+ throw new Error(`Failed to clone ${upstreamRepoUrl}${upstreamBranch ? ` (branch/tag: ${upstreamBranch})` : ''}.\n Raw git error: ${raw.trim()}${hintBlock}`);
950
+ }
951
+ // Snapshot upstream package.json before cleanup so we can merge its
952
+ // transitive deps into the project's package.json (step 5 below).
953
+ let upstreamDeps = {};
954
+ let upstreamDevDeps = {};
955
+ let upstreamVersion = '';
956
+ try {
957
+ const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
958
+ if (upstreamPkg && typeof upstreamPkg === 'object') {
959
+ upstreamDeps = upstreamPkg.dependencies || {};
960
+ upstreamDevDeps = upstreamPkg.devDependencies || {};
961
+ upstreamVersion = upstreamPkg.version || '';
962
+ }
963
+ }
964
+ catch (_a) {
965
+ // Best-effort — if we can't read upstream pkg, the starter's own
966
+ // deps should still cover most of the framework's needs.
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
+ }
983
+ // Snapshot the upstream commit SHA for traceability in VENDOR.md.
984
+ let upstreamCommit = '';
985
+ try {
986
+ const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
987
+ upstreamCommit = (sha || '').trim();
988
+ }
989
+ catch (_c) {
990
+ // Non-fatal — VENDOR.md will just show an empty SHA.
991
+ }
992
+ try {
993
+ // ── 2. Copy framework kernel into project src/core/ (flatten-fix) ──
994
+ //
995
+ // Upstream layout: src/core/ (framework sub-dir) + src/index.ts +
996
+ // src/core.module.ts + src/test/ + src/templates/ + src/types/.
997
+ // Target layout: everything flat under <project>/src/core/.
998
+ //
999
+ // We WIPE the starter's (non-existent in npm mode) src/core/ first
1000
+ // just to guarantee idempotency when users run this twice.
1001
+ if (filesystem.exists(coreDir)) {
1002
+ filesystem.remove(coreDir);
1003
+ }
1004
+ const copies = [
1005
+ [`${tmpClone}/src/core`, coreDir],
1006
+ [`${tmpClone}/src/index.ts`, `${coreDir}/index.ts`],
1007
+ [`${tmpClone}/src/core.module.ts`, `${coreDir}/core.module.ts`],
1008
+ [`${tmpClone}/src/test`, `${coreDir}/test`],
1009
+ [`${tmpClone}/src/templates`, `${coreDir}/templates`],
1010
+ [`${tmpClone}/src/types`, `${coreDir}/types`],
1011
+ [`${tmpClone}/LICENSE`, `${coreDir}/LICENSE`],
1012
+ ];
1013
+ for (const [from, to] of copies) {
1014
+ if (filesystem.exists(from)) {
1015
+ filesystem.copy(from, to);
1016
+ }
1017
+ }
1018
+ // Copy bin/migrate.js so the project has a working migrate CLI
1019
+ // independent of node_modules/@lenne.tech/nest-server.
1020
+ // Overwrite existing file if present (convert-mode on existing project).
1021
+ if (filesystem.exists(`${tmpClone}/bin/migrate.js`)) {
1022
+ if (filesystem.exists(`${dest}/bin/migrate.js`)) {
1023
+ filesystem.remove(`${dest}/bin/migrate.js`);
1024
+ }
1025
+ filesystem.copy(`${tmpClone}/bin/migrate.js`, `${dest}/bin/migrate.js`);
1026
+ }
1027
+ // Copy migration-guides for vendor-sync agent reference (optional
1028
+ // but useful — small overhead, big value for the updater agent).
1029
+ // Preserve any project-specific guides by merging instead of overwriting.
1030
+ if (filesystem.exists(`${tmpClone}/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
+ }
1053
+ }
1054
+ }
1055
+ finally {
1056
+ // Always clean up the temp clone, even if copy fails midway.
1057
+ if (filesystem.exists(tmpClone)) {
1058
+ filesystem.remove(tmpClone);
1059
+ }
1060
+ }
1061
+ // ── 3. Apply flatten-fix rewrites on the vendored files ──────────────
1062
+ //
1063
+ // In `src/core/index.ts` and `src/core/core.module.ts` every relative
1064
+ // specifier that used to be `./core/…` (when the file lived on src/)
1065
+ // must now drop the `./core/` prefix. All OTHER internal imports
1066
+ // within common/, modules/, etc. stay identical because their
1067
+ // relative structure is preserved by the copy.
1068
+ //
1069
+ // Known edge cases from the imo pilot:
1070
+ // - src/core/test/test.helper.ts references '../core/common/helpers/db.helper'
1071
+ // which must become '../common/helpers/db.helper' after the flatten.
1072
+ // - src/core/common/interfaces/core-persistence-model.interface.ts
1073
+ // references '../../..' (three levels up to src/index.ts), which
1074
+ // must become '../..' (two levels up to src/core/index.ts).
1075
+ const tsMorphProject = new Project({ skipAddingFilesFromTsConfig: true });
1076
+ const flattenTargets = [`${coreDir}/index.ts`, `${coreDir}/core.module.ts`];
1077
+ for (const target of flattenTargets) {
1078
+ if (!filesystem.exists(target))
1079
+ continue;
1080
+ const sourceFile = tsMorphProject.addSourceFileAtPath(target);
1081
+ for (const decl of sourceFile.getImportDeclarations()) {
1082
+ const spec = decl.getModuleSpecifierValue();
1083
+ if (spec && spec.startsWith('./core/')) {
1084
+ decl.setModuleSpecifier(spec.replace(/^\.\/core\//, './'));
1085
+ }
1086
+ }
1087
+ for (const decl of sourceFile.getExportDeclarations()) {
1088
+ const spec = decl.getModuleSpecifierValue();
1089
+ if (spec && spec.startsWith('./core/')) {
1090
+ decl.setModuleSpecifier(spec.replace(/^\.\/core\//, './'));
1091
+ }
1092
+ }
1093
+ sourceFile.saveSync();
1094
+ }
1095
+ const testHelperPath = `${coreDir}/test/test.helper.ts`;
1096
+ if (filesystem.exists(testHelperPath)) {
1097
+ const sourceFile = tsMorphProject.addSourceFileAtPath(testHelperPath);
1098
+ for (const decl of sourceFile.getImportDeclarations()) {
1099
+ const spec = decl.getModuleSpecifierValue();
1100
+ if (spec && spec.startsWith('../core/')) {
1101
+ decl.setModuleSpecifier(spec.replace(/^\.\.\/core\//, '../'));
1102
+ }
1103
+ }
1104
+ sourceFile.saveSync();
1105
+ }
1106
+ const persistenceIfacePath = `${coreDir}/common/interfaces/core-persistence-model.interface.ts`;
1107
+ if (filesystem.exists(persistenceIfacePath)) {
1108
+ const sourceFile = tsMorphProject.addSourceFileAtPath(persistenceIfacePath);
1109
+ for (const decl of sourceFile.getImportDeclarations()) {
1110
+ const spec = decl.getModuleSpecifierValue();
1111
+ if (spec === '../../..') {
1112
+ decl.setModuleSpecifier('../..');
1113
+ }
1114
+ }
1115
+ sourceFile.saveSync();
1116
+ }
1117
+ // Edge: core-better-auth-user.mapper.ts uses `ScryptOptions` as a type
1118
+ // annotation without an explicit import. Upstream relies on older
1119
+ // @types/node versions where ScryptOptions was a global. With newer
1120
+ // @types/node (25+) it must be imported from 'crypto'. Add the import.
1121
+ const betterAuthMapperPath = `${coreDir}/modules/better-auth/core-better-auth-user.mapper.ts`;
1122
+ if (filesystem.exists(betterAuthMapperPath)) {
1123
+ const raw = filesystem.read(betterAuthMapperPath) || '';
1124
+ if (raw.includes('ScryptOptions') && !raw.includes('type ScryptOptions')) {
1125
+ // Replace the bare `randomBytes, scrypt` crypto import with one
1126
+ // that also pulls in the ScryptOptions type.
1127
+ const patched = raw.replace(/import\s+\{\s*randomBytes\s*,\s*scrypt\s*\}\s+from\s+['"]crypto['"]\s*;/, "import { randomBytes, scrypt, type ScryptOptions } from 'crypto';");
1128
+ if (patched !== raw) {
1129
+ filesystem.write(betterAuthMapperPath, patched);
1130
+ }
1131
+ }
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
+ }
1170
+ // ── 4. Rewrite consumer imports: '@lenne.tech/nest-server' → relative ─
1171
+ //
1172
+ // Every .ts file in the starter's src/server/, src/main.ts, tests/,
1173
+ // migrations/, scripts/, migrations-utils/ currently imports from
1174
+ // '@lenne.tech/nest-server'. After vendoring, these must use a
1175
+ // relative path to src/core whose depth depends on the file location.
1176
+ // We handle static imports, dynamic imports, and CJS require() calls.
1177
+ const codemodGlobs = [
1178
+ `${dest}/src/server/**/*.ts`,
1179
+ `${dest}/src/main.ts`,
1180
+ `${dest}/src/config.env.ts`,
1181
+ `${dest}/tests/**/*.ts`,
1182
+ `${dest}/migrations/**/*.ts`,
1183
+ `${dest}/migrations-utils/*.ts`,
1184
+ `${dest}/scripts/**/*.ts`,
1185
+ ];
1186
+ tsMorphProject.addSourceFilesAtPaths(codemodGlobs);
1187
+ for (const file of tsMorphProject.getSourceFiles()) {
1188
+ const filePath = file.getFilePath();
1189
+ // Skip files inside src/core/ — those are the framework itself.
1190
+ if (filePath.startsWith(coreDir))
1191
+ continue;
1192
+ const fromDir = path.dirname(filePath);
1193
+ let relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
1194
+ if (!relToCore.startsWith('.')) {
1195
+ relToCore = `./${relToCore}`;
1196
+ }
1197
+ let touched = false;
1198
+ // Static import declarations
1199
+ for (const decl of file.getImportDeclarations()) {
1200
+ if (decl.getModuleSpecifierValue() === '@lenne.tech/nest-server') {
1201
+ decl.setModuleSpecifier(relToCore);
1202
+ touched = true;
1203
+ }
1204
+ }
1205
+ // Static export declarations (re-exports)
1206
+ for (const decl of file.getExportDeclarations()) {
1207
+ if (decl.getModuleSpecifierValue() === '@lenne.tech/nest-server') {
1208
+ decl.setModuleSpecifier(relToCore);
1209
+ touched = true;
1210
+ }
1211
+ }
1212
+ // Dynamic imports + CJS require('@lenne.tech/nest-server')
1213
+ file.forEachDescendant((node) => {
1214
+ if (node.getKind() !== SyntaxKind.CallExpression)
1215
+ return;
1216
+ const call = node;
1217
+ const expr = call.getExpression();
1218
+ const exprText = expr.getText();
1219
+ const args = call.getArguments();
1220
+ if (args.length === 0)
1221
+ return;
1222
+ const firstArg = args[0];
1223
+ if (firstArg.getKind() !== SyntaxKind.StringLiteral)
1224
+ return;
1225
+ if (firstArg.getLiteralText() !== '@lenne.tech/nest-server')
1226
+ return;
1227
+ if (exprText === 'require' || exprText === 'import' || expr.getKind() === SyntaxKind.ImportKeyword) {
1228
+ firstArg.replaceWithText(`'${relToCore}'`);
1229
+ touched = true;
1230
+ }
1231
+ });
1232
+ if (touched) {
1233
+ file.saveSync();
1234
+ }
1235
+ }
1236
+ // Also patch migrations-utils/*.js (CJS require) via raw string replace
1237
+ // because ts-morph doesn't load .js files in our Project instance.
1238
+ const migrationsUtilsDir = `${dest}/migrations-utils`;
1239
+ if (filesystem.exists(migrationsUtilsDir)) {
1240
+ const jsFiles = filesystem.find(migrationsUtilsDir, { matching: '*.js', recursive: false });
1241
+ for (const f of jsFiles || []) {
1242
+ try {
1243
+ const content = filesystem.read(f);
1244
+ if (content && content.includes('@lenne.tech/nest-server')) {
1245
+ // The migrate helper path is at core/modules/migrate/helpers/migration.helper
1246
+ // regardless of mode, so we replace the package root reference
1247
+ // with the relative path to the vendored core.
1248
+ const fromDir = path.dirname(f);
1249
+ let relToCore = path.relative(fromDir, coreDir).split(path.sep).join('/');
1250
+ if (!relToCore.startsWith('.')) {
1251
+ relToCore = `./${relToCore}`;
1252
+ }
1253
+ const patched = content
1254
+ .replace(/require\(['"]@lenne\.tech\/nest-server['"]\)/g, `require('${relToCore}')`)
1255
+ .replace(/require\(['"]@lenne\.tech\/nest-server\/dist\/([^'"]+)['"]\)/g, (_m, sub) => `require('${relToCore}/${sub}')`);
1256
+ if (patched !== content) {
1257
+ filesystem.write(f, patched);
1258
+ }
1259
+ }
1260
+ }
1261
+ catch (_d) {
1262
+ // skip unreadable file
1263
+ }
1264
+ }
1265
+ }
1266
+ // Explicit rewrite of migrations-utils/migrate.js — the generic codemod
1267
+ // above works indirectly (via `bin/migrate.js` loading ts-node first),
1268
+ // but it's fragile: if somebody invokes migrate.js standalone, `require
1269
+ // ('../src/core')` would crash because Node cannot load TypeScript.
1270
+ // Replace the file with the explicit, imo-pilot-proven variant that
1271
+ // registers ts-node itself BEFORE requiring the vendored core and
1272
+ // points at the specific migration.helper path (not the index hub).
1273
+ const migrateJsPath = `${dest}/migrations-utils/migrate.js`;
1274
+ filesystem.write(migrateJsPath, [
1275
+ '// The vendored core is TypeScript-only (no prebuilt dist/). Register ts-node (via our',
1276
+ '// custom bootstrap) before requiring any vendor module, so that .ts source files are',
1277
+ '// transparently compiled on demand. Uses the same compiler config as the migrate CLI.',
1278
+ "require('./ts-compiler');",
1279
+ '',
1280
+ "const { createMigrationStore } = require('../src/core/modules/migrate/helpers/migration.helper');",
1281
+ "const config = require('../src/config.env');",
1282
+ '',
1283
+ 'module.exports = createMigrationStore(',
1284
+ ' config.default.mongoose.uri,',
1285
+ " 'migrations' // optional, default is 'migrations'",
1286
+ ');',
1287
+ '',
1288
+ ].join('\n'));
1289
+ // ── 4b. (removed) ─────────────────────────────────────────────────────
1290
+ //
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.
1296
+ //
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.
1299
+ // ── 5. package.json: remove @lenne.tech/nest-server, add migrate/bin ─
1300
+ //
1301
+ // Delete the framework dep — it's no longer needed since src/core/
1302
+ // carries the code inline. Leave the starter's other deps alone (they
1303
+ // already cover everything `@lenne.tech/nest-server` pulled in
1304
+ // transitively because the starter pins them via pnpm overrides / direct
1305
+ // deps already; see nest-server-starter/package.json).
1306
+ const pkgPath = `${dest}/package.json`;
1307
+ if (filesystem.exists(pkgPath)) {
1308
+ const pkg = filesystem.read(pkgPath, 'json');
1309
+ if (pkg && typeof pkg === 'object') {
1310
+ if (pkg.dependencies && typeof pkg.dependencies === 'object') {
1311
+ delete pkg.dependencies['@lenne.tech/nest-server'];
1312
+ }
1313
+ if (pkg.devDependencies && typeof pkg.devDependencies === 'object') {
1314
+ delete pkg.devDependencies['@lenne.tech/nest-server'];
1315
+ }
1316
+ // Merge the framework's transitive deps into the project's own deps.
1317
+ // The starter lists a minimal subset (express, mongoose, class-validator,
1318
+ // etc.) and previously relied on @lenne.tech/nest-server to pull in
1319
+ // all the other framework dependencies (@apollo/server, @nestjs/jwt,
1320
+ // @nestjs/passport, bcrypt, better-auth, ejs, @tus/server, etc.) as
1321
+ // transitive deps. After vendoring we lose that automatic transitivity,
1322
+ // so we must add those deps explicitly to the project package.json.
1323
+ //
1324
+ // Fully dynamic: we pull in EVERY upstream dependency that the
1325
+ // project doesn't already have. No hard-coded package lists —
1326
+ // additions to upstream's package.json surface automatically on
1327
+ // the next vendor-init or vendor-sync.
1328
+ if (!pkg.dependencies)
1329
+ pkg.dependencies = {};
1330
+ const deps = pkg.dependencies;
1331
+ for (const [depName, depVersion] of Object.entries(upstreamDeps)) {
1332
+ if (depName === '@lenne.tech/nest-server')
1333
+ continue;
1334
+ if (!(depName in deps)) {
1335
+ deps[depName] = depVersion;
1336
+ }
1337
+ }
1338
+ // Merge upstream devDependencies that a consumer project actually
1339
+ // needs at compile time. Rather than hard-coding a list of types
1340
+ // packages (which would drift as upstream evolves), we accept any
1341
+ // `@types/*` package whose base package is present in the merged
1342
+ // runtime deps (e.g. if `bcrypt` is in deps, `@types/bcrypt` is
1343
+ // accepted). This scales to new upstream additions automatically.
1344
+ //
1345
+ // Additionally, any upstream devDependency that the CLI knows is
1346
+ // used at runtime by the framework (via the dedicated
1347
+ // `vendorRuntimeDevDeps` predicate below) is promoted into
1348
+ // `dependencies`, because after vendoring the framework code lives
1349
+ // in the project and needs its runtime helpers available in prod.
1350
+ if (!pkg.devDependencies)
1351
+ pkg.devDependencies = {};
1352
+ const devDeps = pkg.devDependencies;
1353
+ for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
1354
+ // Runtime-needed devDeps → promote to dependencies
1355
+ if (this.isVendorRuntimeDep(depName) && !(depName in deps)) {
1356
+ deps[depName] = depVersion;
1357
+ continue;
1358
+ }
1359
+ // @types/<pkg> where <pkg> is a runtime dep → accept as devDep
1360
+ if (depName.startsWith('@types/')) {
1361
+ const basePkg = depName.slice('@types/'.length);
1362
+ const matchesRuntime = basePkg in deps ||
1363
+ basePkg === 'node' ||
1364
+ // scoped types: @types/foo__bar ↔ @foo/bar
1365
+ (basePkg.includes('__') && `@${basePkg.replace('__', '/')}` in deps);
1366
+ if (matchesRuntime && !(depName in devDeps) && !(depName in deps)) {
1367
+ devDeps[depName] = depVersion;
1368
+ }
1369
+ }
1370
+ }
1371
+ // Add a script to run the local bin/migrate.js. The starter's
1372
+ // existing migrate:* scripts are already correct for npm mode; we
1373
+ // need them pointing at the local bin + local ts-compiler.
1374
+ //
1375
+ // Wire the inline vendor-freshness check and hook it into
1376
+ // `check` / `check:fix` / `check:naf` as a non-blocking first step.
1377
+ if (pkg.scripts && typeof pkg.scripts === 'object') {
1378
+ const scripts = pkg.scripts;
1379
+ const migrateArgs = '--store ./migrations-utils/migrate.js --migrations-dir ./migrations --compiler ts:./migrations-utils/ts-compiler.js';
1380
+ scripts['migrate:create'] =
1381
+ `f() { node ./bin/migrate.js create "$1" --template-file ./src/core/modules/migrate/templates/migration-project.template.ts --migrations-dir ./migrations --compiler ts:./migrations-utils/ts-compiler.js; }; f`;
1382
+ scripts['migrate:up'] = `node ./bin/migrate.js up ${migrateArgs}`;
1383
+ scripts['migrate:down'] = `node ./bin/migrate.js down ${migrateArgs}`;
1384
+ scripts['migrate:list'] = `node ./bin/migrate.js list ${migrateArgs}`;
1385
+ scripts['migrate:develop:up'] = `NODE_ENV=develop node ./bin/migrate.js up ${migrateArgs}`;
1386
+ scripts['migrate:test:up'] = `NODE_ENV=test node ./bin/migrate.js up ${migrateArgs}`;
1387
+ scripts['migrate:preview:up'] = `NODE_ENV=preview node ./bin/migrate.js up ${migrateArgs}`;
1388
+ scripts['migrate:prod:up'] = `NODE_ENV=production node ./bin/migrate.js up ${migrateArgs}`;
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('');
1409
+ // Hook vendor-freshness as the first step of check / check:fix /
1410
+ // check:naf. Non-blocking (exit 0 even on mismatch), so it just
1411
+ // surfaces the warning at the top of the log.
1412
+ const hookFreshness = (scriptName) => {
1413
+ const existing = scripts[scriptName];
1414
+ if (!existing)
1415
+ return;
1416
+ if (existing.includes('check:vendor-freshness'))
1417
+ return;
1418
+ const installPrefix = 'pnpm install && ';
1419
+ if (existing.startsWith(installPrefix)) {
1420
+ scripts[scriptName] = `${installPrefix}pnpm run check:vendor-freshness && ${existing.slice(installPrefix.length)}`;
1421
+ }
1422
+ else {
1423
+ scripts[scriptName] = `pnpm run check:vendor-freshness && ${existing}`;
1424
+ }
1425
+ };
1426
+ hookFreshness('check');
1427
+ hookFreshness('check:fix');
1428
+ hookFreshness('check:naf');
1429
+ }
1430
+ filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
1431
+ }
1432
+ }
1433
+ // ── 6. migrations-utils/ts-compiler.js bootstrap ─────────────────────
1434
+ //
1435
+ // The project's tsconfig.json restricts `types` to vitest/globals
1436
+ // which hides Node globals (__dirname, require, exports). This
1437
+ // bootstrap registers ts-node with an explicit Node-aware config.
1438
+ // Always write it fresh in vendor mode — overwrites whatever the
1439
+ // starter shipped (which relies on node_modules/@lenne.tech/nest-server).
1440
+ const tsCompilerPath = `${dest}/migrations-utils/ts-compiler.js`;
1441
+ filesystem.write(tsCompilerPath, [
1442
+ '/**',
1443
+ ' * ts-node bootstrap for the migrate CLI (vendor mode).',
1444
+ ' *',
1445
+ " * The project's tsconfig.json restricts `types` to vitest/globals",
1446
+ ' * which hides Node globals (__dirname, require, exports). This',
1447
+ ' * bootstrap registers ts-node with an explicit Node-aware config.',
1448
+ ' */',
1449
+ "const tsNode = require('ts-node');",
1450
+ '',
1451
+ 'tsNode.register({',
1452
+ ' transpileOnly: true,',
1453
+ ' compilerOptions: {',
1454
+ " module: 'commonjs',",
1455
+ " target: 'es2022',",
1456
+ ' esModuleInterop: true,',
1457
+ ' experimentalDecorators: true,',
1458
+ ' emitDecoratorMetadata: true,',
1459
+ ' skipLibCheck: true,',
1460
+ " types: ['node'],",
1461
+ ' },',
1462
+ '});',
1463
+ '',
1464
+ ].join('\n'));
1465
+ // ── 6b. extras/sync-packages.mjs: replace with vendor-aware stub ─────
1466
+ //
1467
+ // The starter's `extras/sync-packages.mjs` pulls the latest deps of
1468
+ // `@lenne.tech/nest-server` from the npm registry and merges them into
1469
+ // the project's package.json. In vendor mode the framework is no
1470
+ // longer an npm dependency, so the script has nothing meaningful to
1471
+ // sync and would either no-op or error out.
1472
+ //
1473
+ // Replace it with a small informational stub that points the user at
1474
+ // the canonical vendor-update path (the `nest-server-core-updater`
1475
+ // Claude Code agent). Keeps `pnpm run update` from dead-exiting with
1476
+ // a confusing message.
1477
+ const syncPackagesPath = `${dest}/extras/sync-packages.mjs`;
1478
+ if (filesystem.exists(syncPackagesPath)) {
1479
+ filesystem.write(syncPackagesPath, [
1480
+ '#!/usr/bin/env node',
1481
+ '',
1482
+ "'use strict';",
1483
+ '',
1484
+ '/**',
1485
+ ' * Vendor-mode stub for extras/sync-packages.mjs.',
1486
+ ' *',
1487
+ ' * The original script is designed for npm-mode projects where',
1488
+ " * `@lenne.tech/nest-server` is an installed dependency and",
1489
+ ' * `pnpm run update` pulls the latest upstream deps into the',
1490
+ ' * project package.json.',
1491
+ ' *',
1492
+ ' * This project runs in VENDOR mode: the framework core/ tree is',
1493
+ ' * copied directly into src/core/ and there is no framework npm',
1494
+ ' * dep to sync. To update the vendored core, use the',
1495
+ ' * `nest-server-core-updater` Claude Code agent.',
1496
+ ' */',
1497
+ '',
1498
+ "console.warn('');",
1499
+ "console.warn('⚠ pnpm run update is a no-op in vendor mode.');",
1500
+ "console.warn(' Framework source lives directly in src/core/ and is updated');",
1501
+ "console.warn(' via the nest-server-core-updater agent:');",
1502
+ "console.warn('');",
1503
+ "console.warn(' /lt-dev:backend:update-nest-server-core');",
1504
+ "console.warn('');",
1505
+ "console.warn(' See src/core/VENDOR.md for the current baseline and sync history.');",
1506
+ "console.warn('');",
1507
+ 'process.exit(0);',
1508
+ '',
1509
+ ].join('\n'));
1510
+ }
1511
+ // ── 7. tsconfig.json excludes + Node types ───────────────────────────
1512
+ //
1513
+ // The vendored migrate template references `from '@lenne.tech/nest-server'`
1514
+ // as a placeholder that only makes sense in the *generated* migration
1515
+ // file's context. Exclude it from compilation.
1516
+ //
1517
+ // The starter's tsconfig.json restricts `types` to `['vitest/globals']`
1518
+ // because the shipped framework dist was pre-compiled with Node types
1519
+ // baked in. In vendor mode we compile the framework source directly,
1520
+ // so we MUST also load `@types/node` — otherwise types like
1521
+ // `ScryptOptions` in core-better-auth-user.mapper.ts break the build.
1522
+ this.widenTsconfigExcludes(`${dest}/tsconfig.json`);
1523
+ this.widenTsconfigExcludes(`${dest}/tsconfig.build.json`);
1524
+ this.widenTsconfigTypes(`${dest}/tsconfig.json`);
1525
+ this.widenTsconfigTypes(`${dest}/tsconfig.build.json`);
1526
+ // ── 9b. Prepend a vendor-mode block to projects/api/CLAUDE.md ────────
1527
+ //
1528
+ // Claude Code's project-level CLAUDE.md is the single source of truth
1529
+ // for "what kind of project is this". Prepending a short vendor block
1530
+ // tells any downstream agent (backend-dev, generating-nest-servers,
1531
+ // nest-server-updater, …) that the framework lives at src/core/ and
1532
+ // that generated imports must use relative paths, before they even
1533
+ // read the rest of the file.
1534
+ const apiClaudeMdPath = `${dest}/CLAUDE.md`;
1535
+ if (filesystem.exists(apiClaudeMdPath)) {
1536
+ const existing = filesystem.read(apiClaudeMdPath) || '';
1537
+ const marker = '<!-- lt-vendor-marker -->';
1538
+ if (!existing.includes(marker)) {
1539
+ const vendorBlock = [
1540
+ marker,
1541
+ '',
1542
+ '# Vendor-Mode Notice',
1543
+ '',
1544
+ 'This api project runs in **vendor mode**: the `@lenne.tech/nest-server`',
1545
+ 'core/ tree has been copied directly into `src/core/` as first-class',
1546
+ 'project code. There is **no** `@lenne.tech/nest-server` npm dependency.',
1547
+ '',
1548
+ '- **Read framework code from `src/core/**`** — not from `node_modules/`.',
1549
+ '- **Generated imports use relative paths** to `src/core`, e.g.',
1550
+ ' `import { CrudService } from \'../../../core\';`',
1551
+ ' The exact depth depends on the file location. `lt server module`',
1552
+ ' computes it automatically.',
1553
+ '- **Baseline + patch log** live in `src/core/VENDOR.md`. Log any',
1554
+ ' substantial local change there so the `nest-server-core-updater`',
1555
+ ' agent can classify it at sync time.',
1556
+ '- **Update flow:** run `/lt-dev:backend:update-nest-server-core` (the',
1557
+ ' agent clones upstream, computes a delta, and presents a review).',
1558
+ '- **Contribute back:** run `/lt-dev:backend:contribute-nest-server-core`',
1559
+ ' to propose local fixes as upstream PRs.',
1560
+ '- **Freshness check:** `pnpm run check:vendor-freshness` warns (non-',
1561
+ ' blockingly) when upstream has a newer release than the baseline.',
1562
+ '',
1563
+ '---',
1564
+ '',
1565
+ ].join('\n');
1566
+ filesystem.write(apiClaudeMdPath, vendorBlock + existing);
1567
+ }
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
+ }
1596
+ // ── 10. VENDOR.md baseline ───────────────────────────────────────────
1597
+ //
1598
+ // Record the exact upstream version + commit SHA we vendored from, so
1599
+ // the `nest-server-core-updater` agent has a reliable base for
1600
+ // computing upstream deltas on the next sync.
1601
+ const vendorMdPath = `${dest}/src/core/VENDOR.md`;
1602
+ if (!filesystem.exists(vendorMdPath)) {
1603
+ const today = new Date().toISOString().slice(0, 10);
1604
+ const versionLine = upstreamVersion
1605
+ ? `- **Baseline-Version:** ${upstreamVersion}`
1606
+ : '- **Baseline-Version:** (not detected — run `/lt-dev:backend:update-nest-server-core` to record)';
1607
+ const commitLine = upstreamCommit
1608
+ ? `- **Baseline-Commit:** \`${upstreamCommit}\``
1609
+ : '- **Baseline-Commit:** (not detected)';
1610
+ const syncHistoryTo = upstreamVersion
1611
+ ? `${upstreamVersion}${upstreamCommit ? ` (\`${upstreamCommit.slice(0, 10)}\`)` : ''}`
1612
+ : 'initial import';
1613
+ filesystem.write(vendorMdPath, [
1614
+ '# @lenne.tech/nest-server – core (vendored)',
1615
+ '',
1616
+ 'This directory is a curated vendor copy of the `core/` tree from',
1617
+ '@lenne.tech/nest-server. It is first-class project code, not a',
1618
+ 'node_modules shadow copy. Edit freely; log substantial changes in',
1619
+ 'the "Local changes" table below so the `nest-server-core-updater`',
1620
+ 'agent can classify them at sync time.',
1621
+ '',
1622
+ 'The flatten-fix was applied during `lt fullstack init`: the',
1623
+ 'upstream `src/index.ts`, `src/core.module.ts`, `src/test/`,',
1624
+ '`src/templates/`, `src/types/`, and `LICENSE` were moved under',
1625
+ '`src/core/` and their relative `./core/…` specifiers were',
1626
+ 'stripped. See the init code in',
1627
+ '`lenneTech/cli/src/extensions/server.ts#convertCloneToVendored`.',
1628
+ '',
1629
+ '## Baseline',
1630
+ '',
1631
+ '- **Upstream-Repo:** https://github.com/lenneTech/nest-server',
1632
+ versionLine,
1633
+ commitLine,
1634
+ `- **Vendored am:** ${today}`,
1635
+ `- **Vendored von:** lt CLI (\`lt fullstack init --framework-mode vendor\`)`,
1636
+ '',
1637
+ '## Sync history',
1638
+ '',
1639
+ '| Date | From | To | Notes |',
1640
+ '| ---- | ---- | -- | ----- |',
1641
+ `| ${today} | — | ${syncHistoryTo} | scaffolded by lt CLI |`,
1642
+ '',
1643
+ '## Local changes',
1644
+ '',
1645
+ '| Date | Commit | Scope | Reason | Status |',
1646
+ '| ---- | ------ | ----- | ------ | ------ |',
1647
+ '| — | — | (none, pristine) | initial vendor | — |',
1648
+ '',
1649
+ '## Upstream PRs',
1650
+ '',
1651
+ '| PR | Title | Commits | Status |',
1652
+ '| -- | ----- | ------- | ------ |',
1653
+ '| — | (none yet) | — | — |',
1654
+ '',
1655
+ ].join('\n'));
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
+ }
1674
+ return { upstreamDeps, upstreamDevDeps };
1675
+ });
1676
+ }
1677
+ /**
1678
+ * Predicate: is a given upstream `devDependencies` key actually a runtime
1679
+ * dep in disguise that needs to live in `dependencies` after vendoring?
1680
+ *
1681
+ * `@lenne.tech/nest-server` keeps a few packages in devDependencies that
1682
+ * the framework code imports at runtime (e.g. `find-file-up` in its
1683
+ * config loader). When we vendor the framework source into a consumer
1684
+ * project, those must end up in `dependencies` so the compiled/dist
1685
+ * runtime has them available.
1686
+ *
1687
+ * The list of such helpers lives in `src/config/vendor-runtime-deps.json`
1688
+ * under the `runtimeHelpers` key. Adding a new helper is a data-only
1689
+ * change (no CLI release required). If the config file is missing or
1690
+ * unreadable, the predicate safely returns `false` for everything.
1691
+ */
1692
+ isVendorRuntimeDep(pkgName) {
1693
+ if (!this._vendorRuntimeHelpers) {
1694
+ try {
1695
+ const path = require('path');
1696
+ const configPath = path.join(__dirname, '..', 'config', 'vendor-runtime-deps.json');
1697
+ const raw = this.filesystem.read(configPath, 'json');
1698
+ const list = Array.isArray(raw === null || raw === void 0 ? void 0 : raw.runtimeHelpers) ? raw.runtimeHelpers : [];
1699
+ this._vendorRuntimeHelpers = new Set(list.filter((e) => typeof e === 'string'));
1700
+ }
1701
+ catch (_a) {
1702
+ this._vendorRuntimeHelpers = new Set();
1703
+ }
1704
+ }
1705
+ return this._vendorRuntimeHelpers.has(pkgName);
1706
+ }
1707
+ /**
1708
+ * Reads the GraphQL-only package list from the starter's
1709
+ * `api-mode.manifest.json`. Must be called BEFORE `processApiMode`
1710
+ * runs, because `processApiMode` deletes the manifest at the end of
1711
+ * its REST-mode pass.
1712
+ *
1713
+ * Returns a flat list of package names that the manifest declares as
1714
+ * GraphQL-specific (both `packages` and `devPackages` arrays from the
1715
+ * `graphql` mode config). Empty list if the manifest is missing or
1716
+ * malformed.
1717
+ */
1718
+ readApiModeGraphqlEssentials(dest) {
1719
+ var _a;
1720
+ const manifestPath = `${dest}/api-mode.manifest.json`;
1721
+ if (!this.filesystem.exists(manifestPath))
1722
+ return [];
1723
+ try {
1724
+ const manifest = this.filesystem.read(manifestPath, 'json');
1725
+ const gqlMode = (_a = manifest === null || manifest === void 0 ? void 0 : manifest.modes) === null || _a === void 0 ? void 0 : _a.graphql;
1726
+ if (!gqlMode || typeof gqlMode !== 'object')
1727
+ return [];
1728
+ const packages = [];
1729
+ if (Array.isArray(gqlMode.packages))
1730
+ packages.push(...gqlMode.packages);
1731
+ if (Array.isArray(gqlMode.devPackages))
1732
+ packages.push(...gqlMode.devPackages);
1733
+ return packages.filter((p) => typeof p === 'string' && p.length > 0);
1734
+ }
1735
+ catch (_b) {
1736
+ return [];
1737
+ }
1738
+ }
1739
+ /**
1740
+ * Restores framework-core-essential dependencies that the
1741
+ * `api-mode.manifest.json` of the starter strips when running in
1742
+ * REST-only mode. The vendored framework core at `src/core/` always
1743
+ * imports these packages (e.g. `core-auth.module.ts` imports
1744
+ * `graphql-subscriptions`), so even a pure REST project needs them
1745
+ * available at compile time.
1746
+ *
1747
+ * The essentials list is captured BEFORE `processApiMode` runs (via
1748
+ * `readApiModeGraphqlEssentials`) and passed in here. Versions come
1749
+ * from the upstream `@lenne.tech/nest-server` package.json snapshot
1750
+ * (passed in via `upstreamDeps`). No hard-coded package lists or
1751
+ * versions — drift between starter and upstream is handled automatically
1752
+ * at the next init.
1753
+ */
1754
+ restoreVendorCoreEssentials(options) {
1755
+ var _a;
1756
+ const { dest, essentials, upstreamDeps = {} } = options;
1757
+ if (!essentials || essentials.length === 0)
1758
+ return;
1759
+ const pkgPath = `${dest}/package.json`;
1760
+ if (!this.filesystem.exists(pkgPath))
1761
+ return;
1762
+ const pkg = this.filesystem.read(pkgPath, 'json');
1763
+ if (!pkg || typeof pkg !== 'object')
1764
+ return;
1765
+ if (!pkg.dependencies)
1766
+ pkg.dependencies = {};
1767
+ const deps = pkg.dependencies;
1768
+ for (const name of essentials) {
1769
+ if (deps[name])
1770
+ continue;
1771
+ // Prefer the upstream-pinned version when available, else leave
1772
+ // unversioned (`latest`) — install will resolve either way.
1773
+ const version = (_a = upstreamDeps[name]) !== null && _a !== void 0 ? _a : 'latest';
1774
+ deps[name] = version;
1775
+ }
1776
+ this.filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
1777
+ }
1778
+ /**
1779
+ * Ensures the given tsconfig contains both `node` and `vitest/globals`
1780
+ * in its `types` array.
1781
+ *
1782
+ * - `node` is required because the vendored framework source uses Node
1783
+ * globals / types like `ScryptOptions` (from `node:crypto`), which
1784
+ * the starter's compiled dist never needed.
1785
+ * - `vitest/globals` is required because `src/core/test/test.helper.ts`
1786
+ * uses the global `expect` from vitest, and that file is transitively
1787
+ * pulled into the build via TestHelper consumers. tsconfig `exclude`
1788
+ * doesn't help because exclude is a non-transitive filter.
1789
+ *
1790
+ * Idempotent. Handles both bracketed arrays and absent keys, and
1791
+ * leaves the file untouched if both types are already listed.
1792
+ */
1793
+ widenTsconfigTypes(tsconfigPath) {
1794
+ if (!this.filesystem.exists(tsconfigPath))
1795
+ return;
1796
+ try {
1797
+ let raw = this.filesystem.read(tsconfigPath) || '';
1798
+ const needed = ['node', 'vitest/globals'];
1799
+ for (const type of needed) {
1800
+ // Already contains this type in some types array — skip it.
1801
+ const alreadyRegex = new RegExp(`"types"\\s*:\\s*\\[[^\\]]*"${type.replace(/\//g, '\\/')}"`);
1802
+ if (alreadyRegex.test(raw))
1803
+ continue;
1804
+ const typesRegex = /("types"\s*:\s*\[)([^\]]*)(\])/;
1805
+ if (typesRegex.test(raw)) {
1806
+ raw = raw.replace(typesRegex, (_m, head, body, tail) => {
1807
+ const trimmed = body.trim();
1808
+ const joiner = trimmed.length > 0 ? ', ' : '';
1809
+ return `${head}${body}${joiner}"${type}"${tail}`;
1810
+ });
1811
+ }
1812
+ else {
1813
+ const compilerOptionsRegex = /("compilerOptions"\s*:\s*\{)/;
1814
+ if (compilerOptionsRegex.test(raw)) {
1815
+ raw = raw.replace(compilerOptionsRegex, `$1\n "types": ["${type}"],`);
1816
+ }
1817
+ }
1818
+ }
1819
+ this.filesystem.write(tsconfigPath, raw);
1820
+ }
1821
+ catch (_a) {
1822
+ // best-effort
1823
+ }
1824
+ }
1825
+ /**
1826
+ * Adds the vendored migrate template file AND the vendored `src/core/test/`
1827
+ * directory to the `exclude` array of a tsconfig (build or base).
1828
+ *
1829
+ * - The migrate template file is a text template that references
1830
+ * `@lenne.tech/nest-server` as a placeholder — only meaningful in the
1831
+ * *generated* migration file's context. Exclude from compilation.
1832
+ * - `src/core/test/` contains `TestHelper`, which uses vitest globals
1833
+ * (`expect`, etc.) and is not intended for production dist. The
1834
+ * starter's `tsconfig.build.json` overrides `types` to `["node"]`,
1835
+ * which strips vitest globals; compiling `test.helper.ts` there
1836
+ * breaks. Exclude the whole `src/core/test/` subtree from the build.
1837
+ *
1838
+ * Handles both arrays with pre-existing entries and absent `exclude`
1839
+ * keys. Idempotent.
1840
+ */
1841
+ widenTsconfigExcludes(tsconfigPath) {
1842
+ if (!this.filesystem.exists(tsconfigPath)) {
1843
+ return;
1844
+ }
1845
+ const EXCLUDE_ENTRIES = [
1846
+ 'src/core/modules/migrate/templates/**/*.template.ts',
1847
+ 'src/core/test/**/*.ts',
1848
+ ];
1849
+ try {
1850
+ // The upstream tsconfig files may contain comments — standard JSON parse
1851
+ // breaks on them. Use a regex-based patch as a fallback.
1852
+ let raw = this.filesystem.read(tsconfigPath) || '';
1853
+ for (const entry of EXCLUDE_ENTRIES) {
1854
+ if (raw.includes(entry))
1855
+ continue;
1856
+ const excludeRegex = /("exclude"\s*:\s*\[)([^\]]*)(\])/;
1857
+ if (excludeRegex.test(raw)) {
1858
+ raw = raw.replace(excludeRegex, (_match, head, body, tail) => {
1859
+ const trimmed = body.trim();
1860
+ const joiner = trimmed.length > 0 ? ', ' : '';
1861
+ return `${head}${body}${joiner}"${entry}"${tail}`;
1862
+ });
1863
+ }
1864
+ else {
1865
+ // No exclude key at all — add one before the closing brace.
1866
+ const lastBrace = raw.lastIndexOf('}');
1867
+ if (lastBrace === -1)
1868
+ continue;
1869
+ const before = raw.slice(0, lastBrace);
1870
+ const after = raw.slice(lastBrace);
1871
+ const separator = before.trimEnd().endsWith(',') || before.trimEnd().endsWith('{') ? '' : ',';
1872
+ raw = `${before.trimEnd()}${separator}\n "exclude": ["${entry}"]\n${after}`;
1873
+ }
1874
+ }
1875
+ this.filesystem.write(tsconfigPath, raw);
1876
+ }
1877
+ catch (_a) {
1878
+ // Best-effort; the project will still work, it just may need a
1879
+ // manual `tsconfig.json` exclude when building.
1880
+ }
1881
+ }
766
1882
  /**
767
1883
  * Patch config.env.ts using TypeScript AST manipulation
768
1884
  * - Replace SECRET_OR_PRIVATE_KEY placeholders with random secrets
@@ -833,6 +1949,30 @@ class Server {
833
1949
  }
834
1950
  this.filesystem.write(claudeMdPath, content);
835
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
+ }
836
1976
  /**
837
1977
  * Replace secret or private keys in string (e.g. for config files)
838
1978
  */
@@ -857,6 +1997,338 @@ class Server {
857
1997
  return `'${secretMap.get(placeholder)}'`;
858
1998
  });
859
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
+ }
860
2332
  }
861
2333
  exports.Server = Server;
862
2334
  /**