From 01b6d38fd388c15b268b4654883925ccc8ef4c9e Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 16:11:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20VS=20Code=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=20(server-embed=20=EC=86=8C=EB=B9=84?= =?UTF-8?q?=EC=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-05-vscode-extension-design.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-vscode-extension-design.md 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하므로 패키징 시 레포 동봉 전제. +마켓플레이스 게시는 후속(퍼블리셔 계정 필요). From 18aab48c0f8423346349ddaa6462a1769dfec6a4 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 16:16:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(vscode):=20Maestro=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=9E=84=EB=B2=A0=EB=93=9C=20VS=20Code=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/server-embed.mjs를 재사용하는 얇은 소비자: 명령 4종(시작/중지/ 대시보드/로그), 상태바 토글, Output 채널 로그, Simple Browser 우선 대시보드. 서버 소유권 의미(재사용 서버 불간섭)는 embed 핸들 그대로. 라이프사이클 상태 머신은 vscode 비의존 모듈로 분리해 루트 스위트에서 단위 테스트(중복 시작·정지·실패·재사용 5건). Co-Authored-By: Claude Fable 5 --- tests/vscode-lifecycle.test.mjs | 82 +++++++++++++++++++++++++++ vscode-extension/README.md | 42 ++++++++++++++ vscode-extension/extension.cjs | 99 +++++++++++++++++++++++++++++++++ vscode-extension/lifecycle.cjs | 50 +++++++++++++++++ vscode-extension/package.json | 50 +++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 tests/vscode-lifecycle.test.mjs create mode 100644 vscode-extension/README.md create mode 100644 vscode-extension/extension.cjs create mode 100644 vscode-extension/lifecycle.cjs create mode 100644 vscode-extension/package.json 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": "감시할 저장소 경로 (비우면 첫 번째 워크스페이스 폴더)" + } + } + } + } +}