diff --git a/docs/superpowers/specs/2026-08-05-vscode-extension-design.md b/docs/superpowers/specs/2026-08-05-vscode-extension-design.md new file mode 100644 index 0000000..4a7165c --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-vscode-extension-design.md @@ -0,0 +1,39 @@ +# Maestro Coding VS Code 확장 설계 (서버 임베드 재사용) + +- 날짜: 2026-08-05 +- 상태: 확정 (2026-07 다음 후보 목록의 마지막 항목) +- 범위: `vscode-extension/`(신규) + `tests/` + docs. 본체·lib 무변경. + +## 0. 목표 + +Claude Code 플러그인과 같은 소비자 패턴으로, VS Code 안에서 Maestro 서버를 +한 명령으로 띄우고 대시보드를 여는 확장. `lib/server-embed.mjs`의 +`startMaestroServer` 핸들을 그대로 재사용한다. + +## 1. 구성 + +- `vscode-extension/package.json`: 명령 4종 — + `maestro.startServer` / `maestro.stopServer` / `maestro.openDashboard` / + `maestro.showLogs`. 설정 `maestro.port`(기본 8080), + `maestro.repoPath`(기본: 첫 워크스페이스 폴더). +- `vscode-extension/extension.cjs`: 확장 진입점 — + OutputChannel("Maestro")로 서버 로그, 상태바 아이템(`$(play) Maestro 8080` + ↔ `$(circle-slash)`), openDashboard는 Simple Browser 우선·실패 시 외부 + 브라우저. deactivate 시 소유 서버 stop (reuse한 서버는 건드리지 않음 — + embed 핸들 의미 그대로). +- `vscode-extension/lifecycle.cjs`: **vscode 비의존** 상태 머신 — + `createLifecycle({ embed })` → `start(options)`(중복 시작 방지·재사용 표시), + `stop()`, `status()`. 단위 테스트 대상. + +## 2. 테스트 (`tests/vscode-lifecycle.test.mjs`, 루트 스위트 편입) + +가짜 embed 주입으로: ① start→running(url/pid), 중복 start는 기존 핸들 유지, +② stop 후 idle·재시작 가능, ③ embed 실패 시 idle 유지+오류 전파, +④ alreadyRunning(재사용) 상태 구분. VS Code API 통합(F5 개발 호스트)은 +수동 스모크로 체크리스트화. + +## 3. 배포 + +MVP는 개발 호스트(F5)/`vsce package` 수동 — 확장이 레포 내 상대경로로 +`lib/server-embed.mjs`를 import하므로 패키징 시 레포 동봉 전제. +마켓플레이스 게시는 후속(퍼블리셔 계정 필요). diff --git a/tests/vscode-lifecycle.test.mjs b/tests/vscode-lifecycle.test.mjs new file mode 100644 index 0000000..e344afc --- /dev/null +++ b/tests/vscode-lifecycle.test.mjs @@ -0,0 +1,82 @@ +// VS Code 확장 라이프사이클 (스펙 2026-08-05 §2) — vscode 비의존 상태 머신 검증. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLifecycle } from '../vscode-extension/lifecycle.cjs'; + +function createFakeEmbed() { + const calls = []; + let stopped = 0; + const embed = async (options) => { + calls.push(options); + if (options.port === 9999) { + throw new Error('기동 실패 시뮬레이션'); + } + return { + url: `http://127.0.0.1:${options.port}`, + port: options.port, + alreadyRunning: options.port === 8081, + pid: options.port === 8081 ? null : 4242, + stop: async () => { + stopped += 1; + }, + }; + }; + return { embed, calls, stopCount: () => stopped }; +} + +test('start는 running 상태가 되고 중복 start는 기존 핸들을 유지한다', async () => { + const fake = createFakeEmbed(); + const lifecycle = createLifecycle({ embed: fake.embed }); + + const first = await lifecycle.start({ port: 8080 }); + assert.equal(first.url, 'http://127.0.0.1:8080'); + assert.equal(lifecycle.status().state, 'running'); + assert.equal(lifecycle.status().url, 'http://127.0.0.1:8080'); + + const second = await lifecycle.start({ port: 8080 }); + assert.equal(second, first, '이미 실행 중이면 같은 핸들 반환'); + assert.equal(fake.calls.length, 1, 'embed는 한 번만 호출'); +}); + +test('stop 후 idle이 되고 다시 시작할 수 있다', async () => { + const fake = createFakeEmbed(); + const lifecycle = createLifecycle({ embed: fake.embed }); + + await lifecycle.start({ port: 8080 }); + await lifecycle.stop(); + assert.equal(lifecycle.status().state, 'idle'); + assert.equal(fake.stopCount(), 1); + + await lifecycle.start({ port: 8080 }); + assert.equal(lifecycle.status().state, 'running'); + assert.equal(fake.calls.length, 2); +}); + +test('embed 실패 시 idle을 유지하고 오류를 전파한다', async () => { + const fake = createFakeEmbed(); + const lifecycle = createLifecycle({ embed: fake.embed }); + + await assert.rejects(lifecycle.start({ port: 9999 }), /기동 실패/); + assert.equal(lifecycle.status().state, 'idle'); +}); + +test('재사용(alreadyRunning) 서버는 상태에 구분 표시되고 stop은 조용히 통과한다', async () => { + const fake = createFakeEmbed(); + const lifecycle = createLifecycle({ embed: fake.embed }); + + await lifecycle.start({ port: 8081 }); + const status = lifecycle.status(); + assert.equal(status.state, 'running'); + assert.equal(status.reused, true); + + await lifecycle.stop(); // 소유하지 않은 서버 — embed 핸들의 no-op stop 호출 + assert.equal(lifecycle.status().state, 'idle'); +}); + +test('idle 상태의 stop은 아무 일도 하지 않는다', async () => { + const fake = createFakeEmbed(); + const lifecycle = createLifecycle({ embed: fake.embed }); + await lifecycle.stop(); + assert.equal(lifecycle.status().state, 'idle'); + assert.equal(fake.stopCount(), 0); +}); diff --git a/vscode-extension/README.md b/vscode-extension/README.md new file mode 100644 index 0000000..931eb9f --- /dev/null +++ b/vscode-extension/README.md @@ -0,0 +1,42 @@ +# Maestro Coding VS Code 확장 + +VS Code 안에서 Maestro 승인 서버를 띄우고 대시보드를 여는 얇은 소비자 — +[`lib/server-embed.mjs`](../lib/server-embed.mjs)의 supervisor 핸들을 그대로 쓴다 +(Claude Code 플러그인과 같은 패턴, 서버 코드 무변경). + +## 명령 + +| 명령 | 동작 | +| --- | --- | +| `Maestro: 서버 시작` | 설정 포트(기본 8080)로 서버 기동, 이미 떠 있으면 재사용 | +| `Maestro: 대시보드 열기` | Simple Browser(에디터 안) 우선, 실패 시 외부 브라우저 | +| `Maestro: 서버 중지` | 이 확장이 소유한 서버만 종료 (재사용 서버는 건드리지 않음) | +| `Maestro: 서버 로그 보기` | Output 채널 "Maestro" | + +상태바: `▶ Maestro 8080`(클릭=대시보드) ↔ `⊘ Maestro`(클릭=시작). +설정: `maestro.port`, `maestro.repoPath`(비우면 첫 워크스페이스 폴더). + +## 개발 실행 (F5) + +1. VS Code로 **이 레포 루트**를 연다. +2. 실행 대상: Extension Development Host — + `.vscode/launch.json` 없이도 `vscode-extension/`을 확장 폴더로 지정해 + `code --extensionDevelopmentPath=$(pwd)/vscode-extension .` 로 실행 가능. +3. 개발 호스트에서 명령 팔레트 → "Maestro: 서버 시작". + +## 수동 스모크 체크리스트 + +- [ ] 서버 시작 → 상태바 `▶ Maestro 8080` + 알림 +- [ ] 대시보드 열기 → 레인 UI 표시 +- [ ] 이미 떠 있는 서버가 있을 때 시작 → "(기존 서버 재사용)" 표시 +- [ ] 서버 중지 → 상태바 `⊘`, 재사용 서버는 살아 있음 +- [ ] 창 종료(deactivate) → 소유 서버 정리 + +## 패키징 (후속) + +확장이 레포 상대경로(`../lib`, `../maestro-server.js`)에 의존하므로 `vsce +package`는 레포 동봉 구조가 전제다. 마켓플레이스 게시는 퍼블리셔 계정 +확보 후 별도 스펙으로. + +라이프사이클 상태 머신은 vscode 비의존(`lifecycle.cjs`)이며 루트 스위트의 +`tests/vscode-lifecycle.test.mjs`가 검증한다. diff --git a/vscode-extension/extension.cjs b/vscode-extension/extension.cjs new file mode 100644 index 0000000..7f4a9b2 --- /dev/null +++ b/vscode-extension/extension.cjs @@ -0,0 +1,99 @@ +// Maestro Coding VS Code 확장 진입점 — lib/server-embed.mjs 소비자 (스펙 2026-08-05). +// 서버 소유권 의미는 embed 핸들 그대로: 재사용한 서버는 stop해도 건드리지 않는다. +const path = require('node:path'); +const vscode = require('vscode'); +const { createLifecycle } = require('./lifecycle.cjs'); + +const EMBED_PATH = path.resolve(__dirname, '..', 'lib', 'server-embed.mjs'); + +let lifecycle = null; +let outputChannel = null; +let statusBarItem = null; + +function refreshStatusBar() { + const status = lifecycle.status(); + if (status.state === 'running') { + statusBarItem.text = `$(play) Maestro ${status.port}${status.reused ? ' (재사용)' : ''}`; + statusBarItem.tooltip = `Maestro 서버 실행 중 — ${status.url}`; + statusBarItem.command = 'maestro.openDashboard'; + } else { + statusBarItem.text = '$(circle-slash) Maestro'; + statusBarItem.tooltip = 'Maestro 서버 꺼짐 — 클릭하여 시작'; + statusBarItem.command = 'maestro.startServer'; + } + statusBarItem.show(); +} + +function resolveRepoPath() { + const configured = vscode.workspace.getConfiguration('maestro').get('repoPath'); + if (configured) return configured; + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || undefined; +} + +async function startServer() { + const port = vscode.workspace.getConfiguration('maestro').get('port') || 8080; + const repoPath = resolveRepoPath(); + try { + const handle = await lifecycle.start({ + port, + repoPath, + onLog: (line) => outputChannel.appendLine(line), + }); + refreshStatusBar(); + const reuseNote = handle.alreadyRunning ? ' (기존 서버 재사용)' : ''; + const action = await vscode.window.showInformationMessage( + `Maestro 서버 실행 중 — ${handle.url}${reuseNote}`, + '대시보드 열기', + ); + if (action === '대시보드 열기') { + await openDashboard(); + } + } catch (error) { + outputChannel.appendLine(String(error?.message || error)); + vscode.window.showErrorMessage(`Maestro 서버 시작 실패: ${error?.message || error}`); + } +} + +async function stopServer() { + await lifecycle.stop(); + refreshStatusBar(); + vscode.window.showInformationMessage('Maestro 서버를 중지했습니다.'); +} + +async function openDashboard() { + const status = lifecycle.status(); + if (status.state !== 'running') { + const action = await vscode.window.showWarningMessage('Maestro 서버가 꺼져 있습니다.', '서버 시작'); + if (action === '서버 시작') await startServer(); + return; + } + try { + // 에디터 안 Simple Browser 우선, 실패 시 외부 브라우저 + await vscode.commands.executeCommand('simpleBrowser.show', status.url); + } catch { + await vscode.env.openExternal(vscode.Uri.parse(status.url)); + } +} + +async function activate(context) { + const { startMaestroServer } = await import(EMBED_PATH); + lifecycle = createLifecycle({ embed: startMaestroServer }); + outputChannel = vscode.window.createOutputChannel('Maestro'); + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + refreshStatusBar(); + + context.subscriptions.push( + outputChannel, + statusBarItem, + vscode.commands.registerCommand('maestro.startServer', startServer), + vscode.commands.registerCommand('maestro.stopServer', stopServer), + vscode.commands.registerCommand('maestro.openDashboard', openDashboard), + vscode.commands.registerCommand('maestro.showLogs', () => outputChannel.show(true)), + ); +} + +async function deactivate() { + if (lifecycle) await lifecycle.stop(); +} + +module.exports = { activate, deactivate }; diff --git a/vscode-extension/lifecycle.cjs b/vscode-extension/lifecycle.cjs new file mode 100644 index 0000000..89313ac --- /dev/null +++ b/vscode-extension/lifecycle.cjs @@ -0,0 +1,50 @@ +// VS Code 확장의 서버 라이프사이클 상태 머신 (vscode 비의존 — 단위 테스트 대상). +// embed는 lib/server-embed.mjs의 startMaestroServer(또는 테스트 대역)를 주입받는다. + +function createLifecycle({ embed }) { + if (typeof embed !== 'function') { + throw new Error('embed 함수가 필요합니다'); + } + + let handle = null; + let starting = null; + + return { + async start(options = {}) { + if (handle) return handle; + if (starting) return starting; + + starting = embed(options) + .then((nextHandle) => { + handle = nextHandle; + return nextHandle; + }) + .finally(() => { + starting = null; + }); + return starting; + }, + + async stop() { + if (!handle) return; + const current = handle; + handle = null; + await current.stop(); + }, + + status() { + if (!handle) { + return { state: 'idle' }; + } + return { + state: 'running', + url: handle.url, + port: handle.port, + pid: handle.pid, + reused: Boolean(handle.alreadyRunning), + }; + }, + }; +} + +module.exports = { createLifecycle }; diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..b00834e --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,50 @@ +{ + "name": "maestro-coding-vscode", + "displayName": "Maestro Coding", + "description": "VS Code에서 Maestro 승인 서버를 띄우고 대시보드를 엽니다 (read-only 결정 관제탑).", + "version": "0.1.0", + "private": true, + "license": "PolyForm-Noncommercial-1.0.0", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other" + ], + "main": "./extension.cjs", + "contributes": { + "commands": [ + { + "command": "maestro.startServer", + "title": "Maestro: 서버 시작" + }, + { + "command": "maestro.stopServer", + "title": "Maestro: 서버 중지" + }, + { + "command": "maestro.openDashboard", + "title": "Maestro: 대시보드 열기" + }, + { + "command": "maestro.showLogs", + "title": "Maestro: 서버 로그 보기" + } + ], + "configuration": { + "title": "Maestro Coding", + "properties": { + "maestro.port": { + "type": "number", + "default": 8080, + "description": "Maestro 서버 포트" + }, + "maestro.repoPath": { + "type": "string", + "default": "", + "description": "감시할 저장소 경로 (비우면 첫 번째 워크스페이스 폴더)" + } + } + } + } +}