diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17351ebb2..e14add275 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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,17 @@ jobs: - name: Typecheck extensions run: npm run typecheck:extensions - - name: Run unit tests - run: npm run test:unit - - name: Run package tests run: npm run test:packages + - name: Run Windows portability tests + run: >- + node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs + --experimental-strip-types --test + src/tests/windows-portability.test.ts + src/resources/extensions/gsd/tests/validate-directory.test.ts + src/tests/integration/web-mode-windows-hide.test.ts + rtk-portability: timeout-minutes: 20 needs: detect-changes diff --git a/package.json b/package.json index a99e91223..91692ade1 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/pi-coding-agent/src/core/lsp/config.ts b/packages/pi-coding-agent/src/core/lsp/config.ts index 758657856..29401a363 100644 --- a/packages/pi-coding-agent/src/core/lsp/config.ts +++ b/packages/pi-coding-agent/src/core/lsp/config.ts @@ -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); } diff --git a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts index c510e63b4..24fd8bb7a 100644 --- a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +++ b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -305,11 +305,13 @@ async function handleShareCommand(ctx: SlashCommandContext): Promise { 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(); }); diff --git a/scripts/dev.js b/scripts/dev.js index faf9a75d2..0eea64072 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -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' }) ] diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 000000000..dea550585 --- /dev/null +++ b/scripts/install-hooks.mjs @@ -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'); diff --git a/scripts/parallel-monitor.mjs b/scripts/parallel-monitor.mjs index b29109682..e3acd6545 100755 --- a/scripts/parallel-monitor.mjs +++ b/scripts/parallel-monitor.mjs @@ -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 {} + } } } diff --git a/scripts/prepublish-check.mjs b/scripts/prepublish-check.mjs new file mode 100644 index 000000000..c47cafbbd --- /dev/null +++ b/scripts/prepublish-check.mjs @@ -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); diff --git a/scripts/secret-scan.mjs b/scripts/secret-scan.mjs new file mode 100644 index 000000000..e8f1a5f79 --- /dev/null +++ b/scripts/secret-scan.mjs @@ -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'); diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js index e4bbe6277..b35bc1b5a 100644 --- a/scripts/validate-pack.js +++ b/scripts/validate-pack.js @@ -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,38 @@ 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', + shell: process.platform === 'win32', + 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 +72,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 +83,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 +105,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 +123,16 @@ 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', + shell: process.platform === 'win32', stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: DEFAULT_MAX_BUFFER, + env: { + ...process.env, + npm_config_cache: npmCacheDir, + }, }); console.log(installOutput); console.log('==> Install succeeded.'); @@ -145,11 +183,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 +212,7 @@ try { if (tarball && existsSync(tarball)) { rmSync(tarball, { force: true }); } + if (npmCacheDir && existsSync(npmCacheDir)) { + rmSync(npmCacheDir, { recursive: true, force: true }); + } } diff --git a/scripts/with-env.mjs b/scripts/with-env.mjs new file mode 100644 index 000000000..a338ffb3f --- /dev/null +++ b/scripts/with-env.mjs @@ -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); +}); diff --git a/src/resources/extensions/gsd/pre-execution-checks.ts b/src/resources/extensions/gsd/pre-execution-checks.ts index 634a9a000..1e49a3909 100644 --- a/src/resources/extensions/gsd/pre-execution-checks.ts +++ b/src/resources/extensions/gsd/pre-execution-checks.ts @@ -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 = ""; diff --git a/src/resources/extensions/gsd/tests/validate-directory.test.ts b/src/resources/extensions/gsd/tests/validate-directory.test.ts index 72c45be38..c86e08a80 100644 --- a/src/resources/extensions/gsd/tests/validate-directory.test.ts +++ b/src/resources/extensions/gsd/tests/validate-directory.test.ts @@ -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 }); } }); diff --git a/src/resources/extensions/gsd/validate-directory.ts b/src/resources/extensions/gsd/validate-directory.ts index 4341826c2..6923abd49 100644 --- a/src/resources/extensions/gsd/validate-directory.ts +++ b/src/resources/extensions/gsd/validate-directory.ts @@ -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) { diff --git a/src/resources/extensions/slash-commands/audit.ts b/src/resources/extensions/slash-commands/audit.ts index b5f3bf85c..fe7d3f046 100644 --- a/src/resources/extensions/slash-commands/audit.ts +++ b/src/resources/extensions/slash-commands/audit.ts @@ -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 ─────────────────────── diff --git a/src/resources/extensions/subagent/isolation.ts b/src/resources/extensions/subagent/isolation.ts index a326f55d3..e862e65ff 100644 --- a/src/resources/extensions/subagent/isolation.ts +++ b/src/resources/extensions/subagent/isolation.ts @@ -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"; } } - diff --git a/src/tests/integration/web-mode-cli.test.ts b/src/tests/integration/web-mode-cli.test.ts index 68b6c9c1b..9eee1f803 100644 --- a/src/tests/integration/web-mode-cli.test.ts +++ b/src/tests/integration/web-mode-cli.test.ts @@ -165,6 +165,7 @@ test('launchWebMode prefers the packaged standalone host and opens the resolved detached: true, stdio: 'ignore', windowsHide: true, + shell: false, env: { TEST_ENV: '1', HOSTNAME: '127.0.0.1', diff --git a/src/tests/integration/web-mode-windows-hide.test.ts b/src/tests/integration/web-mode-windows-hide.test.ts index aeb6baeea..c1b2902f5 100644 --- a/src/tests/integration/web-mode-windows-hide.test.ts +++ b/src/tests/integration/web-mode-windows-hide.test.ts @@ -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", + ); }); diff --git a/src/tests/windows-portability.test.ts b/src/tests/windows-portability.test.ts new file mode 100644 index 000000000..30dbde0e5 --- /dev/null +++ b/src/tests/windows-portability.test.ts @@ -0,0 +1,78 @@ +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", + ); + const validatePack = readFileSync( + path.join(process.cwd(), "scripts", "validate-pack.js"), + "utf8", + ); + + assert.match(gsdClient, /shell:\s*process\.platform === "win32"/); + assert.match(updateService, /npm\.cmd/); + assert.match(preExecution, /npm\.cmd/); + assert.match(validatePack, /shell:\s*process\.platform === 'win32'/); +}); diff --git a/src/web-mode.ts b/src/web-mode.ts index 665e0f5a8..3d917431c 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -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, }, ) diff --git a/src/web/update-service.ts b/src/web/update-service.ts index 62c728161..5b6ccfef8 100644 --- a/src/web/update-service.ts +++ b/src/web/update-service.ts @@ -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 = "" diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts index b2a872c5e..ef6d65978 100644 --- a/vscode-extension/src/gsd-client.ts +++ b/vscode-extension/src/gsd-client.ts @@ -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;