fix(windows): harden portability across runtime and tooling

This commit is contained in:
Jeremy 2026-04-10 20:33:18 -05:00
parent 67767c2527
commit 61204ce771
21 changed files with 624 additions and 102 deletions

View file

@ -155,7 +155,7 @@ jobs:
run: npm run test:coverage
windows-portability:
timeout-minutes: 15
timeout-minutes: 25
needs: detect-changes
if: >-
needs.detect-changes.outputs.docs-only != 'true'
@ -180,12 +180,18 @@ jobs:
- name: Typecheck extensions
run: npm run typecheck:extensions
- name: Validate package is installable
run: npm run validate-pack
- name: Run unit tests
run: npm run test:unit
- name: Run package tests
run: npm run test:packages
- name: Run integration tests
run: npm run test:integration
rtk-portability:
timeout-minutes: 20
needs: detect-changes

View file

@ -56,22 +56,22 @@
"copy-themes": "node scripts/copy-themes.cjs",
"copy-export-html": "node scripts/copy-export-html.cjs",
"test:compile": "node scripts/compile-tests.mjs",
"test:unit": "npm run test:compile && node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test-reporter=./scripts/test-reporter-compact.mjs --test 'dist-test/src/tests/*.test.js' 'dist-test/src/resources/extensions/gsd/tests/*.test.js' 'dist-test/src/resources/extensions/gsd/tests/*.test.mjs' 'dist-test/src/resources/extensions/shared/tests/*.test.js' 'dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js' 'dist-test/src/resources/extensions/github-sync/tests/*.test.js' 'dist-test/src/resources/extensions/universal-config/tests/*.test.js' 'dist-test/src/resources/extensions/voice/tests/*.test.js' 'dist-test/src/resources/extensions/mcp-client/tests/*.test.js'",
"test:packages": "node --test packages/pi-coding-agent/dist/core/*.test.js",
"test:marketplace": "GSD_TEST_CLONE_MARKETPLACES=1 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/claude-import-tui.test.ts src/resources/extensions/gsd/tests/plugin-importer-live.test.ts src/tests/marketplace-discovery.test.ts",
"test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=40 --lines=40 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts src/resources/extensions/shared/tests/*.test.ts",
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/tests/integration/*.test.ts' 'src/resources/extensions/gsd/tests/integration/*.test.ts' 'src/resources/extensions/async-jobs/*.test.ts' 'src/resources/extensions/browser-tools/tests/*.test.mjs'",
"test:unit": "npm run test:compile && node --import ./scripts/dist-test-resolve.mjs --experimental-test-isolation=process --test-reporter=./scripts/test-reporter-compact.mjs --test \"dist-test/src/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.js\" \"dist-test/src/resources/extensions/gsd/tests/*.test.mjs\" \"dist-test/src/resources/extensions/shared/tests/*.test.js\" \"dist-test/src/resources/extensions/claude-code-cli/tests/*.test.js\" \"dist-test/src/resources/extensions/github-sync/tests/*.test.js\" \"dist-test/src/resources/extensions/universal-config/tests/*.test.js\" \"dist-test/src/resources/extensions/voice/tests/*.test.js\" \"dist-test/src/resources/extensions/mcp-client/tests/*.test.js\"",
"test:packages": "node --test packages/pi-coding-agent/dist/core/*.test.js packages/pi-coding-agent/dist/core/tools/spawn-shell-windows.test.js",
"test:marketplace": "node scripts/with-env.mjs GSD_TEST_CLONE_MARKETPLACES=1 -- node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/claude-import-tui.test.ts src/resources/extensions/gsd/tests/plugin-importer-live.test.ts src/tests/marketplace-discovery.test.ts",
"test:coverage": "c8 --reporter=text --reporter=lcov --exclude=\"src/resources/extensions/gsd/tests/**\" --exclude=\"src/tests/**\" --exclude=\"scripts/**\" --exclude=\"native/**\" --exclude=\"node_modules/**\" --check-coverage --statements=40 --lines=40 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts src/resources/extensions/shared/tests/*.test.ts",
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test \"src/tests/integration/*.test.ts\" \"src/resources/extensions/gsd/tests/integration/*.test.ts\" \"src/resources/extensions/async-jobs/*.test.ts\" \"src/resources/extensions/browser-tools/tests/*.test.mjs\"",
"pretest": "npm run typecheck:extensions",
"test": "npm run test:unit && npm run test:integration",
"test:smoke": "node --experimental-strip-types tests/smoke/run.ts",
"test:fixtures": "node --experimental-strip-types tests/fixtures/run.ts",
"test:fixtures:record": "GSD_FIXTURE_MODE=record node --experimental-strip-types tests/fixtures/record.ts",
"test:live": "GSD_LIVE_TESTS=1 node --experimental-strip-types tests/live/run.ts",
"test:fixtures:record": "node scripts/with-env.mjs GSD_FIXTURE_MODE=record -- node --experimental-strip-types tests/fixtures/record.ts",
"test:live": "node scripts/with-env.mjs GSD_LIVE_TESTS=1 -- node --experimental-strip-types tests/live/run.ts",
"test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs",
"test:native": "node --test packages/native/src/__tests__/grep.test.mjs",
"test:secret-scan": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/tests/secret-scan.test.ts",
"secret-scan": "bash scripts/secret-scan.sh",
"secret-scan:install-hook": "bash scripts/install-hooks.sh",
"secret-scan": "node scripts/secret-scan.mjs",
"secret-scan:install-hook": "node scripts/install-hooks.mjs",
"build:native": "node native/scripts/build.js",
"build:native:dev": "node native/scripts/build.js --dev",
"dev": "node scripts/dev.js",
@ -92,7 +92,7 @@
"release:update-changelog": "node scripts/update-changelog.mjs",
"docker:build-runtime": "docker build --target runtime -t ghcr.io/gsd-build/gsd-pi .",
"docker:build-builder": "docker build --target builder -t ghcr.io/gsd-build/gsd-ci-builder .",
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && ([ \"$CI\" = 'true' ] || git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1)) && npm run build && npm run typecheck:extensions && npm run validate-pack",
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts"
},
"dependencies": {

View file

@ -172,16 +172,49 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
// Local Binary Resolution
// =============================================================================
const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
{ markers: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"], binDir: "node_modules/.bin" },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".venv/bin" },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: "venv/bin" },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".env/bin" },
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "vendor/bundle/bin" },
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "bin" },
{ markers: ["go.mod", "go.sum"], binDir: "bin" },
const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDirs: string[] }> = [
{ markers: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"], binDirs: ["node_modules/.bin"] },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDirs: [".venv/bin", ".venv/Scripts"] },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDirs: ["venv/bin", "venv/Scripts"] },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDirs: [".env/bin", ".env/Scripts"] },
{ markers: ["Gemfile", "Gemfile.lock"], binDirs: ["vendor/bundle/bin"] },
{ markers: ["Gemfile", "Gemfile.lock"], binDirs: ["bin"] },
{ markers: ["go.mod", "go.sum"], binDirs: ["bin"] },
];
function getWindowsBinaryCandidates(command: string): string[] {
const ext = path.extname(command).toLowerCase();
if (ext) {
return [command];
}
return [
command,
`${command}.cmd`,
`${command}.bat`,
`${command}.exe`,
];
}
export function resolveLocalBinaryPath(command: string, cwd: string, isWindows: boolean): string | null {
for (const { markers, binDirs } of LOCAL_BIN_PATHS) {
if (!hasRootMarkers(cwd, markers)) continue;
for (const binDir of binDirs) {
const basePath = path.join(cwd, binDir, command);
const candidates = isWindows ? getWindowsBinaryCandidates(basePath) : [basePath];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
}
return null;
}
export function which(command: string): string | null {
// On Windows, prefer `where.exe` over `which` — MSYS/Git Bash's `which`
// returns POSIX paths (/c/Users/...) that Node's spawn() can't execute.
@ -196,15 +229,8 @@ export function which(command: string): string | null {
}
export function resolveCommand(command: string, cwd: string): string | null {
for (const { markers, binDir } of LOCAL_BIN_PATHS) {
if (hasRootMarkers(cwd, markers)) {
const localPath = path.join(cwd, binDir, command);
if (fs.existsSync(localPath)) {
return localPath;
}
}
}
const localPath = resolveLocalBinaryPath(command, cwd, process.platform === "win32");
if (localPath) return localPath;
return which(command);
}

View file

@ -305,11 +305,13 @@ async function handleShareCommand(ctx: SlashCommandContext): Promise<void> {
ctx.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
let stdout = "";
let stderr = "";
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile], {
shell: process.platform === "win32",
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});

View file

@ -11,15 +11,18 @@
import { spawn } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
const require = createRequire(import.meta.url)
const tscBin = require.resolve('typescript/bin/tsc')
const procs = [
spawn('node', [resolve(__dirname, 'watch-resources.js')], {
cwd: root, stdio: 'inherit'
}),
spawn(resolve(root, 'node_modules', '.bin', 'tsc'), ['--watch'], {
spawn(process.execPath, [tscBin, '--watch'], {
cwd: root, stdio: 'inherit'
})
]

52
scripts/install-hooks.mjs Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const MARKER = '# gsd-secret-scan';
function git(args) {
return execFileSync('git', args, {
encoding: 'utf8',
shell: process.platform === 'win32',
}).trim();
}
const gitDir = git(['rev-parse', '--git-dir']);
const repoRoot = git(['rev-parse', '--show-toplevel']);
const hookDir = join(gitDir, 'hooks');
const hookFile = join(hookDir, 'pre-commit');
const hookCommand = `node "${join(repoRoot, 'scripts', 'secret-scan.mjs')}"`;
mkdirSync(hookDir, { recursive: true });
if (existsSync(hookFile)) {
const current = readFileSync(hookFile, 'utf8');
if (current.includes(MARKER)) {
process.stdout.write('secret-scan pre-commit hook already installed.\n');
process.exit(0);
}
const next = `${current.replace(/\s*$/, '\n')}${MARKER}\n${hookCommand}\n`;
writeFileSync(hookFile, next, 'utf8');
process.stdout.write('secret-scan appended to existing pre-commit hook.\n');
process.exit(0);
}
const hookBody = [
'#!/usr/bin/env sh',
'# gsd-secret-scan',
'# Pre-commit hook: scan staged files for hardcoded secrets',
hookCommand,
'',
].join('\n');
writeFileSync(hookFile, hookBody, 'utf8');
try {
chmodSync(hookFile, 0o755);
} catch {
// Best effort on Windows filesystems that do not honor chmod.
}
process.stdout.write('secret-scan pre-commit hook installed.\n');

View file

@ -42,7 +42,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { execSync, spawn, spawnSync } from 'node:child_process';
// ─── Configuration ───────────────────────────────────────────────────────────
@ -294,7 +294,10 @@ function findGsdLoader() {
// 3. Try `which gsd` and resolve symlink
try {
const bin = execSync('which gsd', { encoding: 'utf-8', timeout: 3000 }).trim();
const pathLookup = process.platform === 'win32' ? 'where.exe' : 'which';
const lookupArgs = ['gsd'];
const result = spawnSync(pathLookup, lookupArgs, { encoding: 'utf-8', timeout: 3000 });
const bin = result.status === 0 ? result.stdout.trim().split(/\r?\n/)[0]?.trim() : '';
if (bin) {
const realBin = fs.realpathSync(bin);
const loader = path.resolve(path.dirname(realBin), '..', 'dist', 'loader.js');
@ -309,7 +312,7 @@ const GSD_LOADER = findGsdLoader();
/**
* Respawn a dead worker. Returns the new PID or null on failure.
* Uses nohup + output redirection so the child is fully detached.
* Uses a detached Node child with log file descriptors so the child is fully detached.
*/
function respawnWorker(mid) {
const worktreeDir = path.resolve(PROJECT_ROOT, `.gsd/worktrees/${mid}`);
@ -319,41 +322,37 @@ function respawnWorker(mid) {
const stdoutLog = path.resolve(PROJECT_ROOT, PARALLEL_DIR, `${mid}.stdout.log`);
const stderrLog = path.resolve(PROJECT_ROOT, PARALLEL_DIR, `${mid}.stderr.log`);
let stdoutFd;
let stderrFd;
try {
const env = [
`GSD_MILESTONE_LOCK=${mid}`,
`GSD_PROJECT_ROOT=${PROJECT_ROOT}`,
`GSD_PARALLEL_WORKER=1`,
].join(' ');
// Use a shell script written to a temp file to avoid quoting hell
const script = [
'#!/bin/bash',
`cd "${worktreeDir}"`,
`export GSD_MILESTONE_LOCK=${mid}`,
`export GSD_PROJECT_ROOT="${PROJECT_ROOT}"`,
`export GSD_PARALLEL_WORKER=1`,
`exec node "${GSD_LOADER}" headless --json auto > "${stdoutLog}" 2>> "${stderrLog}"`,
].join('\n');
const scriptPath = path.resolve(PROJECT_ROOT, PARALLEL_DIR, `${mid}.respawn.sh`);
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
// Launch detached via nohup
const result = execSync(
`nohup bash "${scriptPath}" > /dev/null 2>&1 & echo $!`,
{ timeout: 5000, encoding: 'utf-8', cwd: worktreeDir }
).trim();
// Clean up the temp script after a delay (process already forked)
setTimeout(() => {
try { fs.unlinkSync(scriptPath); } catch {}
}, 5000);
const newPid = parseInt(result, 10);
return isNaN(newPid) ? null : newPid;
fs.mkdirSync(path.dirname(stdoutLog), { recursive: true });
stdoutFd = fs.openSync(stdoutLog, 'a');
stderrFd = fs.openSync(stderrLog, 'a');
const child = spawn(process.execPath, [GSD_LOADER, 'headless', '--json', 'auto'], {
cwd: worktreeDir,
detached: true,
env: {
...process.env,
GSD_MILESTONE_LOCK: mid,
GSD_PROJECT_ROOT: PROJECT_ROOT,
GSD_PARALLEL_WORKER: '1',
},
stdio: ['ignore', stdoutFd, stderrFd],
windowsHide: true,
});
child.unref();
return child.pid ?? null;
} catch (err) {
return null;
} finally {
if (stdoutFd !== undefined) {
try { fs.closeSync(stdoutFd); } catch {}
}
if (stderrFd !== undefined) {
try { fs.closeSync(stderrFd); } catch {}
}
}
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
if (process.env.CI === 'true' || process.env.CI === '1') {
process.exit(0);
}
const result = spawnSync('git', ['diff', '--exit-code'], {
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.status === 0) {
process.exit(0);
}
process.stderr.write('ERROR: version sync changed files — commit them before publishing\n');
process.exit(result.status ?? 1);

184
scripts/secret-scan.mjs Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
const RED = '\x1b[0;31m';
const YELLOW = '\x1b[1;33m';
const NC = '\x1b[0m';
const IGNORE_FILE = '.secretscanignore';
const PATTERNS = [
{ label: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g },
{ label: 'Generic API Key', regex: /(api[_-]?key|apikey|api[_-]?secret)[ \t]*[:=][ \t]*['"][0-9a-zA-Z_./-]{20,}['"]/gi },
{ label: 'Generic Secret', regex: /(secret|token|password|passwd|pwd|credential)[ \t]*[:=][ \t]*['"][^\s'"]{8,}['"]/gi },
{ label: 'Authorization Header', regex: /(authorization|bearer)[ \t]*[:=][ \t]*['"][^\s'"]{8,}['"]/gi },
{ label: 'Private Key', regex: /-----BEGIN\s+(RSA|DSA|EC|OPENSSH|PGP)\s+PRIVATE\s+KEY-----/g },
{ label: 'Database URL', regex: /(mysql|postgres|postgresql|mongodb|redis|amqp|mssql):\/\/[^\s'"]{8,}/gi },
{ label: 'GitHub Token', regex: /gh[pousr]_[0-9a-zA-Z]{36,}/g },
{ label: 'GitLab Token', regex: /glpat-[0-9a-zA-Z-]{20,}/g },
{ label: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z-]{10,}/g },
{ label: 'Slack Webhook', regex: /hooks\.slack\.com\/services\/T[0-9A-Z]{8,}\/B[0-9A-Z]{8,}\/[0-9a-zA-Z]{20,}/g },
{ label: 'Google API Key', regex: /AIza[0-9A-Za-z_-]{35}/g },
{ label: 'Stripe Key', regex: /[sr]k_(live|test)_[0-9a-zA-Z]{20,}/g },
{ label: 'npm Token', regex: /npm_[0-9a-zA-Z]{36,}/g },
{ label: 'Hex Secret', regex: /(secret|key|token|password)[ \t]*[:=][ \t]*['"]?[0-9a-f]{32,}['"]?/gi },
{ label: 'Hardcoded Password', regex: /password[ \t]*[:=][ \t]*['"][^'"]{4,}['"]/gi },
];
function runGit(args) {
try {
return execFileSync('git', args, {
encoding: 'utf8',
shell: process.platform === 'win32',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return '';
}
}
function parseArgs(argv) {
if (argv[0] === '--diff') {
return { mode: 'diff', ref: argv[1] || 'HEAD' };
}
if (argv[0] === '--file') {
return { mode: 'file', file: argv[1] || '' };
}
return { mode: 'staged' };
}
function getFiles(options) {
if (options.mode === 'diff') {
return runGit(['diff', '--name-only', '--diff-filter=ACMR', options.ref]);
}
if (options.mode === 'file') {
return options.file;
}
return runGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
}
function shouldScan(file) {
const lower = file.toLowerCase();
const skippedExtensions = [
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot',
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.7z', '.rar', '.exe', '.dll', '.so', '.dylib',
'.o', '.a', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.lock', '.map', '.node', '.wasm',
];
if (skippedExtensions.some((ext) => lower.endsWith(ext))) return false;
if (
lower === '.secretscanignore' ||
lower === '.gitignore' ||
lower === '.gitattributes' ||
lower.startsWith('license') ||
lower.startsWith('changelog') ||
lower.endsWith('.md') ||
lower === 'package-lock.json' ||
lower === 'pnpm-lock.yaml' ||
lower === 'bun.lock'
) {
return false;
}
if (
lower.startsWith('node_modules/') ||
lower.startsWith('dist/') ||
lower.startsWith('coverage/') ||
lower.startsWith('.gsd/')
) {
return false;
}
if (lower.endsWith('.min.js') || lower.endsWith('.min.css')) return false;
return true;
}
function getContent(file, mode) {
if (mode === 'staged') {
const staged = runGit(['show', `:${file}`]);
if (staged) return staged;
}
try {
return readFileSync(file, 'utf8');
} catch {
return '';
}
}
function loadIgnorePatterns() {
if (!existsSync(IGNORE_FILE)) return [];
return readFileSync(IGNORE_FILE, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
}
function isIgnored(file, lineContent, ignorePatterns) {
return ignorePatterns.some((pattern) => {
const splitIndex = pattern.indexOf(':');
if (splitIndex > 0) {
const ignoreFile = pattern.slice(0, splitIndex);
const ignoreRegex = pattern.slice(splitIndex + 1);
if (file !== ignoreFile) return false;
try {
return new RegExp(ignoreRegex, 'i').test(lineContent);
} catch {
return false;
}
}
try {
return new RegExp(pattern, 'i').test(lineContent);
} catch {
return false;
}
});
}
function resetRegex(regex) {
regex.lastIndex = 0;
return regex;
}
const options = parseArgs(process.argv.slice(2));
const files = getFiles(options)
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean);
if (files.length === 0) {
process.stdout.write('secret-scan: no files to scan\n');
process.exit(0);
}
const ignorePatterns = loadIgnorePatterns();
let findings = 0;
for (const file of files) {
if (!shouldScan(file)) continue;
const content = getContent(file, options.mode);
if (!content) continue;
const lines = content.split(/\r?\n/);
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
for (const pattern of PATTERNS) {
if (!resetRegex(pattern.regex).test(line)) continue;
if (isIgnored(file, line, ignorePatterns)) continue;
process.stdout.write(`${RED}[SECRET DETECTED]${NC} ${YELLOW}${pattern.label}${NC}\n`);
process.stdout.write(` File: ${file}:${lineIndex + 1}\n`);
process.stdout.write(` Line: ${line.slice(0, 120)}...\n\n`);
findings++;
}
}
}
if (findings > 0) {
process.stdout.write(`${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`);
process.stdout.write(`${RED}Found ${findings} potential secret(s) in scanned files.${NC}\n`);
process.stdout.write(`${RED}Commit blocked. Remove the secrets or add exceptions${NC}\n`);
process.stdout.write(`${RED}to .secretscanignore if these are false positives.${NC}\n`);
process.stdout.write(`${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`);
process.exit(1);
}
process.stdout.write('secret-scan: no secrets detected ✓\n');

View file

@ -3,8 +3,8 @@
// Usage: npm run validate-pack (or node scripts/validate-pack.js)
// Exit 0 = safe to publish, Exit 1 = broken package.
import { execSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -15,8 +15,37 @@ const ROOT = resolve(__dirname, '..');
let tarball = null;
let installDir = null;
let npmCacheDir = null;
const DEFAULT_MAX_BUFFER = 50 * 1024 * 1024;
function getNpmCommand() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function runNpm(args, options = {}) {
return execFileSync(getNpmCommand(), args, {
cwd: ROOT,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
maxBuffer: DEFAULT_MAX_BUFFER,
env: {
...process.env,
npm_config_cache: npmCacheDir ?? process.env.npm_config_cache,
},
...options,
});
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
try {
npmCacheDir = mkdtempSync(join(tmpdir(), 'validate-pack-npm-cache-'));
mkdirSync(npmCacheDir, { recursive: true });
// --- Guard: workspace packages must not have @gsd/* cross-deps ---
console.log('==> Checking workspace packages for @gsd/* cross-deps...');
const workspaces = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui'];
@ -42,12 +71,10 @@ try {
// --- Pack tarball ---
console.log('==> Packing tarball...');
const packOutput = execSync('npm pack --ignore-scripts', {
cwd: ROOT,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const tarballName = packOutput.trim().split('\n').pop();
const packOutput = runNpm(['pack', '--json', '--ignore-scripts']);
const packEntries = JSON.parse(packOutput);
const packEntry = Array.isArray(packEntries) ? packEntries[0] : null;
const tarballName = packEntry?.filename;
tarball = join(ROOT, tarballName);
if (!existsSync(tarball)) {
@ -55,12 +82,16 @@ try {
process.exit(1);
}
const stats = execSync(`du -h "${tarball}"`, { encoding: 'utf8' }).split('\t')[0].trim();
console.log(`==> Tarball: ${tarballName} (${stats} compressed)`);
const stats = statSync(tarball);
console.log(`==> Tarball: ${tarballName} (${formatBytes(stats.size)} compressed)`);
// --- Check critical files using tar listing ---
// --- Check critical files using npm pack metadata ---
console.log('==> Checking critical files...');
const tarList = execSync(`tar tzf "${tarball}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
const packedFiles = new Set(
Array.isArray(packEntry?.files)
? packEntry.files.map((entry) => entry?.path).filter(Boolean)
: [],
);
const requiredFiles = [
'dist/loader.js',
@ -73,7 +104,7 @@ try {
let missing = false;
for (const required of requiredFiles) {
if (!tarList.includes(`package/${required}`)) {
if (!packedFiles.has(required)) {
console.log(` MISSING: ${required}`);
missing = true;
}
@ -91,10 +122,15 @@ try {
writeFileSync(join(installDir, 'package.json'), JSON.stringify({ name: 'test-install', version: '1.0.0', private: true }, null, 2));
try {
const installOutput = execSync(`npm install "${tarball}"`, {
const installOutput = execFileSync(getNpmCommand(), ['install', tarball], {
cwd: installDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
maxBuffer: DEFAULT_MAX_BUFFER,
env: {
...process.env,
npm_config_cache: npmCacheDir,
},
});
console.log(installOutput);
console.log('==> Install succeeded.');
@ -145,11 +181,12 @@ try {
process.exit(1);
}
try {
const versionOutput = execSync(`node "${loaderPath}" -v`, {
const versionOutput = execFileSync(process.execPath, [loaderPath, '-v'], {
cwd: installDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000,
maxBuffer: DEFAULT_MAX_BUFFER,
}).trim();
console.log(` gsd -v => ${versionOutput}`);
if (!versionOutput.match(/^\d+\.\d+\.\d+/)) {
@ -173,4 +210,7 @@ try {
if (tarball && existsSync(tarball)) {
rmSync(tarball, { force: true });
}
if (npmCacheDir && existsSync(npmCacheDir)) {
rmSync(npmCacheDir, { recursive: true, force: true });
}
}

46
scripts/with-env.mjs Normal file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
const args = process.argv.slice(2);
const env = { ...process.env };
let separatorIndex = args.indexOf('--');
let commandStart = separatorIndex >= 0 ? separatorIndex + 1 : 0;
for (let i = 0; i < (separatorIndex >= 0 ? separatorIndex : args.length); i++) {
const arg = args[i];
const eq = arg.indexOf('=');
if (eq <= 0) {
commandStart = i;
separatorIndex = -1;
break;
}
env[arg.slice(0, eq)] = arg.slice(eq + 1);
}
const commandArgs = args.slice(commandStart);
if (commandArgs.length === 0) {
process.stderr.write('with-env: expected a command after environment assignments\n');
process.exit(1);
}
const [command, ...childArgs] = commandArgs;
const child = spawn(command, childArgs, {
stdio: 'inherit',
env,
shell: process.platform === 'win32',
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
child.on('error', (error) => {
process.stderr.write(`with-env: failed to run ${command}: ${error.message}\n`);
process.exit(1);
});

View file

@ -20,6 +20,8 @@ import { resolve } from "node:path";
import type { TaskRow } from "./gsd-db.ts";
import type { PreExecutionCheckJSON } from "./verification-evidence.ts";
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
// ─── Result Types ────────────────────────────────────────────────────────────
export interface PreExecutionResult {
@ -126,9 +128,10 @@ async function checkPackageOnNpm(
timeoutMs = 5000
): Promise<{ exists: boolean; error?: string }> {
return new Promise((resolve) => {
const child = spawn("npm", ["view", packageName, "name"], {
const child = spawn(NPM_COMMAND, ["view", packageName, "name"], {
stdio: ["ignore", "pipe", "pipe"],
timeout: timeoutMs,
shell: process.platform === "win32",
});
let stdout = "";

View file

@ -74,6 +74,27 @@ test("validateDirectory: C:\\Windows is blocked", { skip: !isWindows ? "Windows-
assert.equal(result.severity, "blocked");
});
test("validateDirectory: D:\\Windows is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => {
const result = validateDirectory("D:\\Windows");
assert.equal(result.safe, false);
assert.equal(result.severity, "blocked");
assert.ok(result.reason?.includes("system directory"));
});
test("validateDirectory: E:\\Program Files is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => {
const result = validateDirectory("E:\\Program Files");
assert.equal(result.safe, false);
assert.equal(result.severity, "blocked");
assert.ok(result.reason?.includes("system directory"));
});
test("validateDirectory: any Windows drive root is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => {
const result = validateDirectory("D:\\");
assert.equal(result.safe, false);
assert.equal(result.severity, "blocked");
assert.ok(result.reason?.includes("system directory"));
});
// ─── Home directory (cross-platform) ─────────────────────────────────────────────
test("validateDirectory: home directory itself is blocked", () => {
@ -104,7 +125,13 @@ test("validateDirectory: subdirectory of home is NOT blocked", () => {
// Regression test for #1317: GSD worktree inside $HOME must not be blocked even
// when the resolved project root equals $HOME (e.g. home dir is a git repo).
test("validateDirectory: GSD worktree path nested under home is NOT blocked (#1317)", () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const fakeHome = makeTempDir("fake-home");
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
const worktreePath = join(homedir(), ".gsd", "worktrees", "M001");
const worktreeRoot = join(fakeHome, ".gsd", "worktrees", "M001");
mkdirSync(worktreePath, { recursive: true });
try {
// The worktree CWD itself is a valid location — it must pass.
@ -112,7 +139,12 @@ test("validateDirectory: GSD worktree path nested under home is NOT blocked (#13
assert.equal(result.safe, true, "GSD worktree path should be safe to run in");
assert.equal(result.severity, "ok");
} finally {
rmSync(join(homedir(), ".gsd", "worktrees", "M001"), { recursive: true, force: true });
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
rmSync(worktreeRoot, { recursive: true, force: true });
rmSync(fakeHome, { recursive: true, force: true });
}
});

View file

@ -61,6 +61,33 @@ const WINDOWS_BLOCKED_PATHS = new Set([
"C:\\Program Files (x86)",
]);
const WINDOWS_BLOCKED_SUFFIXES = new Set([
"\\",
"\\windows",
"\\windows\\system32",
"\\program files",
"\\program files (x86)",
]);
function normalizePathForComparison(dirPath: string): string {
let normalized = dirPath.replace(/[/\\]+$/, "");
if (normalized === "") {
normalized = "/";
} else if (/^[A-Za-z]:$/.test(normalized)) {
normalized += "\\";
}
return platform() === "win32" ? normalized.toLowerCase() : normalized;
}
function isBlockedWindowsPath(normalized: string): boolean {
if (!/^[a-z]:\\/.test(normalized)) {
return false;
}
const suffix = normalized.slice(2);
return WINDOWS_BLOCKED_SUFFIXES.has(suffix);
}
// ─── Core Validation ────────────────────────────────────────────────────────────
/**
@ -84,16 +111,11 @@ export function validateDirectory(dirPath: string): DirectoryValidationResult {
// Normalize trailing slashes for consistent comparison.
// Special cases: "/" → "/" (not ""), "C:\" → "C:\" (not "C:")
let normalized = resolved.replace(/[/\\]+$/, "");
if (normalized === "") {
normalized = "/";
} else if (/^[A-Za-z]:$/.test(normalized)) {
normalized = normalized + "\\";
}
const normalized = normalizePathForComparison(resolved);
// ── Check 1: Blocked system paths ──────────────────────────────────────
const blockedPaths = platform() === "win32" ? WINDOWS_BLOCKED_PATHS : UNIX_BLOCKED_PATHS;
if (blockedPaths.has(normalized)) {
if (platform() === "win32" ? isBlockedWindowsPath(normalized) : blockedPaths.has(normalized)) {
return {
safe: false,
severity: "blocked",
@ -104,9 +126,9 @@ export function validateDirectory(dirPath: string): DirectoryValidationResult {
// ── Check 2: Home directory itself (not subdirs) ───────────────────────
let resolvedHome: string;
try {
resolvedHome = realpathSync(resolve(homedir())).replace(/[/\\]+$/, "");
resolvedHome = normalizePathForComparison(realpathSync(resolve(homedir())));
} catch {
resolvedHome = resolve(homedir()).replace(/[/\\]+$/, "");
resolvedHome = normalizePathForComparison(resolve(homedir()));
}
if (normalized === resolvedHome) {
@ -120,9 +142,9 @@ export function validateDirectory(dirPath: string): DirectoryValidationResult {
// ── Check 3: Temp directory root ───────────────────────────────────────
let resolvedTmp: string;
try {
resolvedTmp = realpathSync(resolve(tmpdir())).replace(/[/\\]+$/, "");
resolvedTmp = normalizePathForComparison(realpathSync(resolve(tmpdir())));
} catch {
resolvedTmp = resolve(tmpdir()).replace(/[/\\]+$/, "");
resolvedTmp = normalizePathForComparison(resolve(tmpdir()));
}
if (normalized === resolvedTmp) {

View file

@ -1,4 +1,5 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { mkdirSync } from "node:fs";
export default function auditCommand(pi: ExtensionAPI) {
pi.registerCommand("audit", {
@ -39,7 +40,7 @@ export default function auditCommand(pi: ExtensionAPI) {
// ── Step 3: Ensure the output directory exists ───────────────────────
await pi.exec("mkdir", ["-p", ".gsd/audits"]);
mkdirSync(".gsd/audits", { recursive: true });
// ── Step 4: Send the audit prompt to the agent ───────────────────────

View file

@ -53,8 +53,10 @@ interface Baseline {
// Directory helpers
// ============================================================================
function encodeCwd(cwd: string): string {
return cwd.replace(/\//g, "--");
export function encodeCwd(cwd: string): string {
// Encode the entire cwd so Windows drive letters, separators, and UNC
// prefixes cannot leak into the isolation path.
return Buffer.from(cwd, "utf8").toString("base64url");
}
const gsdHome = process.env.GSD_HOME || path.join(os.homedir(), ".gsd");
@ -500,4 +502,3 @@ export function readIsolationMode(): IsolationMode {
return "none";
}
}

View file

@ -117,4 +117,9 @@ test("launchWebMode source-dev host also passes windowsHide: true", async (t) =>
true,
"source-dev spawn must also include windowsHide: true (#2628)",
);
assert.equal(
capturedOptions!.shell,
true,
"source-dev spawn must include shell: true when launching npm.cmd on Windows",
);
});

View file

@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resolveLocalBinaryPath } from "../../packages/pi-coding-agent/src/core/lsp/config.ts";
import { encodeCwd } from "../resources/extensions/subagent/isolation.ts";
function makeTempDir(prefix: string): string {
const dir = path.join(
os.tmpdir(),
`gsd-windows-portability-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(dir, { recursive: true });
return dir;
}
test("resolveLocalBinaryPath finds Windows npm shims", () => {
const dir = makeTempDir("lsp-shim");
try {
writeFileSync(path.join(dir, "package.json"), "{}");
mkdirSync(path.join(dir, "node_modules", ".bin"), { recursive: true });
const shimPath = path.join(dir, "node_modules", ".bin", "tsc.cmd");
writeFileSync(shimPath, "@echo off\r\n");
const resolved = resolveLocalBinaryPath("tsc", dir, true);
assert.equal(resolved, shimPath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("resolveLocalBinaryPath finds Windows venv Scripts executables", () => {
const dir = makeTempDir("lsp-scripts");
try {
writeFileSync(path.join(dir, "pyproject.toml"), "");
mkdirSync(path.join(dir, "venv", "Scripts"), { recursive: true });
const exePath = path.join(dir, "venv", "Scripts", "python.exe");
writeFileSync(exePath, "");
const resolved = resolveLocalBinaryPath("python", dir, true);
assert.equal(resolved, exePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("encodeCwd produces a filesystem-safe token for Windows paths", () => {
const encoded = encodeCwd("C:\\Users\\Alice\\repo");
assert.match(encoded, /^[A-Za-z0-9_-]+$/);
assert.ok(!encoded.includes(":"));
assert.ok(!encoded.includes("\\"));
assert.ok(!encoded.includes("/"));
});
test("Windows launch points use shell-safe shims", () => {
const gsdClient = readFileSync(
path.join(process.cwd(), "vscode-extension", "src", "gsd-client.ts"),
"utf8",
);
const updateService = readFileSync(
path.join(process.cwd(), "src", "web", "update-service.ts"),
"utf8",
);
const preExecution = readFileSync(
path.join(process.cwd(), "src", "resources", "extensions", "gsd", "pre-execution-checks.ts"),
"utf8",
);
assert.match(gsdClient, /shell:\s*process\.platform === "win32"/);
assert.match(updateService, /npm\.cmd/);
assert.match(preExecution, /npm\.cmd/);
});

View file

@ -353,6 +353,10 @@ function getSpawnCommandForSourceHost(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'npm.cmd' : 'npm'
}
function needsWindowsShell(command: string, platform: NodeJS.Platform): boolean {
return platform === 'win32' && /\.(cmd|bat)$/i.test(command)
}
function formatLaunchStatus(status: WebModeLaunchStatus): string {
if (status.ok) {
return `[gsd] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`
@ -636,6 +640,7 @@ export async function launchWebMode(
detached: true,
stdio: 'ignore',
windowsHide: true,
shell: needsWindowsShell(spawnSpec.command, deps.platform ?? process.platform),
env,
},
)

View file

@ -4,6 +4,7 @@ import { compareSemver } from "../update-check.ts"
const NPM_PACKAGE_NAME = "gsd-pi"
const REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`
const FETCH_TIMEOUT_MS = 5000
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm"
// --- Version check ---
@ -69,11 +70,12 @@ export function triggerUpdate(targetVersion?: string): boolean {
updateState = { status: "running", targetVersion }
const child = spawn("npm", ["install", "-g", "gsd-pi@latest"], {
const child = spawn(NPM_COMMAND, ["install", "-g", "gsd-pi@latest"], {
stdio: ["ignore", "ignore", "pipe"],
// Detach so the child process is not killed if the parent exits
detached: false,
windowsHide: true,
shell: process.platform === "win32",
})
let stderr = ""

View file

@ -127,6 +127,7 @@ export class GsdClient implements vscode.Disposable {
cwd: this.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
shell: process.platform === "win32",
});
this.process = proc;