diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md new file mode 100644 index 0000000..7031759 --- /dev/null +++ b/docs/vscode-extension.md @@ -0,0 +1,38 @@ +# VS Code Extension Draft + +이 문서는 Maximus VS Code extension draft의 현재 계약을 설명합니다. + +## 목표 + +첫 번째 버전은 아래 범위로 제한합니다. + +- output channel 중심 실행 +- CLI wrapper 제공 +- `audit`, `doctor`, `fix` 명령만 우선 지원 +- diagnostics provider, marketplace publish automation, telemetry 확장은 후속 작업으로 분리 + +## 실행 방식 + +확장은 VS Code command palette에서 실행됩니다. + +- `Maximus: Run Audit` +- `Maximus: Run Doctor` +- `Maximus: Run Fix` + +명령을 실행하면 output channel에 다음 정보를 남깁니다. + +- 선택된 workspace 경로 +- 실제로 실행된 CLI 명령 +- stdout / stderr +- 종료 코드 + +## CLI wrapper 우선순위 + +1. workspace root에 `bin/maximus.js`가 있으면 `node`로 실행합니다. +2. 없으면 PATH 상의 `maximus` 실행 파일을 사용합니다. + +이 순서는 로컬 저장소에서 직접 작업할 때와 설치된 CLI를 사용할 때를 모두 지원하기 위한 것입니다. + +## 현재 상태 + +이 slice는 draft 수준의 소스와 문서만 추가합니다. 실제 VS Code 배포용 transpile / package 단계와 diagnostics 확장은 아직 포함하지 않습니다. diff --git a/package.json b/package.json index 8b338e7..1cae164 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Canonical Rust runtime launcher for the Maximus CLI.", "author": "JeremyDev87", "type": "module", + "workspaces": [ + "packages/*" + ], "bin": { "maximus": "./bin/maximus.js" }, diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md new file mode 100644 index 0000000..037a7c0 --- /dev/null +++ b/packages/vscode-extension/README.md @@ -0,0 +1,26 @@ +# Maximus VS Code Extension + +이 패키지는 Maximus CLI를 VS Code 안에서 실행하는 draft 확장입니다. + +첫 버전의 기준은 다음과 같습니다. + +- diagnostics provider 대신 output channel 중심으로 동작합니다. +- `audit`, `doctor`, `fix` 같은 CLI 명령을 그대로 감쌉니다. +- 로컬 저장소에서 `bin/maximus.js`가 있으면 그 경로를 우선 사용하고, 없으면 `maximus` 실행 파일을 찾습니다. + +## 명령 + +- `Maximus: Run Audit` +- `Maximus: Run Doctor` +- `Maximus: Run Fix` + +## 검증 + +패키지 루트에서 다음 검증을 돌릴 수 있습니다. + +```bash +npm test +npm --prefix packages/vscode-extension test +``` + +이 draft는 아직 transpile / bundle 단계를 포함하지 않기 때문에, 실제 VS Code 배포 파이프라인은 후속 slice에서 마무리합니다. diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json new file mode 100644 index 0000000..3f9c213 --- /dev/null +++ b/packages/vscode-extension/package.json @@ -0,0 +1,47 @@ +{ + "name": "maximus-vscode-extension", + "displayName": "Maximus", + "description": "Output channel first VS Code draft for the Maximus CLI.", + "version": "0.1.0", + "publisher": "jeremydev87", + "private": true, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onCommand:maximus.runAudit", + "onCommand:maximus.runDoctor", + "onCommand:maximus.runFix" + ], + "main": "./src/extension.ts", + "contributes": { + "commands": [ + { + "command": "maximus.runAudit", + "title": "Maximus: Run Audit" + }, + { + "command": "maximus.runDoctor", + "title": "Maximus: Run Doctor" + }, + { + "command": "maximus.runFix", + "title": "Maximus: Run Fix" + } + ] + }, + "scripts": { + "test": "node --check ./src/extension.ts && node --test ./test/*.test.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/JeremyDev87/maximus.git" + }, + "homepage": "https://github.com/JeremyDev87/maximus#readme", + "bugs": { + "url": "https://github.com/JeremyDev87/maximus/issues" + } +} diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts new file mode 100644 index 0000000..29fe326 --- /dev/null +++ b/packages/vscode-extension/src/extension.ts @@ -0,0 +1,230 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const CHANNEL_NAME = "Maximus"; +const COMMAND_IDS = { + audit: "maximus.runAudit", + doctor: "maximus.runDoctor", + fix: "maximus.runFix", +}; + +/** + * @param {import("vscode").ExtensionContext} context + */ +function activate(context) { + const vscode = getVscode(); + const output = vscode.window.createOutputChannel(CHANNEL_NAME); + + context.subscriptions.push(output); + context.subscriptions.push( + vscode.commands.registerCommand(COMMAND_IDS.audit, (resource) => { + return runMaximusCommand("audit", output, resource); + }), + vscode.commands.registerCommand(COMMAND_IDS.doctor, (resource) => { + return runMaximusCommand("doctor", output, resource); + }), + vscode.commands.registerCommand(COMMAND_IDS.fix, (resource) => { + return runMaximusCommand("fix", output, resource); + }), + ); + + output.appendLine("Maximus VS Code extension activated."); +} + +function deactivate() {} + +/** + * @param {"audit" | "doctor" | "fix"} mode + * @param {import("vscode").OutputChannel} output + * @param {unknown} resource + */ +async function runMaximusCommand(mode, output, resource) { + const vscode = getVscode(); + const workspaceRoot = await resolveWorkspaceRoot(resource); + + if (!workspaceRoot) { + await showMissingWorkspaceWarning(output, vscode); + return; + } + + const invocation = resolveInvocation(workspaceRoot, mode); + output.clear(); + output.show(true); + output.appendLine(`Workspace: ${workspaceRoot}`); + output.appendLine(`Command: ${formatCommand(invocation.command, invocation.args)}`); + output.appendLine(""); + + await new Promise((resolve) => { + const child = spawn(invocation.command, invocation.args, { + cwd: workspaceRoot, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + ...invocation.options, + }); + let settled = false; + + const finish = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + child.stdout.on("data", (chunk) => { + output.append(chunk.toString()); + }); + + child.stderr.on("data", (chunk) => { + output.append(chunk.toString()); + }); + + child.on("error", async (error) => { + output.appendLine(""); + output.appendLine(`실행 실패: ${error.message}`); + await vscode.window.showErrorMessage(`Maximus 실행에 실패했습니다: ${error.message}`); + finish(); + }); + + child.on("close", (code) => { + output.appendLine(""); + output.appendLine(`종료 코드: ${code ?? "unknown"}`); + finish(); + }); + }); +} + +/** + * @param {import("vscode").OutputChannel} output + * @param {typeof import("vscode")} vscode + */ +async function showMissingWorkspaceWarning(output, vscode = getVscode()) { + const message = "워크스페이스를 열고 다시 실행해 주세요."; + output.appendLine(message); + await vscode.window.showWarningMessage(message); +} + +/** + * @param {unknown} resource + * @returns {string | undefined} + */ +async function resolveWorkspaceRoot(resource, vscode = getVscode()) { + const selectedFolder = resolveWorkspaceFolderFromResource(resource, vscode); + if (selectedFolder) { + return selectedFolder; + } + + const activeEditorFolder = resolveActiveEditorWorkspaceRoot(vscode); + if (activeEditorFolder) { + return activeEditorFolder; + } + + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + if (workspaceFolders.length === 1) { + return workspaceFolders[0].uri.fsPath; + } + + if (workspaceFolders.length > 1) { + const pickedFolder = await vscode.window.showWorkspaceFolderPick({ + placeHolder: "Maximus를 실행할 워크스페이스를 선택해 주세요.", + }); + + return pickedFolder?.uri.fsPath; + } + + return undefined; +} + +/** + * @param {unknown} resource + * @param {typeof import("vscode")} vscode + * @returns {string | undefined} + */ +function resolveWorkspaceFolderFromResource(resource, vscode = getVscode()) { + if (!resource || typeof resource !== "object") { + return undefined; + } + + const uri = /** @type {{ fsPath?: string, scheme?: string }} */ (resource); + if (typeof uri.fsPath !== "string" || uri.fsPath.length === 0) { + return undefined; + } + + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(uri.fsPath)); + if (folder) { + return folder.uri.fsPath; + } + + return fs.existsSync(uri.fsPath) && fs.statSync(uri.fsPath).isDirectory() + ? uri.fsPath + : path.dirname(uri.fsPath); +} + +/** + * @param {typeof import("vscode")} vscode + * @returns {string | undefined} + */ +function resolveActiveEditorWorkspaceRoot(vscode = getVscode()) { + const activeEditorUri = vscode.window.activeTextEditor?.document?.uri; + if (!activeEditorUri) { + return undefined; + } + + return vscode.workspace.getWorkspaceFolder(activeEditorUri)?.uri.fsPath; +} + +/** + * @param {string} workspaceRoot + * @param {"audit" | "doctor" | "fix"} mode + * @param {NodeJS.Platform} platform + */ +function resolveInvocation(workspaceRoot, mode, platform = process.platform) { + const localCli = path.join(workspaceRoot, "bin", "maximus.js"); + if (fs.existsSync(localCli)) { + return { + command: process.execPath, + args: [localCli, mode], + options: {}, + }; + } + + if (platform === "win32") { + return { + command: process.env.comspec || "cmd.exe", + args: ["/d", "/s", "/c", "maximus", mode], + options: { + windowsHide: true, + }, + }; + } + + return { + command: "maximus", + args: [mode], + options: {}, + }; +} + +/** + * @param {string} command + * @param {string[]} args + */ +function formatCommand(command, args) { + return [command, ...args].join(" "); +} + +function getVscode() { + return require("vscode"); +} + +module.exports = { + activate, + deactivate, + resolveActiveEditorWorkspaceRoot, + resolveInvocation, + resolveWorkspaceFolderFromResource, + resolveWorkspaceRoot, + showMissingWorkspaceWarning, +}; diff --git a/packages/vscode-extension/test/extension.test.js b/packages/vscode-extension/test/extension.test.js new file mode 100644 index 0000000..c994425 --- /dev/null +++ b/packages/vscode-extension/test/extension.test.js @@ -0,0 +1,112 @@ +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + resolveInvocation, + resolveWorkspaceRoot, + showMissingWorkspaceWarning, +} = require("../src/extension.ts"); + +function createFakeVscode({ + workspaceFolders = [], + activeEditorPath, + onPick, +} = {}) { + return { + Uri: { + file(fsPath) { + return { fsPath }; + }, + }, + workspace: { + workspaceFolders: workspaceFolders.map((folderPath) => ({ + uri: { fsPath: folderPath }, + })), + getWorkspaceFolder(uri) { + const matched = workspaceFolders.find((folderPath) => { + const relative = path.relative(folderPath, uri.fsPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }); + + return matched ? { uri: { fsPath: matched } } : undefined; + }, + }, + window: { + activeTextEditor: activeEditorPath + ? { + document: { + uri: { fsPath: activeEditorPath }, + }, + } + : undefined, + async showWorkspaceFolderPick() { + return onPick?.(); + }, + }, + }; +} + +test("resolveWorkspaceRoot prefers the active editor workspace in multi-root windows", async () => { + const fakeVscode = createFakeVscode({ + workspaceFolders: ["/repo-one", "/repo-two"], + activeEditorPath: "/repo-two/src/index.ts", + }); + + const resolved = await resolveWorkspaceRoot(undefined, fakeVscode); + + assert.equal(resolved, "/repo-two"); +}); + +test("resolveWorkspaceRoot prompts when multiple workspaces exist without a resource or active editor", async () => { + const fakeVscode = createFakeVscode({ + workspaceFolders: ["/repo-one", "/repo-two"], + onPick: () => ({ uri: { fsPath: "/repo-two" } }), + }); + + const resolved = await resolveWorkspaceRoot(undefined, fakeVscode); + + assert.equal(resolved, "/repo-two"); +}); + +test("resolveWorkspaceRoot returns undefined when the workspace picker is canceled", async () => { + const fakeVscode = createFakeVscode({ + workspaceFolders: ["/repo-one", "/repo-two"], + onPick: () => undefined, + }); + + const resolved = await resolveWorkspaceRoot(undefined, fakeVscode); + + assert.equal(resolved, undefined); +}); + +test("resolveInvocation routes PATH fallback through cmd on Windows", () => { + const invocation = resolveInvocation("/workspace", "audit", "win32"); + + assert.equal(invocation.command.endsWith("cmd.exe") || invocation.command === "cmd.exe", true); + assert.deepEqual(invocation.args, ["/d", "/s", "/c", "maximus", "audit"]); + assert.deepEqual(invocation.options, { windowsHide: true }); +}); + +test("showMissingWorkspaceWarning appends the message and notifies VS Code", async () => { + const messages = []; + const warnings = []; + + await showMissingWorkspaceWarning( + { + appendLine(message) { + messages.push(message); + }, + }, + { + window: { + async showWarningMessage(message) { + warnings.push(message); + }, + }, + }, + ); + + assert.deepEqual(messages, ["워크스페이스를 열고 다시 실행해 주세요."]); + assert.deepEqual(warnings, ["워크스페이스를 열고 다시 실행해 주세요."]); +});