From a5680db054e665aafcf2cece8a13ce3b8e73a85b Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 22 Jul 2026 19:57:04 +0900 Subject: [PATCH 1/4] docs: add server embed module + CLI spec (plugin track phase 1) Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-22-server-embed-design.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-server-embed-design.md diff --git a/docs/superpowers/specs/2026-07-22-server-embed-design.md b/docs/superpowers/specs/2026-07-22-server-embed-design.md new file mode 100644 index 0000000..9e93708 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-server-embed-design.md @@ -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 `(기본: cwd가 git 레포면 cwd, 아니면 서버 기본 규칙), `--no-mdns`, `--token `, `--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`를 그대로 소비한다. From 76ea39333498541f7b180aab218662dc60202492 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 22 Jul 2026 19:57:44 +0900 Subject: [PATCH 2/4] docs: add server embed implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-22-server-embed.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-server-embed.md diff --git a/docs/superpowers/plans/2026-07-22-server-embed.md b/docs/superpowers/plans/2026-07-22-server-embed.md new file mode 100644 index 0000000..8ea5851 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-server-embed.md @@ -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. 갭 없음. From a601036ac2167b7c154962ccfa65b68448af5761 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 22 Jul 2026 19:59:56 +0900 Subject: [PATCH 3/4] feat(embed): add startMaestroServer supervisor module Co-Authored-By: Claude Fable 5 --- lib/server-embed.mjs | 134 ++++++++++++++++++++++++++++++++++++ tests/server-embed.test.mjs | 123 +++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 lib/server-embed.mjs create mode 100644 tests/server-embed.test.mjs diff --git a/lib/server-embed.mjs b/lib/server-embed.mjs new file mode 100644 index 0000000..317d6ba --- /dev/null +++ b/lib/server-embed.mjs @@ -0,0 +1,134 @@ +// 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 { + url, + wsUrl, + port, + host, + alreadyRunning: true, + pid: null, + health: probe.health, + stop: async () => {}, // 이 핸들이 소유하지 않은 서버는 건드리지 않는다 + }; + } + 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; + child.on('exit', () => { + exited = true; + }); + + 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, + }; +} diff --git a/tests/server-embed.test.mjs b/tests/server-embed.test.mjs new file mode 100644 index 0000000..0ca01da --- /dev/null +++ b/tests/server-embed.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import { resolve } from 'node:path'; +import { startMaestroServer } from '../lib/server-embed.mjs'; + +function randomPort() { + return 14000 + Math.floor(Math.random() * 2000); +} + +function createFixtureRepo() { + const repoPath = mkdtempSync(resolve(os.tmpdir(), 'maestro-embed-repo-')); + const git = (...args) => execFileSync('git', ['-C', repoPath, ...args]); + git('init', '-qb', 'main'); + git('config', 'user.email', 'embed@test.local'); + git('config', 'user.name', 'Embed'); + writeFileSync(resolve(repoPath, 'README.md'), '# embed fixture\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + return repoPath; +} + +const scratchEnv = (label) => ({ + MAESTRO_HISTORY_STORE_PATH: resolve(os.tmpdir(), `maestro-embed-history-${label}-${Date.now()}.json`), + MAESTRO_AGENT_STORE_PATH: resolve(os.tmpdir(), `maestro-embed-agents-${label}-${Date.now()}.json`), +}); + +async function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +test('startMaestroServer boots the server against a repo and stops it', async (t) => { + const repoPath = createFixtureRepo(); + t.after(() => rmSync(repoPath, { recursive: true, force: true })); + + const handle = await startMaestroServer({ + port: randomPort(), + repoPath, + mdns: false, + env: scratchEnv('boot'), + }); + + assert.equal(handle.alreadyRunning, false); + assert.ok(handle.pid > 0); + assert.equal(handle.wsUrl, `ws://127.0.0.1:${handle.port}`); + + const health = await (await fetch(`${handle.url}/health`)).json(); + assert.equal(health.status, 'ok'); + assert.equal(health.project.path, repoPath); + + await handle.stop(); + assert.equal(await isProcessAlive(handle.pid), false); + + // stop은 멱등 + await handle.stop(); +}); + +test('startMaestroServer reuses an already-running server', async (t) => { + const repoPath = createFixtureRepo(); + t.after(() => rmSync(repoPath, { recursive: true, force: true })); + + const first = await startMaestroServer({ + port: randomPort(), + repoPath, + mdns: false, + env: scratchEnv('reuse'), + }); + t.after(() => first.stop()); + + const second = await startMaestroServer({ port: first.port, repoPath, mdns: false }); + assert.equal(second.alreadyRunning, true); + assert.equal(second.pid, null); + + // 재사용 핸들의 stop은 기존 서버를 죽이지 않는다 + await second.stop(); + const health = await (await fetch(`${first.url}/health`)).json(); + assert.equal(health.status, 'ok'); +}); + +test('startMaestroServer disables mdns advertising when mdns:false', async (t) => { + const repoPath = createFixtureRepo(); + t.after(() => rmSync(repoPath, { recursive: true, force: true })); + + const lines = []; + const handle = await startMaestroServer({ + port: randomPort(), + repoPath, + mdns: false, + env: scratchEnv('mdns'), + onLog: (line) => lines.push(line), + }); + t.after(() => handle.stop()); + + // 기동 로그가 이미 수집됐고 mDNS 광고 라인이 없어야 한다 + assert.ok(lines.some((line) => line.includes('Maestro Backend Server')), `no boot log in: ${lines.slice(0, 3).join(' | ')}`); + assert.ok(!lines.some((line) => line.includes('mDNS 광고:')), 'mdns:false인데 광고 로그 존재'); +}); + +test('startMaestroServer fails clearly when the port is held by a non-Maestro process', async (t) => { + const port = randomPort(); + const blocker = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('not maestro'); + }); + await new Promise((resolveListen) => blocker.listen(port, '127.0.0.1', resolveListen)); + t.after(() => new Promise((resolveClose) => blocker.close(resolveClose))); + + await assert.rejects( + () => startMaestroServer({ port, mdns: false, startTimeoutMs: 4000 }), + (error) => { + assert.match(error.message, /Maestro 서버가 아닙니다/); + return true; + }, + ); +}); From ab742a74d26b522174cd022d1321743d094623d4 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 22 Jul 2026 20:04:49 +0900 Subject: [PATCH 4/4] feat(cli): add maestro-server one-line CLI on the embed supervisor Co-Authored-By: Claude Fable 5 --- USER_GUIDE.md | 15 +++++++ bin/maestro-server.mjs | 85 +++++++++++++++++++++++++++++++++++++ lib/server-embed.mjs | 9 +++- package.json | 5 ++- tests/server-embed.test.mjs | 53 +++++++++++++++++++++++ 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100755 bin/maestro-server.mjs diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e1aa2fa..cee9ac5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -81,6 +81,21 @@ npm run dev --- +## 한 줄 실행 (maestro-server CLI) + +`.env` 설정 없이 관리할 git 레포 폴더에서 바로 서버를 띄울 수 있습니다: + +```bash +node bin/maestro-server.mjs --repo /path/to/your-repo +``` + +- 현재 폴더가 git 레포면 `--repo` 생략 가능. `npm link` 후에는 어디서든 `maestro-server`로 실행됩니다. +- 주요 옵션: `--port 8080`, `--host 0.0.0.0`(iPad 등 LAN 접속 허용), `--no-mdns`, `--token `, `--help` +- 같은 포트에 Maestro 서버가 이미 떠 있으면 새로 띄우지 않고 재사용을 알리고 종료합니다. +- 프로그래밍 방식 통합(플러그인/확장)은 `lib/server-embed.mjs`의 `startMaestroServer(options)`를 사용하세요. + +--- + ## 프로젝트 등록/전환 쉽게 하기 매번 `MAIN_REPO_PATH`를 손으로 바꾸지 않도록, 자주 쓰는 프로젝트를 등록해두고 선택만 할 수 있습니다. diff --git a/bin/maestro-server.mjs b/bin/maestro-server.mjs new file mode 100755 index 0000000..2c6a019 --- /dev/null +++ b/bin/maestro-server.mjs @@ -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 포트 (기본 8080) + --host 바인딩 호스트 (기본 127.0.0.1 — iPad 등 LAN 접속은 0.0.0.0) + --repo 관리할 git 레포 경로 (기본: 현재 폴더가 git 레포면 현재 폴더) + --no-mdns Bonjour(mDNS) 광고 끄기 + --token 서버 인증 토큰 (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); diff --git a/lib/server-embed.mjs b/lib/server-embed.mjs index 317d6ba..b601bd6 100644 --- a/lib/server-embed.mjs +++ b/lib/server-embed.mjs @@ -52,6 +52,7 @@ export async function startMaestroServer(options = {}) { pid: null, health: probe.health, stop: async () => {}, // 이 핸들이 소유하지 않은 서버는 건드리지 않는다 + waitForExit: async () => null, }; } if (probe.reachable) { @@ -88,8 +89,11 @@ export async function startMaestroServer(options = {}) { child.stderr.on('data', handleChunk); let exited = false; - child.on('exit', () => { - exited = true; + const exitPromise = new Promise((resolveExit) => { + child.on('exit', (code, signal) => { + exited = true; + resolveExit({ code, signal }); + }); }); const deadline = Date.now() + startTimeoutMs; @@ -130,5 +134,6 @@ export async function startMaestroServer(options = {}) { pid: child.pid, health: healthy, stop, + waitForExit: () => exitPromise, }; } diff --git a/package.json b/package.json index f40e41c..5854f4b 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,10 @@ "name": "maestro-coding", "private": true, "version": "0.95.0", - "license": "SEE LICENSE IN LICENSE", "type": "module", + "bin": { + "maestro-server": "bin/maestro-server.mjs" + }, "scripts": { "dev": "vite", "build": "vite build", @@ -28,6 +30,7 @@ "project:add": "node scripts/projects.js add", "project:use": "node scripts/projects.js use" }, + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@capacitor/core": "^8.4.2", "@capacitor/haptics": "^8.0.2", diff --git a/tests/server-embed.test.mjs b/tests/server-embed.test.mjs index 0ca01da..ebe42f2 100644 --- a/tests/server-embed.test.mjs +++ b/tests/server-embed.test.mjs @@ -5,8 +5,12 @@ import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; import { startMaestroServer } from '../lib/server-embed.mjs'; +const ROOT_DIR_FOR_TEST = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + function randomPort() { return 14000 + Math.floor(Math.random() * 2000); } @@ -104,6 +108,55 @@ test('startMaestroServer disables mdns advertising when mdns:false', async (t) = assert.ok(!lines.some((line) => line.includes('mDNS 광고:')), 'mdns:false인데 광고 로그 존재'); }); +test('maestro-server CLI boots, reports address, and shuts down on SIGTERM', async (t) => { + const repoPath = createFixtureRepo(); + t.after(() => rmSync(repoPath, { recursive: true, force: true })); + + const port = randomPort(); + const { spawn } = await import('node:child_process'); + const cli = spawn(process.execPath, [ + resolve(ROOT_DIR_FOR_TEST, 'bin/maestro-server.mjs'), + '--port', String(port), + '--repo', repoPath, + '--no-mdns', + ], { + env: { ...process.env, ...scratchEnv('cli') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + cli.stdout.on('data', (chunk) => { output += chunk.toString(); }); + cli.stderr.on('data', (chunk) => { output += chunk.toString(); }); + + // CLI의 ready 라인(시그널 핸들러 등록 이후 출력)을 기준으로 대기 — 조기 SIGTERM 레이스 방지 + const deadline = Date.now() + 15000; + while (Date.now() < deadline && !output.includes('Maestro 서버 실행 중')) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + } + assert.ok(output.includes('Maestro 서버 실행 중'), `CLI가 ready를 출력하지 않음. output:\n${output}`); + assert.ok(output.includes(`ws://127.0.0.1:${port}`), 'CLI가 연결 주소를 안내하지 않음'); + + const health = await (await fetch(`http://127.0.0.1:${port}/health`)).json(); + assert.equal(health.status, 'ok'); + + cli.kill('SIGTERM'); + await new Promise((resolveExit) => cli.once('exit', resolveExit)); + + // 서버(자식)도 함께 내려갔는지 폴링으로 확인 (종료 타이밍 레이스 방지) + const serverGone = await (async () => { + const shutdownDeadline = Date.now() + 4000; + while (Date.now() < shutdownDeadline) { + try { + await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); + } catch { + return true; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + } + return false; + })(); + assert.ok(serverGone, 'CLI 종료 후에도 서버가 살아 있음'); +}); + test('startMaestroServer fails clearly when the port is held by a non-Maestro process', async (t) => { const port = randomPort(); const blocker = http.createServer((req, res) => {