-
Notifications
You must be signed in to change notification settings - Fork 0
feat(vscode): Maestro 서버 임베드 VS Code 확장 #60
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
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,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하므로 패키징 시 레포 동봉 전제. | ||
| 마켓플레이스 게시는 후속(퍼블리셔 계정 필요). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`가 검증한다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
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.
If the user runs Stop Server or VS Code calls Useful? React with 👍 / 👎. |
||
| 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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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": "감시할 저장소 경로 (비우면 첫 번째 워크스페이스 폴더)" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
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.
On Windows,
EMBED_PATHis a drive-letter path such asC:\...\lib\server-embed.mjs; passing that string directly to dynamicimport()makes Node treatc:as a URL scheme and reject withERR_UNSUPPORTED_ESM_URL_SCHEME, so the extension fails during activation before any Maestro commands are usable. Convert the filesystem path withpathToFileURL(EMBED_PATH).hrefbefore importing.Useful? React with 👍 / 👎.