@bsb/base 9.1.5 → 9.1.8

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 (33) hide show
  1. package/lib/base/BSBService.d.ts +1 -1
  2. package/lib/base/BSBService.js +2 -7
  3. package/lib/base/BSBService.js.map +1 -1
  4. package/lib/base/PluginConfig.d.ts +3 -24
  5. package/lib/base/PluginConfig.js.map +1 -1
  6. package/lib/dev.js +11 -0
  7. package/lib/dev.js.map +1 -1
  8. package/lib/interfaces/schema-events.d.ts +3 -5
  9. package/lib/interfaces/schema-events.js +1 -4
  10. package/lib/interfaces/schema-events.js.map +1 -1
  11. package/lib/plugins/service-default3/index.js +0 -1
  12. package/lib/plugins/service-default3/index.js.map +1 -1
  13. package/lib/schemas/config-default.json +1 -1
  14. package/lib/schemas/config-default.plugin.json +7 -1
  15. package/lib/schemas/events-default.json +1 -1
  16. package/lib/schemas/events-default.plugin.json +7 -1
  17. package/lib/schemas/observable-default.json +1 -1
  18. package/lib/schemas/observable-default.plugin.json +7 -1
  19. package/lib/schemas/service-benchmarkify.json +1 -1
  20. package/lib/schemas/service-default0.json +1 -1
  21. package/lib/schemas/service-default1.json +1 -1
  22. package/lib/schemas/service-default2.json +1 -1
  23. package/lib/schemas/service-default3.json +1 -1
  24. package/lib/schemas/service-default4.json +1 -1
  25. package/lib/scripts/bsb-client-cli.js +6 -6
  26. package/lib/scripts/bsb-client-cli.js.map +1 -1
  27. package/lib/scripts/bsb-plugin-cli.js +561 -39
  28. package/lib/scripts/bsb-plugin-cli.js.map +1 -1
  29. package/lib/scripts/extract-schemas-from-source.js +55 -12
  30. package/lib/scripts/extract-schemas-from-source.js.map +1 -1
  31. package/lib/scripts/generate-plugin-json.js +5 -5
  32. package/lib/scripts/generate-plugin-json.js.map +1 -1
  33. package/package.json +106 -106
@@ -13,9 +13,11 @@
13
13
  * bsb-plugin-cli clean - Remove build artifacts
14
14
  */
15
15
  import { execSync, execFileSync, spawn } from 'node:child_process';
16
+ import { createHash } from 'node:crypto';
16
17
  import * as fs from 'node:fs';
17
18
  import * as path from 'node:path';
18
19
  import { createRequire } from 'node:module';
20
+ import chokidar from 'chokidar';
19
21
  import { getModuleDir, toImportUrl } from '../base/module-runtime.js';
20
22
  // Colors for terminal output
21
23
  const colors = {
@@ -44,6 +46,236 @@ function info(message) {
44
46
  const CWD = process.cwd();
45
47
  const COMMAND = process.argv[2];
46
48
  const MODULE_DIR = getModuleDir(import.meta.url);
49
+ const BSB_DIR = path.join(CWD, 'src', '.bsb');
50
+ const CACHE_DIR = path.join(BSB_DIR, 'cache');
51
+ const BUILD_CACHE_PATH = path.join(CACHE_DIR, 'build-state.json');
52
+ const TSC_BUILD_INFO_PATH = path.join(CACHE_DIR, 'tsc.tsbuildinfo');
53
+ const DEV_WATCH_PATHS = [
54
+ path.join(CWD, 'package.json'),
55
+ path.join(CWD, 'sec-config.yaml'),
56
+ path.join(CWD, 'src'),
57
+ ];
58
+ const DEV_IGNORE_PATHS = [
59
+ path.join(CWD, '.git'),
60
+ path.join(CWD, 'lib'),
61
+ path.join(CWD, 'node_modules'),
62
+ path.join(CWD, 'src', '.bsb'),
63
+ ];
64
+ const SCHEMA_FILE_BASENAMES = new Set([
65
+ 'index.ts',
66
+ 'index.tsx',
67
+ 'config.ts',
68
+ 'config.tsx',
69
+ 'events.ts',
70
+ 'events.tsx',
71
+ 'schema.ts',
72
+ 'schema.tsx',
73
+ 'schemas.ts',
74
+ 'schemas.tsx',
75
+ 'types.ts',
76
+ 'types.tsx',
77
+ ]);
78
+ function readPackageJson() {
79
+ const packageJsonPath = path.join(CWD, 'package.json');
80
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
81
+ }
82
+ function ensureDir(dirPath) {
83
+ if (!fs.existsSync(dirPath)) {
84
+ fs.mkdirSync(dirPath, { recursive: true });
85
+ }
86
+ }
87
+ function normalizePath(inputPath) {
88
+ return inputPath.replace(/\\/g, '/');
89
+ }
90
+ function normalizeChangedPath(filePath) {
91
+ const resolvedPath = path.isAbsolute(filePath) ? path.relative(CWD, filePath) : filePath;
92
+ return normalizePath(resolvedPath);
93
+ }
94
+ function hashStrings(values) {
95
+ const hash = createHash('sha256');
96
+ for (const value of values.sort()) {
97
+ hash.update(value);
98
+ hash.update('\n');
99
+ }
100
+ return hash.digest('hex');
101
+ }
102
+ function hashFile(filePath) {
103
+ const hash = createHash('sha256');
104
+ hash.update(fs.readFileSync(filePath));
105
+ return hash.digest('hex');
106
+ }
107
+ function collectFilesRecursive(dirPath, predicate) {
108
+ if (!fs.existsSync(dirPath)) {
109
+ return [];
110
+ }
111
+ const files = [];
112
+ const stack = [dirPath];
113
+ while (stack.length > 0) {
114
+ const current = stack.pop();
115
+ const entries = fs.readdirSync(current, { withFileTypes: true });
116
+ for (const entry of entries) {
117
+ const fullPath = path.join(current, entry.name);
118
+ if (entry.isDirectory()) {
119
+ stack.push(fullPath);
120
+ }
121
+ else if (entry.isFile() && predicate(fullPath)) {
122
+ files.push(fullPath);
123
+ }
124
+ }
125
+ }
126
+ return files;
127
+ }
128
+ function readBuildCache() {
129
+ try {
130
+ if (!fs.existsSync(BUILD_CACHE_PATH)) {
131
+ return { version: 1 };
132
+ }
133
+ const parsed = JSON.parse(fs.readFileSync(BUILD_CACHE_PATH, 'utf-8'));
134
+ if (parsed.version === 1) {
135
+ return parsed;
136
+ }
137
+ }
138
+ catch {
139
+ // Ignore malformed cache files.
140
+ }
141
+ return { version: 1 };
142
+ }
143
+ function writeBuildCache(cache) {
144
+ ensureDir(CACHE_DIR);
145
+ fs.writeFileSync(BUILD_CACHE_PATH, JSON.stringify(cache, null, 2), 'utf-8');
146
+ }
147
+ function getCoreSchemasHash() {
148
+ const bsbBasePath = resolveBsbBasePath();
149
+ if (!bsbBasePath) {
150
+ return '';
151
+ }
152
+ const packageJsonPath = path.join(bsbBasePath, 'package.json');
153
+ const inputs = [fs.existsSync(packageJsonPath) ? `pkg:${hashFile(packageJsonPath)}` : 'pkg:missing'];
154
+ const sourcePluginsDir = path.join(bsbBasePath, 'src', 'plugins');
155
+ if (fs.existsSync(sourcePluginsDir)) {
156
+ inputs.push(...collectFilesRecursive(sourcePluginsDir, (filePath) => {
157
+ const ext = path.extname(filePath).toLowerCase();
158
+ if (ext !== '.ts' && ext !== '.tsx') {
159
+ return false;
160
+ }
161
+ return SCHEMA_FILE_BASENAMES.has(path.basename(filePath));
162
+ }).map((filePath) => `${normalizePath(path.relative(bsbBasePath, filePath))}:${hashFile(filePath)}`));
163
+ }
164
+ else {
165
+ const schemaDir = path.join(bsbBasePath, 'lib', 'schemas');
166
+ inputs.push(...collectFilesRecursive(schemaDir, (filePath) => filePath.endsWith('.json'))
167
+ .map((filePath) => `${normalizePath(path.relative(bsbBasePath, filePath))}:${hashFile(filePath)}`));
168
+ }
169
+ return hashStrings(inputs);
170
+ }
171
+ function getSchemaInputsHash(plugins, coreSchemasHash) {
172
+ const inputs = [`core:${coreSchemasHash}`];
173
+ const packageJsonPath = path.join(CWD, 'package.json');
174
+ if (fs.existsSync(packageJsonPath)) {
175
+ inputs.push(`pkg:${hashFile(packageJsonPath)}`);
176
+ }
177
+ for (const plugin of plugins) {
178
+ const pluginFiles = collectFilesRecursive(plugin.srcDir, (filePath) => {
179
+ const ext = path.extname(filePath).toLowerCase();
180
+ if (ext !== '.ts' && ext !== '.tsx') {
181
+ return false;
182
+ }
183
+ return SCHEMA_FILE_BASENAMES.has(path.basename(filePath));
184
+ });
185
+ for (const filePath of pluginFiles) {
186
+ inputs.push(`${normalizePath(path.relative(CWD, filePath))}:${hashFile(filePath)}`);
187
+ }
188
+ }
189
+ return hashStrings(inputs);
190
+ }
191
+ function getGeneratedSchemasHash() {
192
+ const schemaDir = path.join(CWD, 'src', '.bsb', 'schemas');
193
+ const schemaFiles = collectFilesRecursive(schemaDir, (filePath) => filePath.endsWith('.json'));
194
+ if (schemaFiles.length === 0) {
195
+ return '';
196
+ }
197
+ return hashStrings(schemaFiles.map((filePath) => `${normalizePath(path.relative(CWD, filePath))}:${hashFile(filePath)}`));
198
+ }
199
+ function hasGeneratedSchemas() {
200
+ const schemaDir = path.join(CWD, 'src', '.bsb', 'schemas');
201
+ return fs.existsSync(schemaDir) && fs.readdirSync(schemaDir).some((file) => file.endsWith('.json'));
202
+ }
203
+ function hasGeneratedClients() {
204
+ const clientsDir = path.join(CWD, 'src', '.bsb', 'clients');
205
+ return fs.existsSync(clientsDir) && fs.readdirSync(clientsDir).some((file) => file.endsWith('.ts'));
206
+ }
207
+ function isTsSourcePath(filePath) {
208
+ const normalized = normalizeChangedPath(filePath);
209
+ return normalized.startsWith('src/') && (normalized.endsWith('.ts') || normalized.endsWith('.tsx'));
210
+ }
211
+ function isSchemaRelevantPath(filePath) {
212
+ const normalized = normalizeChangedPath(filePath);
213
+ if (normalized === 'package.json') {
214
+ return true;
215
+ }
216
+ if (!isTsSourcePath(normalized)) {
217
+ return false;
218
+ }
219
+ return SCHEMA_FILE_BASENAMES.has(path.basename(normalized));
220
+ }
221
+ function isAssetPath(filePath) {
222
+ const normalized = normalizeChangedPath(filePath);
223
+ if (!normalized.startsWith('src/plugins/')) {
224
+ return false;
225
+ }
226
+ const ext = path.extname(normalized).toLowerCase();
227
+ return ext !== '.ts' && ext !== '.tsx';
228
+ }
229
+ function isStaticAssetPath(filePath) {
230
+ return normalizeChangedPath(filePath).includes('/static/');
231
+ }
232
+ function isConfigPath(filePath) {
233
+ const normalized = normalizeChangedPath(filePath);
234
+ return normalized === 'sec-config.yaml' || normalized === 'package.json';
235
+ }
236
+ function copySingleAssetFile(changedPath) {
237
+ const normalized = normalizeChangedPath(changedPath);
238
+ if (!isAssetPath(normalized)) {
239
+ return false;
240
+ }
241
+ const absoluteSource = path.join(CWD, normalized);
242
+ const relativeToSrc = path.relative(path.join(CWD, 'src'), absoluteSource);
243
+ const destination = path.join(CWD, 'lib', relativeToSrc);
244
+ if (fs.existsSync(absoluteSource)) {
245
+ ensureDir(path.dirname(destination));
246
+ fs.copyFileSync(absoluteSource, destination);
247
+ }
248
+ else if (fs.existsSync(destination)) {
249
+ fs.rmSync(destination, { force: true });
250
+ }
251
+ return true;
252
+ }
253
+ function hasLibSchemas() {
254
+ const libSchemas = path.join(CWD, 'lib', 'schemas');
255
+ return fs.existsSync(libSchemas) && fs.readdirSync(libSchemas).some((file) => file.endsWith('.json'));
256
+ }
257
+ function hasPluginMetadataOutputs(plugins) {
258
+ const schemasDir = path.join(CWD, 'lib', 'schemas');
259
+ return plugins.every((plugin) => fs.existsSync(path.join(schemasDir, `${plugin.name}.plugin.json`)));
260
+ }
261
+ function getMetadataInputsHash(plugins) {
262
+ const packageJsonPath = path.join(CWD, 'package.json');
263
+ const inputs = [];
264
+ if (fs.existsSync(packageJsonPath)) {
265
+ inputs.push(`pkg:${hashFile(packageJsonPath)}`);
266
+ }
267
+ for (const plugin of plugins) {
268
+ const indexPath = path.join(plugin.srcDir, 'index.ts');
269
+ if (fs.existsSync(indexPath)) {
270
+ inputs.push(`${normalizePath(path.relative(CWD, indexPath))}:${hashFile(indexPath)}`);
271
+ }
272
+ const schemaPath = path.join(CWD, 'src', '.bsb', 'schemas', `${plugin.name}.json`);
273
+ if (fs.existsSync(schemaPath)) {
274
+ inputs.push(`${normalizePath(path.relative(CWD, schemaPath))}:${hashFile(schemaPath)}`);
275
+ }
276
+ }
277
+ return hashStrings(inputs);
278
+ }
47
279
  // Resolve @bsb/base package root using Node module resolution.
48
280
  // Handles npm workspaces (hoisted node_modules), symlinks, and pnpm.
49
281
  function resolveBsbBasePath() {
@@ -222,6 +454,7 @@ async function generatePluginJson(plugin) {
222
454
  }
223
455
  info(`Generating plugin metadata for ${plugin.name}`);
224
456
  const metadata = pluginModule.Config.metadata;
457
+ const packageJson = readPackageJson();
225
458
  // Auto-detect category from plugin directory name
226
459
  const category = plugin.name.startsWith('service-') ? 'service' :
227
460
  plugin.name.startsWith('observable-') ? 'observable' :
@@ -231,7 +464,7 @@ async function generatePluginJson(plugin) {
231
464
  const pluginMetadata = {
232
465
  id: plugin.name,
233
466
  name: metadata.name,
234
- version: metadata.version || '1.0.0',
467
+ version: packageJson.version || '1.0.0',
235
468
  description: metadata.description || '',
236
469
  category,
237
470
  tags: metadata.tags || [],
@@ -239,10 +472,10 @@ async function generatePluginJson(plugin) {
239
472
  dependencies: [],
240
473
  };
241
474
  // Only include optional fields if they have real values
242
- if (metadata.author)
243
- pluginMetadata.author = metadata.author;
244
- if (metadata.license)
245
- pluginMetadata.license = metadata.license;
475
+ if (packageJson.author)
476
+ pluginMetadata.author = packageJson.author;
477
+ if (packageJson.license)
478
+ pluginMetadata.license = packageJson.license;
246
479
  if (metadata.homepage)
247
480
  pluginMetadata.homepage = metadata.homepage;
248
481
  if (metadata.repository)
@@ -443,51 +676,183 @@ function generateRootTestsJson(plugins) {
443
676
  fs.writeFileSync(testsPath, JSON.stringify(updated, null, 2), 'utf-8');
444
677
  success(`Generated bsb-tests.json with ${plugins.length} plugin(s)`);
445
678
  }
679
+ function shouldRunSchemaPipeline(changedPaths) {
680
+ if (!changedPaths || changedPaths.length === 0) {
681
+ return true;
682
+ }
683
+ return changedPaths.some(isSchemaRelevantPath);
684
+ }
685
+ function getBsbCliPath() {
686
+ const bsbBase = resolveBsbBasePath();
687
+ if (!bsbBase) {
688
+ error('BSB CLI not found. Make sure @bsb/base is installed.');
689
+ }
690
+ const bsbCliPath = path.join(bsbBase, 'lib', 'cli.js');
691
+ if (!fs.existsSync(bsbCliPath)) {
692
+ error(`BSB CLI entry not found at ${bsbCliPath}. @bsb/base may need rebuilding.`);
693
+ }
694
+ return bsbCliPath;
695
+ }
696
+ function getBsbDevPath() {
697
+ const bsbBase = resolveBsbBasePath();
698
+ if (!bsbBase) {
699
+ error('BSB dev entry not found. Make sure @bsb/base is installed.');
700
+ }
701
+ const sourceDevPath = path.join(bsbBase, 'src', 'dev.ts');
702
+ if (fs.existsSync(sourceDevPath)) {
703
+ return sourceDevPath;
704
+ }
705
+ const compiledDevPath = path.join(bsbBase, 'lib', 'dev.js');
706
+ if (fs.existsSync(compiledDevPath)) {
707
+ return compiledDevPath;
708
+ }
709
+ error(`BSB dev entry not found in ${bsbBase}`);
710
+ }
711
+ function typecheckDev() {
712
+ try {
713
+ info('Type checking TypeScript');
714
+ execSync(`npx tsc --noEmit --incremental --tsBuildInfoFile "${normalizePath(TSC_BUILD_INFO_PATH)}"`, { cwd: CWD, stdio: 'inherit' });
715
+ success('Type checking TypeScript');
716
+ return true;
717
+ }
718
+ catch {
719
+ return false;
720
+ }
721
+ }
722
+ async function prepareGeneratedArtifacts(options = {}) {
723
+ ensureDir(CACHE_DIR);
724
+ const plugins = detectPluginStructure();
725
+ const cache = readBuildCache();
726
+ const changedPaths = options.changedPaths ?? [];
727
+ let coreSchemasHash = getCoreSchemasHash();
728
+ let schemaInputsHash = getSchemaInputsHash(plugins, coreSchemasHash);
729
+ const runSchemaPipeline = shouldRunSchemaPipeline(options.changedPaths);
730
+ const metadataInputsHash = getMetadataInputsHash(plugins);
731
+ const coreSchemasChanged = cache.coreSchemasHash !== coreSchemasHash;
732
+ const shouldSyncCoreSchemas = coreSchemasChanged || !hasGeneratedClients() || !hasGeneratedSchemas();
733
+ if (shouldSyncCoreSchemas) {
734
+ syncParentSchemas();
735
+ coreSchemasHash = getCoreSchemasHash();
736
+ schemaInputsHash = getSchemaInputsHash(plugins, coreSchemasHash);
737
+ }
738
+ else {
739
+ info('Skipping core schema sync (unchanged)');
740
+ }
741
+ const shouldExtractSchemas = runSchemaPipeline && (shouldSyncCoreSchemas ||
742
+ cache.schemaInputsHash !== schemaInputsHash ||
743
+ !hasGeneratedSchemas());
744
+ if (shouldExtractSchemas) {
745
+ await extractSchemasFromSource();
746
+ }
747
+ else {
748
+ info('Skipping schema extraction (unchanged)');
749
+ }
750
+ const generatedSchemasHash = getGeneratedSchemasHash();
751
+ const shouldGenerateClients = runSchemaPipeline && (shouldExtractSchemas ||
752
+ cache.clientInputsHash !== generatedSchemasHash ||
753
+ !hasGeneratedClients());
754
+ if (shouldGenerateClients) {
755
+ generateVirtualClients();
756
+ }
757
+ else {
758
+ info('Skipping virtual client generation (unchanged)');
759
+ }
760
+ writeBuildCache({
761
+ version: 1,
762
+ coreSchemasHash,
763
+ schemaInputsHash,
764
+ clientInputsHash: getGeneratedSchemasHash(),
765
+ metadataInputsHash: changedPaths.length > 0 ? cache.metadataInputsHash : metadataInputsHash,
766
+ });
767
+ return {
768
+ plugins,
769
+ coreSchemasHash,
770
+ schemaInputsHash,
771
+ generatedSchemasHash: getGeneratedSchemasHash(),
772
+ };
773
+ }
446
774
  // Build the plugin
447
- async function build() {
775
+ async function build(options = {}) {
448
776
  log('\n=== Building BSB Plugin ===\n', 'bright');
449
- // Step 1: Sync schemas with parent @bsb/base core plugins FIRST
450
- // This ensures latest types are available during compilation
451
- syncParentSchemas();
452
- // Step 2: Extract schemas from TypeScript source (pre-compilation)
453
- // This reads TS files directly, no compiled JS needed — breaks circular dependency
454
- await extractSchemasFromSource();
455
- // Step 3: Generate virtual clients from extracted schemas
456
- // These will be compiled alongside the project in step 5
457
- generateVirtualClients();
777
+ const cache = readBuildCache();
778
+ const changedPaths = options.changedPaths ?? [];
779
+ const { plugins, coreSchemasHash, schemaInputsHash } = await prepareGeneratedArtifacts(options);
780
+ const runSchemaPipeline = shouldRunSchemaPipeline(options.changedPaths);
781
+ const metadataInputsHash = getMetadataInputsHash(plugins);
782
+ const packageChanged = changedPaths.some((filePath) => normalizeChangedPath(filePath) === 'package.json');
783
+ const assetChanged = changedPaths.some(isAssetPath);
458
784
  // Step 4: Clean
459
- clean();
785
+ if (options.clean !== false) {
786
+ clean();
787
+ }
788
+ else {
789
+ info('Skipping clean for incremental rebuild');
790
+ }
460
791
  // Step 5: Compile TypeScript (virtual clients in src/.bsb/clients/ compile with the project)
461
- exec('npx tsc', 'Compiling TypeScript');
792
+ if (options.incremental) {
793
+ exec(`npx tsc --incremental --tsBuildInfoFile "${normalizePath(TSC_BUILD_INFO_PATH)}"`, 'Compiling TypeScript incrementally');
794
+ }
795
+ else {
796
+ exec('npx tsc', 'Compiling TypeScript');
797
+ }
462
798
  // Step 6: Copy non-TypeScript assets for each plugin
463
- const plugins = detectPluginStructure();
464
- for (const plugin of plugins) {
465
- copyPluginAssets(plugin);
799
+ const shouldCopyAllAssets = !options.changedPaths || changedPaths.length === 0 || assetChanged || !fs.existsSync(path.join(CWD, 'lib'));
800
+ if (shouldCopyAllAssets) {
801
+ for (const plugin of plugins) {
802
+ copyPluginAssets(plugin);
803
+ }
804
+ }
805
+ else {
806
+ info('Skipping asset copy (unchanged)');
466
807
  }
467
808
  // Step 7: Copy extracted schemas to lib/schemas/
468
- copySchemasToLib();
809
+ const shouldCopySchemas = runSchemaPipeline || !hasLibSchemas();
810
+ if (shouldCopySchemas) {
811
+ copySchemasToLib();
812
+ }
813
+ else {
814
+ info('Skipping schema copy (unchanged)');
815
+ }
469
816
  // Step 8: Generate per-plugin metadata JSON (needs compiled JS for Config.metadata)
470
- for (const plugin of plugins) {
471
- await generatePluginJson(plugin);
817
+ const shouldGenerateMetadata = runSchemaPipeline ||
818
+ packageChanged ||
819
+ cache.metadataInputsHash !== metadataInputsHash ||
820
+ !hasPluginMetadataOutputs(plugins);
821
+ if (shouldGenerateMetadata) {
822
+ for (const plugin of plugins) {
823
+ await generatePluginJson(plugin);
824
+ }
825
+ }
826
+ else {
827
+ info('Skipping plugin metadata generation (unchanged)');
472
828
  }
473
829
  // Step 9: Generate root bsb-plugin.json (aggregates all per-plugin metadata)
474
- generateRootPluginJson();
830
+ if (shouldGenerateMetadata || !fs.existsSync(path.join(CWD, 'bsb-plugin.json'))) {
831
+ generateRootPluginJson();
832
+ }
833
+ else {
834
+ info('Skipping bsb-plugin.json generation (unchanged)');
835
+ }
475
836
  // Step 10: Generate root bsb-tests.json (default ignore entries)
476
- generateRootTestsJson(plugins.map(p => ({ id: p.name })));
837
+ if (shouldGenerateMetadata || !fs.existsSync(path.join(CWD, 'bsb-tests.json'))) {
838
+ generateRootTestsJson(plugins.map(p => ({ id: p.name })));
839
+ }
840
+ else {
841
+ info('Skipping bsb-tests.json generation (unchanged)');
842
+ }
843
+ writeBuildCache({
844
+ version: 1,
845
+ coreSchemasHash,
846
+ schemaInputsHash,
847
+ clientInputsHash: getGeneratedSchemasHash(),
848
+ metadataInputsHash: getMetadataInputsHash(plugins),
849
+ });
477
850
  log('\n' + colors.green + colors.bright + '[BUILD COMPLETE]' + colors.reset + '\n');
478
851
  }
479
852
  // Start the BSB service
480
853
  function start() {
481
854
  log('\n=== Starting BSB Service ===\n', 'bright');
482
- // Find the BSB CLI (uses Node module resolution to handle workspace hoisting)
483
- const bsbBase = resolveBsbBasePath();
484
- if (!bsbBase) {
485
- error('BSB CLI not found. Make sure @bsb/base is installed.');
486
- }
487
- const bsbCliPath = path.join(bsbBase, 'lib', 'cli.js');
488
- if (!fs.existsSync(bsbCliPath)) {
489
- error(`BSB CLI entry not found at ${bsbCliPath}. @bsb/base may need rebuilding.`);
490
- }
855
+ const bsbCliPath = getBsbCliPath();
491
856
  info('Starting service');
492
857
  // Spawn the process and inherit stdio for real-time output
493
858
  const child = spawn('node', [bsbCliPath], {
@@ -507,13 +872,170 @@ function start() {
507
872
  child.kill('SIGINT');
508
873
  });
509
874
  }
510
- // Development mode (build + start)
875
+ function startServiceProcess() {
876
+ const bsbDevPath = getBsbDevPath();
877
+ info('Starting service');
878
+ return spawn('node', ['--import', 'tsx', bsbDevPath], {
879
+ cwd: CWD,
880
+ stdio: 'inherit',
881
+ env: {
882
+ ...process.env,
883
+ APP_DIR: CWD,
884
+ BSB_DEV_EXTERNAL_WATCH: '1',
885
+ BSB_DEV_LOADER: 'tsx',
886
+ },
887
+ });
888
+ }
889
+ async function stopServiceProcess(child) {
890
+ if (!child || child.killed || child.exitCode !== null) {
891
+ return;
892
+ }
893
+ await new Promise((resolve) => {
894
+ child.once('exit', () => resolve());
895
+ child.kill('SIGINT');
896
+ setTimeout(() => {
897
+ if (child.exitCode === null) {
898
+ child.kill('SIGTERM');
899
+ }
900
+ }, 2000);
901
+ setTimeout(() => {
902
+ if (child.exitCode === null) {
903
+ child.kill('SIGKILL');
904
+ }
905
+ }, 4000);
906
+ });
907
+ }
908
+ // Development mode with incremental rebuilds and restarts
511
909
  async function dev() {
512
910
  log('\n=== Development Mode ===\n', 'bright');
513
- // Build first
514
- await build();
515
- // Then start
516
- start();
911
+ let child = null;
912
+ let watcher = null;
913
+ let isRebuilding = false;
914
+ let restartPending = false;
915
+ const pendingChanges = new Set();
916
+ let debounceTimer = null;
917
+ const rebuildAndRestart = async () => {
918
+ if (isRebuilding) {
919
+ restartPending = true;
920
+ info('Rebuild already in progress, queueing another pass');
921
+ return;
922
+ }
923
+ isRebuilding = true;
924
+ const changedPaths = Array.from(pendingChanges);
925
+ pendingChanges.clear();
926
+ try {
927
+ if (changedPaths.length > 0) {
928
+ info(`Detected changes: ${changedPaths.join(', ')}`);
929
+ }
930
+ const configOnly = changedPaths.length > 0 &&
931
+ changedPaths.every((filePath) => isConfigPath(filePath));
932
+ const assetOnly = changedPaths.length > 0 &&
933
+ changedPaths.every((filePath) => isAssetPath(filePath) || isConfigPath(filePath));
934
+ const staticAssetOnly = changedPaths.length > 0 &&
935
+ changedPaths.every((filePath) => isStaticAssetPath(filePath) || isConfigPath(filePath));
936
+ const sourceCodeChanged = changedPaths.some(isTsSourcePath);
937
+ const copiedAnyAsset = assetOnly
938
+ ? changedPaths.map(copySingleAssetFile).some(Boolean)
939
+ : false;
940
+ if (configOnly) {
941
+ info('Skipping rebuild (config-only change)');
942
+ }
943
+ else if (!assetOnly || !copiedAnyAsset) {
944
+ info('Preparing generated artifacts for dev');
945
+ await prepareGeneratedArtifacts({ changedPaths });
946
+ if (sourceCodeChanged) {
947
+ const typecheckOk = typecheckDev();
948
+ if (!typecheckOk) {
949
+ info('Type check failed, keeping current service instance');
950
+ return;
951
+ }
952
+ }
953
+ }
954
+ else {
955
+ info('Skipping TypeScript rebuild (asset/config-only change)');
956
+ }
957
+ if (staticAssetOnly && copiedAnyAsset && !configOnly) {
958
+ info('Skipping restart (static asset-only change)');
959
+ return;
960
+ }
961
+ info('Restarting service');
962
+ await stopServiceProcess(child);
963
+ child = startServiceProcess();
964
+ }
965
+ catch (err) {
966
+ const message = err instanceof Error ? err.message : String(err);
967
+ log(`Dev rebuild failed: ${message}`, 'red');
968
+ }
969
+ finally {
970
+ isRebuilding = false;
971
+ if (restartPending) {
972
+ restartPending = false;
973
+ void rebuildAndRestart();
974
+ }
975
+ }
976
+ };
977
+ const queueChange = (filePath) => {
978
+ const normalizedPath = normalizeChangedPath(filePath);
979
+ info(`Change detected: ${normalizedPath}`);
980
+ pendingChanges.add(normalizedPath);
981
+ if (debounceTimer) {
982
+ clearTimeout(debounceTimer);
983
+ }
984
+ debounceTimer = setTimeout(() => {
985
+ void rebuildAndRestart();
986
+ }, 200);
987
+ };
988
+ try {
989
+ info(`Watching paths: ${DEV_WATCH_PATHS.map(normalizePath).join(', ')}`);
990
+ watcher = chokidar.watch(DEV_WATCH_PATHS, {
991
+ ignored: (watchPath) => {
992
+ const normalized = normalizeChangedPath(String(watchPath));
993
+ return DEV_IGNORE_PATHS.some((ignorePath) => {
994
+ const normalizedIgnorePath = normalizeChangedPath(ignorePath);
995
+ return normalized === normalizedIgnorePath || normalized.startsWith(`${normalizedIgnorePath}/`);
996
+ });
997
+ },
998
+ ignoreInitial: true,
999
+ persistent: true,
1000
+ });
1001
+ watcher.on('add', queueChange);
1002
+ watcher.on('change', queueChange);
1003
+ watcher.on('unlink', queueChange);
1004
+ await new Promise((resolve) => {
1005
+ watcher.once('ready', () => {
1006
+ info('Watching for changes');
1007
+ resolve();
1008
+ });
1009
+ });
1010
+ info('Preparing generated artifacts for dev');
1011
+ await prepareGeneratedArtifacts();
1012
+ if (typecheckDev()) {
1013
+ child = startServiceProcess();
1014
+ }
1015
+ else {
1016
+ info('Initial type check failed, waiting for changes');
1017
+ }
1018
+ process.on('SIGINT', async () => {
1019
+ if (debounceTimer) {
1020
+ clearTimeout(debounceTimer);
1021
+ }
1022
+ if (watcher) {
1023
+ await watcher.close();
1024
+ }
1025
+ await stopServiceProcess(child);
1026
+ process.exit(0);
1027
+ });
1028
+ await new Promise(() => { });
1029
+ }
1030
+ finally {
1031
+ if (debounceTimer) {
1032
+ clearTimeout(debounceTimer);
1033
+ }
1034
+ if (watcher) {
1035
+ await watcher.close();
1036
+ }
1037
+ await stopServiceProcess(child);
1038
+ }
517
1039
  }
518
1040
  // Run tests
519
1041
  function test() {