@vibecodetown/mcp-server 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ // adapters/mcp-ts/src/tools/vibe_pm/publish_mcp.ts
2
+ // MCP build and publish workflow
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { getCredentialStore } from "../../auth/credential_store.js";
7
+ /**
8
+ * Check if MCP source files have changed since last build
9
+ */
10
+ async function hasSourceChanges(mcpRoot) {
11
+ const srcDir = path.join(mcpRoot, "src");
12
+ const buildDir = path.join(mcpRoot, "build");
13
+ // If no build folder, definitely changed
14
+ if (!fs.existsSync(buildDir)) {
15
+ return { changed: true, files: ["build/ not found"] };
16
+ }
17
+ // Get build timestamp
18
+ const buildIndex = path.join(buildDir, "index.js");
19
+ if (!fs.existsSync(buildIndex)) {
20
+ return { changed: true, files: ["build/index.js not found"] };
21
+ }
22
+ const buildStat = fs.statSync(buildIndex);
23
+ const buildTime = buildStat.mtime.getTime();
24
+ // Find source files newer than build
25
+ const changedFiles = [];
26
+ function checkDir(dir) {
27
+ if (!fs.existsSync(dir))
28
+ return;
29
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ const fullPath = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ checkDir(fullPath);
34
+ }
35
+ else if (entry.name.endsWith(".ts")) {
36
+ const stat = fs.statSync(fullPath);
37
+ if (stat.mtime.getTime() > buildTime) {
38
+ changedFiles.push(path.relative(mcpRoot, fullPath));
39
+ }
40
+ }
41
+ }
42
+ }
43
+ checkDir(srcDir);
44
+ return { changed: changedFiles.length > 0, files: changedFiles };
45
+ }
46
+ /**
47
+ * Run TypeScript build
48
+ */
49
+ function runBuild(mcpRoot) {
50
+ try {
51
+ const result = spawnSync("npm", ["run", "build"], {
52
+ cwd: mcpRoot,
53
+ encoding: "utf-8",
54
+ shell: true,
55
+ timeout: 120_000, // 2 minutes
56
+ });
57
+ if (result.status === 0) {
58
+ return { success: true, output: result.stdout || "" };
59
+ }
60
+ return { success: false, output: result.stderr || result.stdout || "Build failed" };
61
+ }
62
+ catch (e) {
63
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
64
+ }
65
+ }
66
+ /**
67
+ * Bump version in package.json
68
+ */
69
+ function bumpVersion(mcpRoot, type) {
70
+ try {
71
+ const result = spawnSync("npm", ["version", type, "--no-git-tag-version"], {
72
+ cwd: mcpRoot,
73
+ encoding: "utf-8",
74
+ shell: true,
75
+ });
76
+ if (result.status === 0) {
77
+ // Read new version from package.json
78
+ const pkgPath = path.join(mcpRoot, "package.json");
79
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
80
+ return pkg.version;
81
+ }
82
+ throw new Error(result.stderr || "Version bump failed");
83
+ }
84
+ catch (e) {
85
+ throw new Error(`Version bump failed: ${e instanceof Error ? e.message : String(e)}`);
86
+ }
87
+ }
88
+ /**
89
+ * Run npm publish
90
+ */
91
+ async function runPublish(mcpRoot, dryRun) {
92
+ const store = getCredentialStore();
93
+ const npmToken = await store.getNpmToken();
94
+ // Set NPM_TOKEN if we have one stored
95
+ const env = { ...process.env };
96
+ if (npmToken) {
97
+ env.NPM_TOKEN = npmToken;
98
+ }
99
+ try {
100
+ const args = ["publish", "--access", "public"];
101
+ if (dryRun) {
102
+ args.push("--dry-run");
103
+ }
104
+ const result = spawnSync("npm", args, {
105
+ cwd: mcpRoot,
106
+ encoding: "utf-8",
107
+ shell: true,
108
+ env,
109
+ timeout: 120_000, // 2 minutes
110
+ });
111
+ if (result.status === 0) {
112
+ return { success: true, output: result.stdout || "" };
113
+ }
114
+ return { success: false, output: result.stderr || result.stdout || "Publish failed" };
115
+ }
116
+ catch (e) {
117
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
118
+ }
119
+ }
120
+ /**
121
+ * vibe_pm.publish_mcp - Build and publish MCP package
122
+ *
123
+ * Workflow:
124
+ * 1. Check for source changes (skip if no changes unless --force)
125
+ * 2. Run npm build (tsc)
126
+ * 3. Optionally bump version
127
+ * 4. Run npm publish
128
+ *
129
+ * PM-friendly description:
130
+ * MCP 패키지를 빌드하고 npm에 배포합니다.
131
+ */
132
+ export async function publishMcp(input, basePath = process.cwd()) {
133
+ const warnings = [];
134
+ // Find MCP root (adapters/mcp-ts)
135
+ const mcpRoot = path.join(basePath, "adapters", "mcp-ts");
136
+ if (!fs.existsSync(path.join(mcpRoot, "package.json"))) {
137
+ return {
138
+ success: false,
139
+ phase: "check",
140
+ version: "unknown",
141
+ built: false,
142
+ published: false,
143
+ message: "MCP 패키지를 찾을 수 없습니다: adapters/mcp-ts/package.json",
144
+ warnings,
145
+ };
146
+ }
147
+ // Read current version
148
+ const pkgPath = path.join(mcpRoot, "package.json");
149
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
150
+ let version = pkg.version;
151
+ // Phase 1: Check for changes
152
+ const { changed, files } = await hasSourceChanges(mcpRoot);
153
+ if (!changed && !input.force && !input.skip_build) {
154
+ return {
155
+ success: true,
156
+ phase: "check",
157
+ version,
158
+ built: false,
159
+ published: false,
160
+ message: "소스 변경 없음. 빌드 스킵.",
161
+ warnings: ["--force로 강제 빌드 가능"],
162
+ };
163
+ }
164
+ // Phase 2: Build
165
+ let built = false;
166
+ if (!input.skip_build) {
167
+ const buildResult = runBuild(mcpRoot);
168
+ if (!buildResult.success) {
169
+ return {
170
+ success: false,
171
+ phase: "build",
172
+ version,
173
+ built: false,
174
+ published: false,
175
+ message: `빌드 실패: ${buildResult.output}`,
176
+ warnings,
177
+ };
178
+ }
179
+ built = true;
180
+ if (files.length > 0) {
181
+ warnings.push(`변경된 파일: ${files.slice(0, 5).join(", ")}${files.length > 5 ? ` 외 ${files.length - 5}개` : ""}`);
182
+ }
183
+ }
184
+ // Build only mode
185
+ if (input.build_only) {
186
+ return {
187
+ success: true,
188
+ phase: "build",
189
+ version,
190
+ built,
191
+ published: false,
192
+ message: `빌드 완료 (v${version})`,
193
+ warnings,
194
+ next_action: {
195
+ tool: "vibe_pm.publish_mcp",
196
+ reason: "배포하려면 build_only: false로 다시 실행",
197
+ },
198
+ };
199
+ }
200
+ // Phase 3: Version bump (optional)
201
+ if (input.bump) {
202
+ try {
203
+ version = bumpVersion(mcpRoot, input.bump);
204
+ warnings.push(`버전 범프: ${pkg.version} → ${version}`);
205
+ }
206
+ catch (e) {
207
+ return {
208
+ success: false,
209
+ phase: "publish",
210
+ version,
211
+ built,
212
+ published: false,
213
+ message: e instanceof Error ? e.message : String(e),
214
+ warnings,
215
+ };
216
+ }
217
+ }
218
+ // Phase 4: Publish
219
+ const publishResult = await runPublish(mcpRoot, input.dry_run ?? false);
220
+ if (!publishResult.success) {
221
+ // Check if it's an auth error
222
+ if (publishResult.output.includes("ENEEDAUTH") || publishResult.output.includes("401")) {
223
+ return {
224
+ success: false,
225
+ phase: "publish",
226
+ version,
227
+ built,
228
+ published: false,
229
+ message: "npm 인증 실패. NPM_TOKEN을 설정하거나 npm login을 실행하세요.",
230
+ warnings: [
231
+ "vibe config set npm_token <TOKEN> 으로 토큰 저장 가능",
232
+ "또는 NPM_TOKEN 환경변수 설정",
233
+ ],
234
+ };
235
+ }
236
+ return {
237
+ success: false,
238
+ phase: "publish",
239
+ version,
240
+ built,
241
+ published: false,
242
+ message: `배포 실패: ${publishResult.output}`,
243
+ warnings,
244
+ };
245
+ }
246
+ const dryRunMsg = input.dry_run ? " (dry-run)" : "";
247
+ return {
248
+ success: true,
249
+ phase: "complete",
250
+ version,
251
+ built,
252
+ published: !input.dry_run,
253
+ message: `@vibecodetown/mcp-server@${version} 배포 완료${dryRunMsg}`,
254
+ warnings,
255
+ next_action: {
256
+ tool: "vibe_pm.status",
257
+ reason: "배포 상태 확인",
258
+ },
259
+ };
260
+ }
261
+ /**
262
+ * Quick check if MCP needs rebuild
263
+ */
264
+ export async function needsMcpBuild(basePath = process.cwd()) {
265
+ const mcpRoot = path.join(basePath, "adapters", "mcp-ts");
266
+ if (!fs.existsSync(path.join(mcpRoot, "package.json"))) {
267
+ return false;
268
+ }
269
+ const { changed } = await hasSourceChanges(mcpRoot);
270
+ return changed;
271
+ }
@@ -1,9 +1,11 @@
1
1
  // adapters/mcp-ts/src/tools/vibe_pm/run_app.ts
2
2
  // vibe_pm.run_app - One-click application launch
3
+ //
4
+ // All subprocess invocations go through cli_invoker for centralized management.
3
5
  import * as fs from "node:fs";
4
6
  import * as path from "node:path";
5
- import { spawn, spawnSync } from "node:child_process";
6
7
  import { createHash } from "node:crypto";
8
+ import { invokeSystem, invokeSystemSync, spawnDetached } from "../../runtime/cli_invoker.js";
7
9
  import { resolveRunDir, resolveRunId } from "./context.js";
8
10
  import { buildPodmanRunCommand, filterContainerEnvAllowlist, normalizeMounts, normalizePorts } from "./run_app_podman.js";
9
11
  /**
@@ -49,19 +51,10 @@ export async function runApp(input) {
49
51
  // Step 2: Start process
50
52
  const fullCommand = `${projectInfo.command} ${projectInfo.args.join(" ")}`.trim();
51
53
  try {
52
- const child = spawn(projectInfo.command, projectInfo.args, {
53
- cwd: basePath,
54
- detached: true,
55
- stdio: "ignore",
56
- env: {
57
- ...process.env,
58
- PORT: String(projectInfo.port),
59
- NODE_ENV: mode === "prod" ? "production" : "development"
60
- }
54
+ const pid = spawnDetached(projectInfo.command, projectInfo.args, basePath, {
55
+ PORT: String(projectInfo.port),
56
+ NODE_ENV: mode === "prod" ? "production" : "development"
61
57
  });
62
- // Unref to allow parent to exit independently
63
- child.unref();
64
- const pid = child.pid;
65
58
  // Give the process a moment to start
66
59
  await new Promise((resolve) => setTimeout(resolve, 500));
67
60
  writeRunAppEvidenceBestEffort(basePath, {
@@ -70,11 +63,11 @@ export async function runApp(input) {
70
63
  port: projectInfo.port,
71
64
  url: projectInfo.url,
72
65
  command_executed: fullCommand,
73
- process_id: pid
66
+ process_id: pid ?? undefined
74
67
  });
75
68
  return {
76
69
  success: true,
77
- process_id: pid,
70
+ process_id: pid ?? undefined,
78
71
  url: projectInfo.url,
79
72
  command_executed: fullCommand,
80
73
  message: `애플리케이션이 시작되었습니다. ${projectInfo.url} 에서 확인하세요.`,
@@ -160,10 +153,31 @@ function normalizeOpaPaths(basePath, values) {
160
153
  }
161
154
  return out;
162
155
  }
156
+ /**
157
+ * P1-3: Extract script name from package manager commands
158
+ * npm run dev -> "dev", npm start -> "start", etc.
159
+ */
160
+ function extractScriptName(command, args) {
161
+ const packageManagers = ["npm", "yarn", "pnpm", "bun"];
162
+ if (!packageManagers.includes(command))
163
+ return undefined;
164
+ // npm run <script>, yarn run <script>, pnpm run <script>, bun run <script>
165
+ const runIdx = args.indexOf("run");
166
+ if (runIdx >= 0 && args[runIdx + 1]) {
167
+ return args[runIdx + 1];
168
+ }
169
+ // npm start, npm test, etc. (shortcuts)
170
+ const shortcuts = ["start", "test", "build"];
171
+ if (args.length > 0 && shortcuts.includes(args[0])) {
172
+ return args[0];
173
+ }
174
+ return undefined;
175
+ }
163
176
  function buildOpaPolicyInput(basePath, command, args, opts) {
164
177
  const cwd = normalizeOpaPath(basePath);
165
178
  const paths = normalizeOpaPaths(basePath, opts?.paths ?? [basePath]);
166
179
  const mounts = opts?.mounts ? normalizeOpaPaths(basePath, opts.mounts) : undefined;
180
+ const scriptName = extractScriptName(command, args);
167
181
  return {
168
182
  action: "run_app",
169
183
  project_root: cwd,
@@ -172,7 +186,8 @@ function buildOpaPolicyInput(basePath, command, args, opts) {
172
186
  args,
173
187
  cwd,
174
188
  paths,
175
- mounts
189
+ mounts,
190
+ script_name: scriptName
176
191
  },
177
192
  meta: {
178
193
  tool: "vibe_pm.run_app",
@@ -198,14 +213,14 @@ function evaluateOpaGate(basePath, input) {
198
213
  writeJsonBestEffort(resultPath, deny);
199
214
  return deny;
200
215
  }
201
- const opa = spawnSync("opa", ["eval", "-f", "json", "-I", "-d", policyPath, "data.vibepm.runapp"], { input: JSON.stringify(input), encoding: "utf-8", timeout: 1500 });
202
- if (opa.error) {
203
- const err = opa.error;
204
- const missing = err?.code === "ENOENT";
216
+ const opa = invokeSystemSync("opa", ["eval", "-f", "json", "-I", "-d", policyPath, "data.vibepm.runapp"], basePath, { input: JSON.stringify(input), timeoutMs: 1500 });
217
+ // Check for spawn errors (binary not found, etc.)
218
+ if (opa.exitCode === 127 || opa.stderr.includes("[SPAWN_ERROR]")) {
219
+ const missing = opa.stderr.includes("ENOENT") || opa.stderr.includes("not found");
205
220
  const deny = {
206
221
  allow: false,
207
222
  reasons: [
208
- missing ? "OPA_POLICY_MISSING: opa binary not found" : `OPA_POLICY_EVAL_FAILED: ${err.message ?? "unknown error"}`
223
+ missing ? "OPA_POLICY_MISSING: opa binary not found" : `OPA_POLICY_EVAL_FAILED: ${opa.stderr}`
209
224
  ],
210
225
  matched_rules: [missing ? "OPA_POLICY_MISSING" : "OPA_POLICY_EVAL_FAILED"]
211
226
  };
@@ -213,10 +228,10 @@ function evaluateOpaGate(basePath, input) {
213
228
  writeJsonBestEffort(resultPath, deny);
214
229
  return deny;
215
230
  }
216
- if (opa.status !== 0) {
231
+ if (opa.exitCode !== 0) {
217
232
  const deny = {
218
233
  allow: false,
219
- reasons: [`OPA_POLICY_EVAL_FAILED: exit_code=${opa.status}`],
234
+ reasons: [`OPA_POLICY_EVAL_FAILED: exit_code=${opa.exitCode}`],
220
235
  matched_rules: ["OPA_POLICY_EVAL_FAILED"]
221
236
  };
222
237
  if (resultPath)
@@ -282,19 +297,10 @@ function writeRunAppEvidenceBestEffort(repoRootAbs, payload) {
282
297
  }
283
298
  }
284
299
  async function assertPodmanAvailable() {
285
- await new Promise((resolve, reject) => {
286
- const p = spawn("podman", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
287
- let out = "";
288
- let err = "";
289
- p.stdout.on("data", (d) => (out += String(d)));
290
- p.stderr.on("data", (d) => (err += String(d)));
291
- p.on("error", reject);
292
- p.on("close", (code) => {
293
- if (code === 0)
294
- return resolve();
295
- reject(new Error(`podman_unavailable: code=${code} out=${out} err=${err}`));
296
- });
297
- });
300
+ const result = await invokeSystem("podman", ["--version"], process.cwd());
301
+ if (result.exitCode !== 0) {
302
+ throw new Error(`podman_unavailable: code=${result.exitCode} out=${result.stdout} err=${result.stderr}`);
303
+ }
298
304
  }
299
305
  function defaultImageForProject(projectType) {
300
306
  if (projectType === "node")
@@ -328,15 +334,12 @@ function buildInsideCommand(projectInfo) {
328
334
  return run;
329
335
  }
330
336
  async function runPodmanDetached(cmd) {
331
- return await new Promise((resolve, reject) => {
332
- const p = spawn(cmd.cmd, cmd.args, { stdio: ["ignore", "pipe", "pipe"] });
333
- let out = "";
334
- let err = "";
335
- p.stdout.on("data", (d) => (out += String(d)));
336
- p.stderr.on("data", (d) => (err += String(d)));
337
- p.on("error", reject);
338
- p.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: out, stderr: err }));
339
- });
337
+ const result = await invokeSystem(cmd.cmd, cmd.args, process.cwd());
338
+ return {
339
+ exitCode: result.exitCode,
340
+ stdout: result.stdout,
341
+ stderr: result.stderr
342
+ };
340
343
  }
341
344
  async function runAppWithPodman(input, repoRootAbs, mode, projectInfo) {
342
345
  const image = input.container_image ?? defaultImageForProject(projectInfo.type);
@@ -0,0 +1,120 @@
1
+ // adapters/mcp-ts/src/tools/vibe_pm/save_rule.ts
2
+ // vibe_pm.save_rule - 프로젝트 워크플로우 규칙 저장
3
+ import { z } from "zod";
4
+ import * as fs from "fs/promises";
5
+ import * as path from "path";
6
+ import { getVibeRepoPaths } from "../../local-mode/paths.js";
7
+ // ============================================================
8
+ // Schema
9
+ // ============================================================
10
+ export const saveRuleInputSchema = z.object({
11
+ base_path: z.string().describe("프로젝트 루트 경로"),
12
+ rule_title: z.string().min(1).describe("규칙 제목 (예: 'API 변경 시 문서 갱신')"),
13
+ trigger: z.string().min(1).describe("언제 적용되는지 (예: 'API 엔드포인트를 수정할 때마다')"),
14
+ action: z.string().min(1).describe("무엇을 해야 하는지 (예: 'docs/api.md를 함께 갱신한다')"),
15
+ example: z.string().optional().describe("구체적인 예시 (선택)")
16
+ }).strict();
17
+ export const saveRuleOutputSchema = z.object({
18
+ status: z.enum(["saved", "error"]),
19
+ message: z.string(),
20
+ rules_file: z.string().describe(".vibe/project_rules.md 경로"),
21
+ rule_count: z.number().describe("현재 저장된 규칙 수")
22
+ }).strict();
23
+ // ============================================================
24
+ // Implementation
25
+ // ============================================================
26
+ const RULES_FILENAME = "project_rules.md";
27
+ /**
28
+ * 규칙 파일 경로 반환
29
+ */
30
+ function getRulesFilePath(basePath) {
31
+ const { vibeDir } = getVibeRepoPaths(basePath);
32
+ return path.join(vibeDir, RULES_FILENAME);
33
+ }
34
+ /**
35
+ * 규칙 파일 읽기 (없으면 기본 템플릿 반환)
36
+ */
37
+ async function readRulesFile(filePath) {
38
+ try {
39
+ return await fs.readFile(filePath, "utf-8");
40
+ }
41
+ catch {
42
+ // 파일이 없으면 기본 템플릿 반환
43
+ return `# Project Workflow Rules
44
+
45
+ > 이 파일은 Vibe PM이 자동으로 관리합니다.
46
+ > 프로젝트별 워크플로우 규칙이 여기에 저장됩니다.
47
+
48
+ ---
49
+
50
+ `;
51
+ }
52
+ }
53
+ /**
54
+ * 규칙 수 계산
55
+ */
56
+ function countRules(content) {
57
+ const lines = content.split("\n");
58
+ let count = 0;
59
+ for (const line of lines) {
60
+ if (line.startsWith("## ") && !line.includes("Project Workflow Rules")) {
61
+ count++;
62
+ }
63
+ }
64
+ return count;
65
+ }
66
+ /**
67
+ * 새 규칙을 마크다운 형식으로 포맷
68
+ */
69
+ function formatRule(input) {
70
+ const timestamp = new Date().toISOString().split("T")[0];
71
+ let rule = `## ${input.rule_title}
72
+
73
+ - **언제**: ${input.trigger}
74
+ - **무엇을**: ${input.action}
75
+ `;
76
+ if (input.example) {
77
+ rule += `- **예시**: ${input.example}
78
+ `;
79
+ }
80
+ rule += `- **등록일**: ${timestamp}
81
+
82
+ ---
83
+
84
+ `;
85
+ return rule;
86
+ }
87
+ /**
88
+ * 규칙 저장
89
+ */
90
+ export async function saveRule(input) {
91
+ const rulesFile = getRulesFilePath(input.base_path);
92
+ try {
93
+ // .vibe 디렉토리 확인/생성
94
+ const vibeDir = path.dirname(rulesFile);
95
+ await fs.mkdir(vibeDir, { recursive: true });
96
+ // 기존 규칙 읽기
97
+ let content = await readRulesFile(rulesFile);
98
+ // 새 규칙 추가
99
+ const newRule = formatRule(input);
100
+ content += newRule;
101
+ // 파일 저장
102
+ await fs.writeFile(rulesFile, content, "utf-8");
103
+ const ruleCount = countRules(content);
104
+ return {
105
+ status: "saved",
106
+ message: `규칙 '${input.rule_title}'이(가) 저장되었습니다.`,
107
+ rules_file: rulesFile,
108
+ rule_count: ruleCount
109
+ };
110
+ }
111
+ catch (e) {
112
+ const msg = e instanceof Error ? e.message : String(e);
113
+ return {
114
+ status: "error",
115
+ message: `규칙 저장 실패: ${msg}`,
116
+ rules_file: rulesFile,
117
+ rule_count: 0
118
+ };
119
+ }
120
+ }
@@ -1,10 +1,10 @@
1
1
  // adapters/mcp-ts/src/tools/vibe_pm/undo_last_task.ts
2
2
  // vibe_pm.undo_last_task - Time machine rollback via git revert
3
+ //
4
+ // All subprocess invocations go through cli_invoker for centralized management.
3
5
  import * as fs from "node:fs";
4
6
  import * as path from "node:path";
5
- import { exec } from "node:child_process";
6
- import { promisify } from "node:util";
7
- const execAsync = promisify(exec);
7
+ import { invokeGit } from "../../runtime/cli_invoker.js";
8
8
  /**
9
9
  * vibe_pm.undo_last_task - Rollback recent commits via git revert
10
10
  *
@@ -43,25 +43,20 @@ export async function undoLastTask(input) {
43
43
  const revertedTasks = [];
44
44
  const revertErrors = [];
45
45
  for (const commit of commits) {
46
- try {
47
- await execAsync(`git revert --no-edit ${commit.hash}`, { cwd: basePath });
46
+ const result = await invokeGit(["revert", "--no-edit", commit.hash], basePath);
47
+ if (result.exitCode === 0) {
48
48
  revertedTasks.push({
49
49
  commit_hash: commit.shortHash,
50
50
  task_summary: commit.subject,
51
51
  timestamp: commit.timestamp
52
52
  });
53
53
  }
54
- catch (err) {
55
- const errMsg = err instanceof Error ? err.message : String(err);
54
+ else {
55
+ const errMsg = result.stderr || result.stdout;
56
56
  revertErrors.push(`${commit.shortHash}: ${errMsg}`);
57
57
  // If revert fails due to conflicts, abort and stop
58
58
  if (errMsg.includes("conflict") || errMsg.includes("CONFLICT")) {
59
- try {
60
- await execAsync("git revert --abort", { cwd: basePath });
61
- }
62
- catch {
63
- // Ignore abort errors
64
- }
59
+ await invokeGit(["revert", "--abort"], basePath);
65
60
  break;
66
61
  }
67
62
  }
@@ -99,9 +94,12 @@ export async function undoLastTask(input) {
99
94
  * Get recent commits from git log
100
95
  */
101
96
  async function getRecentCommits(basePath, count) {
102
- const { stdout } = await execAsync(`git log --oneline -n ${count} --format="%H|%h|%s|%ci"`, { cwd: basePath });
97
+ const result = await invokeGit(["log", "--oneline", "-n", String(count), "--format=%H|%h|%s|%ci"], basePath);
98
+ if (result.exitCode !== 0) {
99
+ throw new Error(result.stderr || "git log failed");
100
+ }
103
101
  const commits = [];
104
- const lines = stdout.trim().split("\n").filter(Boolean);
102
+ const lines = result.stdout.trim().split("\n").filter(Boolean);
105
103
  for (const line of lines) {
106
104
  const [hash, shortHash, subject, timestamp] = line.split("|");
107
105
  if (hash && shortHash && subject) {
@@ -119,13 +117,11 @@ async function getRecentCommits(basePath, count) {
119
117
  * Get current HEAD commit hash
120
118
  */
121
119
  async function getCurrentCommit(basePath) {
122
- try {
123
- const { stdout } = await execAsync("git rev-parse --short HEAD", { cwd: basePath });
124
- return stdout.trim();
125
- }
126
- catch {
120
+ const result = await invokeGit(["rev-parse", "--short", "HEAD"], basePath);
121
+ if (result.exitCode !== 0) {
127
122
  return "unknown";
128
123
  }
124
+ return result.stdout.trim();
129
125
  }
130
126
  /**
131
127
  * Add rollback entry to DEV_LOG
@@ -3,14 +3,14 @@
3
3
  // 정책: 절대 실행을 막지 않는다 (모든 실패는 GO)
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { spawnSync } from "node:child_process";
7
6
  import readline from "node:readline";
7
+ import { invokeGitSync } from "./runtime/cli_invoker.js";
8
8
  function execGit(args, cwd) {
9
- const r = spawnSync("git", args, { cwd, encoding: "utf-8", timeout: 10000 });
9
+ const result = invokeGitSync(args, cwd, { timeoutMs: 10000 });
10
10
  return {
11
- status: r.status,
12
- stdout: r.stdout ?? "",
13
- stderr: r.stderr ?? "",
11
+ status: result.exitCode,
12
+ stdout: result.stdout,
13
+ stderr: result.stderr,
14
14
  };
15
15
  }
16
16
  function repoRootOrNull(cwd) {