Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
1/**
2 * Scaffold Generator — produces the runnable service shell around generated modules.
3 *
4 * Generates:
5 * - Per-service index.ts (barrel re-exports)
6 * - Per-service server.ts (HTTP server with health + metrics)
7 * - Per-service test file
8 * - Root index.ts (service registry)
9 * - Project package.json and tsconfig.json
10 */
11
12import type { ImplementationUnit } from './models/iu.js';
13import type { ResolvedTarget } from './models/architecture.js';
14import { sha256 } from './semhash.js';
15
16export interface ServiceDescriptor {
17 /** Service name, e.g. "api-gateway" */
18 name: string;
19 /** Directory under src/generated/, e.g. "api-gateway" */
20 dir: string;
21 /** Module file names (without path prefix), e.g. ["authentication.ts", "rate-limiting.ts"] */
22 modules: string[];
23 /** The IUs belonging to this service */
24 ius: ImplementationUnit[];
25 /** Default port for this service */
26 port: number;
27}
28
29export interface ScaffoldResult {
30 files: Map<string, string>; // path → content
31}
32
33/**
34 * Derive service descriptors from the IU plan.
35 */
36export function deriveServices(ius: ImplementationUnit[]): ServiceDescriptor[] {
37 const serviceMap = new Map<string, ServiceDescriptor>();
38 let nextPort = 3000;
39
40 for (const iu of ius) {
41 for (const outputFile of iu.output_files) {
42 // outputFile is like "src/generated/api-gateway/authentication.ts"
43 const parts = outputFile.replace('src/generated/', '').split('/');
44 if (parts.length < 2) continue;
45
46 const dir = parts[0];
47 const moduleFile = parts.slice(1).join('/');
48
49 let svc = serviceMap.get(dir);
50 if (!svc) {
51 svc = {
52 name: dirToName(dir),
53 dir,
54 modules: [],
55 ius: [],
56 port: nextPort++,
57 };
58 serviceMap.set(dir, svc);
59 }
60 svc.modules.push(moduleFile);
61 svc.ius.push(iu);
62 }
63 }
64
65 return [...serviceMap.values()].sort((a, b) => a.dir.localeCompare(b.dir));
66}
67
68/**
69 * Generate all scaffold files.
70 */
71export function generateScaffold(
72 services: ServiceDescriptor[],
73 projectName: string = 'phoenix-project',
74 target?: ResolvedTarget | null,
75): ScaffoldResult {
76 const files = new Map<string, string>();
77
78 // Architecture shared files (db.ts, app.ts, etc.)
79 if (target) {
80 const arch = target.architecture;
81 const rt = target.runtime;
82 for (const [path, content] of Object.entries(rt.sharedFiles)) {
83 files.set(path, content);
84 }
85
86 // Generate architecture-specific server entry point that mounts all generated modules
87 const routeImports: string[] = [];
88 const routeMounts: string[] = [];
89 for (const svc of services) {
90 for (let i = 0; i < svc.modules.length; i++) {
91 const mod = svc.modules[i];
92 const iu = svc.ius[i];
93 const modName = mod.replace('.ts', '').replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '_');
94 const importPath = `./generated/${svc.dir}/${mod.replace('.ts', '.js')}`;
95 routeImports.push(`import ${modName} from '${importPath}';`);
96 // Derive mount path from IU name: "Todos" → "/todos", "Categories" → "/categories"
97 const iuName = iu?.name ?? mod.replace('.ts', '');
98 const lowerName = iuName.toLowerCase();
99 // Web interface / UI modules mount at root
100 const isWebUI = /\b(web|ui|frontend|interface|page|dashboard)\b/.test(lowerName);
101 const prefix = isWebUI ? '' : '/' + lowerName.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
102 routeMounts.push(`mount('${prefix}', ${modName});`);
103 }
104 }
105
106 const serverContent = [
107 `import { serve } from '@hono/node-server';`,
108 `import { app, mount } from './app.js';`,
109 `import { runMigrations } from './db.js';`,
110 ``,
111 `// Generated route modules`,
112 ...routeImports,
113 ``,
114 `// Mount routes`,
115 ...routeMounts,
116 ``,
117 `const port = parseInt(process.env.PORT ?? '3000', 10);`,
118 `runMigrations();`,
119 `console.log(\`Server running at http://localhost:\${port}\`);`,
120 `serve({ fetch: app.fetch, port });`,
121 ``,
122 ].join('\n');
123
124 files.set('src/server.ts', serverContent);
125 }
126
127 for (const svc of services) {
128 // Service barrel index
129 files.set(
130 `src/generated/${svc.dir}/index.ts`,
131 generateServiceIndex(svc),
132 );
133
134 if (!target) {
135 // Only generate per-service servers when no architecture is set
136 files.set(
137 `src/generated/${svc.dir}/server.ts`,
138 generateServiceServer(svc),
139 );
140 }
141
142 // Service tests
143 files.set(
144 `src/generated/${svc.dir}/__tests__/${svc.dir}.test.ts`,
145 target ? generateArchTests(svc) : generateServiceTests(svc),
146 );
147 }
148
149 // Root index
150 files.set('src/generated/index.ts', generateRootIndex(services));
151
152 // Project config
153 files.set('package.json', generatePackageJson(services, projectName, target));
154 files.set('tsconfig.json', generateTsConfig());
155 files.set('vitest.config.ts', generateVitestConfig());
156
157 return { files };
158}
159
160/**
161 * Generate tests for architecture-based services.
162 */
163function generateArchTests(svc: ServiceDescriptor): string {
164 const lines: string[] = [];
165 lines.push(`/**`);
166 lines.push(` * ${svc.name} — Generated Tests`);
167 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
168 lines.push(` */`);
169 lines.push(``);
170 lines.push(`import { describe, it, expect } from 'vitest';`);
171
172 for (const mod of svc.modules) {
173 const importName = mod.replace('.ts', '').replace(/-/g, '_');
174 lines.push(`import ${importName} from '../${mod.replace('.ts', '.js')}';`);
175 }
176
177 lines.push(``);
178 lines.push(`describe('${svc.name} modules', () => {`);
179
180 for (const mod of svc.modules) {
181 const importName = mod.replace('.ts', '').replace(/-/g, '_');
182 const displayName = mod.replace('.ts', '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
183 lines.push(` describe('${displayName}', () => {`);
184 lines.push(` it('exports a Hono router as default', () => {`);
185 lines.push(` expect(${importName}).toBeDefined();`);
186 lines.push(` expect(typeof ${importName}.fetch).toBe('function');`);
187 lines.push(` });`);
188 lines.push(` });`);
189 }
190
191 lines.push(`});`);
192 lines.push(``);
193 return lines.join('\n');
194}
195
196// ─── Service Index ───────────────────────────────────────────────────────────
197
198function generateServiceIndex(svc: ServiceDescriptor): string {
199 const lines: string[] = [];
200
201 lines.push(`/**`);
202 lines.push(` * ${svc.name}`);
203 lines.push(` *`);
204 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
205 lines.push(` * Barrel export for all ${svc.name} modules.`);
206 lines.push(` */`);
207 lines.push('');
208
209 for (const mod of svc.modules) {
210 const modName = mod.replace(/\.ts$/, '');
211 const importName = toCamelCase(modName);
212 lines.push(`export * as ${importName} from './${modName}.js';`);
213 }
214 lines.push('');
215
216 return lines.join('\n');
217}
218
219// ─── Web Client Detection ────────────────────────────────────────────────────
220
221const WEB_HINTS = ['ui', 'client', 'frontend', 'web', 'page', 'view', 'html', 'css', 'style', 'lobby', 'dashboard', 'display'];
222
223/**
224 * Detect whether a service is a web client (should serve HTML).
225 * Heuristic: service dir or module names contain web-related terms.
226 */
227function isWebClient(svc: ServiceDescriptor): boolean {
228 const dirWords = svc.dir.toLowerCase().split('-');
229 if (dirWords.some(w => WEB_HINTS.includes(w))) return true;
230
231 const moduleWords = svc.modules.flatMap(m => m.replace(/\.ts$/, '').toLowerCase().split('-'));
232 const webModuleCount = moduleWords.filter(w => WEB_HINTS.includes(w)).length;
233 return webModuleCount >= 2;
234}
235
236// ─── Service Server ──────────────────────────────────────────────────────────
237
238function generateServiceServer(svc: ServiceDescriptor): string {
239 if (isWebClient(svc)) {
240 return generateWebClientServer(svc);
241 }
242 return generateApiServer(svc);
243}
244
245function generateApiServer(svc: ServiceDescriptor): string {
246 const lines: string[] = [];
247 const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`;
248
249 lines.push(`/**`);
250 lines.push(` * ${svc.name} — HTTP Server`);
251 lines.push(` *`);
252 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
253 lines.push(` * Provides health check, metrics, and module endpoints.`);
254 lines.push(` */`);
255 lines.push('');
256 lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`);
257 lines.push('');
258
259 // Import modules
260 for (const mod of svc.modules) {
261 const modName = mod.replace(/\.ts$/, '');
262 const importName = toCamelCase(modName);
263 lines.push(`import * as ${importName} from './${modName}.js';`);
264 }
265 lines.push('');
266
267 // Metrics tracking
268 lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`);
269 lines.push('');
270 lines.push(`const _svcMetrics = {`);
271 lines.push(` requests_total: 0,`);
272 lines.push(` requests_by_path: {} as Record<string, number>,`);
273 lines.push(` errors_total: 0,`);
274 lines.push(` uptime_start: Date.now(),`);
275 lines.push(`};`);
276 lines.push('');
277
278 // Module registry
279 lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`);
280 lines.push('');
281 lines.push(`const _svcModules = {`);
282 for (const mod of svc.modules) {
283 const modName = mod.replace(/\.ts$/, '');
284 const importName = toCamelCase(modName);
285 lines.push(` '${modName}': ${importName},`);
286 }
287 lines.push(`};`);
288 lines.push('');
289
290 // Router
291 lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`);
292 lines.push('');
293 lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`);
294 lines.push('');
295 lines.push(`const routes: Record<string, Handler> = {`);
296 lines.push(` '/health': (_req, res) => {`);
297 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
298 lines.push(` res.end(JSON.stringify({`);
299 lines.push(` status: 'ok',`);
300 lines.push(` service: '${svc.name}',`);
301 lines.push(` uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`);
302 lines.push(` modules: Object.keys(_svcModules),`);
303 lines.push(` }));`);
304 lines.push(` },`);
305 lines.push('');
306 lines.push(` '/metrics': (_req, res) => {`);
307 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
308 lines.push(` res.end(JSON.stringify({`);
309 lines.push(` ..._svcMetrics,`);
310 lines.push(` uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`);
311 lines.push(` }, null, 2));`);
312 lines.push(` },`);
313 lines.push('');
314 lines.push(` '/modules': (_req, res) => {`);
315 lines.push(` const info = Object.entries(_svcModules).map(([name, mod]) => {`);
316 lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`);
317 lines.push(` return {`);
318 lines.push(` name,`);
319 lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`);
320 lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`);
321 lines.push(` };`);
322 lines.push(` });`);
323 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
324 lines.push(` res.end(JSON.stringify(info, null, 2));`);
325 lines.push(` },`);
326 lines.push(`};`);
327 lines.push('');
328
329 // Server factory
330 lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`);
331 lines.push('');
332 lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`);
333 lines.push(` const url = req.url ?? '/';`);
334 lines.push(` const path = url.split('?')[0];`);
335 lines.push('');
336 lines.push(` _svcMetrics.requests_total++;`);
337 lines.push(` _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1;`);
338 lines.push('');
339 lines.push(` const handler = routes[path];`);
340 lines.push(` if (handler) {`);
341 lines.push(` try {`);
342 lines.push(` handler(req, res);`);
343 lines.push(` } catch (err) {`);
344 lines.push(` _svcMetrics.errors_total++;`);
345 lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`);
346 lines.push(` res.end(JSON.stringify({ error: String(err) }));`);
347 lines.push(` }`);
348 lines.push(` } else {`);
349 lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`);
350 lines.push(` res.end(JSON.stringify({`);
351 lines.push(` error: 'Not Found',`);
352 lines.push(` path,`);
353 lines.push(` available: Object.keys(routes),`);
354 lines.push(` }));`);
355 lines.push(` }`);
356 lines.push(`}`);
357 lines.push('');
358
359 lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`);
360 lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`);
361 lines.push(` const server = createServer(handleRequest);`);
362 lines.push(` let actualPort = requestedPort;`);
363 lines.push('');
364 lines.push(` const ready = new Promise<void>(resolve => {`);
365 lines.push(` server.listen(requestedPort, () => {`);
366 lines.push(` const addr = server.address();`);
367 lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`);
368 lines.push(` result.port = actualPort;`);
369 lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`);
370 lines.push(` console.log(\` /health — health check\`);`);
371 lines.push(` console.log(\` /metrics — request metrics\`);`);
372 lines.push(` console.log(\` /modules — registered modules\`);`);
373 lines.push(` resolve();`);
374 lines.push(` });`);
375 lines.push(` });`);
376 lines.push('');
377 lines.push(` const result = { server, port: actualPort, ready };`);
378 lines.push(` return result;`);
379 lines.push(`}`);
380 lines.push('');
381
382 // Main
383 lines.push(`// Start when run directly`);
384 lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`);
385 lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`);
386 lines.push(`if (isMain) {`);
387 lines.push(` startServer();`);
388 lines.push(`}`);
389 lines.push('');
390
391 return lines.join('\n');
392}
393
394// ─── Web Client Server ──────────────────────────────────────────────────────
395
396function generateWebClientServer(svc: ServiceDescriptor): string {
397 const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`;
398
399 // Categorize modules by role
400 const styleModules = svc.modules.filter(m => /styl/i.test(m) || /css/i.test(m) || /theme/i.test(m));
401 const uiModules = svc.modules.filter(m => !styleModules.includes(m));
402
403 const imports = svc.modules.map(mod => {
404 const modName = mod.replace(/\.ts$/, '');
405 const importName = toCamelCase(modName);
406 return { modName, importName };
407 });
408
409 const lines: string[] = [];
410
411 lines.push(`/**`);
412 lines.push(` * ${svc.name} — Web Server`);
413 lines.push(` *`);
414 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
415 lines.push(` * Serves the web client HTML, plus health/metrics/modules endpoints.`);
416 lines.push(` */`);
417 lines.push('');
418 lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`);
419 lines.push('');
420
421 for (const { modName, importName } of imports) {
422 lines.push(`import * as ${importName} from './${modName}.js';`);
423 }
424 lines.push('');
425
426 // Metrics
427 lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`);
428 lines.push('');
429 lines.push(`const _svcMetrics = {`);
430 lines.push(` requests_total: 0,`);
431 lines.push(` requests_by_path: {} as Record<string, number>,`);
432 lines.push(` errors_total: 0,`);
433 lines.push(` uptime_start: Date.now(),`);
434 lines.push(`};`);
435 lines.push('');
436
437 // Module registry
438 lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`);
439 lines.push('');
440 lines.push(`const _svcModules = {`);
441 for (const { modName, importName } of imports) {
442 lines.push(` '${modName}': ${importName},`);
443 }
444 lines.push(`};`);
445 lines.push('');
446
447 // HTML renderer
448 lines.push(`// ─── HTML Renderer ───────────────────────────────────────────────────────────`);
449 lines.push('');
450 lines.push(`function renderPage(): string {`);
451 lines.push(` // Collect CSS from style modules`);
452 lines.push(` let css = '';`);
453
454 for (const mod of styleModules) {
455 const importName = toCamelCase(mod.replace(/\.ts$/, ''));
456 // Try common patterns: generateCSS(), getCSS(), css, styles
457 lines.push(` try {`);
458 lines.push(` const styleMod = ${importName} as Record<string, unknown>;`);
459 lines.push(` for (const key of Object.keys(styleMod)) {`);
460 lines.push(` const val = styleMod[key];`);
461 lines.push(` if (typeof val === 'function' && /css|style/i.test(key)) {`);
462 lines.push(` const result = (val as Function)();`);
463 lines.push(` if (typeof result === 'string') css += result;`);
464 lines.push(` else if (result && typeof result === 'object' && 'generateCSS' in result) {`);
465 lines.push(` css += (result as { generateCSS: () => string }).generateCSS();`);
466 lines.push(` }`);
467 lines.push(` }`);
468 lines.push(` }`);
469 lines.push(` } catch { /* style module may not have expected exports */ }`);
470 }
471
472 if (styleModules.length === 0) {
473 lines.push(` css = 'body { font-family: system-ui, sans-serif; margin: 2rem; }';`);
474 }
475 lines.push('');
476
477 lines.push(` // Collect HTML from UI modules`);
478 lines.push(` const sections: string[] = [];`);
479
480 for (const mod of uiModules) {
481 const importName = toCamelCase(mod.replace(/\.ts$/, ''));
482 const displayName = mod.replace(/\.ts$/, '').split('-').map((w: string) => w[0].toUpperCase() + w.slice(1)).join(' ');
483 lines.push(` try {`);
484 lines.push(` const uiMod = ${importName} as Record<string, unknown>;`);
485 lines.push(` for (const key of Object.keys(uiMod)) {`);
486 lines.push(` const val = uiMod[key];`);
487 lines.push(` // Look for factory functions that return objects with render/renderHTML`);
488 lines.push(` if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) {`);
489 lines.push(` try {`);
490 lines.push(` const instance = (val as Function)();`);
491 lines.push(` if (typeof instance === 'string' && instance.includes('<')) {`);
492 lines.push(` sections.push(instance);`);
493 lines.push(` } else if (instance && typeof instance === 'object') {`);
494 lines.push(` const obj = instance as Record<string, unknown>;`);
495 lines.push(` if (typeof obj.render === 'function') {`);
496 lines.push(` const html = (obj.render as Function)();`);
497 lines.push(` if (typeof html === 'string') sections.push(html);`);
498 lines.push(` } else if (typeof obj.renderHTML === 'function') {`);
499 lines.push(` const html = (obj.renderHTML as Function)();`);
500 lines.push(` if (typeof html === 'string') sections.push(html);`);
501 lines.push(` }`);
502 lines.push(` }`);
503 lines.push(` } catch { /* factory may require args */ }`);
504 lines.push(` }`);
505 lines.push(` }`);
506 lines.push(` } catch { /* module may not have renderable exports */ }`);
507 }
508 lines.push('');
509
510 lines.push(` return \`<!DOCTYPE html>`);
511 lines.push(`<html lang="en">`);
512 lines.push(`<head>`);
513 lines.push(` <meta charset="utf-8">`);
514 lines.push(` <meta name="viewport" content="width=device-width, initial-scale=1">`);
515 lines.push(` <title>${svc.name}</title>`);
516 lines.push(` <style>\${css}</style>`);
517 lines.push(`</head>`);
518 lines.push(`<body>`);
519 lines.push(` <div class="game-container">`);
520 lines.push(` \${sections.join('\\n')}`);
521 lines.push(` </div>`);
522 lines.push(`</body>`);
523 lines.push(`</html>\`;`);
524 lines.push(`}`);
525 lines.push('');
526
527 // Router
528 lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`);
529 lines.push('');
530 lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`);
531 lines.push('');
532 lines.push(`const routes: Record<string, Handler> = {`);
533
534 // HTML index
535 lines.push(` '/': (_req, res) => {`);
536 lines.push(` res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });`);
537 lines.push(` res.end(renderPage());`);
538 lines.push(` },`);
539 lines.push('');
540
541 // Health
542 lines.push(` '/health': (_req, res) => {`);
543 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
544 lines.push(` res.end(JSON.stringify({`);
545 lines.push(` status: 'ok',`);
546 lines.push(` service: '${svc.name}',`);
547 lines.push(` uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`);
548 lines.push(` modules: Object.keys(_svcModules),`);
549 lines.push(` }));`);
550 lines.push(` },`);
551 lines.push('');
552 lines.push(` '/metrics': (_req, res) => {`);
553 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
554 lines.push(` res.end(JSON.stringify({`);
555 lines.push(` ..._svcMetrics,`);
556 lines.push(` uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`);
557 lines.push(` }, null, 2));`);
558 lines.push(` },`);
559 lines.push('');
560 lines.push(` '/modules': (_req, res) => {`);
561 lines.push(` const info = Object.entries(_svcModules).map(([name, mod]) => {`);
562 lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`);
563 lines.push(` return {`);
564 lines.push(` name,`);
565 lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`);
566 lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`);
567 lines.push(` };`);
568 lines.push(` });`);
569 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`);
570 lines.push(` res.end(JSON.stringify(info, null, 2));`);
571 lines.push(` },`);
572 lines.push(`};`);
573 lines.push('');
574
575 // Server factory
576 lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`);
577 lines.push('');
578 lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`);
579 lines.push(` const url = req.url ?? '/';`);
580 lines.push(` const path = url.split('?')[0];`);
581 lines.push('');
582 lines.push(` _svcMetrics.requests_total++;`);
583 lines.push(` _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1;`);
584 lines.push('');
585 lines.push(` const handler = routes[path];`);
586 lines.push(` if (handler) {`);
587 lines.push(` try {`);
588 lines.push(` handler(req, res);`);
589 lines.push(` } catch (err) {`);
590 lines.push(` _svcMetrics.errors_total++;`);
591 lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`);
592 lines.push(` res.end(JSON.stringify({ error: String(err) }));`);
593 lines.push(` }`);
594 lines.push(` } else {`);
595 lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`);
596 lines.push(` res.end(JSON.stringify({`);
597 lines.push(` error: 'Not Found',`);
598 lines.push(` path,`);
599 lines.push(` available: Object.keys(routes),`);
600 lines.push(` }));`);
601 lines.push(` }`);
602 lines.push(`}`);
603 lines.push('');
604
605 lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`);
606 lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`);
607 lines.push(` const server = createServer(handleRequest);`);
608 lines.push(` let actualPort = requestedPort;`);
609 lines.push('');
610 lines.push(` const ready = new Promise<void>(resolve => {`);
611 lines.push(` server.listen(requestedPort, () => {`);
612 lines.push(` const addr = server.address();`);
613 lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`);
614 lines.push(` result.port = actualPort;`);
615 lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`);
616 lines.push(` console.log(\` / — web client\`);`);
617 lines.push(` console.log(\` /health — health check\`);`);
618 lines.push(` console.log(\` /metrics — request metrics\`);`);
619 lines.push(` console.log(\` /modules — registered modules\`);`);
620 lines.push(` resolve();`);
621 lines.push(` });`);
622 lines.push(` });`);
623 lines.push('');
624 lines.push(` const result = { server, port: actualPort, ready };`);
625 lines.push(` return result;`);
626 lines.push(`}`);
627 lines.push('');
628
629 // Main
630 lines.push(`// Start when run directly`);
631 lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`);
632 lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`);
633 lines.push(`if (isMain) {`);
634 lines.push(` startServer();`);
635 lines.push(`}`);
636 lines.push('');
637
638 return lines.join('\n');
639}
640
641// ─── Service Tests ───────────────────────────────────────────────────────────
642
643function generateServiceTests(svc: ServiceDescriptor): string {
644 const lines: string[] = [];
645
646 lines.push(`/**`);
647 lines.push(` * ${svc.name} — Generated Tests`);
648 lines.push(` *`);
649 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
650 lines.push(` * Tests module structure, server health, and Phoenix traceability.`);
651 lines.push(` */`);
652 lines.push('');
653 lines.push(`import { describe, it, expect, afterAll } from 'vitest';`);
654 lines.push(`import { startServer } from '../server.js';`);
655 lines.push('');
656
657 // Import modules
658 for (const mod of svc.modules) {
659 const modName = mod.replace(/\.ts$/, '');
660 const importName = toCamelCase(modName);
661 lines.push(`import * as ${importName} from '../${modName}.js';`);
662 }
663 lines.push('');
664
665 // Module structure tests
666 lines.push(`describe('${svc.name} modules', () => {`);
667 for (const mod of svc.modules) {
668 const modName = mod.replace(/\.ts$/, '');
669 const importName = toCamelCase(modName);
670 const iu = svc.ius.find(u => u.output_files.some(f => f.includes(modName)));
671 const displayName = iu?.name || modName;
672
673 lines.push(` describe('${displayName}', () => {`);
674 lines.push(` it('exports Phoenix traceability metadata', () => {`);
675 lines.push(` expect(${importName}._phoenix).toBeDefined();`);
676 lines.push(` expect(${importName}._phoenix.name).toBe('${displayName}');`);
677 lines.push(` expect(${importName}._phoenix.risk_tier).toBeTruthy();`);
678 lines.push(` });`);
679 lines.push('');
680 lines.push(` it('has exported functions', () => {`);
681 lines.push(` const exports = Object.keys(${importName}).filter(k => k !== '_phoenix');`);
682 lines.push(` expect(exports.length).toBeGreaterThan(0);`);
683 lines.push(` });`);
684 lines.push(` });`);
685 lines.push('');
686 }
687 lines.push(`});`);
688 lines.push('');
689
690 // Server tests
691 lines.push(`describe('${svc.name} server', () => {`);
692 lines.push(` const instance = startServer(0); // random port`);
693 lines.push('');
694 lines.push(` afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve())));`);
695 lines.push('');
696 lines.push(` it('GET /health returns 200', async () => {`);
697 lines.push(` await instance.ready;`);
698 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/health\`);`);
699 lines.push(` expect(res.status).toBe(200);`);
700 lines.push(` const body = await res.json() as Record<string, unknown>;`);
701 lines.push(` expect(body.status).toBe('ok');`);
702 lines.push(` expect(body.service).toBe('${svc.name}');`);
703 lines.push(` });`);
704 lines.push('');
705 lines.push(` it('GET /metrics returns request counts', async () => {`);
706 lines.push(` await instance.ready;`);
707 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/metrics\`);`);
708 lines.push(` expect(res.status).toBe(200);`);
709 lines.push(` const body = await res.json() as Record<string, unknown>;`);
710 lines.push(` expect(typeof body.requests_total).toBe('number');`);
711 lines.push(` });`);
712 lines.push('');
713 lines.push(` it('GET /modules lists all registered modules', async () => {`);
714 lines.push(` await instance.ready;`);
715 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/modules\`);`);
716 lines.push(` expect(res.status).toBe(200);`);
717 lines.push(` const body = await res.json() as Array<Record<string, unknown>>;`);
718 lines.push(` expect(body.length).toBe(${svc.modules.length});`);
719 lines.push(` });`);
720 lines.push('');
721 lines.push(` it('GET /unknown returns 404', async () => {`);
722 lines.push(` await instance.ready;`);
723 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/unknown\`);`);
724 lines.push(` expect(res.status).toBe(404);`);
725 lines.push(` });`);
726
727 // Web client: test GET / serves HTML
728 if (isWebClient(svc)) {
729 lines.push('');
730 lines.push(` it('GET / serves HTML page', async () => {`);
731 lines.push(` await instance.ready;`);
732 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/\`);`);
733 lines.push(` expect(res.status).toBe(200);`);
734 lines.push(` const ct = res.headers.get('content-type') ?? '';`);
735 lines.push(` expect(ct).toContain('text/html');`);
736 lines.push(` const body = await res.text();`);
737 lines.push(` expect(body).toContain('<!DOCTYPE html>');`);
738 lines.push(` expect(body).toContain('<title>${svc.name}</title>');`);
739 lines.push(` });`);
740 }
741
742 lines.push(`});`);
743 lines.push('');
744
745 return lines.join('\n');
746}
747
748// ─── Root Index ──────────────────────────────────────────────────────────────
749
750function generateRootIndex(services: ServiceDescriptor[]): string {
751 const lines: string[] = [];
752
753 lines.push(`/**`);
754 lines.push(` * Phoenix VCS — Generated Service Registry`);
755 lines.push(` *`);
756 lines.push(` * AUTO-GENERATED by Phoenix VCS`);
757 lines.push(` */`);
758 lines.push('');
759
760 for (const svc of services) {
761 const importName = toCamelCase(svc.dir);
762 lines.push(`export * as ${importName} from './${svc.dir}/index.js';`);
763 }
764 lines.push('');
765
766 lines.push(`export const services = [`);
767 for (const svc of services) {
768 lines.push(` { name: '${svc.name}', dir: '${svc.dir}', port: ${svc.port}, modules: ${svc.modules.length} },`);
769 }
770 lines.push(`] as const;`);
771 lines.push('');
772
773 return lines.join('\n');
774}
775
776// ─── Project Config ──────────────────────────────────────────────────────────
777
778function generatePackageJson(
779 services: ServiceDescriptor[],
780 projectName: string,
781 target?: ResolvedTarget | null,
782): string {
783 let scripts: Record<string, string> = {
784 build: 'tsc',
785 typecheck: 'tsc --noEmit',
786 test: 'vitest run',
787 'test:watch': 'vitest',
788 };
789
790 if (target) {
791 const arch = target.architecture;
792 const rt = target.runtime;
793 // Architecture provides its own scripts
794 const archScripts = (rt.packageExtras?.scripts ?? {}) as Record<string, string>;
795 scripts = { ...scripts, ...archScripts };
796 } else {
797 // Add start script per service (build first, then run)
798 for (const svc of services) {
799 scripts[`start:${svc.dir}`] = `tsc && node dist/generated/${svc.dir}/server.js`;
800 }
801 if (services.length > 0) {
802 scripts.start = `tsc && node dist/generated/${services[0].dir}/server.js`;
803 }
804 }
805
806 const pkg: Record<string, unknown> = {
807 name: projectName,
808 version: '0.1.0',
809 description: `Generated by Phoenix VCS — ${services.length} services`,
810 type: 'module',
811 scripts,
812 };
813
814 if (target) {
815 const arch = target.architecture;
816 const rt = target.runtime;
817 pkg.dependencies = rt.packages;
818 pkg.devDependencies = rt.devPackages;
819 } else {
820 pkg.devDependencies = {
821 typescript: '^5.4.0',
822 vitest: '^2.0.0',
823 '@types/node': '^22.0.0',
824 };
825 }
826
827 return JSON.stringify(pkg, null, 2) + '\n';
828}
829
830function generateTsConfig(): string {
831 const config = {
832 compilerOptions: {
833 target: 'ES2022',
834 module: 'ESNext',
835 moduleResolution: 'bundler',
836 declaration: true,
837 outDir: 'dist',
838 rootDir: 'src',
839 strict: true,
840 esModuleInterop: true,
841 skipLibCheck: true,
842 forceConsistentCasingInFileNames: true,
843 resolveJsonModule: true,
844 sourceMap: true,
845 },
846 include: ['src/**/*'],
847 exclude: ['node_modules', 'dist'],
848 };
849
850 return JSON.stringify(config, null, 2) + '\n';
851}
852
853function generateVitestConfig(): string {
854 return `import { defineConfig } from 'vitest/config';
855
856export default defineConfig({
857 test: {
858 include: ['src/**/__tests__/**/*.test.ts'],
859 },
860});
861`;
862}
863
864// ─── Utilities ───────────────────────────────────────────────────────────────
865
866function dirToName(dir: string): string {
867 return dir
868 .split('-')
869 .map(w => w.charAt(0).toUpperCase() + w.slice(1))
870 .join(' ');
871}
872
873function toCamelCase(str: string): string {
874 return str
875 .replace(/[^a-zA-Z0-9]/g, ' ')
876 .split(/\s+/)
877 .filter(Boolean)
878 .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
879 .join('');
880}