Skip to content

Commit e51dd72

Browse files
committed
feat: add dev all command with output stream multiplexer
Start all dev services simultaneously and switch between their output using number keys. Built-in server/web are auto-included based on project structure; custom `devs` entries from .fba.json are merged in (same-name entries override defaults). Also: run user-defined commands via `sh -c` for proper shell support, rename DevEntry.pwd to .cwd, and add <ProjectDir>/<BackendDir>/ <FrontendDir>/<InfraDir> placeholder substitution in cmd and cwd fields. Made-with: Cursor
1 parent 168d210 commit e51dd72

5 files changed

Lines changed: 367 additions & 8 deletions

File tree

src/commands/dev.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
1-
// dev.ts — dev 命令组:server / web / celery / 自定义
1+
// dev.ts — dev 命令组:server / web / celery / all / 自定义
22
import * as clack from '@clack/prompts'
33
import chalk from 'chalk'
44
import { existsSync } from 'fs'
55
import { join, resolve } from 'path'
6-
import { readProjectConfig, getInfraDir } from '../lib/config.js'
7-
import { requireProjectDir, requireBackendDir, requireFrontendDir, warn } from '../lib/errors.js'
6+
import { readProjectConfig, getInfraDir, getBackendDir, getFrontendDir } from '../lib/config.js'
7+
import { requireProjectDir, requireBackendDir, requireFrontendDir, warn, fatal } from '../lib/errors.js'
88
import { isComposeRunning, composeUp } from '../lib/docker.js'
99
import { runInherited } from '../lib/process.js'
1010
import { t } from '../lib/i18n.js'
1111
import type { DevEntry } from '../types/config.js'
12+
import type { DevProcessDef } from '../lib/dev-all.js'
13+
14+
interface DevDirs {
15+
project: string
16+
backend: string
17+
frontend: string
18+
infra: string
19+
}
20+
21+
function resolveDevVars(template: string, dirs: DevDirs): string {
22+
return template
23+
.replace(/<ProjectDir>/g, dirs.project)
24+
.replace(/<BackendDir>/g, dirs.backend)
25+
.replace(/<FrontendDir>/g, dirs.frontend)
26+
.replace(/<InfraDir>/g, dirs.infra)
27+
}
28+
29+
function buildDevDirs(projectDir: string): DevDirs {
30+
return {
31+
project: projectDir,
32+
backend: getBackendDir(projectDir),
33+
frontend: getFrontendDir(projectDir),
34+
infra: getInfraDir(projectDir),
35+
}
36+
}
1237

1338
/**
1439
* fba dev — 启动后端
@@ -102,11 +127,69 @@ export async function devCustomAction(
102127
options: { project?: string },
103128
) {
104129
const projectDir = requireProjectDir(options.project)
105-
const cwd = entry.pwd ? resolve(projectDir, entry.pwd) : projectDir
130+
const dirs = buildDevDirs(projectDir)
131+
const cmd = resolveDevVars(entry.cmd, dirs)
132+
const cwd = entry.cwd ? resolveDevVars(entry.cwd, dirs) : projectDir
106133

107134
console.log(chalk.cyan(`\n ${t('devCustomStarting')} ${name}...\n`))
108135

109-
const [cmd, ...args] = entry.cmd.trim().split(/\s+/) as [string, ...string[]]
110-
const exitCode = await runInherited(cmd, args, cwd, { env: entry.envs })
136+
const exitCode = await runInherited('sh', ['-c', cmd], cwd, { env: entry.envs })
111137
process.exit(exitCode)
112138
}
139+
140+
/**
141+
* fba dev all — 同时启动所有开发服务(内置 + 自定义),按键切换输出流
142+
*
143+
* 自动包含:
144+
* - server: uv run fba run (如果后端目录存在)
145+
* - web: pnpm dev (如果前端目录存在)
146+
* devs 中的同名条目会覆盖内置默认值。
147+
*/
148+
export async function devAllAction(options: { project?: string }) {
149+
const projectDir = requireProjectDir(options.project)
150+
const config = readProjectConfig(projectDir)
151+
152+
const defsMap = new Map<string, DevProcessDef>()
153+
154+
const backendDir = getBackendDir(projectDir)
155+
if (existsSync(backendDir)) {
156+
defsMap.set('server', {
157+
name: 'server',
158+
cmd: `uv run fba run --port ${config.server_port}`,
159+
cwd: backendDir,
160+
})
161+
}
162+
163+
const frontendDir = getFrontendDir(projectDir)
164+
if (existsSync(frontendDir)) {
165+
defsMap.set('web', {
166+
name: 'web',
167+
cmd: 'pnpm dev',
168+
cwd: frontendDir,
169+
})
170+
}
171+
172+
if (config.devs) {
173+
const dirs = buildDevDirs(projectDir)
174+
for (const [name, entry] of Object.entries(config.devs)) {
175+
defsMap.set(name, {
176+
name,
177+
cmd: resolveDevVars(entry.cmd, dirs),
178+
cwd: entry.cwd ? resolveDevVars(entry.cwd, dirs) : projectDir,
179+
envs: entry.envs,
180+
})
181+
}
182+
}
183+
184+
const defs = Array.from(defsMap.values())
185+
186+
if (defs.length === 0) {
187+
fatal(
188+
t('devAllNoDevs'),
189+
t('devAllHintConfig'),
190+
)
191+
}
192+
193+
const { runDevMultiplexer } = await import('../lib/dev-all.js')
194+
await runDevMultiplexer(defs)
195+
}

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ devCmd
8686
await devCeleryAction(subcommand, { project: program.opts().project })
8787
})
8888

89+
devCmd
90+
.command('all')
91+
.description(t('cmdDevAll'))
92+
.action(async () => {
93+
const { devAllAction } = await import('./commands/dev.js')
94+
await devAllAction({ project: program.opts().project })
95+
})
96+
8997
// Dynamic dev subcommands from project .fba.json
9098
{
9199
const preProject = (() => {
@@ -100,7 +108,7 @@ devCmd
100108
if (projectDir && existsSync(projectDir)) {
101109
const projectConfig = readProjectConfig(projectDir)
102110
if (projectConfig.devs) {
103-
const builtins = new Set(['web', 'celery', 'help'])
111+
const builtins = new Set(['web', 'celery', 'all', 'help'])
104112
for (const [name, entry] of Object.entries(projectConfig.devs)) {
105113
if (builtins.has(name)) continue
106114
devCmd

src/lib/dev-all.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// dev-all.ts — 多进程输出流复用器:同时启动所有 dev 命令,按键切换输出流
2+
import { execa } from 'execa'
3+
import chalk from 'chalk'
4+
import { t } from './i18n.js'
5+
6+
const MAX_BUFFER_LINES = 5000
7+
8+
class OutputBuffer {
9+
private lines: string[] = []
10+
private partial = ''
11+
12+
append(data: string) {
13+
const text = this.partial + data
14+
const parts = text.split('\n')
15+
this.partial = parts.pop()!
16+
this.lines.push(...parts)
17+
if (this.lines.length > MAX_BUFFER_LINES) {
18+
this.lines = this.lines.slice(-MAX_BUFFER_LINES)
19+
}
20+
}
21+
22+
getRecent(n: number): string {
23+
const recent = this.lines.slice(-n)
24+
let result = recent.join('\n')
25+
if (this.partial) result += '\n' + this.partial
26+
return result
27+
}
28+
29+
clear() {
30+
this.lines = []
31+
this.partial = ''
32+
}
33+
}
34+
35+
export interface DevProcessDef {
36+
name: string
37+
cmd: string
38+
cwd: string
39+
envs?: Record<string, string>
40+
}
41+
42+
interface ManagedProcess {
43+
name: string
44+
def: DevProcessDef
45+
child: any
46+
buffer: OutputBuffer
47+
status: 'running' | 'exited' | 'crashed'
48+
exitCode?: number
49+
}
50+
51+
export async function runDevMultiplexer(defs: DevProcessDef[]): Promise<void> {
52+
const processes: ManagedProcess[] = []
53+
let activeIndex = 0
54+
let shuttingDown = false
55+
const isTTY = !!(process.stdout.isTTY && process.stdin.isTTY)
56+
57+
function spawnOne(def: DevProcessDef): ManagedProcess {
58+
const child = execa('sh', ['-c', def.cmd], {
59+
cwd: def.cwd,
60+
stdio: ['ignore', 'pipe', 'pipe'],
61+
env: def.envs,
62+
reject: false,
63+
})
64+
65+
return {
66+
name: def.name,
67+
def,
68+
child,
69+
buffer: new OutputBuffer(),
70+
status: 'running',
71+
}
72+
}
73+
74+
function attachOutput(proc: ManagedProcess) {
75+
const onData = (chunk: Buffer) => {
76+
if (!processes.includes(proc)) return
77+
const text = chunk.toString()
78+
proc.buffer.append(text)
79+
const idx = processes.indexOf(proc)
80+
81+
if (isTTY) {
82+
if (idx === activeIndex) process.stdout.write(chunk)
83+
} else {
84+
const prefix = chalk.dim(`[${proc.name}] `)
85+
for (const line of text.split('\n')) {
86+
if (line) process.stdout.write(prefix + line + '\n')
87+
}
88+
}
89+
}
90+
91+
proc.child.stdout?.on('data', onData)
92+
proc.child.stderr?.on('data', onData)
93+
94+
proc.child.then(
95+
(result: any) => {
96+
if (!processes.includes(proc)) return
97+
proc.exitCode = result.exitCode ?? 0
98+
proc.status = result.exitCode === 0 ? 'exited' : 'crashed'
99+
onProcDone(proc)
100+
},
101+
(error: any) => {
102+
if (!processes.includes(proc)) return
103+
proc.exitCode = 1
104+
proc.status = 'crashed'
105+
proc.buffer.append(`\n${chalk.red(error.message ?? String(error))}\n`)
106+
onProcDone(proc)
107+
},
108+
)
109+
}
110+
111+
function onProcDone(proc: ManagedProcess) {
112+
if (isTTY && processes.indexOf(proc) === activeIndex) {
113+
const label = proc.status === 'crashed'
114+
? chalk.red(`\n ✗ ${proc.name} exited with code ${proc.exitCode}`)
115+
: chalk.dim(`\n ○ ${proc.name} ${t('devAllExited')}`)
116+
process.stdout.write(label + '\n')
117+
}
118+
119+
if (!shuttingDown && processes.every(p => p.status !== 'running')) {
120+
shutdown()
121+
}
122+
}
123+
124+
// ─── Spawn all ───
125+
for (const def of defs) {
126+
const proc = spawnOne(def)
127+
processes.push(proc)
128+
attachOutput(proc)
129+
}
130+
131+
const names = processes.map(p => p.name).join(', ')
132+
console.log(chalk.cyan(`\n ${t('devAllStarting')} ${processes.length} ${t('devAllServices')}: ${names}\n`))
133+
134+
// Non-TTY: prefixed output, wait for all
135+
if (!isTTY) {
136+
await Promise.allSettled(processes.map(p => p.child))
137+
process.exit(0)
138+
}
139+
140+
// TTY: status bar + keyboard switching
141+
printStatusBar()
142+
143+
process.stdin.setRawMode(true)
144+
process.stdin.resume()
145+
process.stdin.setEncoding('utf8')
146+
147+
process.stdin.on('data', (key: string) => {
148+
if (shuttingDown) return
149+
150+
const num = parseInt(key)
151+
if (num >= 1 && num <= processes.length && num - 1 !== activeIndex) {
152+
activeIndex = num - 1
153+
redraw()
154+
return
155+
}
156+
157+
if (key === 'q' || key === '\x03') {
158+
shutdown()
159+
return
160+
}
161+
162+
if (key === 'r') {
163+
restartCurrent()
164+
return
165+
}
166+
})
167+
168+
process.stdout.on('resize', () => {
169+
if (!shuttingDown) redraw()
170+
})
171+
172+
process.on('SIGTERM', () => shutdown())
173+
174+
function printStatusBar() {
175+
const indicators = processes.map((p, i) => {
176+
const dot = p.status === 'running' ? chalk.green('●')
177+
: p.status === 'exited' ? chalk.dim('○')
178+
: chalk.red('✗')
179+
const name = i === activeIndex ? chalk.bold.white(p.name) : chalk.dim(p.name)
180+
return `${chalk.dim(`[${i + 1}]`)} ${name} ${dot}`
181+
}).join(' ')
182+
183+
const hint = chalk.dim(
184+
`[1-${processes.length}: ${t('devAllSwitch')} | q: ${t('devAllQuit')} | r: ${t('devAllRestart')}]`,
185+
)
186+
const cols = process.stdout.columns ?? 80
187+
const sep = chalk.dim('─'.repeat(cols))
188+
189+
process.stdout.write(` ${indicators}\n ${hint}\n${sep}\n`)
190+
}
191+
192+
function redraw() {
193+
const rows = process.stdout.rows ?? 24
194+
process.stdout.write('\x1b[2J\x1b[H')
195+
printStatusBar()
196+
const available = Math.max(rows - 5, 10)
197+
const recent = processes[activeIndex]!.buffer.getRecent(available)
198+
if (recent) {
199+
process.stdout.write('\x1b[0m' + recent)
200+
if (!recent.endsWith('\n')) process.stdout.write('\n')
201+
}
202+
}
203+
204+
async function restartCurrent() {
205+
const old = processes[activeIndex]!
206+
if (old.status === 'running') {
207+
old.child.kill('SIGTERM')
208+
}
209+
try { await old.child } catch {}
210+
211+
const newProc = spawnOne(old.def)
212+
processes[activeIndex] = newProc
213+
attachOutput(newProc)
214+
redraw()
215+
}
216+
217+
function shutdown() {
218+
if (shuttingDown) return
219+
shuttingDown = true
220+
221+
if (isTTY) {
222+
process.stdin.setRawMode(false)
223+
process.stdin.pause()
224+
}
225+
226+
for (const p of processes) {
227+
if (p.status === 'running') {
228+
p.child.kill('SIGTERM')
229+
}
230+
}
231+
232+
process.stdout.write('\x1b[0m')
233+
console.log(chalk.dim(`\n ${t('devAllStopped')}\n`))
234+
process.exit(0)
235+
}
236+
237+
await new Promise<never>(() => {})
238+
}

0 commit comments

Comments
 (0)