diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index cfee0a7ff..1616ec77c 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -25,7 +25,7 @@ import { isDbAvailable, } from "./gsd-db.js"; import { atomicWriteSync } from "./atomic-write.js"; -import { execSync, execFileSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; import { gsdRoot } from "./paths.js"; import { @@ -477,7 +477,7 @@ export function runWorktreePostCreateHook( } try { - execSync(resolved, { + execFileSync(resolved, [], { cwd: worktreeDir, env: { ...process.env, @@ -1172,7 +1172,7 @@ export function mergeMilestoneToMain( if (prefs.auto_push === true && !nothingToCommit) { const remote = prefs.remote ?? "origin"; try { - execSync(`git push ${remote} ${mainBranch}`, { + execFileSync("git", ["push", remote, mainBranch], { cwd: originalBasePath_, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", @@ -1190,20 +1190,23 @@ export function mergeMilestoneToMain( const prTarget = prefs.pr_target_branch ?? mainBranch; try { // Push the milestone branch to remote first - execSync(`git push ${remote} ${milestoneBranch}`, { + execFileSync("git", ["push", remote, milestoneBranch], { cwd: originalBasePath_, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", }); // Create PR via gh CLI - execSync( - `gh pr create --base "${prTarget}" --head "${milestoneBranch}" --title "Milestone ${milestoneId} complete" --body "Auto-created by GSD on milestone completion."`, - { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }, - ); + execFileSync("gh", [ + "pr", "create", + "--base", prTarget, + "--head", milestoneBranch, + "--title", `Milestone ${milestoneId} complete`, + "--body", "Auto-created by GSD on milestone completion.", + ], { + cwd: originalBasePath_, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); prCreated = true; } catch { // PR creation failure is non-fatal — gh may not be installed or authenticated diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index a239c87c8..62c89279d 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -12,6 +12,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; import { extractTrace, type ExecutionTrace } from "./session-forensics.js"; import { nativeParseJsonlTail } from "./native-parser-bridge.js"; @@ -102,9 +103,14 @@ export async function handleForensics( const report = await buildForensicReport(basePath); const savedPath = saveForensicReport(basePath, report, problemDescription); - // Derive GSD source dir for prompt - const __extensionDir = dirname(fileURLToPath(import.meta.url)); - const gsdSourceDir = __extensionDir; + // Derive GSD source dir for prompt — fall back to ~/.gsd/agent/extensions/gsd/ + // when import.meta.url resolves to the npm-global install path (Windows). + let gsdSourceDir = dirname(fileURLToPath(import.meta.url)); + if (!existsSync(join(gsdSourceDir, "prompts"))) { + const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const fallback = join(gsdHome, "agent", "extensions", "gsd"); + if (existsSync(join(fallback, "prompts"))) gsdSourceDir = fallback; + } const forensicData = formatReportForPrompt(report); const content = loadPrompt("forensics", { diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 10900a138..00b4f717f 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -683,10 +683,11 @@ export function createDraftPR( body: string, ): string | null { try { - const result = execSync( - `gh pr create --draft --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`, - { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV }, - ); + const result = execFileSync("gh", [ + "pr", "create", "--draft", + "--title", title, + "--body", body, + ], { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV }); return result.trim(); } catch { return null; diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index a8d9067d2..ab2361296 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -808,7 +808,7 @@ export function nativeCheckoutBranch(basePath: string, branch: string): void { native.gitCheckoutBranch(basePath, branch); return; } - execSync(`git checkout ${branch}`, { + execFileSync("git", ["checkout", branch], { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", @@ -843,7 +843,7 @@ export function nativeMergeSquash(basePath: string, branch: string): GitMergeRes } try { - execSync(`git merge --squash ${branch}`, { + execFileSync("git", ["merge", "--squash", branch], { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts index b5937d7fa..b5e2a37ab 100644 --- a/src/resources/extensions/gsd/prompt-loader.ts +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -17,12 +17,36 @@ * that aren't read until the end of a long auto-mode run. */ -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; import { GSDError, GSD_PARSE_ERROR } from "./errors.js"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; -const __extensionDir = dirname(fileURLToPath(import.meta.url)); +/** + * Resolve the GSD extension directory. + * + * `import.meta.url` resolves to whichever copy of this module is executing. + * On Windows (npm global install via MSYS2 / Git Bash) this can resolve to + * the npm-global `AppData/Roaming/npm/…` path, which does NOT contain the + * prompts/ and templates/ subtrees that initResources() copies to + * `~/.gsd/agent/extensions/gsd/`. Detect the mismatch and fall back to + * the user-local agent directory. + */ +function resolveExtensionDir(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + if (existsSync(join(moduleDir, "prompts"))) return moduleDir; + + // Fallback: user-local agent directory + const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const agentGsdDir = join(gsdHome, "agent", "extensions", "gsd"); + if (existsSync(join(agentGsdDir, "prompts"))) return agentGsdDir; + + // Last resort: return the module dir (warmCache will silently handle the miss) + return moduleDir; +} + +const __extensionDir = resolveExtensionDir(); const promptsDir = join(__extensionDir, "prompts"); const templatesDir = join(__extensionDir, "templates"); @@ -45,7 +69,11 @@ function warmCache(): void { } } } catch { - // prompts/ may not exist in test environments — lazy loading still works + // prompts/ may not exist in test environments — lazy loading still works. + // Emit a diagnostic when running outside tests so wrong-path bugs are visible. + if (!process.env.VITEST && !process.env.NODE_TEST) { + process.stderr.write(`[gsd:prompt-loader] warmCache: prompts dir not found: ${promptsDir}\n`); + } } try { @@ -57,7 +85,10 @@ function warmCache(): void { } } } catch { - // templates/ may not exist in test environments — lazy loading still works + // templates/ may not exist in test environments — lazy loading still works. + if (!process.env.VITEST && !process.env.NODE_TEST) { + process.stderr.write(`[gsd:prompt-loader] warmCache: templates dir not found: ${templatesDir}\n`); + } } } diff --git a/src/resources/extensions/gsd/workflow-templates.ts b/src/resources/extensions/gsd/workflow-templates.ts index d0ae5784c..2c4b9daf1 100644 --- a/src/resources/extensions/gsd/workflow-templates.ts +++ b/src/resources/extensions/gsd/workflow-templates.ts @@ -8,10 +8,21 @@ import { readFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; -const __extensionDir = dirname(fileURLToPath(import.meta.url)); +const __extensionDir = resolveGsdExtensionDir(); const registryPath = join(__extensionDir, "workflow-templates", "registry.json"); +/** Resolve the GSD extension dir with fallback to ~/.gsd/agent/extensions/gsd/. */ +function resolveGsdExtensionDir(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + if (existsSync(join(moduleDir, "workflow-templates"))) return moduleDir; + const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const agentGsdDir = join(gsdHome, "agent", "extensions", "gsd"); + if (existsSync(join(agentGsdDir, "workflow-templates"))) return agentGsdDir; + return moduleDir; +} + // ─── Types ─────────────────────────────────────────────────────────────────── export interface TemplateEntry { diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts index 59f7447eb..041d1c418 100644 --- a/src/resources/extensions/voice/index.ts +++ b/src/resources/extensions/voice/index.ts @@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; import { shortcutDesc } from "../shared/mod.js"; import type { AssistantMessage } from "@gsd/pi-ai"; import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@gsd/pi-tui"; -import { spawn, execSync, type ChildProcess } from "node:child_process"; +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -32,7 +32,7 @@ function linuxPython(): string { function ensureBinary(): boolean { if (fs.existsSync(RECOGNIZER_BIN)) return true; try { - execSync(`swiftc "${SWIFT_SRC}" -o "${RECOGNIZER_BIN}" -framework Speech -framework AVFoundation`, { + execFileSync("swiftc", [SWIFT_SRC, "-o", RECOGNIZER_BIN, "-framework", "Speech", "-framework", "AVFoundation"], { timeout: 60000, }); return true; @@ -54,7 +54,7 @@ function ensureLinuxReady(ctx: ExtensionContext): boolean { // Check python3 exists try { - execSync("which python3", { stdio: "pipe" }); + execFileSync("which", ["python3"], { stdio: "pipe" }); } catch { ctx.ui.notify("Voice: python3 not found — install with: sudo apt install python3", "error"); return false; @@ -63,7 +63,7 @@ function ensureLinuxReady(ctx: ExtensionContext): boolean { // Check that sounddevice is importable const py = linuxPython(); try { - execSync(`${py} -c "import sounddevice"`, { + execFileSync(py, ["-c", "import sounddevice"], { stdio: "pipe", timeout: 10000, });