diff --git a/README.md b/README.md index dd7fc5e..23cbadd 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Building a production-style polyglot microservice environment normally requires Use it to prototype architectures, onboard teams faster, or spin up reproducible demos / PoCs. ## Features + - ๐Ÿš€ Rapid polyglot monorepo scaffolding (Node.js, Python/FastAPI, Go, Java Spring Boot, Next.js) - ๐Ÿงฉ Optional presets: Turborepo, Nx, or Basic runner - ๐Ÿณ Automatic Dockerfile + Docker Compose generation @@ -61,6 +62,7 @@ Use it to prototype architectures, onboard teams faster, or spin up reproducible - ๐Ÿ“ฆ Shared package (`packages/shared`) for cross-service JS utilities - ๐Ÿงช Vitest test setup for the CLI itself - ๐ŸŒˆ Colorized dev logs & health probing for Node/frontend services +- ๐Ÿ”ฅ Unified hot reload aggregator (`create-polyglot hot`) for Node, Next.js, Python (uvicorn), Go, and Java (Spring Boot) - ๐Ÿ”Œ Plugin skeleton generation (`create-polyglot add plugin `) - ๐Ÿ“„ Single source of truth: `polyglot.json` - โœ… Safe guards: port collision checks, reserved name checks, graceful fallbacks @@ -69,6 +71,7 @@ Use it to prototype architectures, onboard teams faster, or spin up reproducible ## Quick Start Scaffold a workspace named `my-org` with multiple services: +| `create-polyglot hot [--services ] [--dry-run]` | Unified hot reload (restart / HMR) across services. | ```bash npx create-polyglot init my-org -s node,python,go,java,frontend --git --yes ``` @@ -77,6 +80,20 @@ Then run everything (Node + frontend locally): ```bash create-polyglot dev ``` +Unified hot reload (auto restart / HMR): +```bash +create-polyglot hot +``` + +Dry run (see what would execute without starting processes): +```bash +create-polyglot hot --dry-run +``` + +Limit to a subset (by names or types): +```bash +create-polyglot hot --services node,python +``` Or via Docker Compose: ```bash diff --git a/bin/index.js b/bin/index.js index 6ed5940..60db3d1 100755 --- a/bin/index.js +++ b/bin/index.js @@ -8,6 +8,7 @@ import path from 'path'; import { renderServicesTable } from './lib/ui.js'; import { runDev } from './lib/dev.js'; import { startAdminDashboard } from './lib/admin.js'; +import { runHotReload } from './lib/hotreload.js'; const program = new Command(); @@ -164,6 +165,22 @@ program process.exit(1); } }); + +// Unified hot reload aggregator +program + .command('hot') + .description('Unified hot reload across services (auto-restart / HMR)') + .option('-s, --services ', 'Subset of services (comma names or types)') + .option('--dry-run', 'Show what would run without starting processes') + .action(async (opts) => { + try { + const filter = opts.services ? opts.services.split(',').map(s => s.trim()).filter(Boolean) : []; + await runHotReload({ servicesFilter: filter, dryRun: !!opts.dryRun }); + } catch (e) { + console.error(chalk.red('Failed to start hot reload:'), e.message); + process.exit(1); + } + }); program.parse(); diff --git a/bin/lib/hotreload.js b/bin/lib/hotreload.js new file mode 100644 index 0000000..5cd400d --- /dev/null +++ b/bin/lib/hotreload.js @@ -0,0 +1,226 @@ +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'node:child_process'; +import chalk from 'chalk'; + +/* + Unified Hot Reload Aggregator + ---------------------------- + Goal: Provide a single command that watches source files for all supported + service types and restarts their dev process (or equivalent) on changes. + + Supported service types & strategy: + - node: restart on .js/.mjs/.cjs/.ts changes inside service dir (excluding node_modules). + If service has dev script using nodemon/ts-node-dev already, we just run it. + - frontend (Next.js): rely on next dev internal HMR (no restart). We'll watch config files + (next.config.*, .env*) and trigger a manual restart if they change. + - python (FastAPI): use uvicorn with --reload if available; if requirements specify uvicorn, + we spawn `uvicorn app.main:app --reload --port ` instead of existing dev script. + - go: detect main.go; use `go run .` and restart on .go file changes. + - java (Spring Boot): use `mvn spring-boot:run` and restart on changes to src/ (requires JDK & Maven). + For performance we debounce restarts. + + Edge cases: + - Missing runtime tool (e.g., mvn not installed) -> warn and skip hot reload for that service. + - Large flurries of changes -> debounce restart (default 400ms). + - Service without supported pattern -> skip with yellow message. + + Exposed function: runHotReload({ servicesFilter, dryRun }) + */ + +const DEBOUNCE_MS = 400; + +function colorFor(name) { + const colors = [chalk.cyan, chalk.magenta, chalk.green, chalk.blue, chalk.yellow, chalk.redBright]; + let sum = 0; for (let i=0;i filterSet.has(s.name) || filterSet.has(s.type)); + } + if (!services.length) { + console.log(chalk.yellow('No matching services for hot reload.')); + return; + } + + console.log(chalk.cyan(`\n๐Ÿ”ฅ Unified hot reload starting (${services.length} services)...`)); + + const watchers = []; + const processes = new Map(); + + function spawnService(svc) { + const svcPath = path.join(cwd, svc.path); + if (!fs.existsSync(svcPath)) { + console.log(chalk.yellow(`Skipping ${svc.name} (path missing)`)); + return; + } + const color = colorFor(svc.name); + let cmd, args, watchGlobs, restartStrategy; + switch (svc.type) { + case 'node': { + const pkgPath = path.join(svcPath, 'package.json'); + if (!fs.existsSync(pkgPath)) { console.log(chalk.yellow(`Skipping ${svc.name} (no package.json)`)); return; } + let pkg; try { pkg = JSON.parse(fs.readFileSync(pkgPath,'utf-8')); } catch { console.log(chalk.yellow(`Skipping ${svc.name} (invalid package.json)`)); return; } + const script = pkg.scripts?.dev || pkg.scripts?.start; + if (!script) { console.log(chalk.yellow(`Skipping ${svc.name} (no dev/start script)`)); return; } + // Prefer existing nodemon usage; else run node and restart manually. + const usesNodemon = /nodemon/.test(script); + if (usesNodemon) { + cmd = detectPM(svcPath); + args = ['run', pkg.scripts.dev ? 'dev' : 'start']; + watchGlobs = []; // nodemon handles its own watching + restartStrategy = 'internal'; + } else { + cmd = detectPM(svcPath); + args = ['run', pkg.scripts.dev ? 'dev' : 'start']; + watchGlobs = ['**/*.js','**/*.mjs','**/*.cjs','**/*.ts','!node_modules/**']; + restartStrategy = 'respawn'; + } + break; + } + case 'frontend': { + // Next.js handles HMR internally; only restart if config changes. + cmd = detectPM(svcPath); args = ['run','dev']; + watchGlobs = ['next.config.*','*.env','*.env.*']; + restartStrategy = 'internal+config-restart'; + break; + } + case 'python': { + // Use uvicorn --reload directly if possible. + cmd = 'uvicorn'; + args = ['app.main:app','--reload','--port', String(svc.port)]; + watchGlobs = ['app/**/*.py','*.py']; + restartStrategy = 'respawn'; + break; + } + case 'go': { + cmd = 'go'; args = ['run','.']; + watchGlobs = ['**/*.go']; + restartStrategy = 'respawn'; + break; + } + case 'java': { + // Spring Boot dev run; restart on src changes. + cmd = 'mvn'; args = ['spring-boot:run']; + watchGlobs = ['src/main/java/**/*.java','src/main/resources/**/*']; + restartStrategy = 'respawn'; + break; + } + default: + console.log(chalk.yellow(`Skipping ${svc.name} (unsupported type ${svc.type})`)); + return; + } + + if (dryRun) { + console.log(chalk.gray(`[dry-run] ${svc.name}: ${cmd} ${args.join(' ')} (${restartStrategy})`)); + return; + } + + const child = spawn(cmd, args, { cwd: svcPath, env: { ...process.env, PORT: String(svc.port) }, shell: true }); + processes.set(svc.name, { child, svc, watchGlobs, restartStrategy, svcPath }); + child.stdout.on('data', d => process.stdout.write(color(`[${svc.name}] `) + d.toString())); + child.stderr.on('data', d => process.stderr.write(color(`[${svc.name}] `) + d.toString())); + child.on('exit', code => { + process.stdout.write(color(`[${svc.name}] exited (${code})`)+"\n"); + }); + + if (watchGlobs && watchGlobs.length) { + // Minimal glob watching without external deps: recursive fs watch + filter. + // NOTE: macOS recursive watch limitations; we manually walk tree initially. + const fileList = listFilesRecursive(svcPath); + const matcher = buildMatcher(watchGlobs); + const pending = { timeout: null }; + + function scheduleRestart() { + if (pending.timeout) clearTimeout(pending.timeout); + pending.timeout = setTimeout(() => { + const meta = processes.get(svc.name); + if (!meta) return; + console.log(color(`โ†ป Restarting ${svc.name} due to changes...`)); + meta.child.kill('SIGINT'); + spawnService(svc); // respawn fresh + }, DEBOUNCE_MS); + } + + // Initial watchers per directory + const dirs = new Set(fileList.map(f => path.dirname(f))); + for (const dir of dirs) { + try { + const w = fs.watch(dir, { persistent: true }, (evt, fileName) => { + if (!fileName) return; + const rel = path.relative(svcPath, path.join(dir, fileName)); + if (matcher(rel)) { + scheduleRestart(); + } + }); + watchers.push(w); + } catch {} + } + } + } + + // Spawn all initially + for (const svc of services) spawnService(svc); + + if (!dryRun) { + console.log(chalk.blue('Hot reload active. Press Ctrl+C to exit.')); + process.on('SIGINT', () => { + for (const { child } of processes.values()) child.kill('SIGINT'); + for (const w of watchers) try { w.close(); } catch {} + process.exit(0); + }); + } +} + +function listFilesRecursive(root) { + const out = []; + function walk(p) { + let stats; try { stats = fs.statSync(p); } catch { return; } + if (stats.isDirectory()) { + const entries = fs.readdirSync(p); + for (const e of entries) walk(path.join(p, e)); + } else { + out.push(p); + } + } + walk(root); + return out; +} + +// Very small glob matcher supporting *, **, suffix patterns and exclusion !prefix. +function buildMatcher(globs) { + const positives = globs.filter(g => !g.startsWith('!')); + const negatives = globs.filter(g => g.startsWith('!')).map(g => g.slice(1)); + return rel => { + if (negatives.some(n => minimatchBasic(rel, n))) return false; + return positives.some(p => minimatchBasic(rel, p)); + }; +} + +function minimatchBasic(rel, pattern) { + // Convert pattern to regex roughly; handle **/, *, and dotfiles. + let regex = pattern + .replace(/[.+^${}()|\-]/g, r => `\\${r}`) + .replace(/\\\*\*\//g, '(?:.+/)?') // /**/ style + .replace(/\*\*/g, '.*') + .replace(/\*/g, '[^/]*'); + return new RegExp(`^${regex}$`).test(rel); +} + +function detectPM(root) { + if (fs.existsSync(path.join(root,'pnpm-lock.yaml'))) return 'pnpm'; + if (fs.existsSync(path.join(root,'yarn.lock'))) return 'yarn'; + if (fs.existsSync(path.join(root,'bun.lockb'))) return 'bun'; + return 'npm'; +} diff --git a/package-lock.json b/package-lock.json index 9c46401..34bd17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "create-polyglot", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-polyglot", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.1", "degit": "^2.8.4", + "execa": "^9.6.0", "fs-extra": "^11.3.2", "prompts": "^2.4.2" }, @@ -19,7 +20,6 @@ "create-polyglot": "bin/index.js" }, "devDependencies": { - "execa": "^9.6.0", "vitepress": "^1.3.3", "vitest": "^1.6.1" } @@ -391,7 +391,6 @@ }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", - "dev": true, "license": "MIT" }, "node_modules/@shikijs/core": { @@ -472,7 +471,6 @@ }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1024,7 +1022,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1168,7 +1165,6 @@ }, "node_modules/execa": { "version": "9.6.0", - "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -1193,7 +1189,6 @@ }, "node_modules/figures": { "version": "6.1.0", - "dev": true, "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" @@ -1247,7 +1242,6 @@ }, "node_modules/get-stream": { "version": "9.0.1", - "dev": true, "license": "MIT", "dependencies": { "@sec-ant/readable-stream": "^0.4.1", @@ -1314,7 +1308,6 @@ }, "node_modules/human-signals": { "version": "8.0.1", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -1322,7 +1315,6 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1333,7 +1325,6 @@ }, "node_modules/is-stream": { "version": "4.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1344,7 +1335,6 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1366,7 +1356,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/js-tokens": { @@ -1597,7 +1586,6 @@ }, "node_modules/npm-run-path": { "version": "6.0.0", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^4.0.0", @@ -1612,7 +1600,6 @@ }, "node_modules/npm-run-path/node_modules/path-key": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1661,7 +1648,6 @@ }, "node_modules/parse-ms": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1672,7 +1658,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1767,7 +1752,6 @@ }, "node_modules/pretty-ms": { "version": "9.3.0", - "dev": true, "license": "MIT", "dependencies": { "parse-ms": "^4.0.0" @@ -1878,7 +1862,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1889,7 +1872,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1917,7 +1899,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -1980,7 +1961,6 @@ }, "node_modules/strip-final-newline": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2061,7 +2041,6 @@ }, "node_modules/unicorn-magic": { "version": "0.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2461,7 +2440,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2501,7 +2479,6 @@ }, "node_modules/yoctocolors": { "version": "2.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 4ee5e23..c19fc77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-polyglot", - "version": "1.6.0", + "version": "1.6.1", "description": "Scaffold polyglot microservice monorepos with built-in templates for Node, Python, Go, and more.", "main": "bin/index.js", "scripts": { @@ -52,10 +52,10 @@ "commander": "^14.0.1", "degit": "^2.8.4", "fs-extra": "^11.3.2", - "prompts": "^2.4.2" + "prompts": "^2.4.2", + "execa": "^9.6.0" }, "devDependencies": { - "execa": "^9.6.0", "vitepress": "^1.3.3", "vitest": "^1.6.1" } diff --git a/tests/dev-command.test.js b/tests/dev-command.test.js index 6d43967..82db9bd 100644 --- a/tests/dev-command.test.js +++ b/tests/dev-command.test.js @@ -38,7 +38,7 @@ describe('polyglot dev command (non-docker)', () => { proc.kill('SIGINT'); await proc.catch(()=>{}); // ignore exit errors due to SIGINT expect(fs.existsSync(path.join(projectPath,'polyglot.json'))).toBe(true); - }, 20000); + }, 30000); // New tests for updated dev.js it('warns and skips service with no package.json', async () => { diff --git a/tests/hotreload.test.js b/tests/hotreload.test.js new file mode 100644 index 0000000..9f12824 --- /dev/null +++ b/tests/hotreload.test.js @@ -0,0 +1,43 @@ +import { execa } from 'execa'; +import { describe, it, expect, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Tests for unified hot reload command + +describe('hot reload command', () => { + const tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'polyglot-hot-')); + const tmpDir = path.join(tmpParent, 'workspace'); + fs.mkdirSync(tmpDir); + const projName = 'hot-proj'; + + afterAll(() => { try { fs.rmSync(tmpParent, { recursive: true, force: true }); } catch {} }); + + it('dry-run lists spawn strategies for node service', async () => { + const repoRoot = process.cwd(); + const cliPath = path.join(repoRoot, 'bin/index.js'); + // Use fewer services to speed up scaffold + await execa('node', [cliPath, 'init', projName, '--services', 'node', '--no-install', '--yes'], { cwd: tmpDir, env: { ...process.env, CI: 'true' } }); + const projectPath = path.join(tmpDir, projName); + + // Ensure dev scripts exist where needed for node/frontend + const nodePkgPath = path.join(projectPath, 'services/node/package.json'); + const nodePkg = JSON.parse(fs.readFileSync(nodePkgPath,'utf-8')); + nodePkg.scripts = nodePkg.scripts || {}; nodePkg.scripts.dev = 'node src/index.js'; + fs.writeFileSync(nodePkgPath, JSON.stringify(nodePkg, null, 2)); + + const proc = await execa('node', [cliPath, 'hot', '--dry-run'], { cwd: projectPath, env: { ...process.env, CI: 'true' } }); + expect(proc.stdout).toMatch(/\[dry-run\] node:/); + }, 30000); + + it('filters node service via --services option', async () => { + const repoRoot = process.cwd(); + const cliPath = path.join(repoRoot, 'bin/index.js'); + await execa('node', [cliPath, 'init', projName+'2', '--services', 'node', '--no-install', '--yes'], { cwd: tmpDir, env: { ...process.env, CI: 'true' } }); + const projectPath = path.join(tmpDir, projName+'2'); + + const proc = await execa('node', [cliPath, 'hot', '--dry-run', '--services', 'node'], { cwd: projectPath, env: { ...process.env, CI: 'true' } }); + expect(proc.stdout).toMatch(/\[dry-run\] node:/); + }, 30000); +});