diff --git a/bin/helpers/db-utils.ts b/bin/helpers/db-utils.ts index 5899039..4eb16e9 100644 --- a/bin/helpers/db-utils.ts +++ b/bin/helpers/db-utils.ts @@ -1,5 +1,8 @@ -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; // ─── Types ────────────────────────────────────────────────────────────────── export interface InfisicalConfig { url: string; clientId: string; clientSecret: string; projectId: string } @@ -41,19 +44,28 @@ export function getInfisicalConfig(): InfisicalConfig { }; } +// Use execFileSync (no shell) so single-quoted JSON bodies survive on Windows +// cmd.exe, where ' is literal rather than a string delimiter. Passing curl +// args as an array bypasses shell parsing on every platform. export function getInfisicalToken(config: InfisicalConfig): string { - const response = execSync( - `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, - { encoding: 'utf-8' } - ); + const body = JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }); + const response = execFileSync('curl', [ + '-s', + '-X', 'POST', + `${config.url}/api/v1/auth/universal-auth/login`, + '-H', 'Content-Type: application/json', + '-d', body, + ], { encoding: 'utf-8' }); return JSON.parse(response).accessToken; } export function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record { - const response = execSync( - `curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`, - { encoding: 'utf-8' } - ); + const url = `${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}`; + const response = execFileSync('curl', [ + '-s', + url, + '-H', `Authorization: Bearer ${token}`, + ], { encoding: 'utf-8' }); const data = JSON.parse(response); const secrets: Record = {}; for (const secret of data.secrets || []) { @@ -80,6 +92,25 @@ export function setupSSHTunnel(dbHost: string, localPort: number): void { // ─── psql ─────────────────────────────────────────────────────────────────── function findPsqlPath(): string { + if (process.platform === 'win32') { + // `which psql` under Git Bash returns an MSYS path (e.g. `/c/Program Files/...`) + // that Node's execSync (running cmd.exe) cannot resolve. Probe known install + // locations and fall back to `where psql` which returns native Windows paths. + const winPaths = [ + 'C:\\Program Files\\PostgreSQL\\18\\bin\\psql.exe', + 'C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe', + 'C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe', + 'C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe', + 'C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe', + ]; + for (const p of winPaths) { if (fs.existsSync(p)) return p; } + try { + const result = execSync('where psql', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); + const first = result.split(/\r?\n/).map(s => s.trim()).find(s => s.length > 0); + if (first) return first; + } catch { /* fallthrough */ } + throw new Error('PostgreSQL client (psql) not found. Install from https://www.postgresql.org/download/windows/'); + } try { const result = execSync('which psql', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); if (result.trim()) return result.trim(); @@ -91,10 +122,29 @@ function findPsqlPath(): string { export function queryDB(conn: DBConnection, sql: string): string { const psql = findPsqlPath(); - return execSync(`"${psql}" -h ${conn.host} -p ${conn.port} -U ${conn.user} -d ${conn.database} -t -A --quiet -c "${sql.replace(/"/g, '\\"')}"`, { - encoding: 'utf-8', - env: { ...process.env, PGPASSWORD: conn.password }, - }).trim(); + // Write SQL to a temp file and use `psql -f` rather than `psql -c ""`. + // On Windows, cmd.exe truncates embedded newlines inside a quoted -c argument, + // so multi-statement transactions silently execute only their first line and + // psql still exits 0 — caller thinks the grant/update committed when nothing changed. + // ON_ERROR_STOP=1 makes any failing statement abort the script with non-zero exit. + const tmpFile = path.join(os.tmpdir(), `optima-psql-${crypto.randomBytes(8).toString('hex')}.sql`); + fs.writeFileSync(tmpFile, sql, { encoding: 'utf-8' }); + try { + return execFileSync(psql, [ + '-h', conn.host, + '-p', String(conn.port), + '-U', conn.user, + '-d', conn.database, + '-t', '-A', '--quiet', + '-v', 'ON_ERROR_STOP=1', + '-f', tmpFile, + ], { + encoding: 'utf-8', + env: { ...process.env, PGPASSWORD: conn.password }, + }).trim(); + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } } // ─── High-level connection helpers ────────────────────────────────────────── diff --git a/bin/helpers/query-db.ts b/bin/helpers/query-db.ts index 3892400..935fc2c 100755 --- a/bin/helpers/query-db.ts +++ b/bin/helpers/query-db.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import * as fs from 'fs'; interface InfisicalConfig { @@ -127,19 +127,27 @@ function getInfisicalConfig(): InfisicalConfig { }; } +// See db-utils.ts: execSync goes through cmd.exe on Windows where single +// quotes are literal. Use execFileSync with argv array to bypass shell. function getInfisicalToken(config: InfisicalConfig): string { - const response = execSync( - `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, - { encoding: 'utf-8' } - ); + const body = JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }); + const response = execFileSync('curl', [ + '-s', + '-X', 'POST', + `${config.url}/api/v1/auth/universal-auth/login`, + '-H', 'Content-Type: application/json', + '-d', body, + ], { encoding: 'utf-8' }); return JSON.parse(response).accessToken; } function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record { - const response = execSync( - `curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`, - { encoding: 'utf-8' } - ); + const url = `${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}`; + const response = execFileSync('curl', [ + '-s', + url, + '-H', `Authorization: Bearer ${token}`, + ], { encoding: 'utf-8' }); const data = JSON.parse(response); const secrets: Record = {}; for (const secret of data.secrets || []) { diff --git a/bin/helpers/show-env.ts b/bin/helpers/show-env.ts index 26f7eaa..6f5391d 100644 --- a/bin/helpers/show-env.ts +++ b/bin/helpers/show-env.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; interface InfisicalConfig { url: string; @@ -57,19 +57,28 @@ function getInfisicalConfig(): InfisicalConfig { }; } +// Use execFileSync (no shell) so single-quoted JSON bodies survive on Windows +// cmd.exe, where ' is literal rather than a string delimiter. Passing curl +// args as an array bypasses shell parsing on every platform. function getInfisicalToken(config: InfisicalConfig): string { - const response = execSync( - `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`, - { encoding: 'utf-8' } - ); + const body = JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }); + const response = execFileSync('curl', [ + '-s', + '-X', 'POST', + `${config.url}/api/v1/auth/universal-auth/login`, + '-H', 'Content-Type: application/json', + '-d', body, + ], { encoding: 'utf-8' }); return JSON.parse(response).accessToken; } function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record { - const response = execSync( - `curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}" -H "Authorization: Bearer ${token}"`, - { encoding: 'utf-8' } - ); + const url = `${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}`; + const response = execFileSync('curl', [ + '-s', + url, + '-H', `Authorization: Bearer ${token}`, + ], { encoding: 'utf-8' }); const data = JSON.parse(response); const secrets: Record = {}; for (const secret of data.secrets || []) { diff --git a/package.json b/package.json index 19f9fd8..c0fd443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@optima-chat/dev-skills", - "version": "0.7.32", + "version": "0.7.33", "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools", "main": "index.js", "bin": {