From e1bc0304e6b36acdeb23fa0c90f9ebcfe090897b Mon Sep 17 00:00:00 2001 From: lizhiying Date: Sun, 10 May 2026 22:27:10 +0800 Subject: [PATCH 1/4] fix(show-env): use execFileSync to avoid Windows cmd.exe single-quote bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show-env was failing on Windows with `Command failed: curl -s -X POST ...` because execSync goes through cmd.exe, where single quotes are literal characters (not string delimiters as in /bin/sh). The Infisical auth body `-d '{"clientId":"..."}'` was reaching curl as the literal string `'{"clientId":"..."}'` (with single quotes baked in), which Infisical rejected. Switched the two curl invocations from execSync (shell-interpreted command string) to execFileSync (argv array, no shell). This works on every platform regardless of the shell because Node calls CreateProcess / execvp directly with each argv element preserved. Verified on Windows 11 + Node 22: $ optima-show-env optima-generation stage --filter KIE ✓ Obtained Infisical access token ✓ Retrieved secrets from Infisical (path: /services/optima-generation) KIE_API_KEY=... Bump 0.7.32 → 0.7.33. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/helpers/show-env.ts | 27 ++++++++++++++++++--------- package.json | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/bin/helpers/show-env.ts b/bin/helpers/show-env.ts index 26f7eaa..fdecac8 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 — see https://github.com/Optima-Chat/optima-dev-skills/issues/. +// 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": { From d2e0b0477c3e24d10f6f27c4b581995b3f051ecb Mon Sep 17 00:00:00 2001 From: lizhiying Date: Sun, 10 May 2026 23:21:32 +0800 Subject: [PATCH 2/4] address review: drop unfilled placeholder in comment --- bin/helpers/show-env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/helpers/show-env.ts b/bin/helpers/show-env.ts index fdecac8..6f5391d 100644 --- a/bin/helpers/show-env.ts +++ b/bin/helpers/show-env.ts @@ -58,8 +58,8 @@ function getInfisicalConfig(): InfisicalConfig { } // Use execFileSync (no shell) so single-quoted JSON bodies survive on Windows -// cmd.exe — see https://github.com/Optima-Chat/optima-dev-skills/issues/. -// Passing curl args as an array bypasses shell parsing on every platform. +// 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 body = JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }); const response = execFileSync('curl', [ From b3c2b19e2f545eff0ba4d081297bb3ac0557fd1b Mon Sep 17 00:00:00 2001 From: lizhiying Date: Mon, 11 May 2026 11:58:53 +0800 Subject: [PATCH 3/4] extend Windows shell-quoting fix to db-utils.ts and query-db.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same execSync+single-quoted-JSON bug exists in two other helper files, not just show-env.ts. Discovered when trying optima-grant-balance on Windows hit identical Infisical auth failure path. Applies the exact same execFileSync(argv) fix: - bin/helpers/db-utils.ts (shared by grant-balance / grant-subscription) - bin/helpers/query-db.ts (used by query-db tool) After this commit, optima-grant-balance / optima-grant-subscription / optima-query-db all work on Windows alongside the already-fixed optima-show-env. Note: optima-grant-balance has a separate Windows issue (SSH key parsing in libcrypto) — that's downstream of this fix and tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/helpers/db-utils.ts | 27 ++++++++++++++++++--------- bin/helpers/query-db.ts | 26 +++++++++++++++++--------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/bin/helpers/db-utils.ts b/bin/helpers/db-utils.ts index 5899039..8dce6d5 100644 --- a/bin/helpers/db-utils.ts +++ b/bin/helpers/db-utils.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import * as fs from 'fs'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -41,19 +41,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 || []) { 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 || []) { From 7ce79bf81f79fcb78c856e3f08618bbbf7b1533a Mon Sep 17 00:00:00 2001 From: lizhiying Date: Thu, 21 May 2026 10:32:47 +0800 Subject: [PATCH 4/4] fix(db-utils): Windows psql path + silent multi-line SQL failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Windows-only bugs in bin/helpers/db-utils.ts that broke optima-grant-balance / optima-grant-subscription end-to-end. Surfaced when topping up a prod user wallet: CLI reported success but DB never changed (lost a real $20 grant on first attempt). 1. findPsqlPath(): on Windows, `which psql` under Git Bash returns an MSYS path like `/c/Program Files/PostgreSQL/18/bin/psql`. Node's execSync runs that through cmd.exe, which cannot resolve MSYS paths and throws "系统找不到指定的路径" (system cannot find the specified path). Fix: probe known Windows install paths first, then fall back to `where psql` which returns native Windows paths. Pattern matches the already-correct findPsqlPath in query-db.ts. 2. queryDB(): used `psql -c ""`. cmd.exe truncates embedded newlines inside a quoted -c argument, so multi-statement transactions like grant-balance's BEGIN; UPDATE; INSERT; COMMIT only execute their first line (BEGIN), and psql still exits 0 — the CLI prints "✓ Granted" while granted_balance_micros is unchanged. This is the dangerous one: silent partial success on prod money paths. Fix: write SQL to a temp file and use `psql -f`, bypassing all shell quoting; add `-v ON_ERROR_STOP=1` so any failing statement aborts with non-zero exit instead of psql swallowing it. query-db.ts has the same -c pattern (line 230) but only takes single-statement SQL from CLI argv, so it doesn't hit the newline truncation; left for a follow-up. Verified on Windows: optima-grant-balance --amount 20 --env prod now actually inserts into usd_wallet_topups and updates granted_balance_micros. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/helpers/db-utils.ts | 49 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/bin/helpers/db-utils.ts b/bin/helpers/db-utils.ts index 8dce6d5..4eb16e9 100644 --- a/bin/helpers/db-utils.ts +++ b/bin/helpers/db-utils.ts @@ -1,5 +1,8 @@ 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 } @@ -89,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(); @@ -100,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 ──────────────────────────────────────────