@chidchanun/bcp 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,10 @@ import {
19
19
  scanApiRoutes,
20
20
  scanRoutes,
21
21
  } from "../../router/src/index.js";
22
+ import {
23
+ collectProjectDiagnostics,
24
+ type ProjectDiagnostics,
25
+ } from "./developer-tools-v2.js";
22
26
 
23
27
  export type DoctorStatus =
24
28
  | "pass"
@@ -56,6 +60,7 @@ export interface InspectReport {
56
60
  platform: NodeJS.Platform;
57
61
  arch: string;
58
62
  };
63
+ project: ProjectDiagnostics;
59
64
  environment: {
60
65
  mode: "development";
61
66
  files: string[];
@@ -264,6 +269,90 @@ export async function collectDoctorReport(
264
269
  }
265
270
  }
266
271
 
272
+ const diagnostics =
273
+ collectProjectDiagnostics(
274
+ rootDirectory
275
+ );
276
+
277
+ addCheck(
278
+ checks,
279
+ "project.lockfile",
280
+ diagnostics.lockfile
281
+ ? "pass"
282
+ : "warn",
283
+ "Dependency lockfile",
284
+ diagnostics.lockfile
285
+ ? `${diagnostics.lockfile} detected (${diagnostics.packageManager ?? "unknown package manager"}).`
286
+ : "No supported dependency lockfile was found. Reproducible installs are recommended for CI and Docker."
287
+ );
288
+
289
+ addCheck(
290
+ checks,
291
+ "dependencies.framework-declarations",
292
+ diagnostics.frameworkDeclarations.length <= 1
293
+ ? "pass"
294
+ : "fail",
295
+ "Framework dependency declarations",
296
+ diagnostics.frameworkDeclarations.length === 0
297
+ ? "No BCP dependency declaration detected yet."
298
+ : diagnostics.frameworkDeclarations.length === 1
299
+ ? `${diagnostics.frameworkDeclarations[0].name} is declared once in ${diagnostics.frameworkDeclarations[0].section}.`
300
+ : `Multiple BCP declarations detected: ${diagnostics.frameworkDeclarations.map((item) => `${item.name} (${item.section})`).join(", ")}. Keep only the application dependency key bcp.`
301
+ );
302
+
303
+ addCheck(
304
+ checks,
305
+ "runtime.production-hardening",
306
+ diagnostics.hardening.valid
307
+ ? "pass"
308
+ : "fail",
309
+ "Production hardening configuration",
310
+ diagnostics.hardening.message
311
+ );
312
+
313
+ addCheck(
314
+ checks,
315
+ "production.build",
316
+ diagnostics.productionBuild
317
+ ? "pass"
318
+ : "warn",
319
+ "Standalone production artifact",
320
+ diagnostics.productionBuild
321
+ ? ".bcp-framework/build/server/server.mjs is present."
322
+ : "No standalone production artifact is present. Run `bcp build` before production deployment."
323
+ );
324
+
325
+ if (
326
+ diagnostics.dockerfile
327
+ ) {
328
+ addCheck(
329
+ checks,
330
+ "production.docker-lockfile",
331
+ diagnostics.lockfile ===
332
+ "package-lock.json"
333
+ ? "pass"
334
+ : "warn",
335
+ "Docker reproducible install",
336
+ diagnostics.lockfile ===
337
+ "package-lock.json"
338
+ ? `${diagnostics.dockerfile} and package-lock.json are present for npm ci.`
339
+ : `${diagnostics.dockerfile} is present but package-lock.json was not detected. Ensure the Dockerfile uses the matching package-manager lockfile.`
340
+ );
341
+ }
342
+
343
+ if (
344
+ diagnostics.storage.provider !==
345
+ "none"
346
+ ) {
347
+ addCheck(
348
+ checks,
349
+ "storage.provider",
350
+ "pass",
351
+ "Storage provider",
352
+ `${diagnostics.storage.provider} detected from ${diagnostics.storage.file}.`
353
+ );
354
+ }
355
+
267
356
  const appDirectory =
268
357
  path.join(
269
358
  rootDirectory,
@@ -687,6 +776,10 @@ export async function collectInspectReport(
687
776
  arch:
688
777
  process.arch,
689
778
  },
779
+ project:
780
+ collectProjectDiagnostics(
781
+ rootDirectory
782
+ ),
690
783
  environment: {
691
784
  mode:
692
785
  "development",
@@ -832,6 +925,28 @@ function printInspectReport(
832
925
  `Runtime: Node.js ${report.runtime.node} | ${report.runtime.platform} ${report.runtime.arch}`
833
926
  );
834
927
  console.log("");
928
+ console.log(
929
+ "Project diagnostics:"
930
+ );
931
+ console.log(
932
+ ` package manager: ${report.project.packageManager ?? "unknown"}`
933
+ );
934
+ console.log(
935
+ ` lockfile: ${report.project.lockfile ?? "(none)"}`
936
+ );
937
+ console.log(
938
+ ` production build: ${report.project.productionBuild ? "present" : "missing"}`
939
+ );
940
+ console.log(
941
+ ` Dockerfile: ${report.project.dockerfile ?? "(none)"}`
942
+ );
943
+ console.log(
944
+ ` storage: ${report.project.storage.provider}${report.project.storage.file ? ` (${report.project.storage.file})` : ""}`
945
+ );
946
+ console.log(
947
+ ` hardening: ${report.project.hardening.valid ? "valid" : "invalid"} - ${report.project.hardening.message}`
948
+ );
949
+ console.log("");
835
950
  console.log(
836
951
  "Environment:"
837
952
  );
@@ -0,0 +1,475 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ runDatabaseMigrationCommand,
6
+ } from "./database-migrations.js";
7
+
8
+ export type GenerateKind =
9
+ | "page"
10
+ | "api"
11
+ | "middleware"
12
+ | "migration";
13
+
14
+ export interface GenerateOptions {
15
+ rootDirectory: string;
16
+ kind: GenerateKind;
17
+ name?: string;
18
+ force?: boolean;
19
+ }
20
+
21
+ export interface GeneratedArtifact {
22
+ kind: GenerateKind;
23
+ file: string;
24
+ route?: string;
25
+ }
26
+
27
+ export async function runGenerate(
28
+ options: GenerateOptions
29
+ ): Promise<GeneratedArtifact> {
30
+ const rootDirectory =
31
+ path.resolve(
32
+ options.rootDirectory
33
+ );
34
+
35
+ switch (options.kind) {
36
+ case "page":
37
+ return generatePage(
38
+ rootDirectory,
39
+ requireGenerateName(
40
+ options.kind,
41
+ options.name
42
+ ),
43
+ options.force === true
44
+ );
45
+
46
+ case "api":
47
+ return generateApiRoute(
48
+ rootDirectory,
49
+ requireGenerateName(
50
+ options.kind,
51
+ options.name
52
+ ),
53
+ options.force === true
54
+ );
55
+
56
+ case "middleware":
57
+ return generateMiddleware(
58
+ rootDirectory,
59
+ options.force === true
60
+ );
61
+
62
+ case "migration": {
63
+ const name =
64
+ requireGenerateName(
65
+ options.kind,
66
+ options.name
67
+ );
68
+ const migrationDirectory =
69
+ path.join(
70
+ rootDirectory,
71
+ "migrations"
72
+ );
73
+ const before =
74
+ listFiles(
75
+ migrationDirectory
76
+ );
77
+
78
+ await runDatabaseMigrationCommand({
79
+ rootDirectory,
80
+ action:
81
+ "create",
82
+ name,
83
+ });
84
+
85
+ const after =
86
+ listFiles(
87
+ migrationDirectory
88
+ );
89
+ let created:
90
+ string | undefined;
91
+
92
+ for (const file of after) {
93
+ if (
94
+ !before.has(
95
+ file
96
+ )
97
+ ) {
98
+ created =
99
+ file;
100
+ break;
101
+ }
102
+ }
103
+
104
+ if (!created) {
105
+ throw new Error(
106
+ "BCP Generate: migration command completed without creating a migration file."
107
+ );
108
+ }
109
+
110
+ return {
111
+ kind:
112
+ "migration",
113
+ file:
114
+ path.join(
115
+ migrationDirectory,
116
+ created
117
+ ),
118
+ };
119
+ }
120
+ }
121
+ }
122
+
123
+ export function normalizeGenerateRoute(
124
+ value: string
125
+ ): string {
126
+ const normalized =
127
+ value
128
+ .trim()
129
+ .replace(
130
+ /\\/g,
131
+ "/"
132
+ )
133
+ .replace(
134
+ /^\/+|\/+$/g,
135
+ ""
136
+ );
137
+
138
+ if (!normalized) {
139
+ throw new Error(
140
+ "BCP Generate: route must not be empty."
141
+ );
142
+ }
143
+
144
+ const segments =
145
+ normalized.split(
146
+ "/"
147
+ );
148
+
149
+ for (const segment of segments) {
150
+ if (
151
+ !segment ||
152
+ segment === "." ||
153
+ segment === ".." ||
154
+ segment.includes(
155
+ "\0"
156
+ )
157
+ ) {
158
+ throw new Error(
159
+ `BCP Generate: invalid route segment "${segment}".`
160
+ );
161
+ }
162
+
163
+ if (
164
+ !/^(?:\([A-Za-z0-9_-]+\)|\[\[\.\.\.[A-Za-z0-9_-]+\]\]|\[\.\.\.[A-Za-z0-9_-]+\]|\[[A-Za-z0-9_-]+\]|[A-Za-z0-9_-]+)$/.test(
165
+ segment
166
+ )
167
+ ) {
168
+ throw new Error(
169
+ `BCP Generate: invalid route segment "${segment}".`
170
+ );
171
+ }
172
+ }
173
+
174
+ return segments.join(
175
+ "/"
176
+ );
177
+ }
178
+
179
+ function generatePage(
180
+ rootDirectory: string,
181
+ routeValue: string,
182
+ force: boolean
183
+ ): GeneratedArtifact {
184
+ const route =
185
+ normalizeGenerateRoute(
186
+ routeValue
187
+ );
188
+ const file =
189
+ path.join(
190
+ rootDirectory,
191
+ "app",
192
+ ...route.split("/"),
193
+ "page.tsx"
194
+ );
195
+ const componentName =
196
+ `${routeToIdentifier(route)}Page`;
197
+ const title =
198
+ routeToTitle(
199
+ route
200
+ );
201
+
202
+ writeGeneratedFile(
203
+ file,
204
+ `export default function ${componentName}() {\n return (\n <main>\n <h1>${escapeJsxText(title)}</h1>\n </main>\n );\n}\n`,
205
+ force
206
+ );
207
+
208
+ printGenerated(
209
+ rootDirectory,
210
+ "page",
211
+ file,
212
+ `/${route}`
213
+ );
214
+
215
+ return {
216
+ kind:
217
+ "page",
218
+ file,
219
+ route:
220
+ `/${route}`,
221
+ };
222
+ }
223
+
224
+ function generateApiRoute(
225
+ rootDirectory: string,
226
+ routeValue: string,
227
+ force: boolean
228
+ ): GeneratedArtifact {
229
+ const normalized =
230
+ normalizeGenerateRoute(
231
+ routeValue
232
+ );
233
+ const route =
234
+ normalized.startsWith(
235
+ "api/"
236
+ )
237
+ ? normalized.slice(
238
+ "api/".length
239
+ )
240
+ : normalized;
241
+
242
+ if (!route) {
243
+ throw new Error(
244
+ "BCP Generate: API route must include a path after api/."
245
+ );
246
+ }
247
+
248
+ const file =
249
+ path.join(
250
+ rootDirectory,
251
+ "app",
252
+ "api",
253
+ ...route.split("/"),
254
+ "route.ts"
255
+ );
256
+
257
+ writeGeneratedFile(
258
+ file,
259
+ `export async function GET() {\n return Response.json({\n ok: true,\n });\n}\n`,
260
+ force
261
+ );
262
+
263
+ printGenerated(
264
+ rootDirectory,
265
+ "api",
266
+ file,
267
+ `/api/${route}`
268
+ );
269
+
270
+ return {
271
+ kind:
272
+ "api",
273
+ file,
274
+ route:
275
+ `/api/${route}`,
276
+ };
277
+ }
278
+
279
+ function generateMiddleware(
280
+ rootDirectory: string,
281
+ force: boolean
282
+ ): GeneratedArtifact {
283
+ const file =
284
+ path.join(
285
+ rootDirectory,
286
+ "middleware.ts"
287
+ );
288
+
289
+ writeGeneratedFile(
290
+ file,
291
+ `import type {\n MiddlewarePipelineHandler,\n} from "bcp/middleware";\n\nexport const middleware:\n MiddlewarePipelineHandler =\n async (\n _request,\n next\n ) => {\n return next();\n };\n`,
292
+ force
293
+ );
294
+
295
+ printGenerated(
296
+ rootDirectory,
297
+ "middleware",
298
+ file
299
+ );
300
+
301
+ return {
302
+ kind:
303
+ "middleware",
304
+ file,
305
+ };
306
+ }
307
+
308
+ function writeGeneratedFile(
309
+ file: string,
310
+ content: string,
311
+ force: boolean
312
+ ): void {
313
+ if (
314
+ fs.existsSync(
315
+ file
316
+ ) &&
317
+ !force
318
+ ) {
319
+ throw new Error(
320
+ `BCP Generate: ${file} already exists. Pass --force to replace it.`
321
+ );
322
+ }
323
+
324
+ fs.mkdirSync(
325
+ path.dirname(
326
+ file
327
+ ),
328
+ {
329
+ recursive: true,
330
+ }
331
+ );
332
+ fs.writeFileSync(
333
+ file,
334
+ content,
335
+ "utf8"
336
+ );
337
+ }
338
+
339
+ function requireGenerateName(
340
+ kind: GenerateKind,
341
+ value: string | undefined
342
+ ): string {
343
+ const normalized =
344
+ value?.trim();
345
+
346
+ if (!normalized) {
347
+ throw new Error(
348
+ `BCP Generate: ${kind} requires a name or route.`
349
+ );
350
+ }
351
+
352
+ return normalized;
353
+ }
354
+
355
+ function routeToIdentifier(
356
+ route: string
357
+ ): string {
358
+ const words =
359
+ route
360
+ .replace(
361
+ /[\[\]().]+/g,
362
+ " "
363
+ )
364
+ .split(
365
+ /[\/_-]+|\s+/g
366
+ )
367
+ .filter(Boolean)
368
+ .map(
369
+ capitalize
370
+ );
371
+
372
+ const value =
373
+ words.join("") ||
374
+ "Generated";
375
+
376
+ return /^\d/.test(
377
+ value
378
+ )
379
+ ? `Route${value}`
380
+ : value;
381
+ }
382
+
383
+ function routeToTitle(
384
+ route: string
385
+ ): string {
386
+ const segment =
387
+ route
388
+ .split("/")
389
+ .filter(
390
+ (value) =>
391
+ !value.startsWith("(")
392
+ )
393
+ .at(-1) ??
394
+ route;
395
+ const cleaned =
396
+ segment
397
+ .replace(
398
+ /^\[+|\]+$/g,
399
+ ""
400
+ )
401
+ .replace(
402
+ /^\.\.\./,
403
+ ""
404
+ )
405
+ .replace(
406
+ /[-_]+/g,
407
+ " "
408
+ )
409
+ .trim();
410
+
411
+ return cleaned
412
+ .split(/\s+/)
413
+ .filter(Boolean)
414
+ .map(capitalize)
415
+ .join(" ") ||
416
+ "Generated Page";
417
+ }
418
+
419
+ function capitalize(
420
+ value: string
421
+ ): string {
422
+ return value.length === 0
423
+ ? value
424
+ : value[0].toUpperCase() +
425
+ value.slice(1);
426
+ }
427
+
428
+ function escapeJsxText(
429
+ value: string
430
+ ): string {
431
+ return value
432
+ .replace(/&/g, "&amp;")
433
+ .replace(/</g, "&lt;")
434
+ .replace(/>/g, "&gt;")
435
+ .replace(/{/g, "&#123;")
436
+ .replace(/}/g, "&#125;");
437
+ }
438
+
439
+ function listFiles(
440
+ directory: string
441
+ ): Set<string> {
442
+ if (
443
+ !fs.existsSync(
444
+ directory
445
+ )
446
+ ) {
447
+ return new Set();
448
+ }
449
+
450
+ return new Set(
451
+ fs.readdirSync(
452
+ directory
453
+ )
454
+ );
455
+ }
456
+
457
+ function printGenerated(
458
+ rootDirectory: string,
459
+ kind: GenerateKind,
460
+ file: string,
461
+ route?: string
462
+ ): void {
463
+ const relative =
464
+ path.relative(
465
+ rootDirectory,
466
+ file
467
+ ).replace(
468
+ /\\/g,
469
+ "/"
470
+ );
471
+
472
+ console.log(
473
+ `[BCP Generate] ${kind}: ${relative}${route ? ` -> ${route}` : ""}`
474
+ );
475
+ }
@@ -71,6 +71,11 @@ switch (cliOptions.command) {
71
71
  break;
72
72
  }
73
73
 
74
+ case "generate": {
75
+ await runGenerateCommand();
76
+ break;
77
+ }
78
+
74
79
  case "doctor": {
75
80
  await runDeveloperCommand(
76
81
  "doctor"
@@ -480,6 +485,39 @@ async function runDatabaseCommand() {
480
485
  }
481
486
  }
482
487
 
488
+ async function runGenerateCommand(): Promise<void> {
489
+ const kind =
490
+ cliOptions.generateKind;
491
+
492
+ if (
493
+ kind === undefined ||
494
+ kind === "help"
495
+ ) {
496
+ printGenerateHelp();
497
+ return;
498
+ }
499
+
500
+ const rootDirectory =
501
+ resolveProjectRoot(
502
+ cliOptions.rootDirectory
503
+ );
504
+ const {
505
+ runGenerate,
506
+ } =
507
+ await import(
508
+ "./generate.js"
509
+ );
510
+
511
+ await runGenerate({
512
+ rootDirectory,
513
+ kind,
514
+ name:
515
+ cliOptions.generateName,
516
+ force:
517
+ cliOptions.force,
518
+ });
519
+ }
520
+
483
521
  async function runDeveloperCommand(
484
522
  command:
485
523
  "doctor" |
@@ -695,6 +733,32 @@ Examples:
695
733
  `);
696
734
  }
697
735
 
736
+ function printGenerateHelp() {
737
+ console.log(`
738
+ BCP Project Generators
739
+
740
+ Usage:
741
+ bcp generate <kind> [name-or-route] [--force]
742
+
743
+ Generators:
744
+ page <route> Create app/<route>/page.tsx
745
+ api <route> Create app/api/<route>/route.ts
746
+ middleware Create middleware.ts in the project root
747
+ migration <name> Create a timestamped migration in migrations/
748
+
749
+ Options:
750
+ --force Replace an existing page/API/middleware scaffold
751
+ --root <path> Project root directory
752
+
753
+ Examples:
754
+ bcp generate page dashboard/users
755
+ bcp generate page users/[id]
756
+ bcp generate api users
757
+ bcp generate middleware
758
+ bcp generate migration create_users
759
+ `);
760
+ }
761
+
698
762
  function printHelp() {
699
763
  console.log(`
700
764
  BCP Framework v${FRAMEWORK_VERSION}
@@ -709,6 +773,7 @@ Commands:
709
773
  routes Print discovered page and API routes
710
774
  update [target] Update BCP Framework (default target: latest)
711
775
  db <command> Manage database migrations
776
+ generate <kind> Generate pages, API routes, middleware or migrations
712
777
  doctor Run project/runtime health diagnostics
713
778
  inspect Print resolved env, config, dependencies and routes
714
779
  help Show this help message
@@ -722,6 +787,7 @@ Options:
722
787
  --check Check for a BCP update without changing files
723
788
  --dry-run Preview a BCP update without changing files
724
789
  --json JSON output for doctor/inspect
790
+ --force Replace existing generated scaffold files
725
791
  -h, --help Show help
726
792
  -v, --version Show version
727
793
 
@@ -734,6 +800,9 @@ Examples:
734
800
  bcp build
735
801
  bcp start
736
802
  bcp routes
803
+ bcp generate page dashboard/users
804
+ bcp generate api users
805
+ bcp generate middleware
737
806
  bcp doctor
738
807
  bcp doctor --json
739
808
  bcp inspect