Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/superpowers/specs/2026-08-05-vscode-extension-design.md
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하므로 패키징 시 레포 동봉 전제.
마켓플레이스 게시는 후속(퍼블리셔 계정 필요).
82 changes: 82 additions & 0 deletions tests/vscode-lifecycle.test.mjs
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);
});
42 changes: 42 additions & 0 deletions vscode-extension/README.md
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`가 검증한다.
99 changes: 99 additions & 0 deletions vscode-extension/extension.cjs
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load the embed module via a file URL

On Windows, EMBED_PATH is a drive-letter path such as C:\...\lib\server-embed.mjs; passing that string directly to dynamic import() makes Node treat c: as a URL scheme and reject with ERR_UNSUPPORTED_ESM_URL_SCHEME, so the extension fails during activation before any Maestro commands are usable. Convert the filesystem path with pathToFileURL(EMBED_PATH).href before importing.

Useful? React with 👍 / 👎.

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 };
50 changes: 50 additions & 0 deletions vscode-extension/lifecycle.cjs
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor stop requests during startup

If the user runs Stop Server or VS Code calls deactivate() while startServer() is still waiting for embed() to finish, handle is still null and this returns immediately; when startup later resolves, line 19 stores a live handle that was never stopped, leaving the server running after the stop/deactivate request. Track pending startup cancellation or await starting here and stop the resulting handle so stops during the health-check window are respected.

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 };
50 changes: 50 additions & 0 deletions vscode-extension/package.json
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": "감시할 저장소 경로 (비우면 첫 번째 워크스페이스 폴더)"
}
}
}
}
}
Loading