@rs-x/cli 2.0.0-next.20 → 2.0.0-next.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/rsx.cjs CHANGED
@@ -211,6 +211,22 @@ function detectPackageManager(explicitPm) {
211
211
  return 'npm';
212
212
  }
213
213
 
214
+ function resolveCliPackageManager(projectRoot, explicitPm) {
215
+ if (explicitPm) {
216
+ return explicitPm;
217
+ }
218
+
219
+ const cliConfig = resolveRsxCliConfig(projectRoot);
220
+ if (
221
+ typeof cliConfig.packageManager === 'string' &&
222
+ ['pnpm', 'npm', 'yarn', 'bun'].includes(cliConfig.packageManager)
223
+ ) {
224
+ return cliConfig.packageManager;
225
+ }
226
+
227
+ return detectPackageManager(undefined);
228
+ }
229
+
214
230
  function applyTagToPackages(packages, tag) {
215
231
  return packages.map((pkg) => {
216
232
  const lastAt = pkg.lastIndexOf('@');
@@ -249,6 +265,41 @@ function resolveInstallTag(flags) {
249
265
  return undefined;
250
266
  }
251
267
 
268
+ function resolveConfiguredInstallTag(projectRoot, flags) {
269
+ const explicitTag = resolveInstallTag(flags);
270
+ if (explicitTag) {
271
+ return explicitTag;
272
+ }
273
+
274
+ const cliConfig = resolveRsxCliConfig(projectRoot);
275
+ if (
276
+ typeof cliConfig.installTag === 'string' &&
277
+ ['latest', 'next'].includes(cliConfig.installTag)
278
+ ) {
279
+ return cliConfig.installTag;
280
+ }
281
+
282
+ return undefined;
283
+ }
284
+
285
+ function resolveCliVerifyFlag(projectRoot, flags, sectionName) {
286
+ if (flags.verify !== undefined) {
287
+ return parseBooleanFlag(flags.verify, false);
288
+ }
289
+
290
+ const cliConfig = resolveRsxCliConfig(projectRoot);
291
+ const sectionConfig = cliConfig[sectionName];
292
+ if (
293
+ typeof sectionConfig === 'object' &&
294
+ sectionConfig &&
295
+ typeof sectionConfig.verify === 'boolean'
296
+ ) {
297
+ return sectionConfig.verify;
298
+ }
299
+
300
+ return false;
301
+ }
302
+
252
303
  function installPackages(pm, packages, options = {}) {
253
304
  const { dev = false, dryRun = false, label = 'packages', tag, cwd } = options;
254
305
  const resolvedPackages = tag ? applyTagToPackages(packages, tag) : packages;
@@ -484,6 +535,13 @@ function findRepoRoot(startDir) {
484
535
  function runDoctor() {
485
536
  const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
486
537
  const hasCode = hasCommand('code');
538
+ const availablePackageManagers = ['pnpm', 'npm', 'yarn', 'bun'].filter((pm) =>
539
+ hasCommand(pm),
540
+ );
541
+ const packageRoot = findNearestPackageRoot(process.cwd());
542
+ const duplicateRsxPackages = packageRoot
543
+ ? findDuplicateRsxPackages(packageRoot)
544
+ : [];
487
545
  const checks = [
488
546
  {
489
547
  name: 'Node.js >= 20',
@@ -497,12 +555,25 @@ function runDoctor() {
497
555
  },
498
556
  {
499
557
  name: 'Package manager (pnpm/npm/yarn/bun)',
500
- ok:
501
- hasCommand('pnpm') ||
502
- hasCommand('npm') ||
503
- hasCommand('yarn') ||
504
- hasCommand('bun'),
505
- details: 'required for compiler package installation',
558
+ ok: availablePackageManagers.length > 0,
559
+ details:
560
+ availablePackageManagers.length > 0
561
+ ? `available: ${availablePackageManagers.join(', ')}`
562
+ : 'required for compiler package installation',
563
+ },
564
+ {
565
+ name: 'Duplicate @rs-x package versions',
566
+ ok: duplicateRsxPackages.length === 0,
567
+ details: packageRoot
568
+ ? duplicateRsxPackages.length === 0
569
+ ? `not detected in ${path.relative(process.cwd(), packageRoot) || '.'}`
570
+ : duplicateRsxPackages
571
+ .map(
572
+ ({ name, versions }) =>
573
+ `${name} (${Array.from(versions).join(', ')})`,
574
+ )
575
+ .join('; ')
576
+ : 'no package.json found in current directory tree',
506
577
  },
507
578
  ];
508
579
 
@@ -510,6 +581,149 @@ function runDoctor() {
510
581
  const tag = check.ok ? '[OK]' : '[WARN]';
511
582
  console.log(`${tag} ${check.name} - ${check.details}`);
512
583
  }
584
+
585
+ const failingChecks = checks.filter((check) => !check.ok);
586
+ if (failingChecks.length > 0) {
587
+ console.log('');
588
+ console.log('Suggested next steps:');
589
+ for (const check of failingChecks) {
590
+ if (check.name === 'Node.js >= 20') {
591
+ console.log(
592
+ ' - Install Node.js 20 or newer before running `rsx setup` or `rsx project`.',
593
+ );
594
+ } else if (check.name === 'VS Code CLI (code)') {
595
+ console.log(
596
+ ' - Install the VS Code shell command or use `rsx install vscode --force` later.',
597
+ );
598
+ } else if (check.name === 'Package manager (pnpm/npm/yarn/bun)') {
599
+ console.log(
600
+ ' - Install npm, pnpm, yarn, or bun so the CLI can add RS-X packages.',
601
+ );
602
+ } else if (check.name === 'Duplicate @rs-x package versions') {
603
+ console.log(
604
+ ' - Reinstall dependencies so all `@rs-x/*` packages resolve to a single version, then rerun `rsx doctor`.',
605
+ );
606
+ console.log(
607
+ ' - If you are linking local packages, remove nested `node_modules` inside linked RS-X packages.',
608
+ );
609
+ }
610
+ }
611
+ return;
612
+ }
613
+
614
+ console.log('');
615
+ console.log('Suggested next steps:');
616
+ console.log(
617
+ ' - Run `rsx project <framework>` to scaffold a starter, or `rsx setup` inside an existing app.',
618
+ );
619
+ console.log(' - Use `rsx add` to create your first expression file.');
620
+ }
621
+
622
+ function findNearestPackageRoot(startDirectory) {
623
+ let currentDirectory = path.resolve(startDirectory);
624
+ while (true) {
625
+ if (fs.existsSync(path.join(currentDirectory, 'package.json'))) {
626
+ return currentDirectory;
627
+ }
628
+ const parentDirectory = path.dirname(currentDirectory);
629
+ if (parentDirectory === currentDirectory) {
630
+ return null;
631
+ }
632
+ currentDirectory = parentDirectory;
633
+ }
634
+ }
635
+
636
+ function readRsxPackageVersionsFromNodeModules(
637
+ nodeModulesDirectory,
638
+ versionsByPackage,
639
+ ) {
640
+ const scopeDirectory = path.join(nodeModulesDirectory, '@rs-x');
641
+ if (!fs.existsSync(scopeDirectory)) {
642
+ return [];
643
+ }
644
+
645
+ const packageDirectories = fs
646
+ .readdirSync(scopeDirectory, { withFileTypes: true })
647
+ .filter((entry) => entry.isDirectory())
648
+ .map((entry) => path.join(scopeDirectory, entry.name));
649
+
650
+ for (const packageDirectory of packageDirectories) {
651
+ const packageJsonPath = path.join(packageDirectory, 'package.json');
652
+ if (!fs.existsSync(packageJsonPath)) {
653
+ continue;
654
+ }
655
+
656
+ try {
657
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
658
+ const packageName = packageJson.name;
659
+ const packageVersion = packageJson.version;
660
+ if (
661
+ typeof packageName === 'string' &&
662
+ typeof packageVersion === 'string'
663
+ ) {
664
+ if (!versionsByPackage.has(packageName)) {
665
+ versionsByPackage.set(packageName, new Set());
666
+ }
667
+ versionsByPackage.get(packageName).add(packageVersion);
668
+ }
669
+ } catch {
670
+ // Ignore malformed package.json files during doctor output.
671
+ }
672
+ }
673
+
674
+ return packageDirectories;
675
+ }
676
+
677
+ function collectNestedRsxPackageVersions(
678
+ packageDirectory,
679
+ versionsByPackage,
680
+ depth,
681
+ ) {
682
+ if (depth <= 0) {
683
+ return;
684
+ }
685
+
686
+ const nestedNodeModulesDirectory = path.join(
687
+ packageDirectory,
688
+ 'node_modules',
689
+ );
690
+ if (!fs.existsSync(nestedNodeModulesDirectory)) {
691
+ return;
692
+ }
693
+
694
+ const nestedPackageDirectories = readRsxPackageVersionsFromNodeModules(
695
+ nestedNodeModulesDirectory,
696
+ versionsByPackage,
697
+ );
698
+
699
+ for (const nestedPackageDirectory of nestedPackageDirectories) {
700
+ collectNestedRsxPackageVersions(
701
+ nestedPackageDirectory,
702
+ versionsByPackage,
703
+ depth - 1,
704
+ );
705
+ }
706
+ }
707
+
708
+ function findDuplicateRsxPackages(projectRoot) {
709
+ const rootNodeModulesDirectory = path.join(projectRoot, 'node_modules');
710
+ if (!fs.existsSync(rootNodeModulesDirectory)) {
711
+ return [];
712
+ }
713
+
714
+ const versionsByPackage = new Map();
715
+ const rootPackageDirectories = readRsxPackageVersionsFromNodeModules(
716
+ rootNodeModulesDirectory,
717
+ versionsByPackage,
718
+ );
719
+
720
+ for (const packageDirectory of rootPackageDirectories) {
721
+ collectNestedRsxPackageVersions(packageDirectory, versionsByPackage, 3);
722
+ }
723
+
724
+ return Array.from(versionsByPackage.entries())
725
+ .filter(([, versions]) => versions.size > 1)
726
+ .map(([name, versions]) => ({ name, versions }));
513
727
  }
514
728
 
515
729
  function isValidTsIdentifier(input) {
@@ -587,25 +801,311 @@ function stripTsLikeExtension(fileName) {
587
801
  return fileName.replace(/\.[cm]?[jt]sx?$/u, '');
588
802
  }
589
803
 
590
- function createModelTemplate() {
804
+ function inferModelKeysFromExpression(expressionSource) {
805
+ const matches = expressionSource.matchAll(
806
+ /(?<![\w$.])([A-Za-z_$][A-Za-z0-9_$]*)(?![\w$])/gu,
807
+ );
808
+ const identifiers = [];
809
+ const ignoredIdentifiers = new Set([
810
+ 'undefined',
811
+ 'null',
812
+ 'true',
813
+ 'false',
814
+ 'Math',
815
+ 'Date',
816
+ 'Number',
817
+ 'String',
818
+ 'Boolean',
819
+ 'Array',
820
+ 'Object',
821
+ 'JSON',
822
+ 'console',
823
+ ]);
824
+
825
+ for (const match of matches) {
826
+ const identifier = match[1];
827
+ if (
828
+ TS_RESERVED_WORDS.has(identifier) ||
829
+ ignoredIdentifiers.has(identifier)
830
+ ) {
831
+ continue;
832
+ }
833
+ if (!identifiers.includes(identifier)) {
834
+ identifiers.push(identifier);
835
+ }
836
+ }
837
+
838
+ return identifiers;
839
+ }
840
+
841
+ function createModelTemplate(expressionSource) {
842
+ const modelKeys = inferModelKeysFromExpression(expressionSource);
843
+ const modelBody =
844
+ modelKeys.length > 0
845
+ ? modelKeys.map((key) => ` ${key}: 1,`).join('\n')
846
+ : ' a: 1,';
847
+
591
848
  return `export const model = {
592
- a: 1,
849
+ ${modelBody}
593
850
  };
594
851
  `;
595
852
  }
596
853
 
854
+ function createInlineExpressionTemplate(expressionName, expressionSource) {
855
+ return `import { rsx } from '@rs-x/expression-parser';
856
+
857
+ ${createModelTemplate(expressionSource).trim()}
858
+
859
+ export const ${expressionName} = rsx(${JSON.stringify(expressionSource)})(model);
860
+ `;
861
+ }
862
+
863
+ function createInlineExpressionAppendTemplate(
864
+ expressionName,
865
+ expressionSource,
866
+ fileContent,
867
+ ) {
868
+ const hasRsxImport = fileContent.includes("from '@rs-x/expression-parser'");
869
+ const hasModelExport = /\bexport\s+const\s+model\s*=/u.test(fileContent);
870
+ const sections = [];
871
+
872
+ if (!hasRsxImport) {
873
+ sections.push("import { rsx } from '@rs-x/expression-parser';");
874
+ }
875
+
876
+ if (!hasModelExport) {
877
+ sections.push(createModelTemplate(expressionSource).trim());
878
+ }
879
+
880
+ sections.push(
881
+ `export const ${expressionName} = rsx(${JSON.stringify(expressionSource)})(model);`,
882
+ );
883
+ return `\n${sections.join('\n\n')}\n`;
884
+ }
885
+
597
886
  function createExpressionTemplate(
598
887
  expressionName,
888
+ expressionSource,
599
889
  modelImportPath,
600
890
  modelExportName,
601
891
  ) {
602
892
  return `import { rsx } from '@rs-x/expression-parser';
603
893
  import { ${modelExportName} } from '${modelImportPath}';
604
894
 
605
- export const ${expressionName} = rsx('a')(${modelExportName});
895
+ export const ${expressionName} = rsx(${JSON.stringify(expressionSource)})(${modelExportName});
606
896
  `;
607
897
  }
608
898
 
899
+ function findExpressionFiles(searchRoot) {
900
+ const results = [];
901
+ const expressionPattern =
902
+ /\b(?:export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*rsx\(/u;
903
+
904
+ function visit(currentPath) {
905
+ if (!fs.existsSync(currentPath)) {
906
+ return;
907
+ }
908
+
909
+ const stat = fs.statSync(currentPath);
910
+ if (stat.isDirectory()) {
911
+ const baseName = path.basename(currentPath);
912
+ if (
913
+ baseName === 'node_modules' ||
914
+ baseName === 'dist' ||
915
+ baseName === 'build' ||
916
+ baseName === '.git' ||
917
+ baseName === '.next' ||
918
+ baseName === 'coverage' ||
919
+ baseName === '.tests' ||
920
+ baseName === 'tmp'
921
+ ) {
922
+ return;
923
+ }
924
+
925
+ for (const entry of fs.readdirSync(currentPath)) {
926
+ visit(path.join(currentPath, entry));
927
+ }
928
+ return;
929
+ }
930
+
931
+ if (!/\.[cm]?[jt]sx?$/u.test(currentPath)) {
932
+ return;
933
+ }
934
+
935
+ const content = fs.readFileSync(currentPath, 'utf8');
936
+ if (
937
+ content.includes("from '@rs-x/expression-parser'") &&
938
+ expressionPattern.test(content)
939
+ ) {
940
+ results.push(currentPath);
941
+ }
942
+ }
943
+
944
+ visit(searchRoot);
945
+ return results.sort((left, right) => left.localeCompare(right));
946
+ }
947
+
948
+ function resolveRsxCliAddConfig(projectRoot) {
949
+ const cliConfig = resolveRsxCliConfig(projectRoot);
950
+ const addConfig = cliConfig.add ?? {};
951
+ const defaultSearchRoots = ['src', 'app', 'expressions'];
952
+ const configuredSearchRoots = Array.isArray(addConfig.searchRoots)
953
+ ? addConfig.searchRoots.filter(
954
+ (value) => typeof value === 'string' && value.trim() !== '',
955
+ )
956
+ : defaultSearchRoots;
957
+ const defaultDirectory =
958
+ typeof addConfig.defaultDirectory === 'string' &&
959
+ addConfig.defaultDirectory.trim() !== ''
960
+ ? addConfig.defaultDirectory.trim()
961
+ : 'src/expressions';
962
+
963
+ return {
964
+ defaultDirectory,
965
+ searchRoots: configuredSearchRoots,
966
+ };
967
+ }
968
+
969
+ function filterPreferredExpressionFiles(candidates, projectRoot, addConfig) {
970
+ const preferredRootPrefixes = addConfig.searchRoots
971
+ .map((rootPath) =>
972
+ path.isAbsolute(rootPath) ? rootPath : path.join(projectRoot, rootPath),
973
+ )
974
+ .map((value) => `${value.replace(/\\/gu, '/')}/`);
975
+
976
+ const preferredCandidates = candidates.filter((candidate) => {
977
+ const normalizedCandidate = candidate.replace(/\\/gu, '/');
978
+ return preferredRootPrefixes.some((prefix) =>
979
+ normalizedCandidate.startsWith(prefix),
980
+ );
981
+ });
982
+
983
+ return preferredCandidates.length > 0 ? preferredCandidates : candidates;
984
+ }
985
+
986
+ function rankExpressionFiles(candidates, resolvedDirectory, projectRoot) {
987
+ const sourceRootPatterns = ['/src/', '/app/', '/expressions/'];
988
+
989
+ function score(filePath) {
990
+ const normalized = filePath.replace(/\\/gu, '/');
991
+ let value = 0;
992
+
993
+ if (normalized.startsWith(resolvedDirectory.replace(/\\/gu, '/'))) {
994
+ value += 1000;
995
+ }
996
+
997
+ if (sourceRootPatterns.some((pattern) => normalized.includes(pattern))) {
998
+ value += 100;
999
+ }
1000
+
1001
+ const relativeSegments = path
1002
+ .relative(projectRoot, filePath)
1003
+ .replace(/\\/gu, '/')
1004
+ .split('/').length;
1005
+ value -= relativeSegments;
1006
+ return value;
1007
+ }
1008
+
1009
+ return [...candidates].sort((left, right) => {
1010
+ const scoreDifference = score(right) - score(left);
1011
+ if (scoreDifference !== 0) {
1012
+ return scoreDifference;
1013
+ }
1014
+ return left.localeCompare(right);
1015
+ });
1016
+ }
1017
+
1018
+ async function askForExistingExpressionFile(rl, resolvedDirectory) {
1019
+ const projectRoot = process.cwd();
1020
+ const addConfig = resolveRsxCliAddConfig(projectRoot);
1021
+ const directoryCandidates = filterPreferredExpressionFiles(
1022
+ findExpressionFiles(resolvedDirectory),
1023
+ projectRoot,
1024
+ addConfig,
1025
+ );
1026
+ const fallbackCandidates =
1027
+ directoryCandidates.length > 0
1028
+ ? []
1029
+ : rankExpressionFiles(
1030
+ filterPreferredExpressionFiles(
1031
+ findExpressionFiles(projectRoot),
1032
+ projectRoot,
1033
+ addConfig,
1034
+ ),
1035
+ resolvedDirectory,
1036
+ projectRoot,
1037
+ );
1038
+ const candidates =
1039
+ directoryCandidates.length > 0 ? directoryCandidates : fallbackCandidates;
1040
+
1041
+ if (candidates.length > 0) {
1042
+ console.log(
1043
+ directoryCandidates.length > 0
1044
+ ? 'Existing expression files in the selected directory:'
1045
+ : 'Existing expression files in the current project:',
1046
+ );
1047
+ candidates.forEach((candidate, index) => {
1048
+ console.log(
1049
+ ` ${index + 1}. ${path.relative(process.cwd(), candidate).replace(/\\/gu, '/')}`,
1050
+ );
1051
+ });
1052
+ console.log(' 0. Enter a custom path');
1053
+ }
1054
+
1055
+ while (true) {
1056
+ const answer = (
1057
+ await rl.question(
1058
+ candidates.length > 0
1059
+ ? 'Choose a file number or enter a file path: '
1060
+ : 'Existing file path (relative to output dir or absolute): ',
1061
+ )
1062
+ ).trim();
1063
+
1064
+ if (!answer && candidates.length > 0) {
1065
+ logWarn('Please choose a file or enter a path.');
1066
+ continue;
1067
+ }
1068
+
1069
+ if (candidates.length > 0 && /^\d+$/u.test(answer)) {
1070
+ const selectedIndex = Number.parseInt(answer, 10);
1071
+ if (selectedIndex === 0) {
1072
+ const customPath = await askUntilNonEmpty(
1073
+ rl,
1074
+ 'Existing file path (relative to output dir or absolute): ',
1075
+ );
1076
+ const resolvedPath = path.isAbsolute(customPath)
1077
+ ? customPath
1078
+ : path.resolve(resolvedDirectory, customPath);
1079
+
1080
+ if (!fs.existsSync(resolvedPath)) {
1081
+ logWarn(`File not found: ${resolvedPath}`);
1082
+ continue;
1083
+ }
1084
+
1085
+ return resolvedPath;
1086
+ }
1087
+
1088
+ if (selectedIndex >= 1 && selectedIndex <= candidates.length) {
1089
+ return candidates[selectedIndex - 1];
1090
+ }
1091
+
1092
+ logWarn(`"${answer}" is not a valid file number.`);
1093
+ continue;
1094
+ }
1095
+
1096
+ const resolvedPath = path.isAbsolute(answer)
1097
+ ? answer
1098
+ : path.resolve(resolvedDirectory, answer);
1099
+
1100
+ if (!fs.existsSync(resolvedPath)) {
1101
+ logWarn(`File not found: ${resolvedPath}`);
1102
+ continue;
1103
+ }
1104
+
1105
+ return resolvedPath;
1106
+ }
1107
+ }
1108
+
609
1109
  async function askForIdentifierWithDefault(rl, prompt, defaultValue) {
610
1110
  while (true) {
611
1111
  const answer = (await rl.question(prompt)).trim();
@@ -621,6 +1121,64 @@ async function askForIdentifierWithDefault(rl, prompt, defaultValue) {
621
1121
  }
622
1122
  }
623
1123
 
1124
+ async function askForExpressionSource(rl) {
1125
+ while (true) {
1126
+ const answer = (
1127
+ await rl.question("Initial expression string ['a']: ")
1128
+ ).trim();
1129
+
1130
+ if (!answer) {
1131
+ return 'a';
1132
+ }
1133
+
1134
+ return answer;
1135
+ }
1136
+ }
1137
+
1138
+ async function askForDirectoryPath(rl, defaultDirectory) {
1139
+ while (true) {
1140
+ const prompt = defaultDirectory
1141
+ ? `Directory path (relative or absolute) [${defaultDirectory}]: `
1142
+ : 'Directory path (relative or absolute): ';
1143
+ const answer = (await rl.question(prompt)).trim();
1144
+
1145
+ if (answer) {
1146
+ return answer;
1147
+ }
1148
+
1149
+ if (defaultDirectory) {
1150
+ return defaultDirectory;
1151
+ }
1152
+
1153
+ logWarn('Please enter a directory path.');
1154
+ }
1155
+ }
1156
+
1157
+ async function askForAddMode(rl) {
1158
+ console.log('Choose add mode:');
1159
+ console.log(
1160
+ ' 1. Create new expression file (model in same file) [Recommended]',
1161
+ );
1162
+ console.log(' 2. Create new expression file with separate model');
1163
+ console.log(' 3. Update an existing expression file');
1164
+
1165
+ while (true) {
1166
+ const answer = (await rl.question('Add mode [1]: ')).trim();
1167
+
1168
+ if (!answer || answer === '1') {
1169
+ return 'create-inline';
1170
+ }
1171
+ if (answer === '2') {
1172
+ return 'create-separate';
1173
+ }
1174
+ if (answer === '3') {
1175
+ return 'update-existing';
1176
+ }
1177
+
1178
+ logWarn(`"${answer}" is not a valid add mode. Use 1, 2, or 3.`);
1179
+ }
1180
+ }
1181
+
624
1182
  async function runAdd() {
625
1183
  const rl = readline.createInterface({
626
1184
  input: process.stdin,
@@ -628,18 +1186,21 @@ async function runAdd() {
628
1186
  });
629
1187
 
630
1188
  try {
1189
+ const projectRoot = process.cwd();
1190
+ const addConfig = resolveRsxCliAddConfig(projectRoot);
631
1191
  const expressionName = await askUntilValidIdentifier(rl);
1192
+ const expressionSource = await askForExpressionSource(rl);
632
1193
 
633
1194
  const kebabAnswer = await rl.question('Use kebab-case file name? [Y/n]: ');
634
1195
  const useKebabCase = normalizeYesNo(kebabAnswer, true);
635
1196
 
636
- const directoryInput = await askUntilNonEmpty(
1197
+ const directoryInput = await askForDirectoryPath(
637
1198
  rl,
638
- 'Directory path (relative or absolute): ',
1199
+ addConfig.defaultDirectory,
639
1200
  );
640
1201
  const resolvedDirectory = path.isAbsolute(directoryInput)
641
1202
  ? directoryInput
642
- : path.resolve(process.cwd(), directoryInput);
1203
+ : path.resolve(projectRoot, directoryInput);
643
1204
 
644
1205
  const baseFileName = useKebabCase
645
1206
  ? toKebabCase(expressionName)
@@ -649,14 +1210,93 @@ async function runAdd() {
649
1210
  const modelFileName = `${expressionFileBase}.model.ts`;
650
1211
  const expressionPath = path.join(resolvedDirectory, expressionFileName);
651
1212
  const modelPath = path.join(resolvedDirectory, modelFileName);
652
- const useExistingModelAnswer = await rl.question(
653
- 'Use existing model file? [y/N]: ',
654
- );
655
- const useExistingModel = normalizeYesNo(useExistingModelAnswer, false);
1213
+ const addMode = await askForAddMode(rl);
1214
+ const updateExistingFile = addMode === 'update-existing';
1215
+ const keepModelInSameFile =
1216
+ addMode === 'create-inline'
1217
+ ? true
1218
+ : addMode === 'create-separate'
1219
+ ? false
1220
+ : normalizeYesNo(
1221
+ await rl.question('Keep model in the same file? [Y/n]: '),
1222
+ true,
1223
+ );
1224
+
1225
+ if (updateExistingFile) {
1226
+ const resolvedExistingFilePath = await askForExistingExpressionFile(
1227
+ rl,
1228
+ resolvedDirectory,
1229
+ );
1230
+ const existingFileContent = fs.readFileSync(
1231
+ resolvedExistingFilePath,
1232
+ 'utf8',
1233
+ );
1234
+ let appendContent;
1235
+
1236
+ if (keepModelInSameFile) {
1237
+ appendContent = createInlineExpressionAppendTemplate(
1238
+ expressionName,
1239
+ expressionSource,
1240
+ existingFileContent,
1241
+ );
1242
+ } else {
1243
+ const useExistingModelAnswer = await rl.question(
1244
+ 'Use existing model file? [y/N]: ',
1245
+ );
1246
+ const useExistingModel = normalizeYesNo(useExistingModelAnswer, false);
1247
+ let modelImportPath = './model';
1248
+ let modelExportName = 'model';
1249
+
1250
+ if (useExistingModel) {
1251
+ const existingModelPathInput = await askUntilNonEmpty(
1252
+ rl,
1253
+ 'Existing model file path (relative to output dir or absolute): ',
1254
+ );
1255
+ const resolvedExistingModelPath = path.isAbsolute(
1256
+ existingModelPathInput,
1257
+ )
1258
+ ? existingModelPathInput
1259
+ : path.resolve(resolvedDirectory, existingModelPathInput);
1260
+
1261
+ if (!fs.existsSync(resolvedExistingModelPath)) {
1262
+ logError(`Model file not found: ${resolvedExistingModelPath}`);
1263
+ return;
1264
+ }
1265
+
1266
+ modelImportPath = toModuleImportPath(
1267
+ resolvedExistingFilePath,
1268
+ resolvedExistingModelPath,
1269
+ );
1270
+ modelExportName = await askForIdentifierWithDefault(
1271
+ rl,
1272
+ 'Model export name [model]: ',
1273
+ 'model',
1274
+ );
1275
+ }
1276
+
1277
+ appendContent = `\n${createExpressionTemplate(
1278
+ expressionName,
1279
+ expressionSource,
1280
+ modelImportPath,
1281
+ modelExportName,
1282
+ )}`;
1283
+ }
1284
+
1285
+ fs.appendFileSync(resolvedExistingFilePath, appendContent, 'utf8');
1286
+ logOk(`Updated ${resolvedExistingFilePath}`);
1287
+ return;
1288
+ }
1289
+
1290
+ const useExistingModel = !keepModelInSameFile
1291
+ ? normalizeYesNo(
1292
+ await rl.question('Use existing model file? [y/N]: '),
1293
+ false,
1294
+ )
1295
+ : false;
656
1296
 
657
1297
  if (
658
1298
  fs.existsSync(expressionPath) ||
659
- (!useExistingModel && fs.existsSync(modelPath))
1299
+ (!keepModelInSameFile && !useExistingModel && fs.existsSync(modelPath))
660
1300
  ) {
661
1301
  const overwriteAnswer = await rl.question(
662
1302
  `One or more target files already exist. Overwrite? [y/N]: `,
@@ -672,6 +1312,16 @@ async function runAdd() {
672
1312
  let modelImportPath = `./${expressionFileBase}.model`;
673
1313
  let modelExportName = 'model';
674
1314
 
1315
+ if (keepModelInSameFile) {
1316
+ fs.writeFileSync(
1317
+ expressionPath,
1318
+ createInlineExpressionTemplate(expressionName, expressionSource),
1319
+ 'utf8',
1320
+ );
1321
+ logOk(`Created ${expressionPath}`);
1322
+ return;
1323
+ }
1324
+
675
1325
  if (useExistingModel) {
676
1326
  const existingModelPathInput = await askUntilNonEmpty(
677
1327
  rl,
@@ -696,7 +1346,11 @@ async function runAdd() {
696
1346
  'model',
697
1347
  );
698
1348
  } else {
699
- fs.writeFileSync(modelPath, createModelTemplate(), 'utf8');
1349
+ fs.writeFileSync(
1350
+ modelPath,
1351
+ createModelTemplate(expressionSource),
1352
+ 'utf8',
1353
+ );
700
1354
  logOk(`Created ${modelPath}`);
701
1355
  }
702
1356
 
@@ -704,6 +1358,7 @@ async function runAdd() {
704
1358
  expressionPath,
705
1359
  createExpressionTemplate(
706
1360
  expressionName,
1361
+ expressionSource,
707
1362
  modelImportPath,
708
1363
  modelExportName,
709
1364
  ),
@@ -833,7 +1488,7 @@ function removeFileOrDirectoryWithDryRun(targetPath, dryRun) {
833
1488
  fs.rmSync(targetPath, { recursive: true, force: true });
834
1489
  }
835
1490
 
836
- function resolveAngularProjectTsConfig(projectRoot) {
1491
+ function resolveProjectTsConfig(projectRoot) {
837
1492
  const appTsConfigPath = path.join(projectRoot, 'tsconfig.app.json');
838
1493
  if (fs.existsSync(appTsConfigPath)) {
839
1494
  return appTsConfigPath;
@@ -842,6 +1497,10 @@ function resolveAngularProjectTsConfig(projectRoot) {
842
1497
  return path.join(projectRoot, 'tsconfig.json');
843
1498
  }
844
1499
 
1500
+ function resolveAngularProjectTsConfig(projectRoot) {
1501
+ return resolveProjectTsConfig(projectRoot);
1502
+ }
1503
+
845
1504
  function upsertTypescriptPluginInTsConfig(configPath, dryRun) {
846
1505
  if (!fs.existsSync(configPath)) {
847
1506
  logWarn(`TypeScript config not found: ${configPath}`);
@@ -1333,8 +1992,9 @@ function applyVueRsxTemplate(projectRoot, dryRun) {
1333
1992
  async function runProject(flags) {
1334
1993
  const dryRun = Boolean(flags['dry-run']);
1335
1994
  const skipInstall = Boolean(flags['skip-install']);
1336
- const pm = detectPackageManager(flags.pm);
1337
- const tag = resolveInstallTag(flags);
1995
+ const invocationRoot = process.cwd();
1996
+ const pm = resolveCliPackageManager(invocationRoot, flags.pm);
1997
+ const tag = resolveConfiguredInstallTag(invocationRoot, flags);
1338
1998
  let projectName = typeof flags.name === 'string' ? flags.name.trim() : '';
1339
1999
 
1340
2000
  if (!projectName) {
@@ -1613,6 +2273,7 @@ function scaffoldProjectTemplate(
1613
2273
 
1614
2274
  function applyAngularDemoStarter(projectRoot, projectName, pm, flags) {
1615
2275
  const dryRun = Boolean(flags['dry-run']);
2276
+ ensureRsxConfigFile(projectRoot, 'angular', dryRun);
1616
2277
  const tag = resolveInstallTag(flags);
1617
2278
  const tarballsDir =
1618
2279
  typeof flags['tarballs-dir'] === 'string'
@@ -1687,16 +2348,6 @@ function applyAngularDemoStarter(projectRoot, projectName, pm, flags) {
1687
2348
  start: 'npm run build:rsx && ng serve',
1688
2349
  build: 'ng build',
1689
2350
  };
1690
- packageJson.rsx = {
1691
- build: {
1692
- preparse: true,
1693
- preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
1694
- compiled: true,
1695
- compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
1696
- registrationFile: 'src/rsx-generated/rsx-aot-registration.generated.ts',
1697
- compiledResolvedEvaluator: false,
1698
- },
1699
- };
1700
2351
  packageJson.dependencies = {
1701
2352
  ...(packageJson.dependencies ?? {}),
1702
2353
  '@rs-x/angular': rsxSpecs['@rs-x/angular'],
@@ -1790,6 +2441,7 @@ function applyAngularDemoStarter(projectRoot, projectName, pm, flags) {
1790
2441
 
1791
2442
  function applyReactDemoStarter(projectRoot, projectName, pm, flags) {
1792
2443
  const dryRun = Boolean(flags['dry-run']);
2444
+ ensureRsxConfigFile(projectRoot, 'react', dryRun);
1793
2445
  const tag = resolveInstallTag(flags);
1794
2446
  const tarballsDir =
1795
2447
  typeof flags['tarballs-dir'] === 'string'
@@ -1870,15 +2522,6 @@ function applyReactDemoStarter(projectRoot, projectName, pm, flags) {
1870
2522
  build: 'npm run build:rsx && vite build',
1871
2523
  preview: 'vite preview',
1872
2524
  };
1873
- packageJson.rsx = {
1874
- build: {
1875
- preparse: true,
1876
- preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
1877
- compiled: true,
1878
- compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
1879
- compiledResolvedEvaluator: false,
1880
- },
1881
- };
1882
2525
  packageJson.dependencies = {
1883
2526
  react: packageJson.dependencies?.react ?? '^19.2.4',
1884
2527
  'react-dom': packageJson.dependencies?.['react-dom'] ?? '^19.2.4',
@@ -1924,6 +2567,7 @@ function applyReactDemoStarter(projectRoot, projectName, pm, flags) {
1924
2567
 
1925
2568
  function applyVueDemoStarter(projectRoot, projectName, pm, flags) {
1926
2569
  const dryRun = Boolean(flags['dry-run']);
2570
+ ensureRsxConfigFile(projectRoot, 'vuejs', dryRun);
1927
2571
  const tag = resolveInstallTag(flags);
1928
2572
  const tarballsDir =
1929
2573
  typeof flags['tarballs-dir'] === 'string'
@@ -1990,15 +2634,6 @@ function applyVueDemoStarter(projectRoot, projectName, pm, flags) {
1990
2634
  build: 'npm run build:rsx && vue-tsc -b && vite build',
1991
2635
  preview: 'vite preview',
1992
2636
  };
1993
- packageJson.rsx = {
1994
- build: {
1995
- preparse: true,
1996
- preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
1997
- compiled: true,
1998
- compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
1999
- compiledResolvedEvaluator: false,
2000
- },
2001
- };
2002
2637
  packageJson.dependencies = {
2003
2638
  vue: packageJson.dependencies?.vue ?? '^3.5.30',
2004
2639
  '@rs-x/core': rsxSpecs['@rs-x/core'],
@@ -2038,6 +2673,7 @@ function applyVueDemoStarter(projectRoot, projectName, pm, flags) {
2038
2673
 
2039
2674
  function applyNextDemoStarter(projectRoot, projectName, pm, flags) {
2040
2675
  const dryRun = Boolean(flags['dry-run']);
2676
+ ensureRsxConfigFile(projectRoot, 'nextjs', dryRun);
2041
2677
  const tag = resolveInstallTag(flags);
2042
2678
  const tarballsDir =
2043
2679
  typeof flags['tarballs-dir'] === 'string'
@@ -2099,15 +2735,6 @@ function applyNextDemoStarter(projectRoot, projectName, pm, flags) {
2099
2735
  build: 'npm run build:rsx && next build',
2100
2736
  start: 'next start',
2101
2737
  };
2102
- packageJson.rsx = {
2103
- build: {
2104
- preparse: true,
2105
- preparseFile: 'app/rsx-generated/rsx-aot-preparsed.generated.ts',
2106
- compiled: true,
2107
- compiledFile: 'app/rsx-generated/rsx-aot-compiled.generated.ts',
2108
- compiledResolvedEvaluator: false,
2109
- },
2110
- };
2111
2738
  packageJson.dependencies = {
2112
2739
  ...(packageJson.dependencies ?? {}),
2113
2740
  '@rs-x/core': rsxSpecs['@rs-x/core'],
@@ -2158,7 +2785,8 @@ async function runProjectWithTemplate(template, flags) {
2158
2785
  return;
2159
2786
  }
2160
2787
 
2161
- const pm = detectPackageManager(flags.pm);
2788
+ const invocationRoot = process.cwd();
2789
+ const pm = resolveCliPackageManager(invocationRoot, flags.pm);
2162
2790
  const projectName = await resolveProjectName(flags.name, flags._nameHint);
2163
2791
  const projectRoot = resolveProjectRoot(projectName, flags);
2164
2792
  if (fs.existsSync(projectRoot) && fs.readdirSync(projectRoot).length > 0) {
@@ -2197,6 +2825,12 @@ async function runProjectWithTemplate(template, flags) {
2197
2825
  }
2198
2826
  });
2199
2827
 
2828
+ verifyGeneratedProject(projectRoot, normalizedTemplate);
2829
+ if (resolveCliVerifyFlag(invocationRoot, flags, 'project')) {
2830
+ logInfo('Re-running starter verification (--verify)...');
2831
+ verifyGeneratedProject(projectRoot, normalizedTemplate);
2832
+ }
2833
+
2200
2834
  logOk(`Created RS-X ${normalizedTemplate} project: ${projectRoot}`);
2201
2835
  logInfo('Next steps:');
2202
2836
  console.log(` cd ${projectName}`);
@@ -2210,6 +2844,188 @@ async function runProjectWithTemplate(template, flags) {
2210
2844
  }
2211
2845
  }
2212
2846
 
2847
+ function verifyGeneratedProject(projectRoot, template) {
2848
+ const packageJsonPath = path.join(projectRoot, 'package.json');
2849
+ if (!fs.existsSync(packageJsonPath)) {
2850
+ logError(`Generated project is missing package.json: ${packageJsonPath}`);
2851
+ process.exit(1);
2852
+ }
2853
+
2854
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
2855
+ const scripts = packageJson.scripts ?? {};
2856
+ const dependencies = {
2857
+ ...(packageJson.dependencies ?? {}),
2858
+ ...(packageJson.devDependencies ?? {}),
2859
+ };
2860
+
2861
+ const expectationsByTemplate = {
2862
+ angular: {
2863
+ scripts: ['build:rsx', 'start'],
2864
+ dependencies: [
2865
+ '@rs-x/angular',
2866
+ '@rs-x/compiler',
2867
+ '@rs-x/typescript-plugin',
2868
+ ],
2869
+ files: [
2870
+ 'src/main.ts',
2871
+ 'src/app/app.component.ts',
2872
+ 'src/app/virtual-table/virtual-table.component.ts',
2873
+ ],
2874
+ },
2875
+ react: {
2876
+ scripts: ['build:rsx', 'dev', 'build'],
2877
+ dependencies: [
2878
+ '@rs-x/react',
2879
+ '@rs-x/compiler',
2880
+ '@rs-x/typescript-plugin',
2881
+ ],
2882
+ files: [
2883
+ 'src/main.tsx',
2884
+ 'src/rsx-bootstrap.ts',
2885
+ 'src/app/app.tsx',
2886
+ 'src/app/virtual-table/virtual-table-shell.tsx',
2887
+ ],
2888
+ },
2889
+ vuejs: {
2890
+ scripts: ['build:rsx', 'dev', 'build'],
2891
+ dependencies: ['@rs-x/vue', '@rs-x/compiler', '@rs-x/typescript-plugin'],
2892
+ files: [
2893
+ 'src/main.ts',
2894
+ 'src/App.vue',
2895
+ 'src/lib/rsx-bootstrap.ts',
2896
+ 'src/components/VirtualTableShell.vue',
2897
+ ],
2898
+ },
2899
+ nextjs: {
2900
+ scripts: ['build:rsx', 'dev', 'build'],
2901
+ dependencies: [
2902
+ '@rs-x/react',
2903
+ '@rs-x/compiler',
2904
+ '@rs-x/typescript-plugin',
2905
+ ],
2906
+ files: [
2907
+ 'app/layout.tsx',
2908
+ 'app/page.tsx',
2909
+ 'components/demo-app.tsx',
2910
+ 'lib/rsx-bootstrap.ts',
2911
+ ],
2912
+ },
2913
+ };
2914
+
2915
+ const expected = expectationsByTemplate[template];
2916
+ if (!expected) {
2917
+ return;
2918
+ }
2919
+
2920
+ for (const scriptName of expected.scripts) {
2921
+ if (
2922
+ typeof scripts[scriptName] !== 'string' ||
2923
+ scripts[scriptName].trim() === ''
2924
+ ) {
2925
+ logError(
2926
+ `Generated ${template} project is missing script "${scriptName}" in package.json.`,
2927
+ );
2928
+ process.exit(1);
2929
+ }
2930
+ }
2931
+
2932
+ for (const dependencyName of expected.dependencies) {
2933
+ if (typeof dependencies[dependencyName] !== 'string') {
2934
+ logError(
2935
+ `Generated ${template} project is missing dependency "${dependencyName}" in package.json.`,
2936
+ );
2937
+ process.exit(1);
2938
+ }
2939
+ }
2940
+
2941
+ for (const relativeFilePath of expected.files) {
2942
+ const absoluteFilePath = path.join(projectRoot, relativeFilePath);
2943
+ if (!fs.existsSync(absoluteFilePath)) {
2944
+ logError(
2945
+ `Generated ${template} project is missing expected file: ${absoluteFilePath}`,
2946
+ );
2947
+ process.exit(1);
2948
+ }
2949
+ }
2950
+
2951
+ const rsxConfigPath = path.join(projectRoot, 'rsx.config.json');
2952
+ if (!fs.existsSync(rsxConfigPath)) {
2953
+ logError(
2954
+ `Generated ${template} project is missing expected file: ${rsxConfigPath}`,
2955
+ );
2956
+ process.exit(1);
2957
+ }
2958
+
2959
+ logOk(`Verified generated ${template} project structure.`);
2960
+ }
2961
+
2962
+ function verifySetupOutput(projectRoot, template) {
2963
+ const packageJsonPath = path.join(projectRoot, 'package.json');
2964
+ if (!fs.existsSync(packageJsonPath)) {
2965
+ logError(`Project is missing package.json: ${packageJsonPath}`);
2966
+ process.exit(1);
2967
+ }
2968
+
2969
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
2970
+ const scripts = packageJson.scripts ?? {};
2971
+
2972
+ const setupExpectations = {
2973
+ react: {
2974
+ scripts: ['build:rsx', 'typecheck:rsx', 'dev', 'build'],
2975
+ files: ['vite.config.ts', 'src/rsx-bootstrap.ts'],
2976
+ },
2977
+ vuejs: {
2978
+ scripts: ['build:rsx', 'typecheck:rsx', 'dev', 'build'],
2979
+ files: ['vite.config.ts', 'src/rsx-bootstrap.ts'],
2980
+ },
2981
+ next: {
2982
+ scripts: ['build:rsx', 'typecheck:rsx', 'dev', 'build'],
2983
+ files: [
2984
+ 'next.config.js',
2985
+ 'rsx-webpack-loader.cjs',
2986
+ 'app/rsx-bootstrap.ts',
2987
+ ],
2988
+ },
2989
+ angular: {
2990
+ scripts: ['build:rsx', 'typecheck:rsx', 'prebuild', 'start'],
2991
+ files: ['src/main.ts', 'angular.json'],
2992
+ },
2993
+ };
2994
+
2995
+ const expected = setupExpectations[template];
2996
+ if (!expected) {
2997
+ return;
2998
+ }
2999
+
3000
+ for (const scriptName of expected.scripts) {
3001
+ if (
3002
+ typeof scripts[scriptName] !== 'string' ||
3003
+ scripts[scriptName].trim() === ''
3004
+ ) {
3005
+ logError(
3006
+ `Setup output is missing script "${scriptName}" in package.json.`,
3007
+ );
3008
+ process.exit(1);
3009
+ }
3010
+ }
3011
+
3012
+ for (const relativeFilePath of expected.files) {
3013
+ const absoluteFilePath = path.join(projectRoot, relativeFilePath);
3014
+ if (!fs.existsSync(absoluteFilePath)) {
3015
+ logError(`Setup output is missing expected file: ${absoluteFilePath}`);
3016
+ process.exit(1);
3017
+ }
3018
+ }
3019
+
3020
+ const rsxConfigPath = path.join(projectRoot, 'rsx.config.json');
3021
+ if (!fs.existsSync(rsxConfigPath)) {
3022
+ logError(`Setup output is missing expected file: ${rsxConfigPath}`);
3023
+ process.exit(1);
3024
+ }
3025
+
3026
+ logOk(`Verified ${template} setup output.`);
3027
+ }
3028
+
2213
3029
  function detectProjectContext(projectRoot) {
2214
3030
  const packageJsonPath = path.join(projectRoot, 'package.json');
2215
3031
  let dependencies = {};
@@ -2326,11 +3142,14 @@ function rsxBootstrapFilePath(entryFile) {
2326
3142
  }
2327
3143
 
2328
3144
  function ensureRsxBootstrapFile(bootstrapFile, dryRun) {
2329
- const content = `import { InjectionContainer } from '@rs-x/core';\nimport { RsXExpressionParserModule } from '@rs-x/expression-parser';\n\n// Generated by rsx init\nexport async function initRsx(): Promise<void> {\n await InjectionContainer.load(RsXExpressionParserModule);\n}\n`;
3145
+ const content = `import { InjectionContainer } from '@rs-x/core';\nimport { RsXExpressionParserModule } from '@rs-x/expression-parser';\n\n// Generated by rsx init top-level await ensures DI is ready before any expression module evaluates\nawait InjectionContainer.load(RsXExpressionParserModule);\n`;
2330
3146
 
2331
3147
  if (fs.existsSync(bootstrapFile)) {
2332
3148
  const existing = fs.readFileSync(bootstrapFile, 'utf8');
2333
- if (existing.includes('export async function initRsx')) {
3149
+ if (
3150
+ existing.includes('await InjectionContainer.load') ||
3151
+ existing.includes('export async function initRsx')
3152
+ ) {
2334
3153
  logInfo(`Bootstrap module already exists: ${bootstrapFile}`);
2335
3154
  return;
2336
3155
  }
@@ -2393,16 +3212,14 @@ function indentBlock(text, spaces) {
2393
3212
  }
2394
3213
 
2395
3214
  function wrapReactEntry(source) {
3215
+ // top-level await in rsx-bootstrap.ts ensures DI is loaded before any
3216
+ // expression module evaluates — no async wrapper needed here
2396
3217
  const reactStartPattern =
2397
3218
  /(ReactDOM\s*\.\s*)?createRoot\([\s\S]*?\)\s*\.\s*render\([\s\S]*?\);/mu;
2398
- const match = source.match(reactStartPattern);
2399
- if (!match) {
3219
+ if (!source.match(reactStartPattern)) {
2400
3220
  return null;
2401
3221
  }
2402
-
2403
- const renderCall = match[0].trim();
2404
- const replacement = `const __rsxStart = async () => {\n await initRsx();\n${indentBlock(renderCall, 2)}\n};\n\nvoid __rsxStart();`;
2405
- return source.replace(reactStartPattern, replacement);
3222
+ return source;
2406
3223
  }
2407
3224
 
2408
3225
  function wrapAngularEntry(source) {
@@ -2427,16 +3244,14 @@ function wrapAngularEntry(source) {
2427
3244
  }
2428
3245
 
2429
3246
  function wrapVueEntry(source) {
3247
+ // top-level await in rsx-bootstrap.ts ensures DI is loaded before any
3248
+ // expression module evaluates — no async wrapper needed here
2430
3249
  const vueStartPattern =
2431
3250
  /createApp\([\s\S]*?\)\s*\.\s*mount\([\s\S]*?\)\s*;/mu;
2432
- const match = source.match(vueStartPattern);
2433
- if (!match) {
3251
+ if (!source.match(vueStartPattern)) {
2434
3252
  return null;
2435
3253
  }
2436
-
2437
- const mountCall = match[0].trim();
2438
- const replacement = `const __rsxBootstrap = async () => {\n await initRsx();\n${indentBlock(mountCall, 2)}\n};\n\nvoid __rsxBootstrap();`;
2439
- return source.replace(vueStartPattern, replacement);
3254
+ return source;
2440
3255
  }
2441
3256
 
2442
3257
  function wrapGenericEntry(source) {
@@ -2632,13 +3447,19 @@ function patchNextEntryFile(entryFile, gateFile, dryRun) {
2632
3447
 
2633
3448
  function patchEntryFileForRsx(entryFile, bootstrapFile, context, dryRun) {
2634
3449
  const original = fs.readFileSync(entryFile, 'utf8');
2635
- if (original.includes('initRsx') && original.includes('rsx-bootstrap')) {
3450
+ if (original.includes('rsx-bootstrap')) {
2636
3451
  logInfo(`Entry already wired for RS-X bootstrap: ${entryFile}`);
2637
3452
  return true;
2638
3453
  }
2639
3454
 
2640
3455
  const importPath = toModuleImportPath(entryFile, bootstrapFile);
2641
- const importStatement = `import { initRsx } from '${importPath}';`;
3456
+ // For React/Vue: side-effect import only top-level await in bootstrap
3457
+ // ensures DI is ready before any expression module evaluates.
3458
+ // For Angular/generic: named import used by the async wrapper.
3459
+ const importStatement =
3460
+ context === 'react' || context === 'vuejs'
3461
+ ? `import '${importPath}';`
3462
+ : `import { initRsx } from '${importPath}';`;
2642
3463
 
2643
3464
  let updated = injectImport(original, importStatement);
2644
3465
 
@@ -2687,11 +3508,10 @@ function patchEntryFileForRsx(entryFile, bootstrapFile, context, dryRun) {
2687
3508
 
2688
3509
  function runInit(flags) {
2689
3510
  const dryRun = Boolean(flags['dry-run']);
2690
- const skipVscode = Boolean(flags['skip-vscode']);
2691
3511
  const skipInstall = Boolean(flags['skip-install']);
2692
- const pm = detectPackageManager(flags.pm);
2693
- const tag = resolveInstallTag(flags);
2694
3512
  const projectRoot = process.cwd();
3513
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
3514
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
2695
3515
 
2696
3516
  if (!skipInstall) {
2697
3517
  installRuntimePackages(pm, dryRun, tag, projectRoot, flags);
@@ -2744,53 +3564,13 @@ function runInit(flags) {
2744
3564
 
2745
3565
  if (!patched) {
2746
3566
  logInfo('Manual fallback snippet:');
2747
- console.log(" import { initRsx } from './rsx-bootstrap';");
2748
- console.log(' await initRsx(); // before first rsx(...)');
3567
+ console.log(" import './rsx-bootstrap'; // must be the first import in your entry file");
2749
3568
  }
2750
3569
  }
2751
3570
 
2752
- logOk('RS-X init completed.');
2753
- }
2754
-
2755
- function upsertRsxBuildConfigInPackageJson(projectRoot, dryRun) {
2756
- const packageJsonPath = path.join(projectRoot, 'package.json');
2757
- if (!fs.existsSync(packageJsonPath)) {
2758
- return false;
2759
- }
2760
-
2761
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
2762
- const currentRsx = packageJson.rsx ?? {};
2763
- const currentBuild = currentRsx.build ?? {};
2764
- const nextBuild = {
2765
- preparse: true,
2766
- preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
2767
- compiled: true,
2768
- compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
2769
- registrationFile: 'src/rsx-generated/rsx-aot-registration.generated.ts',
2770
- compiledResolvedEvaluator: false,
2771
- ...currentBuild,
2772
- };
2773
-
2774
- const nextPackageJson = {
2775
- ...packageJson,
2776
- rsx: {
2777
- ...currentRsx,
2778
- build: nextBuild,
2779
- },
2780
- };
2781
-
2782
- if (dryRun) {
2783
- logInfo(`[dry-run] patch ${packageJsonPath} (rsx.build)`);
2784
- return true;
2785
- }
3571
+ ensureRsxConfigFile(projectRoot, effectiveContext, dryRun);
2786
3572
 
2787
- fs.writeFileSync(
2788
- packageJsonPath,
2789
- `${JSON.stringify(nextPackageJson, null, 2)}\n`,
2790
- 'utf8',
2791
- );
2792
- logOk(`Patched ${packageJsonPath} (rsx.build)`);
2793
- return true;
3573
+ logOk('RS-X init completed.');
2794
3574
  }
2795
3575
 
2796
3576
  function ensureAngularProvidersInEntry(entryFile, dryRun) {
@@ -2799,10 +3579,6 @@ function ensureAngularProvidersInEntry(entryFile, dryRun) {
2799
3579
  }
2800
3580
 
2801
3581
  const original = fs.readFileSync(entryFile, 'utf8');
2802
- if (original.includes('providexRsx')) {
2803
- logInfo(`Angular entry already includes providexRsx: ${entryFile}`);
2804
- return true;
2805
- }
2806
3582
 
2807
3583
  if (!original.includes('bootstrapApplication(')) {
2808
3584
  logWarn(
@@ -2814,6 +3590,109 @@ function ensureAngularProvidersInEntry(entryFile, dryRun) {
2814
3590
  return false;
2815
3591
  }
2816
3592
 
3593
+ const bootstrapCallMatch = original.match(
3594
+ /bootstrapApplication\(\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*|\{[\s\S]*?\}))?\s*\)/mu,
3595
+ );
3596
+ const componentIdentifier = bootstrapCallMatch?.[1] ?? null;
3597
+ const configArgument = bootstrapCallMatch?.[2] ?? null;
3598
+ const configImportIdentifier =
3599
+ configArgument && /^[A-Za-z_$][\w$]*$/u.test(configArgument)
3600
+ ? configArgument
3601
+ : original.includes('appConfig')
3602
+ ? 'appConfig'
3603
+ : null;
3604
+
3605
+ const staticImportPattern = new RegExp(
3606
+ String.raw`^\s*import\s+\{\s*([^}]*)\b${componentIdentifier ?? ''}\b([^}]*)\}\s+from\s+['"]([^'"]+)['"];\s*$`,
3607
+ 'mu',
3608
+ );
3609
+ const componentImportMatch = componentIdentifier
3610
+ ? original.match(staticImportPattern)
3611
+ : null;
3612
+ const dynamicComponentImportMatch = componentIdentifier
3613
+ ? original.match(
3614
+ new RegExp(
3615
+ String.raw`const\s+\{\s*${componentIdentifier}\s*\}\s*=\s*await\s+import\('([^']+)'\);`,
3616
+ 'mu',
3617
+ ),
3618
+ )
3619
+ : null;
3620
+ const componentImportPath =
3621
+ componentImportMatch?.[3] ?? dynamicComponentImportMatch?.[1] ?? null;
3622
+
3623
+ const configImportMatch = configImportIdentifier
3624
+ ? original.match(
3625
+ new RegExp(
3626
+ String.raw`^\s*import\s+\{\s*([^}]*)\b${configImportIdentifier}\b([^}]*)\}\s+from\s+['"]([^'"]+)['"];\s*$`,
3627
+ 'mu',
3628
+ ),
3629
+ )
3630
+ : null;
3631
+ const configImportPath = configImportMatch?.[3] ?? null;
3632
+
3633
+ const canPreloadBeforeComponentImport =
3634
+ componentIdentifier !== null && componentImportPath !== null;
3635
+
3636
+ if (canPreloadBeforeComponentImport) {
3637
+ const importLines = original
3638
+ .split('\n')
3639
+ .filter((line) => /^\s*import\s+/u.test(line))
3640
+ .filter((line) => !line.match(staticImportPattern))
3641
+ .filter((line) =>
3642
+ configImportMatch
3643
+ ? !line.match(
3644
+ new RegExp(
3645
+ String.raw`^\s*import\s+\{\s*([^}]*)\b${configImportIdentifier}\b([^}]*)\}\s+from\s+['"][^'"]+['"];\s*$`,
3646
+ 'u',
3647
+ ),
3648
+ )
3649
+ : true,
3650
+ )
3651
+ .filter(
3652
+ (line) =>
3653
+ !line.includes("import { providexRsx } from '@rs-x/angular';"),
3654
+ )
3655
+ .filter(
3656
+ (line) =>
3657
+ !line.includes("import { InjectionContainer } from '@rs-x/core';"),
3658
+ )
3659
+ .filter(
3660
+ (line) =>
3661
+ !line.includes(
3662
+ "import { RsXExpressionParserModule } from '@rs-x/expression-parser';",
3663
+ ),
3664
+ );
3665
+
3666
+ const bootstrapConfigExpression =
3667
+ configImportPath && configImportIdentifier
3668
+ ? `const [{ ${configImportIdentifier} }, { ${componentIdentifier} }] = await Promise.all([\n import('${configImportPath}'),\n import('${componentImportPath}'),\n ]);\n\n await bootstrapApplication(${componentIdentifier}, {\n ...${configImportIdentifier},\n providers: [...(${configImportIdentifier}.providers ?? []), ...providexRsx()],\n });`
3669
+ : `const { ${componentIdentifier} } = await import('${componentImportPath}');\n\n await bootstrapApplication(${componentIdentifier}, {\n providers: [...providexRsx()],\n });`;
3670
+
3671
+ const rewritten = `${importLines.join('\n')}
3672
+ import { providexRsx } from '@rs-x/angular';
3673
+ import { InjectionContainer } from '@rs-x/core';
3674
+ import { RsXExpressionParserModule } from '@rs-x/expression-parser';
3675
+
3676
+ const bootstrap = async (): Promise<void> => {
3677
+ await InjectionContainer.load(RsXExpressionParserModule);
3678
+ ${bootstrapConfigExpression}
3679
+ };
3680
+
3681
+ void bootstrap().catch((error) => {
3682
+ console.error(error);
3683
+ });
3684
+ `;
3685
+
3686
+ if (dryRun) {
3687
+ logInfo(`[dry-run] patch ${entryFile} (providexRsx + preload)`);
3688
+ return true;
3689
+ }
3690
+
3691
+ fs.writeFileSync(entryFile, rewritten, 'utf8');
3692
+ logOk(`Patched ${entryFile} to preload RS-X and include providexRsx.`);
3693
+ return true;
3694
+ }
3695
+
2817
3696
  const sourceWithImport = injectImport(
2818
3697
  original,
2819
3698
  "import { providexRsx } from '@rs-x/angular';",
@@ -3106,9 +3985,13 @@ ${patchBlock}
3106
3985
 
3107
3986
  function runSetupReact(flags) {
3108
3987
  const dryRun = Boolean(flags['dry-run']);
3109
- const pm = detectPackageManager(flags.pm);
3110
- const tag = resolveInstallTag(flags);
3111
3988
  const projectRoot = process.cwd();
3989
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
3990
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
3991
+ const reactTsConfigPath = resolveProjectTsConfig(projectRoot);
3992
+ const reactTsConfigRelative = path
3993
+ .relative(projectRoot, reactTsConfigPath)
3994
+ .replace(/\\/gu, '/');
3112
3995
  const packageJsonPath = path.join(projectRoot, 'package.json');
3113
3996
  if (!fs.existsSync(packageJsonPath)) {
3114
3997
  logError(`package.json not found in ${projectRoot}`);
@@ -3154,10 +4037,17 @@ function runSetupReact(flags) {
3154
4037
  } else {
3155
4038
  logInfo('Skipping RS-X React bindings install (--skip-install).');
3156
4039
  }
4040
+ ensureRsxConfigFile(projectRoot, 'react', dryRun);
3157
4041
  upsertScriptInPackageJson(
3158
4042
  projectRoot,
3159
4043
  'build:rsx',
3160
- 'rsx build --project tsconfig.json --no-emit --prod',
4044
+ `rsx build --project ${reactTsConfigRelative} --no-emit --prod`,
4045
+ dryRun,
4046
+ );
4047
+ upsertScriptInPackageJson(
4048
+ projectRoot,
4049
+ 'typecheck:rsx',
4050
+ `rsx typecheck --project ${reactTsConfigRelative}`,
3161
4051
  dryRun,
3162
4052
  );
3163
4053
  upsertScriptInPackageJson(
@@ -3173,14 +4063,21 @@ function runSetupReact(flags) {
3173
4063
  dryRun,
3174
4064
  );
3175
4065
  wireRsxVitePlugin(projectRoot, dryRun);
4066
+ if (resolveCliVerifyFlag(projectRoot, flags, 'setup')) {
4067
+ verifySetupOutput(projectRoot, 'react');
4068
+ }
3176
4069
  logOk('RS-X React setup completed.');
3177
4070
  }
3178
4071
 
3179
4072
  function runSetupNext(flags) {
3180
4073
  const dryRun = Boolean(flags['dry-run']);
3181
- const pm = detectPackageManager(flags.pm);
3182
- const tag = resolveInstallTag(flags);
3183
4074
  const projectRoot = process.cwd();
4075
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
4076
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
4077
+ const nextTsConfigPath = resolveProjectTsConfig(projectRoot);
4078
+ const nextTsConfigRelative = path
4079
+ .relative(projectRoot, nextTsConfigPath)
4080
+ .replace(/\\/gu, '/');
3184
4081
  runInit({
3185
4082
  ...flags,
3186
4083
  'skip-vscode': true,
@@ -3201,15 +4098,43 @@ function runSetupNext(flags) {
3201
4098
  } else {
3202
4099
  logInfo('Skipping RS-X React bindings install (--skip-install).');
3203
4100
  }
4101
+ ensureRsxConfigFile(projectRoot, 'next', dryRun);
4102
+ upsertScriptInPackageJson(
4103
+ projectRoot,
4104
+ 'build:rsx',
4105
+ `rsx build --project ${nextTsConfigRelative} --no-emit --prod`,
4106
+ dryRun,
4107
+ );
4108
+ upsertScriptInPackageJson(
4109
+ projectRoot,
4110
+ 'typecheck:rsx',
4111
+ `rsx typecheck --project ${nextTsConfigRelative}`,
4112
+ dryRun,
4113
+ );
4114
+ upsertScriptInPackageJson(
4115
+ projectRoot,
4116
+ 'dev',
4117
+ 'npm run build:rsx && next dev',
4118
+ dryRun,
4119
+ );
4120
+ upsertScriptInPackageJson(
4121
+ projectRoot,
4122
+ 'build',
4123
+ 'npm run build:rsx && next build',
4124
+ dryRun,
4125
+ );
3204
4126
  wireRsxNextWebpack(projectRoot, dryRun);
4127
+ if (resolveCliVerifyFlag(projectRoot, flags, 'setup')) {
4128
+ verifySetupOutput(projectRoot, 'next');
4129
+ }
3205
4130
  logOk('RS-X Next.js setup completed.');
3206
4131
  }
3207
4132
 
3208
4133
  function runSetupVue(flags) {
3209
4134
  const dryRun = Boolean(flags['dry-run']);
3210
- const pm = detectPackageManager(flags.pm);
3211
- const tag = resolveInstallTag(flags);
3212
4135
  const projectRoot = process.cwd();
4136
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
4137
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
3213
4138
  runInit({
3214
4139
  ...flags,
3215
4140
  'skip-vscode': true,
@@ -3238,6 +4163,7 @@ function runSetupVue(flags) {
3238
4163
  } else {
3239
4164
  logInfo('Skipping RS-X Vue bindings install (--skip-install).');
3240
4165
  }
4166
+ ensureRsxConfigFile(projectRoot, 'vuejs', dryRun);
3241
4167
  upsertScriptInPackageJson(
3242
4168
  projectRoot,
3243
4169
  'build:rsx',
@@ -3267,14 +4193,17 @@ function runSetupVue(flags) {
3267
4193
  ensureTsConfigIncludePattern(vueTsConfigPath, 'src/**/*.d.ts', dryRun);
3268
4194
  ensureVueEnvTypes(projectRoot, dryRun);
3269
4195
  wireRsxVitePlugin(projectRoot, dryRun);
4196
+ if (resolveCliVerifyFlag(projectRoot, flags, 'setup')) {
4197
+ verifySetupOutput(projectRoot, 'vuejs');
4198
+ }
3270
4199
  logOk('RS-X Vue setup completed.');
3271
4200
  }
3272
4201
 
3273
4202
  function runSetupAngular(flags) {
3274
4203
  const dryRun = Boolean(flags['dry-run']);
3275
- const pm = detectPackageManager(flags.pm);
3276
- const tag = resolveInstallTag(flags);
3277
4204
  const projectRoot = process.cwd();
4205
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
4206
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
3278
4207
  const angularTsConfigPath = resolveAngularProjectTsConfig(projectRoot);
3279
4208
  const angularTsConfigRelative = path
3280
4209
  .relative(projectRoot, angularTsConfigPath)
@@ -3318,7 +4247,7 @@ function runSetupAngular(flags) {
3318
4247
  );
3319
4248
  }
3320
4249
 
3321
- upsertRsxBuildConfigInPackageJson(projectRoot, dryRun);
4250
+ ensureRsxConfigFile(projectRoot, 'angular', dryRun);
3322
4251
 
3323
4252
  upsertScriptInPackageJson(
3324
4253
  projectRoot,
@@ -3386,13 +4315,17 @@ function runSetupAngular(flags) {
3386
4315
  dryRun,
3387
4316
  });
3388
4317
 
4318
+ if (resolveCliVerifyFlag(projectRoot, flags, 'setup')) {
4319
+ verifySetupOutput(projectRoot, 'angular');
4320
+ }
4321
+
3389
4322
  logOk('RS-X Angular setup completed.');
3390
4323
  }
3391
4324
 
3392
4325
  function runSetupAuto(flags) {
3393
4326
  const projectRoot = process.cwd();
3394
4327
  const context = detectProjectContext(projectRoot);
3395
- const tag = resolveInstallTag(flags);
4328
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
3396
4329
 
3397
4330
  if (context === 'react') {
3398
4331
  logInfo('Auto-detected framework: react');
@@ -3419,7 +4352,7 @@ function runSetupAuto(flags) {
3419
4352
  }
3420
4353
 
3421
4354
  logInfo('No framework-specific setup detected; running generic setup.');
3422
- const pm = detectPackageManager(flags.pm);
4355
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
3423
4356
  installRuntimePackages(
3424
4357
  pm,
3425
4358
  Boolean(flags['dry-run']),
@@ -3450,8 +4383,13 @@ function runBuild(flags) {
3450
4383
  const dryRun = Boolean(flags['dry-run']);
3451
4384
  const noEmit = Boolean(flags['no-emit']);
3452
4385
  const prodMode = parseBooleanFlag(flags.prod, false);
4386
+ const invocationConfig = resolveRsxBuildConfig(invocationRoot);
3453
4387
  const projectArg =
3454
- typeof flags.project === 'string' ? flags.project : 'tsconfig.json';
4388
+ typeof flags.project === 'string'
4389
+ ? flags.project
4390
+ : typeof invocationConfig.tsconfig === 'string'
4391
+ ? invocationConfig.tsconfig
4392
+ : 'tsconfig.json';
3455
4393
  const configPath = path.resolve(invocationRoot, projectArg);
3456
4394
  const projectRoot = path.dirname(configPath);
3457
4395
  const context = detectProjectContext(projectRoot);
@@ -3550,7 +4488,9 @@ function runBuild(flags) {
3550
4488
  const outDirOverride =
3551
4489
  typeof flags['out-dir'] === 'string'
3552
4490
  ? path.resolve(projectRoot, flags['out-dir'])
3553
- : null;
4491
+ : typeof rsxBuildConfig.outDir === 'string'
4492
+ ? path.resolve(projectRoot, rsxBuildConfig.outDir)
4493
+ : null;
3554
4494
  const outDir =
3555
4495
  outDirOverride ??
3556
4496
  parsedConfig.options.outDir ??
@@ -4072,20 +5012,286 @@ function ensureAngularPolyfillsContainsFile({
4072
5012
  logOk(`Updated angular.json to inject RS-X AOT runtime registration.`);
4073
5013
  }
4074
5014
 
4075
- function resolveRsxBuildConfig(projectRoot) {
4076
- const packageJsonPath = path.join(projectRoot, 'package.json');
4077
- if (!fs.existsSync(packageJsonPath)) {
4078
- return {};
5015
+ function readJsonFileIfPresent(filePath) {
5016
+ if (!fs.existsSync(filePath)) {
5017
+ return null;
4079
5018
  }
4080
5019
 
4081
5020
  try {
4082
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
4083
- const rsxConfig = packageJson.rsx ?? {};
4084
- const buildConfig = rsxConfig.build ?? {};
4085
- return typeof buildConfig === 'object' && buildConfig ? buildConfig : {};
5021
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
4086
5022
  } catch {
4087
- return {};
5023
+ return null;
5024
+ }
5025
+ }
5026
+
5027
+ function validateRsxConfigShape(config, filePath) {
5028
+ if (typeof config !== 'object' || !config || Array.isArray(config)) {
5029
+ logError(
5030
+ `Invalid RS-X config in ${filePath}: expected a JSON object at the top level.`,
5031
+ );
5032
+ process.exit(1);
5033
+ }
5034
+
5035
+ const build = config.build;
5036
+ if (build !== undefined) {
5037
+ if (typeof build !== 'object' || !build || Array.isArray(build)) {
5038
+ logError(
5039
+ `Invalid RS-X config in ${filePath}: "build" must be an object.`,
5040
+ );
5041
+ process.exit(1);
5042
+ }
5043
+
5044
+ const stringKeys = ['preparseFile', 'compiledFile', 'registrationFile'];
5045
+ const extraStringKeys = ['tsconfig', 'outDir'];
5046
+ for (const key of extraStringKeys) {
5047
+ if (build[key] !== undefined && typeof build[key] !== 'string') {
5048
+ logError(
5049
+ `Invalid RS-X config in ${filePath}: "build.${key}" must be a string.`,
5050
+ );
5051
+ process.exit(1);
5052
+ }
5053
+ }
5054
+ for (const key of stringKeys) {
5055
+ if (build[key] !== undefined && typeof build[key] !== 'string') {
5056
+ logError(
5057
+ `Invalid RS-X config in ${filePath}: "build.${key}" must be a string.`,
5058
+ );
5059
+ process.exit(1);
5060
+ }
5061
+ }
5062
+
5063
+ const booleanKeys = ['preparse', 'compiled', 'compiledResolvedEvaluator'];
5064
+ for (const key of booleanKeys) {
5065
+ if (build[key] !== undefined && typeof build[key] !== 'boolean') {
5066
+ logError(
5067
+ `Invalid RS-X config in ${filePath}: "build.${key}" must be a boolean.`,
5068
+ );
5069
+ process.exit(1);
5070
+ }
5071
+ }
5072
+ }
5073
+
5074
+ const cli = config.cli;
5075
+ if (cli !== undefined) {
5076
+ if (typeof cli !== 'object' || !cli || Array.isArray(cli)) {
5077
+ logError(`Invalid RS-X config in ${filePath}: "cli" must be an object.`);
5078
+ process.exit(1);
5079
+ }
5080
+
5081
+ const cliStringKeys = ['packageManager', 'installTag'];
5082
+ for (const key of cliStringKeys) {
5083
+ if (cli[key] !== undefined && typeof cli[key] !== 'string') {
5084
+ logError(
5085
+ `Invalid RS-X config in ${filePath}: "cli.${key}" must be a string.`,
5086
+ );
5087
+ process.exit(1);
5088
+ }
5089
+ }
5090
+
5091
+ if (
5092
+ cli.packageManager !== undefined &&
5093
+ !['pnpm', 'npm', 'yarn', 'bun'].includes(cli.packageManager)
5094
+ ) {
5095
+ logError(
5096
+ `Invalid RS-X config in ${filePath}: "cli.packageManager" must be one of pnpm, npm, yarn, or bun.`,
5097
+ );
5098
+ process.exit(1);
5099
+ }
5100
+
5101
+ if (
5102
+ cli.installTag !== undefined &&
5103
+ !['latest', 'next'].includes(cli.installTag)
5104
+ ) {
5105
+ logError(
5106
+ `Invalid RS-X config in ${filePath}: "cli.installTag" must be "latest" or "next".`,
5107
+ );
5108
+ process.exit(1);
5109
+ }
5110
+
5111
+ const cliBooleanSections = ['setup', 'project'];
5112
+ for (const key of cliBooleanSections) {
5113
+ if (cli[key] !== undefined) {
5114
+ if (
5115
+ typeof cli[key] !== 'object' ||
5116
+ !cli[key] ||
5117
+ Array.isArray(cli[key])
5118
+ ) {
5119
+ logError(
5120
+ `Invalid RS-X config in ${filePath}: "cli.${key}" must be an object.`,
5121
+ );
5122
+ process.exit(1);
5123
+ }
5124
+ if (
5125
+ cli[key].verify !== undefined &&
5126
+ typeof cli[key].verify !== 'boolean'
5127
+ ) {
5128
+ logError(
5129
+ `Invalid RS-X config in ${filePath}: "cli.${key}.verify" must be a boolean.`,
5130
+ );
5131
+ process.exit(1);
5132
+ }
5133
+ }
5134
+ }
5135
+
5136
+ const add = cli.add;
5137
+ if (add !== undefined) {
5138
+ if (typeof add !== 'object' || !add || Array.isArray(add)) {
5139
+ logError(
5140
+ `Invalid RS-X config in ${filePath}: "cli.add" must be an object.`,
5141
+ );
5142
+ process.exit(1);
5143
+ }
5144
+
5145
+ if (
5146
+ add.defaultDirectory !== undefined &&
5147
+ typeof add.defaultDirectory !== 'string'
5148
+ ) {
5149
+ logError(
5150
+ `Invalid RS-X config in ${filePath}: "cli.add.defaultDirectory" must be a string.`,
5151
+ );
5152
+ process.exit(1);
5153
+ }
5154
+
5155
+ if (add.searchRoots !== undefined) {
5156
+ if (
5157
+ !Array.isArray(add.searchRoots) ||
5158
+ add.searchRoots.some((entry) => typeof entry !== 'string')
5159
+ ) {
5160
+ logError(
5161
+ `Invalid RS-X config in ${filePath}: "cli.add.searchRoots" must be an array of strings.`,
5162
+ );
5163
+ process.exit(1);
5164
+ }
5165
+ }
5166
+ }
5167
+ }
5168
+ }
5169
+
5170
+ function mergeRsxConfig(baseConfig, overrideConfig) {
5171
+ const base = typeof baseConfig === 'object' && baseConfig ? baseConfig : {};
5172
+ const override =
5173
+ typeof overrideConfig === 'object' && overrideConfig ? overrideConfig : {};
5174
+
5175
+ return {
5176
+ ...base,
5177
+ ...override,
5178
+ build: {
5179
+ ...(typeof base.build === 'object' && base.build ? base.build : {}),
5180
+ ...(typeof override.build === 'object' && override.build
5181
+ ? override.build
5182
+ : {}),
5183
+ },
5184
+ cli: {
5185
+ ...(typeof base.cli === 'object' && base.cli ? base.cli : {}),
5186
+ ...(typeof override.cli === 'object' && override.cli ? override.cli : {}),
5187
+ add: {
5188
+ ...(typeof base.cli?.add === 'object' && base.cli?.add
5189
+ ? base.cli.add
5190
+ : {}),
5191
+ ...(typeof override.cli?.add === 'object' && override.cli?.add
5192
+ ? override.cli.add
5193
+ : {}),
5194
+ },
5195
+ },
5196
+ };
5197
+ }
5198
+
5199
+ function resolveRsxProjectConfig(projectRoot) {
5200
+ const fileConfigPath = path.join(projectRoot, 'rsx.config.json');
5201
+ const fileRsxConfig = readJsonFileIfPresent(fileConfigPath) ?? {};
5202
+ validateRsxConfigShape(fileRsxConfig, fileConfigPath);
5203
+ return mergeRsxConfig({}, fileRsxConfig);
5204
+ }
5205
+
5206
+ function defaultCliAddConfigForTemplate(template) {
5207
+ if (template === 'next' || template === 'nextjs') {
5208
+ return {
5209
+ defaultDirectory: 'app/expressions',
5210
+ searchRoots: ['app', 'src', 'expressions'],
5211
+ };
5212
+ }
5213
+
5214
+ return {
5215
+ defaultDirectory: 'src/expressions',
5216
+ searchRoots: ['src', 'app', 'expressions'],
5217
+ };
5218
+ }
5219
+
5220
+ function defaultCliConfigForTemplate(template) {
5221
+ return {
5222
+ packageManager: 'npm',
5223
+ installTag: 'next',
5224
+ setup: {
5225
+ verify: false,
5226
+ },
5227
+ project: {
5228
+ verify: false,
5229
+ },
5230
+ add: defaultCliAddConfigForTemplate(template),
5231
+ };
5232
+ }
5233
+
5234
+ function defaultRsxBuildConfigForTemplate(template) {
5235
+ if (template === 'next' || template === 'nextjs') {
5236
+ return {
5237
+ preparse: true,
5238
+ preparseFile: 'app/rsx-generated/rsx-aot-preparsed.generated.ts',
5239
+ compiled: true,
5240
+ compiledFile: 'app/rsx-generated/rsx-aot-compiled.generated.ts',
5241
+ registrationFile: 'app/rsx-generated/rsx-aot-registration.generated.ts',
5242
+ compiledResolvedEvaluator: false,
5243
+ };
5244
+ }
5245
+
5246
+ return {
5247
+ preparse: true,
5248
+ preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
5249
+ compiled: true,
5250
+ compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
5251
+ registrationFile: 'src/rsx-generated/rsx-aot-registration.generated.ts',
5252
+ compiledResolvedEvaluator: false,
5253
+ };
5254
+ }
5255
+
5256
+ function ensureRsxConfigFile(projectRoot, template, dryRun) {
5257
+ const configPath = path.join(projectRoot, 'rsx.config.json');
5258
+ const defaultConfig = {
5259
+ build: defaultRsxBuildConfigForTemplate(template),
5260
+ cli: defaultCliConfigForTemplate(template),
5261
+ };
5262
+
5263
+ const existingConfig = readJsonFileIfPresent(configPath);
5264
+ const nextConfig = mergeRsxConfig(defaultConfig, existingConfig ?? {});
5265
+
5266
+ if (
5267
+ existingConfig &&
5268
+ JSON.stringify(existingConfig) === JSON.stringify(nextConfig)
5269
+ ) {
5270
+ return false;
5271
+ }
5272
+
5273
+ if (dryRun) {
5274
+ logInfo(`[dry-run] ${existingConfig ? 'patch' : 'create'} ${configPath}`);
5275
+ return true;
4088
5276
  }
5277
+
5278
+ fs.writeFileSync(
5279
+ configPath,
5280
+ `${JSON.stringify(nextConfig, null, 2)}\n`,
5281
+ 'utf8',
5282
+ );
5283
+ logOk(`${existingConfig ? 'Patched' : 'Created'} ${configPath}`);
5284
+ return true;
5285
+ }
5286
+
5287
+ function resolveRsxBuildConfig(projectRoot) {
5288
+ const buildConfig = resolveRsxProjectConfig(projectRoot).build ?? {};
5289
+ return typeof buildConfig === 'object' && buildConfig ? buildConfig : {};
5290
+ }
5291
+
5292
+ function resolveRsxCliConfig(projectRoot) {
5293
+ const cliConfig = resolveRsxProjectConfig(projectRoot).cli ?? {};
5294
+ return typeof cliConfig === 'object' && cliConfig ? cliConfig : {};
4089
5295
  }
4090
5296
 
4091
5297
  function parseBooleanFlag(value, defaultValue) {
@@ -4146,7 +5352,7 @@ function printGeneralHelp() {
4146
5352
  console.log(
4147
5353
  ' typecheck Type-check project + RS-X semantic checks',
4148
5354
  );
4149
- console.log(' version | -v Print CLI version');
5355
+ console.log(' version | v | -v Print CLI version');
4150
5356
  console.log('');
4151
5357
  console.log('Help Aliases:');
4152
5358
  console.log(' rsx -h');
@@ -4180,14 +5386,28 @@ function printAddHelp() {
4180
5386
  console.log(
4181
5387
  ' - Prompts for expression export name (must be valid TS identifier)',
4182
5388
  );
5389
+ console.log(" - Prompts for the initial expression string (default: 'a')");
5390
+ console.log(
5391
+ ' - Seeds the generated model with top-level identifiers found in that expression when possible',
5392
+ );
4183
5393
  console.log(
4184
5394
  ' - Prompts whether file name should be kebab-case (default: yes)',
4185
5395
  );
4186
5396
  console.log(' - Prompts for output directory (relative or absolute)');
4187
- console.log(' - Prompts whether to reuse an existing model file');
4188
- console.log(' - Creates <name>.ts and optionally creates <name>.model.ts');
4189
5397
  console.log(
4190
- ' - Expression file imports selected model and exports rsx expression',
5398
+ ' - Prompts for add mode: new one-file, new separate-model, or update existing',
5399
+ );
5400
+ console.log(
5401
+ ' - Defaults to keeping the model in the same file as the expression',
5402
+ );
5403
+ console.log(
5404
+ ' - If you choose an existing file, shows a list of files that already contain RS-X expressions',
5405
+ );
5406
+ console.log(
5407
+ ' - Can still create or reuse a separate model file when you opt out of same-file mode',
5408
+ );
5409
+ console.log(
5410
+ ' - Respects rsx.config.json (`cli.add`) for add defaults and file discovery',
4191
5411
  );
4192
5412
  }
4193
5413
 
@@ -4226,7 +5446,7 @@ function printInstallHelp(target) {
4226
5446
  function printSetupHelp() {
4227
5447
  console.log('Usage:');
4228
5448
  console.log(
4229
- ' rsx setup [--pm <pnpm|npm|yarn|bun>] [--next] [--force] [--local] [--dry-run]',
5449
+ ' rsx setup [--pm <pnpm|npm|yarn|bun>] [--next] [--verify] [--force] [--local] [--dry-run]',
4230
5450
  );
4231
5451
  console.log('');
4232
5452
  console.log('What it does:');
@@ -4235,12 +5455,17 @@ function printSetupHelp() {
4235
5455
  );
4236
5456
  console.log(' - Installs runtime packages');
4237
5457
  console.log(' - Installs compiler tooling packages');
4238
- console.log(' - Installs VS Code extension');
5458
+ console.log(' - Writes rsx.build config plus build/typecheck scripts');
5459
+ console.log(' - Creates rsx.config.json with CLI defaults you can override');
4239
5460
  console.log(' - Applies framework-specific transform/build integration');
5461
+ console.log(' - Does not install the VS Code extension automatically');
4240
5462
  console.log('');
4241
5463
  console.log('Options:');
4242
5464
  console.log(' --pm Explicit package manager');
4243
5465
  console.log(' --next Install prerelease versions (dist-tag next)');
5466
+ console.log(
5467
+ ' --verify Validate the resulting setup output before returning',
5468
+ );
4244
5469
  console.log(' --force Reinstall extension if already installed');
4245
5470
  console.log(' --local Build/install local VSIX from repo workspace');
4246
5471
  console.log(' --dry-run Print commands without executing them');
@@ -4259,14 +5484,17 @@ function printInitHelp() {
4259
5484
  console.log(
4260
5485
  ' - Detects project context and wires RS-X bootstrap in entry file',
4261
5486
  );
4262
- console.log(' - Installs VS Code extension (unless --skip-vscode)');
5487
+ console.log(' - Creates rsx.config.json with CLI defaults you can override');
5488
+ console.log(' - Does not install the VS Code extension automatically');
4263
5489
  console.log('');
4264
5490
  console.log('Options:');
4265
5491
  console.log(' --pm Explicit package manager');
4266
5492
  console.log(' --entry Explicit application entry file');
4267
5493
  console.log(' --next Install prerelease versions (dist-tag next)');
4268
5494
  console.log(' --skip-install Skip npm/pnpm/yarn/bun package installation');
4269
- console.log(' --skip-vscode Skip VS Code extension installation');
5495
+ console.log(
5496
+ ' --skip-vscode Accepted for compatibility; VS Code is not auto-installed',
5497
+ );
4270
5498
  console.log(' --force Reinstall extension if already installed');
4271
5499
  console.log(' --local Build/install local VSIX from repo workspace');
4272
5500
  console.log(' --dry-run Print commands without executing them');
@@ -4275,7 +5503,7 @@ function printInitHelp() {
4275
5503
  function printProjectHelp() {
4276
5504
  console.log('Usage:');
4277
5505
  console.log(
4278
- ' rsx project [angular|vuejs|react|nextjs|nodejs] [--name <project-name>] [--pm <pnpm|npm|yarn|bun>] [--next] [--template <angular|vuejs|react|nextjs|nodejs>] [--tarballs-dir <path>] [--skip-install] [--skip-vscode] [--dry-run]',
5506
+ ' rsx project [angular|vuejs|react|nextjs|nodejs] [--name <project-name>] [--pm <pnpm|npm|yarn|bun>] [--next] [--template <angular|vuejs|react|nextjs|nodejs>] [--tarballs-dir <path>] [--skip-install] [--skip-vscode] [--verify] [--dry-run]',
4279
5507
  );
4280
5508
  console.log('');
4281
5509
  console.log('What it does:');
@@ -4286,6 +5514,9 @@ function printProjectHelp() {
4286
5514
  );
4287
5515
  console.log(' - Scaffolds framework app and wires RS-X bootstrap/setup');
4288
5516
  console.log(' - Writes package.json with RS-X dependencies');
5517
+ console.log(
5518
+ ' - Creates rsx.config.json with starter CLI defaults you can override',
5519
+ );
4289
5520
  console.log(
4290
5521
  ' - Adds tsconfig + TypeScript plugin config for editor support',
4291
5522
  );
@@ -4295,6 +5526,7 @@ function printProjectHelp() {
4295
5526
  console.log(' - For React/Next templates: also installs @rs-x/react');
4296
5527
  console.log(' - For Vue template: also installs @rs-x/vue');
4297
5528
  console.log(' - Installs dependencies (unless --skip-install)');
5529
+ console.log(' - Verifies the generated starter before reporting success');
4298
5530
  console.log('');
4299
5531
  console.log('Options:');
4300
5532
  console.log(' --name Project folder/package name');
@@ -4308,7 +5540,12 @@ function printProjectHelp() {
4308
5540
  );
4309
5541
  console.log(' (or set RSX_TARBALLS_DIR env var)');
4310
5542
  console.log(' --skip-install Skip dependency installation');
4311
- console.log(' --skip-vscode Skip VS Code extension installation');
5543
+ console.log(
5544
+ ' --skip-vscode Accepted for compatibility; VS Code is not auto-installed',
5545
+ );
5546
+ console.log(
5547
+ ' --verify Re-run starter structure checks explicitly after generation',
5548
+ );
4312
5549
  console.log(' --dry-run Print actions without writing files');
4313
5550
  }
4314
5551
 
@@ -4526,13 +5763,14 @@ function main() {
4526
5763
  }
4527
5764
 
4528
5765
  if (command === 'install' && target === 'compiler') {
4529
- const pm = detectPackageManager(flags.pm);
4530
- const tag = resolveInstallTag(flags);
5766
+ const projectRoot = process.cwd();
5767
+ const pm = resolveCliPackageManager(projectRoot, flags.pm);
5768
+ const tag = resolveConfiguredInstallTag(projectRoot, flags);
4531
5769
  installCompilerPackages(
4532
5770
  pm,
4533
5771
  Boolean(flags['dry-run']),
4534
5772
  tag,
4535
- process.cwd(),
5773
+ projectRoot,
4536
5774
  flags,
4537
5775
  );
4538
5776
  return;