-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 서버 임베드 모듈 + maestro-server CLI (플러그인화 1단계) #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5680db
76ea393
a601036
ab742a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| #!/usr/bin/env node | ||
| // maestro-server CLI — 서버를 한 줄로 실행한다. (npx maestro-server / 플러그인 monitor 공용 진입점) | ||
| import { parseArgs } from 'node:util'; | ||
| import { existsSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { startMaestroServer } from '../lib/server-embed.mjs'; | ||
|
|
||
| const HELP = `maestro-server — Maestro 승인 서버 실행 | ||
|
|
||
| 사용법: | ||
| maestro-server [옵션] | ||
|
|
||
| 옵션: | ||
| --port <n> 포트 (기본 8080) | ||
| --host <h> 바인딩 호스트 (기본 127.0.0.1 — iPad 등 LAN 접속은 0.0.0.0) | ||
| --repo <path> 관리할 git 레포 경로 (기본: 현재 폴더가 git 레포면 현재 폴더) | ||
| --no-mdns Bonjour(mDNS) 광고 끄기 | ||
| --token <t> 서버 인증 토큰 (MAESTRO_SERVER_TOKEN) | ||
| -h, --help 도움말 | ||
|
|
||
| 이미 같은 포트에 Maestro 서버가 떠 있으면 재사용하고 종료합니다.`; | ||
|
|
||
| let values; | ||
| try { | ||
| ({ values } = parseArgs({ | ||
| options: { | ||
| port: { type: 'string', default: '8080' }, | ||
| host: { type: 'string', default: '127.0.0.1' }, | ||
| repo: { type: 'string' }, | ||
| 'no-mdns': { type: 'boolean', default: false }, | ||
| token: { type: 'string' }, | ||
| help: { type: 'boolean', short: 'h', default: false }, | ||
| }, | ||
| })); | ||
| } catch (error) { | ||
| console.error(`인자 오류: ${error.message}\n`); | ||
| console.error(HELP); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (values.help) { | ||
| console.log(HELP); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| let repoPath = values.repo ? path.resolve(values.repo) : undefined; | ||
| if (!repoPath && existsSync(path.resolve(process.cwd(), '.git'))) { | ||
| repoPath = process.cwd(); | ||
| } | ||
|
|
||
| let handle; | ||
| try { | ||
| handle = await startMaestroServer({ | ||
| port: Number(values.port), | ||
| host: values.host, | ||
| repoPath, | ||
| mdns: !values['no-mdns'], | ||
| token: values.token, | ||
| onLog: (line) => console.log(line), | ||
| }); | ||
| } catch (error) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (handle.alreadyRunning) { | ||
| console.log(`이미 실행 중인 Maestro 서버를 재사용합니다: ${handle.url}`); | ||
| console.log(`대시보드/iPad 연결 주소: ${handle.wsUrl}`); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| // ready 출력 전에 핸들러부터 등록 — 소비자가 ready 라인을 보고 보낸 시그널을 놓치지 않는다 | ||
| const shutdown = async () => { | ||
| await handle.stop(); | ||
| process.exit(0); | ||
| }; | ||
| process.on('SIGINT', shutdown); | ||
| process.on('SIGTERM', shutdown); | ||
|
|
||
| console.log(`\n🎼 Maestro 서버 실행 중 (pid ${handle.pid})`); | ||
| console.log(` 연결 주소: ${handle.wsUrl}${values.host === '0.0.0.0' ? ' (iPad에서는 ws://<이 PC의 LAN IP>:' + handle.port + ')' : ''}`); | ||
| console.log(' 종료: Ctrl+C\n'); | ||
|
|
||
| const { code } = (await handle.waitForExit()) || {}; | ||
| process.exit(typeof code === 'number' ? code : 0); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # 서버 임베드 모듈 + CLI Implementation Plan | ||
|
|
||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
|
|
||
| **Goal:** `startMaestroServer(options)` supervisor 모듈과 `npx maestro-server` CLI를 제공해 플러그인/확장/CLI가 한 줄로 서버를 띄우게 한다. | ||
|
|
||
| **Architecture:** maestro-server.js 무변경. `lib/server-embed.mjs`가 자식 프로세스 spawn + `/health` 대기 + 재사용 판정 + 멱등 stop을 담당. CLI는 이 모듈의 소비자 1호. | ||
|
|
||
| **Tech Stack:** node:child_process, node:util parseArgs(신규 의존성 0), node:test. | ||
|
|
||
| ## Global Constraints | ||
| - 서버 본체·대시보드·기존 dev 흐름(`npm run server`) 무변경. `npm run qa` + e2e 그대로 통과. | ||
| - 스펙: `docs/superpowers/specs/2026-07-22-server-embed-design.md` (§2 시그니처 준수) | ||
|
|
||
| ### Task 1: `lib/server-embed.mjs` + `tests/server-embed.test.mjs` (TDD) | ||
| - [ ] 실패 테스트 4종: 기동/stop, reuseExisting(alreadyRunning·pid null·stop no-op), mdns:false 로그 부재, 비-Maestro 포트 점유 에러 | ||
| - [ ] 구현(spawn env 구성: PORT/HOST/MAIN_REPO_PATH/MAESTRO_MDNS/MAESTRO_SERVER_TOKEN + env 오버라이드, 로그 링버퍼로 실패 메시지 구성, SIGTERM→2s→SIGKILL 멱등 stop) → PASS | ||
| - [ ] `npm run test:server` 전체 PASS → Commit `feat(embed): add startMaestroServer supervisor module` | ||
|
|
||
| ### Task 2: CLI + bin + 문서 + PR | ||
| - [ ] `bin/maestro-server.mjs`(parseArgs: --port --host --repo --no-mdns --token --help, cwd git 감지, 로그 passthrough, 시그널 stop) + package.json bin | ||
| - [ ] CLI 스모크 테스트(스폰→health→SIGTERM) 추가 → PASS | ||
| - [ ] USER_GUIDE "한 줄 실행" 섹션 → `npm run qa` && e2e → Commit → PR → CI | ||
|
|
||
| ## Self-Review 결과 | ||
| 스펙 §2→T1, §3→T2, §4 테스트 1–4→T1/5→T2. 갭 없음. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| # 서버 임베드 모듈 + CLI — 플러그인화 트랙 1단계 | ||
|
|
||
| - 날짜: 2026-07-22 | ||
| - 상태: 구현 진행 | ||
| - 배경: Maestro 확산의 병목은 서버 설치 경험("repo clone + npm + .env"). Claude Code 플러그인·VS Code 확장·npx CLI가 모두 같은 방식으로 서버를 띄울 수 있는 **임베드 코어**를 만든다. 2단계(Claude Code 플러그인)는 별도 스펙. | ||
|
|
||
| ## 1. 접근 결정: 재작성이 아니라 감싸기 (supervisor) | ||
|
|
||
| maestro-server.js(약 3,300줄)는 env 기반 top-level 실행 스크립트다. in-process 모듈화(export 분리)는 회귀 리스크가 크고, 소비자(플러그인/확장)에게는 **자식 프로세스가 오히려 더 나은 격리**(호스트 크래시 무영향, 독립 로그)를 준다. 기존 회귀 테스트 하네스가 이미 spawn+health 패턴으로 서버를 구동한다는 것이 이 접근의 검증이다. | ||
|
|
||
| → `startMaestroServer(options)`는 maestro-server.js를 자식 프로세스로 스폰하고 `/health`로 기동을 확인하는 **supervisor 모듈**로 구현한다. 서버 본체는 무변경. | ||
|
|
||
| ## 2. 임베드 모듈 `lib/server-embed.mjs` | ||
|
|
||
| ```js | ||
| startMaestroServer({ | ||
| port = 8080, host = '127.0.0.1', | ||
| repoPath, // MAIN_REPO_PATH. 생략 시 서버 기본(.env/cwd) 규칙 | ||
| mdns = true, // false → MAESTRO_MDNS=off | ||
| token, // MAESTRO_SERVER_TOKEN | ||
| reuseExisting = true,// 기동 전 /health 확인, 살아 있으면 재사용 | ||
| env = {}, // 추가 env 오버라이드 (스토어 경로 등) | ||
| onLog, // (line) => void — 자식 stdout/stderr 라인 콜백(선택) | ||
| startTimeoutMs = 15000, | ||
| }) => Promise<{ | ||
| url, wsUrl, port, host, | ||
| alreadyRunning, // true면 이 핸들이 소유하지 않음 → stop()은 no-op | ||
| pid, // alreadyRunning이면 null | ||
| stop(), // 소유 시 SIGTERM → 2초 대기 → SIGKILL, 멱등 | ||
| }> | ||
| ``` | ||
|
|
||
| - 실패 규약: 타임아웃/즉시 종료 시 자식을 정리하고 마지막 로그 tail을 담은 Error를 던진다. | ||
| - `reuseExisting` 판정: `GET /health` 200 → 재사용. 다른 프로세스가 포트를 점유했지만 health가 아니면 명확한 에러("포트 사용 중, Maestro 아님"). | ||
| - 서버 본체(maestro-server.js)와 기존 `scripts/run-server.mjs` dev 흐름은 무변경. | ||
|
|
||
| ## 3. CLI `bin/maestro-server.mjs` (npx 진입점) | ||
|
|
||
| - package.json `"bin": { "maestro-server": "bin/maestro-server.mjs" }` — 퍼블리시/`npm link` 시 `npx maestro-server`로 실행. | ||
| - 플래그: `--port`, `--host`, `--repo <path>`(기본: cwd가 git 레포면 cwd, 아니면 서버 기본 규칙), `--no-mdns`, `--token <t>`, `--help`. | ||
| - 동작: `startMaestroServer({ reuseExisting: true })` 호출 → 기동/재사용 결과와 대시보드 접속 안내(ws 주소, iPad 안내 한 줄) 출력 → 자식 로그를 그대로 전달하며 포그라운드 유지, SIGINT/SIGTERM 시 stop(). | ||
| - 의존성 추가 없음(플래그 파싱은 node:util `parseArgs`). | ||
|
|
||
| ## 4. 테스트 전략 (TDD) | ||
|
|
||
| `tests/server-embed.test.mjs` (node:test, 기존 하네스 유틸 재사용 가능): | ||
| 1. 픽스처 git 레포로 기동 → health 200 + project.path 일치 → `stop()` 후 프로세스 종료 확인 | ||
| 2. `reuseExisting`: 이미 떠 있는 서버가 있으면 `alreadyRunning: true` + 새 프로세스 미생성(pid null), stop()이 기존 서버를 죽이지 않음 | ||
| 3. `mdns: false` → 로그에 mDNS 광고 없음 | ||
| 4. 포트를 Maestro 아닌 프로세스가 점유 → 명확한 에러 | ||
| 5. CLI 스모크: `--port --repo --no-mdns`로 스폰 → health → SIGTERM 정상 종료 | ||
|
|
||
| 회귀: `npm run qa` + `npm run test:e2e` 불변(서버 본체·대시보드 무변경이므로 그대로 통과해야 함). | ||
|
|
||
| ## 5. 리스크순 로드맵 | ||
|
|
||
| | 순서 | 작업 | 리스크 | | ||
| |---|---|---| | ||
| | 1 | `startMaestroServer` supervisor + 테스트 1·2·3·4 | 중 (프로세스 수명주기/좀비 방지) | | ||
| | 2 | CLI + bin 등록 + 테스트 5 + USER_GUIDE 한 줄 실행 섹션 | 저 | | ||
|
|
||
| ## 6. 자율 진행 중 내린 결정 | ||
|
|
||
| 1. in-process 리팩터 대신 supervisor 래핑 — 회귀 0 목표, 소비자 격리 이점. | ||
| 2. `reuseExisting` 기본 on — 플러그인/확장/CLI가 동시에 있어도 서버 1개. | ||
| 3. 2단계(Claude Code 플러그인)는 공식 규격 조사 완료 후 별도 스펙 — 이 모듈의 `startMaestroServer`를 그대로 소비한다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // server-embed.mjs — maestro-server를 소비자(CLI/플러그인/확장)가 한 줄로 띄우는 supervisor. | ||
| // 서버 본체를 자식 프로세스로 스폰하고 /health로 기동을 확인한다. 서버 코드는 무변경. | ||
| import { spawn } from 'node:child_process'; | ||
| import path from 'node:path'; | ||
| import { setTimeout as delay } from 'node:timers/promises'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); | ||
| const SERVER_ENTRY = path.resolve(ROOT_DIR, 'maestro-server.js'); | ||
| const LOG_TAIL_LINES = 30; | ||
|
|
||
| async function fetchHealth(url, timeoutMs = 1500) { | ||
| try { | ||
| const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) }); | ||
| if (!response.ok) return { reachable: true, maestro: false }; | ||
| const body = await response.json().catch(() => null); | ||
| if (body?.status === 'ok' && body?.project) { | ||
| return { reachable: true, maestro: true, health: body }; | ||
| } | ||
| return { reachable: true, maestro: false }; | ||
| } catch (error) { | ||
| // 연결 거부 = 포트 비어 있음. 그 외(타임아웃 등)는 도달 불가로 간주. | ||
| return { reachable: false, maestro: false, error }; | ||
| } | ||
| } | ||
|
|
||
| export async function startMaestroServer(options = {}) { | ||
| const { | ||
| port = 8080, | ||
| host = '127.0.0.1', | ||
| repoPath, | ||
| mdns = true, | ||
| token, | ||
| reuseExisting = true, | ||
| env: extraEnv = {}, | ||
| onLog, | ||
| startTimeoutMs = 15000, | ||
| } = options; | ||
|
|
||
| const url = `http://${host}:${port}`; | ||
| const wsUrl = `ws://${host}:${port}`; | ||
|
|
||
| if (reuseExisting) { | ||
| const probe = await fetchHealth(url); | ||
| if (probe.maestro) { | ||
| return { | ||
|
Comment on lines
+45
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the default port already hosts Maestro for repository A and a caller requests Useful? React with 👍 / 👎. |
||
| url, | ||
| wsUrl, | ||
| port, | ||
| host, | ||
| alreadyRunning: true, | ||
| pid: null, | ||
| health: probe.health, | ||
| stop: async () => {}, // 이 핸들이 소유하지 않은 서버는 건드리지 않는다 | ||
| waitForExit: async () => null, | ||
| }; | ||
| } | ||
| if (probe.reachable) { | ||
| throw new Error(`포트 ${host}:${port}가 이미 사용 중이지만 Maestro 서버가 아닙니다. 다른 포트를 지정하세요.`); | ||
| } | ||
| } | ||
|
|
||
| const childEnv = { | ||
| ...process.env, | ||
| PORT: String(port), | ||
| HOST: host, | ||
| MAESTRO_MDNS: mdns ? 'on' : 'off', | ||
| ...(repoPath ? { MAIN_REPO_PATH: repoPath } : {}), | ||
| ...(token ? { MAESTRO_SERVER_TOKEN: token } : {}), | ||
| ...extraEnv, | ||
| }; | ||
|
|
||
| const child = spawn(process.execPath, [SERVER_ENTRY], { | ||
| cwd: ROOT_DIR, | ||
| env: childEnv, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }); | ||
|
|
||
| const logTail = []; | ||
| const handleChunk = (chunk) => { | ||
| for (const line of chunk.toString().split('\n')) { | ||
| if (!line.trim()) continue; | ||
| logTail.push(line); | ||
| if (logTail.length > LOG_TAIL_LINES) logTail.shift(); | ||
| if (typeof onLog === 'function') onLog(line); | ||
| } | ||
| }; | ||
| child.stdout.on('data', handleChunk); | ||
| child.stderr.on('data', handleChunk); | ||
|
|
||
| let exited = false; | ||
| const exitPromise = new Promise((resolveExit) => { | ||
| child.on('exit', (code, signal) => { | ||
| exited = true; | ||
| resolveExit({ code, signal }); | ||
| }); | ||
| }); | ||
|
|
||
| const deadline = Date.now() + startTimeoutMs; | ||
| let healthy = null; | ||
| while (Date.now() < deadline) { | ||
| if (exited) break; | ||
| const probe = await fetchHealth(url, 1000); | ||
| if (probe.maestro) { | ||
| healthy = probe.health; | ||
| break; | ||
| } | ||
| await delay(150); | ||
| } | ||
|
|
||
| const stop = async () => { | ||
| if (child.exitCode !== null || child.signalCode !== null) return; | ||
| child.kill('SIGTERM'); | ||
| const killDeadline = Date.now() + 2000; | ||
| while (Date.now() < killDeadline) { | ||
| if (child.exitCode !== null || child.signalCode !== null) return; | ||
| await delay(50); | ||
| } | ||
| child.kill('SIGKILL'); | ||
| }; | ||
|
|
||
| if (!healthy) { | ||
| await stop(); | ||
| const reason = exited ? '프로세스가 조기 종료됨' : `기동 타임아웃(${startTimeoutMs}ms)`; | ||
| throw new Error(`Maestro 서버 기동 실패 — ${reason}\n--- 최근 로그 ---\n${logTail.join('\n')}`); | ||
| } | ||
|
|
||
| return { | ||
| url, | ||
| wsUrl, | ||
| port, | ||
| host, | ||
| alreadyRunning: false, | ||
| pid: child.pid, | ||
| health: healthy, | ||
| stop, | ||
| waitForExit: () => exitPromise, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
hostis an unbracketed IPv6 literal such as::1, which is valid forserver.listen()and accepted by the CLI, this produces the invalid URLhttp://::1:<port>. Every health probe is consequently treated as unreachable even though the child binds successfully, and the supervisor eventually kills the healthy server and reports a startup timeout. Preserve the unbracketed bind host but bracket IPv6 literals when constructing HTTP and WebSocket URLs.Useful? React with 👍 / 👎.