specflow-dev-service 0.0.0-beta.10

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.
@@ -0,0 +1,679 @@
1
+ 'use strict';
2
+
3
+ var node_child_process = require('node:child_process');
4
+ var node_fs = require('node:fs');
5
+ var node_path = require('node:path');
6
+ var defaults = require('./config/defaults.js');
7
+ var loader = require('./config/loader.js');
8
+ var logger = require('./logger.js');
9
+ require('cross-spawn');
10
+ var client = require('./runner/client.js');
11
+
12
+ /**
13
+ * ForgeService — 核心协调器
14
+ *
15
+ * 职责:
16
+ * 1. 解析 specflow.config.ts 配置文件
17
+ * 2. 前端启动逻辑(端口管理 + Vite 进程管理)
18
+ * 3. 后端热更新逻辑(air)
19
+ *
20
+ * 日志原则:只输出关键信息,静默处理正常流程
21
+ */
22
+
23
+
24
+ // ============================================================
25
+ // 类型定义
26
+ // ============================================================
27
+
28
+ // ============================================================
29
+ // Go 进程管理器(基于 air,极致精简)
30
+ // ============================================================
31
+
32
+ class GoProcessManager {
33
+ process = null;
34
+ state = 'idle';
35
+ restartCount = 0;
36
+ /** 端口真正就绪的 Promise(running 信号后开始等待) */
37
+ portReadyResolver = null;
38
+ portReadyPromise = null;
39
+ constructor(serviceDir, port, env, options) {
40
+ this.serviceDir = serviceDir;
41
+ this.port = port;
42
+ this.env = env;
43
+ this.options = options;
44
+ }
45
+ get isRunning() {
46
+ return this.state === 'running' && this.process !== null && !this.process.killed;
47
+ }
48
+ get pid() {
49
+ return this.process?.pid ?? undefined;
50
+ }
51
+ async start() {
52
+ if (this.state === 'starting' || this.state === 'running') return;
53
+ this.state = 'starting';
54
+
55
+ // 用户自定义命令优先
56
+ if (this.options?.command) {
57
+ await this.startCustom();
58
+ return;
59
+ }
60
+
61
+ // 默认使用 air
62
+ await this.startWithAir();
63
+ }
64
+ async stop() {
65
+ if (this.state === 'stopped' || this.state === 'idle') return;
66
+ this.state = 'stopped';
67
+ if (this.process) {
68
+ this.killProcess();
69
+ this.process = null;
70
+ }
71
+ this.state = 'idle';
72
+ }
73
+
74
+ /** 使用 air 启动 */
75
+ async startWithAir() {
76
+ // 检查并自动安装 air
77
+ if (!(await this.ensureAirInstalled())) return;
78
+
79
+ // 自动生成 .air.toml(不存在时)
80
+ this.ensureAirToml();
81
+ const args = this.buildAirArgs();
82
+ this.process = node_child_process.spawn('air', args, {
83
+ cwd: this.serviceDir,
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ env: this.env,
86
+ // 不额外注入 PORT,由 Go 程序自身配置决定端口
87
+ windowsHide: true,
88
+ shell: process.platform === 'win32'
89
+ });
90
+ this.setupAirOutputHandler();
91
+
92
+ // 创建端口就绪 Promise(output handler 的 running... 信号会触发 resolve)
93
+ this.portReadyPromise = new Promise(resolve => {
94
+ this.portReadyResolver = resolve;
95
+ });
96
+
97
+ // 等待 air 启动完成
98
+ await new Promise(res => setTimeout(res, 1200));
99
+ if (this.process && !this.process.killed) {
100
+ this.state = 'running';
101
+ this.restartCount++;
102
+ // 等待端口真正就绪(覆盖 MySQL/Redis/tRPC 初始化窗口)
103
+ await this.portReadyPromise;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 自动生成 .air.toml 配置文件(仅当不存在时)
109
+ *
110
+ * 设计原则:
111
+ * - 生成的配置与用户手动编写的完全等价,无任何降级
112
+ * - 平台自适应:Windows 生成 .exe,Linux/Mac 无后缀
113
+ * - 输出同时写 stdout+stderr(forge-service 通过 pipe 捕获)
114
+ */
115
+ ensureAirToml() {
116
+ const tomlPath = node_path.resolve(this.serviceDir, '.air.toml');
117
+ const binExt = process.platform === 'win32' ? '.exe' : '';
118
+ const tmpDir = 'tmp';
119
+ const binName = `./tmp/main${binExt}`;
120
+
121
+ // 检测损坏的旧版本:如果文件存在但包含未替换的模板变量,则重新生成
122
+ if (node_fs.existsSync(tomlPath)) {
123
+ try {
124
+ const existing = require('node:fs').readFileSync(tomlPath, 'utf-8');
125
+ if (existing.includes('${binExt}') || existing.includes('${tmpDir}')) {
126
+ // 损坏的旧版 → 删除并重新生成
127
+ require('node:fs').unlinkSync(tomlPath);
128
+ } else {
129
+ return; // 已有有效配置,不覆盖
130
+ }
131
+ } catch {
132
+ /* 读取失败,重新生成 */
133
+ }
134
+ }
135
+
136
+ // 确保 tmp 目录存在
137
+ const tmpFullPath = node_path.resolve(this.serviceDir, tmpDir);
138
+ if (!node_fs.existsSync(tmpFullPath)) {
139
+ node_fs.mkdirSync(tmpFullPath, {
140
+ recursive: true
141
+ });
142
+ }
143
+ const tomlContent = ['# Air 配置文件 - Go 热更新(自动生成,可手动修改)', '', 'root = "."', `tmp_dir = "${tmpDir}"`, 'working_dir = "."', '', '[build]', ` entrypoint = "${binName}"`, ` cmd = "go build -o ./tmp/main${binExt} ."`, ' delay = 1000', ` exclude_dir = ["${tmpDir}", "vendor", "client", "bin", "log", "node_modules", ".git"]`, ' include_ext = ["go", "yaml", "yml", "json", "toml", "mod", "sum"]', ' kill_delay = "3s"', ' build_delay = "6s"', ' log = ""', ' send_interrupt = true', ' stop_on_error = false', '', '[log]', ' time = false', ' outputs = ["stdout", "stderr"]', '', '[color]', ' main = "magenta"', ' watcher = "cyan"', ' build = "yellow"', ' runner = "green"', '', '[misc]', ' clean_on_exit = true'].join('\n');
144
+ node_fs.writeFileSync(tomlPath, tomlContent, 'utf-8');
145
+ logger.logProgress('go', 'config', `.air.toml 已生成 (${binName})`);
146
+ }
147
+
148
+ /** 构建 air 参数(始终使用 .air.toml) */
149
+ buildAirArgs() {
150
+ // 始终使用项目中的 .air.toml(ensureAirToml 保证其存在)
151
+ const args = ['-c', '.air.toml'];
152
+ if (this.options?.args) args.push(...this.options.args);
153
+ return args;
154
+ }
155
+
156
+ /** 检查并自动安装 air */
157
+ async ensureAirInstalled() {
158
+ // 兼容旧版 air(v1.65.x 用 -v,新版用 --version)
159
+ const versionFlags = ['-v', '--version'];
160
+ for (const flag of versionFlags) {
161
+ try {
162
+ node_child_process.execSync(`air ${flag}`, {
163
+ stdio: 'pipe',
164
+ timeout: 2000
165
+ });
166
+ return true; // 已安装
167
+ } catch {
168
+ // 这个 flag 不支持,试下一个
169
+ }
170
+ }
171
+
172
+ // 都没成功 → 尝试自动安装
173
+ logger.logProgress('go', 'installing air', 'go install github.com/air-verse/air@latest');
174
+ try {
175
+ await new Promise((resolvePromise, reject) => {
176
+ const installProc = node_child_process.spawn('go', ['install', 'github.com/air-verse/air@latest'], {
177
+ stdio: 'pipe',
178
+ env: this.env,
179
+ windowsHide: true
180
+ });
181
+ installProc.on('close', code => {
182
+ if (code === 0) resolvePromise();else reject(new Error(`air 安装失败 (exit ${code})`));
183
+ });
184
+ installProc.on('error', reject);
185
+ });
186
+
187
+ // 验证安装
188
+ for (const flag of versionFlags) {
189
+ try {
190
+ node_child_process.execSync(`air ${flag}`, {
191
+ stdio: 'pipe',
192
+ timeout: 2000
193
+ });
194
+ return true;
195
+ } catch {
196
+ /* continue */
197
+ }
198
+ }
199
+ throw new Error('安装后验证失败');
200
+ } catch (err) {
201
+ logger.logError(`[go] air 安装失败: ${err instanceof Error ? err.message : String(err)}`);
202
+ logger.logError('[go] 请手动执行: go install github.com/air-verse/air@latest');
203
+ this.state = 'idle';
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 处理 air 输出 — 用户友好模式
210
+ *
211
+ * 正常情况只显示:
212
+ * [go] compiling... (编译进度)
213
+ * [go] ✓ ready http://localhost:11080 (启动成功+端口)
214
+ * [go] ✓ reloaded (热更新成功)
215
+ *
216
+ * 出错才显示:
217
+ * [go] ✗ compile error: (编译失败,显示具体错误)
218
+ * [go] ✗ crashed (运行时崩溃,显示panic)
219
+ */
220
+ setupAirOutputHandler() {
221
+ if (!this.process) return;
222
+ const prefix = '\x1b[36m[go]\x1b[0m';
223
+ const isTTY = process.stdout.isTTY === true;
224
+ let phase = 'idle';
225
+ let errorLines = []; // 编译错误(只在 building 收集)
226
+ let buildDots = 0;
227
+ let buildTimer = null;
228
+ const clearLine = () => {
229
+ if (isTTY) process.stdout.write('\r\x1b[K');
230
+ };
231
+ const startBuildProgress = () => {
232
+ if (buildTimer) return;
233
+ buildDots = 0;
234
+ if (isTTY) {
235
+ buildTimer = setInterval(() => {
236
+ buildDots = (buildDots + 1) % 4;
237
+ process.stdout.write(`\r\x1b[K${prefix} compiling${'.'.repeat(buildDots)}${' '.repeat(3 - buildDots)}`);
238
+ }, 300);
239
+ } else {
240
+ process.stdout.write(`${prefix} compiling...\n`);
241
+ }
242
+ };
243
+ const stopBuildProgress = () => {
244
+ if (buildTimer) {
245
+ clearInterval(buildTimer);
246
+ buildTimer = null;
247
+ }
248
+ clearLine();
249
+ };
250
+
251
+ /** 等待端口真正就绪后输出 ready 并 resolve portReadyPromise */
252
+ const waitForPortReady = () => {
253
+ const {
254
+ port
255
+ } = this;
256
+ const maxWait = 15000; // 最多等 15s(覆盖慢速 DB/Redis 初始化)
257
+ const start = Date.now();
258
+ const check = () => {
259
+ // 先检查进程是否还活着(避免死循环)
260
+ if (!this.process || this.process.killed) return;
261
+ try {
262
+ const socket = require('net').createConnection(port, '127.0.0.1');
263
+ socket.on('connect', () => {
264
+ socket.destroy();
265
+ // 端口就绪 → 输出 + 通知 startWithAir 继续
266
+ if (this.restartCount > 1) {
267
+ console.log(`${prefix} \x1b[32m✓ reloaded\x1b[0m`);
268
+ } else {
269
+ console.log(`${prefix} \x1b[32m✓ ready\x1b[0m \x1b[4mhttp://localhost:${port}\x1b[0m`);
270
+ }
271
+ this.portReadyResolver?.();
272
+ });
273
+ socket.on('error', () => {
274
+ socket.destroy();
275
+ if (Date.now() - start < maxWait) setTimeout(check, 300);else {
276
+ // 超时:仍然放行(可能是服务没监听此端口),打印警告
277
+ console.log(`${prefix} \x1b[33m⚠ port :${port} not responding in ${maxWait / 1000}s, proceeding anyway\x1b[0m`);
278
+ this.portReadyResolver?.();
279
+ }
280
+ });
281
+ setTimeout(() => {
282
+ try {
283
+ socket.destroy();
284
+ } catch {
285
+ /* noop */
286
+ }
287
+ }, 2000);
288
+ } catch {
289
+ // net 模块异常,直接放行
290
+ this.portReadyResolver?.();
291
+ }
292
+ };
293
+ check();
294
+ };
295
+
296
+ // ---- 核心行处理 ----
297
+ const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\r/g, '');
298
+ const stripAirPrefix = s => s.replace(/^[\s]*[✗✓]?\s*\[air\]\s*/i, '');
299
+ const isAirNoise = t => /^[_/\\ |]+$/.test(t) || /^(watching|!exclude|cleaning|see you)/i.test(t) || t.startsWith('air') || /^v\d+\.\d+/.test(t) || /^[|/_\\-]+/.test(t) && t.length < 40 || /built with Go/i.test(t);
300
+ const handlePhaseSignal = t => {
301
+ if (/^building\.\.\./.test(t)) {
302
+ phase = 'building';
303
+ errorLines = [];
304
+ startBuildProgress();
305
+ return true;
306
+ }
307
+ if (/^running\.\.\./.test(t)) {
308
+ stopBuildProgress();
309
+ phase = 'running';
310
+ waitForPortReady();
311
+ return true;
312
+ }
313
+ const exitMatch = t.match(/Process Exit with Code:\s*(\d+)/);
314
+ if (exitMatch) {
315
+ stopBuildProgress();
316
+ const code = exitMatch[1];
317
+ phase = code === '0' ? 'idle' : 'failed';
318
+ if (code !== '0' && errorLines.length > 0) {
319
+ console.log(`\n${prefix} \x1b[31m✗ compile error:\x1b[0m`);
320
+ for (const line of errorLines.slice(0, 10)) {
321
+ console.log(` \x1b[31m${line}\x1b[0m`);
322
+ }
323
+ errorLines = [];
324
+ }
325
+ return true;
326
+ }
327
+ return false;
328
+ };
329
+ const handleBuildingPhase = t => {
330
+ if (/^\d{4}-\d{2}-\d{2}/.test(t)) return;
331
+ if (/\.go:\d+:\d+:/.test(t) || /^#\s/.test(t)) {
332
+ errorLines.push(t);
333
+ return;
334
+ }
335
+ if (/cannot\s+(find|import)\s+package|:\s*undefined:|declared\s+and\s+not\s+used|imported\s+and\s+not\s+used/.test(t)) {
336
+ errorLines.push(t);
337
+ }
338
+ };
339
+ const handleLine = raw => {
340
+ const t = stripAirPrefix(stripAnsi(raw)).trim();
341
+ if (!t || isAirNoise(t)) return;
342
+ if (handlePhaseSignal(t)) return;
343
+ if (phase === 'building') handleBuildingPhase(t);
344
+ };
345
+
346
+ // ---- 绑定流 ----
347
+ let buf = '';
348
+ const drain = chunk => {
349
+ buf += chunk.toString();
350
+ const lines = buf.split('\n');
351
+ buf = lines.pop() || '';
352
+ for (const line of lines) handleLine(line);
353
+ };
354
+ this.process.stdout?.on('data', drain);
355
+ this.process.stderr?.on('data', drain);
356
+ this.process.on('error', () => {
357
+ stopBuildProgress();
358
+ this.state = 'idle';
359
+ });
360
+ this.process.on('exit', (_code, signal) => {
361
+ stopBuildProgress();
362
+ if (signal !== 'SIGTERM' && signal !== 'SIGINT') {
363
+ logger.logError(`[go] air stopped`);
364
+ }
365
+ this.process = null;
366
+ this.state = 'idle';
367
+ });
368
+ }
369
+
370
+ /** 自定义命令启动 */
371
+ async startCustom() {
372
+ const cmd = this.options.command;
373
+ const args = this.options.args || [];
374
+ this.process = node_child_process.spawn(cmd, args, {
375
+ cwd: this.serviceDir,
376
+ stdio: ['ignore', 'pipe', 'pipe'],
377
+ env: this.env,
378
+ windowsHide: true,
379
+ shell: process.platform === 'win32'
380
+ });
381
+ this.setupCustomOutputHandler(cmd);
382
+ await new Promise(res => setTimeout(res, 1500));
383
+ if (this.process && !this.process.killed) {
384
+ this.state = 'running';
385
+ this.restartCount++;
386
+ logger.logProgress('go', 'ready', `http://localhost:${this.port} (${cmd})`);
387
+ }
388
+ }
389
+ setupCustomOutputHandler(cmd) {
390
+ if (!this.process) return;
391
+ const prefix = `\x1b[36m[go]\x1b[0m`;
392
+
393
+ // ---- 绑定 stdout + stderr,复用 handleLine 过滤 ----
394
+ let buf = '';
395
+ const flush = () => {
396
+ if (!buf.trim()) {
397
+ buf = '';
398
+ return;
399
+ }
400
+ for (const line of buf.split('\n')) {
401
+ const t = line.trim();
402
+ if (!t) continue;
403
+ // 自定义命令:透传关键信息,过滤噪音
404
+ if (/launch\s+success|listening|serving|started|ready|error|panic|fatal/i.test(t)) {
405
+ console.log(`${prefix} ${t}`);
406
+ }
407
+ }
408
+ buf = '';
409
+ };
410
+ this.process.stdout?.on('data', d => {
411
+ buf += d.toString();
412
+ const nl = buf.lastIndexOf('\n');
413
+ if (nl !== -1) {
414
+ const chunk = buf.slice(0, nl);
415
+ buf = buf.slice(nl + 1);
416
+ for (const line of chunk.split('\n')) {
417
+ const t = line.trim();
418
+ if (!t) continue;
419
+ if (/launch\s+success|listening|serving|started|ready|error|panic|fatal/i.test(t)) {
420
+ console.log(`${prefix} ${t}`);
421
+ }
422
+ }
423
+ }
424
+ });
425
+ this.process.stderr?.on('data', d => {
426
+ const text = d.toString().trim();
427
+ if (text) logger.logError(`[${cmd}] ${text}`);
428
+ });
429
+ this.process.on('error', err => {
430
+ logger.logError(`[go] ${err.message}`);
431
+ this.state = 'idle';
432
+ });
433
+ this.process.on('exit', () => {
434
+ flush();
435
+ this.process = null;
436
+ this.state = 'idle';
437
+ });
438
+ }
439
+ killProcess() {
440
+ if (!this.process || this.process.killed) return;
441
+ try {
442
+ if (process.platform === 'win32') {
443
+ node_child_process.execSync(`taskkill /PID ${this.process.pid} /T /F`, {
444
+ stdio: 'pipe',
445
+ timeout: 5000,
446
+ windowsHide: true
447
+ });
448
+ } else {
449
+ this.process.kill('SIGTERM');
450
+ // 给进程 1 秒优雅退出
451
+ setTimeout(() => {
452
+ if (this.process && !this.process.killed) {
453
+ this.process.kill('SIGKILL');
454
+ }
455
+ }, 1000);
456
+ }
457
+ } catch {
458
+ // 进程可能已退出,忽略错误
459
+ }
460
+ }
461
+ }
462
+
463
+ // ============================================================
464
+ // ForgeService 主类
465
+ // ============================================================
466
+
467
+ class ForgeService {
468
+ running = false;
469
+ clientHandle = null;
470
+ goManager = null;
471
+ constructor(config, cwd, mode) {
472
+ this.config = config;
473
+ this.cwd = cwd;
474
+ this.mode = mode;
475
+ }
476
+
477
+ /** 停止所有服务 */
478
+ async stop() {
479
+ if (this.goManager) {
480
+ await this.goManager.stop();
481
+ this.goManager = null;
482
+ }
483
+ if (this.clientHandle) {
484
+ client.stopClient(this.clientHandle);
485
+ this.clientHandle = null;
486
+ }
487
+ this.running = false;
488
+ }
489
+
490
+ /** 获取状态 */
491
+ getStatus() {
492
+ return {
493
+ running: this.running,
494
+ config: {
495
+ clientPort: this.config.client.port,
496
+ servicePort: this.config.service.port
497
+ },
498
+ client: this.getClientStatus(),
499
+ service: this.getServiceStatus()
500
+ };
501
+ }
502
+
503
+ /** 启动所有服务(Go 后端先启动,前端后启动) */
504
+ async start(options) {
505
+ const opt = {
506
+ client: true,
507
+ service: true,
508
+ ...options
509
+ };
510
+
511
+ // === 第零步:按 preset 注入自定义环境变量(前端/Go 进程均可读取) ===
512
+ this.injectEnvPreset();
513
+
514
+ // === 第一步:启动 Go 后端 ===
515
+ if (opt.service) {
516
+ logger.logProgress('go', 'starting', `http://localhost:${this.config.service.port}`);
517
+ await this.startGoService();
518
+ // GoProcessManager.start() 内部已等待端口就绪(最多 15s)
519
+ if (this.goManager?.isRunning) {
520
+ logger.logProgress('go', 'ready', `http://localhost:${this.config.service.port}`);
521
+ } else {
522
+ logger.logWarn(`[go] failed — http://localhost:${this.config.service.port} 未就绪`);
523
+ }
524
+ }
525
+
526
+ // === 第二步:启动前端 ===
527
+ if (opt.client) {
528
+ logger.logProgress('client', 'starting', `http://localhost:${this.config.client.port}`);
529
+ const clientConfig = {
530
+ port: this.config.client.port,
531
+ dir: node_path.resolve(this.cwd, this.config.client.dir)
532
+ };
533
+ this.clientHandle = client.startClient(clientConfig);
534
+ }
535
+ this.running = true;
536
+ }
537
+
538
+ /** 按 preset 名称注入环境变量到 process.env */
539
+ injectEnvPreset() {
540
+ const presets = this.config.env || {};
541
+ const presetKeys = Object.keys(presets);
542
+ if (presetKeys.length === 0) return;
543
+
544
+ // 确定使用哪个 preset
545
+ const name = this.mode || presetKeys[0];
546
+ const vars = presets[name];
547
+ if (!vars) {
548
+ logger.logError(`[specflow] 环境变量分组 "${name}" 不存在,可选: ${presetKeys.join(', ')}`);
549
+ return;
550
+ }
551
+ for (const [key, value] of Object.entries(vars)) {
552
+ process.env[key] = value;
553
+ }
554
+ logger.logProgress('specflow', 'env', `"${name}" (${Object.keys(vars).length} 个变量)`);
555
+ }
556
+ getClientStatus() {
557
+ const {
558
+ port
559
+ } = this.config.client;
560
+ const pid = this.clientHandle?.child?.pid;
561
+ return {
562
+ port,
563
+ running: pid !== null && pid !== undefined,
564
+ pid
565
+ };
566
+ }
567
+ getServiceStatus() {
568
+ const {
569
+ port
570
+ } = this.config.service;
571
+ const pid = this.goManager?.pid;
572
+ return {
573
+ port,
574
+ running: this.goManager?.isRunning ?? false,
575
+ pid
576
+ };
577
+ }
578
+ async waitForPort(port, timeoutMs) {
579
+ const start = Date.now();
580
+ while (Date.now() - start < timeoutMs) {
581
+ try {
582
+ const socket = require('net').createConnection(port, '127.0.0.1');
583
+ await new Promise((resolve, reject) => {
584
+ socket.on('connect', () => {
585
+ socket.destroy();
586
+ resolve();
587
+ });
588
+ socket.on('error', reject);
589
+ setTimeout(() => {
590
+ socket.destroy();
591
+ reject(new Error('timeout'));
592
+ }, 2000);
593
+ });
594
+ return; // 端口可达
595
+ } catch {
596
+ // 端口未就绪,等 500ms 再试
597
+ await new Promise(r => setTimeout(r, 500));
598
+ }
599
+ }
600
+ }
601
+
602
+ // ---- Go 服务启动 ----
603
+
604
+ async startGoService() {
605
+ const serviceDir = node_path.resolve(this.cwd, this.config.service.dir);
606
+ if (!node_fs.existsSync(serviceDir)) return;
607
+
608
+ // 检查 main.go 或 go.mod 是否存在
609
+ const hasMainGo = node_fs.existsSync(node_path.resolve(serviceDir, 'main.go'));
610
+ const hasGoMod = node_fs.existsSync(node_path.resolve(serviceDir, 'go.mod'));
611
+ if (!hasMainGo && !hasGoMod) return;
612
+ const goEnv = {
613
+ ...process.env
614
+ // 不注入 PORT 环境变量 — 让 Go 程序使用自身配置文件(yaml/toml)中的端口设置
615
+ // 强制注入 PORT 会导致 tRPC 等框架的行为与直接运行 air 时不一致
616
+ };
617
+
618
+ // 解析 dev 配置
619
+ const {
620
+ dev
621
+ } = this.config.service;
622
+ let devOpts;
623
+ if (typeof dev === 'string' && dev !== 'air') {
624
+ // "make dev" 或 "./scripts/dev.sh"(显式自定义命令)
625
+ // 注意:'air' 是保留字,表示使用内置 air 管理(走 startWithAir 路径)
626
+ devOpts = {
627
+ command: dev
628
+ };
629
+ } else if (dev && typeof dev === 'object') {
630
+ // { command: 'make', args: ['dev'] }
631
+ devOpts = dev;
632
+ }
633
+ // dev === 'air' 或 dev 未配置 → 默认使用内置 startWithAir()
634
+
635
+ this.goManager = new GoProcessManager(serviceDir, this.config.service.port, goEnv, devOpts);
636
+ try {
637
+ await this.goManager.start();
638
+ } catch (err) {
639
+ logger.logError(`[go] ${err instanceof Error ? err.message : String(err)}`);
640
+ }
641
+ }
642
+ }
643
+
644
+ // ============================================================
645
+ // 工厂函数
646
+ // ============================================================
647
+
648
+ async function createForgeService(cwd, configPath, mode) {
649
+ const root = cwd || process.cwd();
650
+ const config = configPath ? await loadConfigFromPath(configPath, root) : await loader.loadConfig(root);
651
+ return new ForgeService(config, root, mode);
652
+ }
653
+ async function loadConfigFromPath(configPath, cwd) {
654
+ const {
655
+ parseConfig
656
+ } = await import('confmix');
657
+ const {
658
+ config: userConfig
659
+ } = await parseConfig(configPath);
660
+ return {
661
+ client: {
662
+ ...defaults.defaultConfig.client,
663
+ ...(userConfig.client || {})
664
+ },
665
+ service: {
666
+ ...defaults.defaultConfig.service,
667
+ ...(userConfig.service || {})
668
+ },
669
+ env: {
670
+ ...defaults.defaultConfig.env,
671
+ ...(userConfig.env || {})
672
+ },
673
+ configDir: cwd
674
+ };
675
+ }
676
+
677
+ exports.ForgeService = ForgeService;
678
+ exports.createForgeService = createForgeService;
679
+ //# sourceMappingURL=orchestrator.js.map