fix: escape parentheses in paths before bash shell-out, fix __extensionDir fallback (#1872)

Closes #1437
This commit is contained in:
TÂCHES 2026-03-21 15:14:40 -06:00 committed by GitHub
parent 77b220e9e5
commit 8bed02c077
7 changed files with 82 additions and 30 deletions

View file

@ -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

View file

@ -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", {

View file

@ -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;

View file

@ -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",

View file

@ -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`);
}
}
}

View file

@ -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 {

View file

@ -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,
});