Merge pull request #3961 from jeremymcs/fix/windows-portability-sweep
fix: harden Windows portability across runtime and tooling
This commit is contained in:
commit
19cbb17683
22 changed files with 634 additions and 105 deletions
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
20
package.json
20
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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
52
scripts/install-hooks.mjs
Normal 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');
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
19
scripts/prepublish-check.mjs
Normal file
19
scripts/prepublish-check.mjs
Normal 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
184
scripts/secret-scan.mjs
Normal 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');
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
scripts/with-env.mjs
Normal file
46
scripts/with-env.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
78
src/tests/windows-portability.test.ts
Normal file
78
src/tests/windows-portability.test.ts
Normal file
|
|
@ -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'/);
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue