diff --git a/README.md b/README.md index 4fe72d2..5fe0a01 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ stackql_anthropic_admin_provider/ docs-driven provider: provider-dev/{source, npm install # patches provider-utils docgen via postinstall npm run build # anthropic: prepass → split → normalize+scrub → analyze → generate → post-pass → guards npm run build-admin # anthropic_admin: same chain from normalize onward -npm run test-meta-routes # SHOW/DESCRIBE walk, zero errors required (STACKQL=/path/to/stackql) +npm run test-meta-routes # SHOW/DESCRIBE walk, zero errors required (auto-downloads stackql if absent) npm run smoke # wire-contract-enforcing mock suite npm run smoke-admin npm run docgen && npm run docgen-admin # regenerate website docs diff --git a/package.json b/package.json index 17b1195..3e1be20 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "generate": "node node_modules/@stackql/provider-utils/bin/provider-dev-utils.mjs generate --input-dir stackql_anthropic_provider/provider-dev/source/split --output-dir stackql_anthropic_provider/provider-dev/openapi/src/anthropic --config-path stackql_anthropic_provider/provider-dev/config/all_services.csv --provider-id anthropic --servers '[{\"url\":\"https://api.anthropic.com\"}]' --provider-config '{\"auth\":{\"type\":\"custom\",\"location\":\"header\",\"name\":\"x-api-key\",\"credentialsenvvar\":\"ANTHROPIC_API_KEY\"}}' --naive-req-body-translate --overwrite && node factory/post-pass.mjs --provider-root stackql_anthropic_provider/provider-dev/openapi/src/anthropic/v00.00.00000", "guards": "node factory/guards.mjs --provider anthropic --provider-root stackql_anthropic_provider/provider-dev/openapi/src/anthropic/v00.00.00000 --spec stackql_anthropic_provider/provider-dev/downloaded/anthropic-openapi.yml --exclusions factory/exclusions.yaml", "build": "npm run prepass && npm run split && npm run normalize && npm run analyze && npm run generate && npm run guards", - "test-meta-routes": "STACKQL=${STACKQL:-stackql} node stackql_anthropic_provider/bin/test-meta-routes.cjs anthropic", + "test-meta-routes": "node stackql_anthropic_provider/bin/test-meta-routes.cjs anthropic", "smoke": "node stackql_anthropic_provider/tests/smoke.cjs", "smoke-live": "node stackql_anthropic_provider/tests/smoke.cjs --live", "build-admin": "rm -rf stackql_anthropic_admin_provider/provider-dev/source/split && mkdir -p stackql_anthropic_admin_provider/provider-dev/source/split && cp stackql_anthropic_admin_provider/provider-dev/source/*.yaml stackql_anthropic_admin_provider/provider-dev/source/split/ && node node_modules/@stackql/provider-utils/bin/provider-dev-utils.mjs normalize --api-dir stackql_anthropic_admin_provider/provider-dev/source/split && node factory/scrub-unions.mjs --api-dir stackql_anthropic_admin_provider/provider-dev/source/split && node node_modules/@stackql/provider-utils/bin/provider-dev-utils.mjs analyze --input-dir stackql_anthropic_admin_provider/provider-dev/source/split --output-dir stackql_anthropic_admin_provider/provider-dev/config && node node_modules/@stackql/provider-utils/bin/provider-dev-utils.mjs generate --input-dir stackql_anthropic_admin_provider/provider-dev/source/split --output-dir stackql_anthropic_admin_provider/provider-dev/openapi/src/anthropic_admin --config-path stackql_anthropic_admin_provider/provider-dev/config/all_services.csv --provider-id anthropic_admin --servers '[{\"url\":\"https://api.anthropic.com\"}]' --provider-config '{\"auth\":{\"type\":\"custom\",\"location\":\"header\",\"name\":\"x-api-key\",\"credentialsenvvar\":\"ANTHROPIC_ADMIN_KEY\"}}' --naive-req-body-translate --overwrite && node factory/post-pass.mjs --provider-root stackql_anthropic_admin_provider/provider-dev/openapi/src/anthropic_admin/v00.00.00000 && node factory/guards.mjs --provider anthropic_admin --provider-root stackql_anthropic_admin_provider/provider-dev/openapi/src/anthropic_admin/v00.00.00000", diff --git a/stackql_anthropic_admin_provider/bin/test-meta-routes.cjs b/stackql_anthropic_admin_provider/bin/test-meta-routes.cjs index 6d717e1..346c1a8 100644 --- a/stackql_anthropic_admin_provider/bin/test-meta-routes.cjs +++ b/stackql_anthropic_admin_provider/bin/test-meta-routes.cjs @@ -170,12 +170,74 @@ async function stopExistingServer() { process.exit(1); } -async function startServer() { - const bin = process.env.STACKQL || path.join(baseDir, 'stackql'); - if (!fs.existsSync(bin)) { - console.error(`[server] ERROR: stackql binary not found at ${bin} (set STACKQL to override)`); +// Resolve the stackql binary to an ABSOLUTE path. fs.existsSync resolves +// bare names against the CWD while spawn() resolves them against PATH, so +// a relative candidate must be absolutized before spawning (a bare +// `STACKQL=stackql` once passed the exists-check via a repo-root file and +// then died in spawn with an unhandled ENOENT). +// +// Order: $STACKQL (path or PATH-resolved command) → /stackql → +// ./stackql → `stackql` on PATH → download the latest release into the +// provider dir (same fallback bin/start-server.sh has always had). +function resolveStackql() { + const tryPath = (p) => { + if (!p) return null; + const abs = path.resolve(p); + return fs.existsSync(abs) && fs.statSync(abs).isFile() ? abs : null; + }; + const fromPathLookup = (cmd) => { + try { + const found = execSync( + process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).split('\n')[0].trim(); + return found ? found : null; + } catch (e) { + return null; + } + }; + + const envBin = process.env.STACKQL; + if (envBin) { + const resolved = envBin.includes(path.sep) || envBin.includes('/') + ? tryPath(envBin) + : (tryPath(envBin) || fromPathLookup(envBin)); + if (resolved) return resolved; + console.warn(`[server] STACKQL='${envBin}' does not resolve to a binary - falling back`); + } + const local = tryPath(path.join(baseDir, 'stackql')) || tryPath(path.join(process.cwd(), 'stackql')); + if (local) return local; + const onPath = fromPathLookup('stackql'); + if (onPath) return onPath; + + // Last resort: download the latest release (linux/darwin, amd64/arm64). + if (process.platform !== 'linux' && process.platform !== 'darwin') { + console.error('[server] ERROR: stackql binary not found (set STACKQL, or place ./stackql in the provider dir)'); + process.exit(1); + } + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + const url = `https://releases.stackql.io/stackql/latest/stackql_${process.platform}_${arch}.zip`; + slog(`stackql binary not found - downloading ${url}`); + try { + execSync( + `curl -sSL -o stackql.zip "${url}" && unzip -o stackql.zip stackql && rm -f stackql.zip && chmod +x stackql`, + { cwd: baseDir, stdio: ['ignore', 'inherit', 'inherit'] }, + ); + } catch (e) { + console.error(`[server] ERROR: stackql download failed: ${e.message}`); + process.exit(1); + } + const downloaded = tryPath(path.join(baseDir, 'stackql')); + if (!downloaded) { + console.error('[server] ERROR: stackql download did not produce a usable binary'); process.exit(1); } + slog(`downloaded stackql to ${downloaded}`); + return downloaded; +} + +async function startServer() { + const bin = resolveStackql(); const regPath = path.join(baseDir, 'provider-dev', 'openapi'); const reg = JSON.stringify({ url: `file://${regPath}`, @@ -190,6 +252,12 @@ async function startServer() { serverChild = spawn(bin, [`--registry=${reg}`, `--pgsrv.port=${port}`, 'srv'], { stdio: ['ignore', out, out], }); + // Without this handler a failed spawn (ENOENT, EACCES) is an unhandled + // 'error' event that crashes node with a raw stack trace. + serverChild.on('error', (err) => { + console.error(`[server] ERROR: failed to start '${bin}': ${err.message}`); + process.exit(1); + }); serverChild.on('exit', (code, signal) => { if (!serverShuttingDown) { console.error(`[server] ERROR: server exited unexpectedly (code=${code} signal=${signal}) - see ${serverLogPath}`); @@ -209,6 +277,7 @@ async function startServer() { function stopServer() { if (!serverChild || serverShuttingDown) return; serverShuttingDown = true; + if (serverChild.pid === undefined) return; // spawn never succeeded slog(`stopping server (pid ${serverChild.pid})`); try { serverChild.kill('SIGTERM'); diff --git a/stackql_anthropic_admin_provider/tests/README.md b/stackql_anthropic_admin_provider/tests/README.md index 9623036..d3c231f 100644 --- a/stackql_anthropic_admin_provider/tests/README.md +++ b/stackql_anthropic_admin_provider/tests/README.md @@ -5,10 +5,10 @@ wire-contract-enforcing mock, which also enforces the disjoint `sk-ant-admin...` key space on `/v1/organizations/*`): ```bash -STACKQL=/path/to/stackql node bin/test-meta-routes.cjs anthropic_admin -STACKQL=/path/to/stackql node ../stackql_anthropic_provider/tests/smoke.cjs \ +node bin/test-meta-routes.cjs anthropic_admin # STACKQL=/path/to/stackql to override +node ../stackql_anthropic_provider/tests/smoke.cjs \ --manifest tests/manifest.yaml # mock mode -STACKQL=/path/to/stackql node ../stackql_anthropic_provider/tests/smoke.cjs \ +node ../stackql_anthropic_provider/tests/smoke.cjs \ --manifest tests/manifest.yaml --live # READ-ONLY live subset ``` diff --git a/stackql_anthropic_provider/bin/test-meta-routes.cjs b/stackql_anthropic_provider/bin/test-meta-routes.cjs index 6d717e1..346c1a8 100644 --- a/stackql_anthropic_provider/bin/test-meta-routes.cjs +++ b/stackql_anthropic_provider/bin/test-meta-routes.cjs @@ -170,12 +170,74 @@ async function stopExistingServer() { process.exit(1); } -async function startServer() { - const bin = process.env.STACKQL || path.join(baseDir, 'stackql'); - if (!fs.existsSync(bin)) { - console.error(`[server] ERROR: stackql binary not found at ${bin} (set STACKQL to override)`); +// Resolve the stackql binary to an ABSOLUTE path. fs.existsSync resolves +// bare names against the CWD while spawn() resolves them against PATH, so +// a relative candidate must be absolutized before spawning (a bare +// `STACKQL=stackql` once passed the exists-check via a repo-root file and +// then died in spawn with an unhandled ENOENT). +// +// Order: $STACKQL (path or PATH-resolved command) → /stackql → +// ./stackql → `stackql` on PATH → download the latest release into the +// provider dir (same fallback bin/start-server.sh has always had). +function resolveStackql() { + const tryPath = (p) => { + if (!p) return null; + const abs = path.resolve(p); + return fs.existsSync(abs) && fs.statSync(abs).isFile() ? abs : null; + }; + const fromPathLookup = (cmd) => { + try { + const found = execSync( + process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).split('\n')[0].trim(); + return found ? found : null; + } catch (e) { + return null; + } + }; + + const envBin = process.env.STACKQL; + if (envBin) { + const resolved = envBin.includes(path.sep) || envBin.includes('/') + ? tryPath(envBin) + : (tryPath(envBin) || fromPathLookup(envBin)); + if (resolved) return resolved; + console.warn(`[server] STACKQL='${envBin}' does not resolve to a binary - falling back`); + } + const local = tryPath(path.join(baseDir, 'stackql')) || tryPath(path.join(process.cwd(), 'stackql')); + if (local) return local; + const onPath = fromPathLookup('stackql'); + if (onPath) return onPath; + + // Last resort: download the latest release (linux/darwin, amd64/arm64). + if (process.platform !== 'linux' && process.platform !== 'darwin') { + console.error('[server] ERROR: stackql binary not found (set STACKQL, or place ./stackql in the provider dir)'); + process.exit(1); + } + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + const url = `https://releases.stackql.io/stackql/latest/stackql_${process.platform}_${arch}.zip`; + slog(`stackql binary not found - downloading ${url}`); + try { + execSync( + `curl -sSL -o stackql.zip "${url}" && unzip -o stackql.zip stackql && rm -f stackql.zip && chmod +x stackql`, + { cwd: baseDir, stdio: ['ignore', 'inherit', 'inherit'] }, + ); + } catch (e) { + console.error(`[server] ERROR: stackql download failed: ${e.message}`); + process.exit(1); + } + const downloaded = tryPath(path.join(baseDir, 'stackql')); + if (!downloaded) { + console.error('[server] ERROR: stackql download did not produce a usable binary'); process.exit(1); } + slog(`downloaded stackql to ${downloaded}`); + return downloaded; +} + +async function startServer() { + const bin = resolveStackql(); const regPath = path.join(baseDir, 'provider-dev', 'openapi'); const reg = JSON.stringify({ url: `file://${regPath}`, @@ -190,6 +252,12 @@ async function startServer() { serverChild = spawn(bin, [`--registry=${reg}`, `--pgsrv.port=${port}`, 'srv'], { stdio: ['ignore', out, out], }); + // Without this handler a failed spawn (ENOENT, EACCES) is an unhandled + // 'error' event that crashes node with a raw stack trace. + serverChild.on('error', (err) => { + console.error(`[server] ERROR: failed to start '${bin}': ${err.message}`); + process.exit(1); + }); serverChild.on('exit', (code, signal) => { if (!serverShuttingDown) { console.error(`[server] ERROR: server exited unexpectedly (code=${code} signal=${signal}) - see ${serverLogPath}`); @@ -209,6 +277,7 @@ async function startServer() { function stopServer() { if (!serverChild || serverShuttingDown) return; serverShuttingDown = true; + if (serverChild.pid === undefined) return; // spawn never succeeded slog(`stopping server (pid ${serverChild.pid})`); try { serverChild.kill('SIGTERM'); diff --git a/stackql_anthropic_provider/tests/README.md b/stackql_anthropic_provider/tests/README.md index 97ad55a..6d5e9f7 100644 --- a/stackql_anthropic_provider/tests/README.md +++ b/stackql_anthropic_provider/tests/README.md @@ -8,14 +8,14 @@ hard-fails on any error, duplicate (resource, verb) signature, or zero-column selectable resource: ```bash -STACKQL=/path/to/stackql node bin/test-meta-routes.cjs anthropic +node bin/test-meta-routes.cjs anthropic # STACKQL=/path/to/stackql to override ``` ## Smoke (manifest-driven) ```bash -STACKQL=/path/to/stackql node tests/smoke.cjs # mock mode -STACKQL=/path/to/stackql node tests/smoke.cjs --live # live subset +node tests/smoke.cjs # mock mode +node tests/smoke.cjs --live # live subset node tests/smoke.cjs --only agents_insert,agents_archive # cherry-pick ``` diff --git a/stackql_anthropic_provider/tests/smoke.cjs b/stackql_anthropic_provider/tests/smoke.cjs index 39d8c27..069fd56 100644 --- a/stackql_anthropic_provider/tests/smoke.cjs +++ b/stackql_anthropic_provider/tests/smoke.cjs @@ -60,7 +60,67 @@ const only = (argOf('--only', '') || '').split(',').filter(Boolean); const manifest = YAML.parse(fs.readFileSync(manifestPath, 'utf8')); const cfg = manifest.config || {}; -const stackqlBin = argOf('--stackql', process.env.STACKQL || cfg.stackql || path.join(baseDir, 'stackql')); + +// Resolve the stackql binary to an ABSOLUTE path. fs.existsSync resolves +// bare names against the CWD while spawn() resolves them against PATH, so +// relative candidates must be absolutized before spawning. Order: +// --stackql / $STACKQL / config.stackql (path or PATH-resolved command) → +// /stackql → ./stackql → `stackql` on PATH → download the latest +// release into the provider dir. +function resolveStackql() { + const { execSync } = require('child_process'); + const tryPath = (p) => { + if (!p) return null; + const abs = path.resolve(p); + return fs.existsSync(abs) && fs.statSync(abs).isFile() ? abs : null; + }; + const fromPathLookup = (cmd) => { + try { + const found = execSync( + process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).split('\n')[0].trim(); + return found ? found : null; + } catch (e) { + return null; + } + }; + for (const candidate of [argOf('--stackql', null), process.env.STACKQL, cfg.stackql]) { + if (!candidate) continue; + const resolved = candidate.includes(path.sep) || candidate.includes('/') + ? tryPath(path.resolve(path.dirname(manifestPath), candidate)) || tryPath(candidate) + : (tryPath(candidate) || fromPathLookup(candidate)); + if (resolved) return resolved; + console.warn(`stackql candidate '${candidate}' does not resolve to a binary - falling back`); + } + const local = tryPath(path.join(baseDir, 'stackql')) || tryPath(path.join(process.cwd(), 'stackql')); + if (local) return local; + const onPath = fromPathLookup('stackql'); + if (onPath) return onPath; + if (process.platform !== 'linux' && process.platform !== 'darwin') { + console.error('stackql binary not found (set STACKQL, or place ./stackql in the provider dir)'); + process.exit(2); + } + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + const url = `https://releases.stackql.io/stackql/latest/stackql_${process.platform}_${arch}.zip`; + console.log(`stackql binary not found - downloading ${url}`); + try { + execSync( + `curl -sSL -o stackql.zip "${url}" && unzip -o stackql.zip stackql && rm -f stackql.zip && chmod +x stackql`, + { cwd: baseDir, stdio: ['ignore', 'inherit', 'inherit'] }, + ); + } catch (e) { + console.error(`stackql download failed: ${e.message}`); + process.exit(2); + } + const downloaded = tryPath(path.join(baseDir, 'stackql')); + if (!downloaded) { + console.error('stackql download did not produce a usable binary'); + process.exit(2); + } + return downloaded; +} +const stackqlBin = resolveStackql(); const queriesDir = path.resolve(path.dirname(manifestPath), cfg.queries_dir || 'queries'); const mockPort = cfg.mock_port || 8990; const fatalPatterns = [...DEFAULT_FATAL, ...(cfg.fatal_patterns || [])];