diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 80f2db2..f3d6984 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -463,6 +463,11 @@ async function writeMetadata() { path.join(root, 'scripts', 'goosed-canary.compose.yml'), path.join(runtimeScriptsDir, 'goosed-canary.compose.yml'), ); + await fs.copyFile( + path.join(root, 'scripts', 'goosed-harness-mysql2-cli.mjs'), + path.join(runtimeScriptsDir, 'goosed-harness-mysql2-cli.mjs'), + ); + await fs.chmod(path.join(runtimeScriptsDir, 'goosed-harness-mysql2-cli.mjs'), 0o755); await fs.copyFile( path.join(root, 'scripts', 'load-env.mjs'), diff --git a/scripts/goosed-harness-mysql2-cli.mjs b/scripts/goosed-harness-mysql2-cli.mjs new file mode 100644 index 0000000..b9a7ef3 --- /dev/null +++ b/scripts/goosed-harness-mysql2-cli.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// mysql-CLI-compatible shim for harness (CODEX_HARNESS_MYSQL) on hosts without a mysql client. +// Supports the subset harness uses: -h HOST -u USER [-N] [--flags] [DATABASE] [-e SQL | stdin]. +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +export function parseArgs(argv) { + const opts = { host: '', user: '', database: '', sql: '', noHeaders: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' && i + 1 < argv.length) { + opts.host = argv[++i]; + } else if (arg === '-u' && i + 1 < argv.length) { + opts.user = argv[++i]; + } else if (arg === '-N') { + opts.noHeaders = true; + } else if (arg === '-e' && i + 1 < argv.length) { + opts.sql = argv[++i]; + } else if (!arg.startsWith('-') && !opts.database) { + opts.database = arg; + } + } + return opts; +} + +export function resolveMysql2Module(env = process.env, requireFrom = import.meta.url) { + const explicit = String(env.MEMIND_MYSQL2_MODULE || '').trim(); + if (explicit) return explicit; + return createRequire(requireFrom).resolve('mysql2/promise'); +} + +// With rowsAsArray, a row set is an array of rows and each row is an array of scalar cells. +// Multi-statement results are an array of row sets and ResultSetHeader objects. +const isRow = (value) => Array.isArray(value) && value.every((cell) => !Array.isArray(cell)); +const isRowSet = (value) => Array.isArray(value) && value.every(isRow); + +export function findLastRowSet(result) { + if (isRowSet(result)) return result; + if (!Array.isArray(result)) return null; + for (let i = result.length - 1; i >= 0; i -= 1) { + if (isRowSet(result[i])) return result[i]; + } + return null; +} + +export function formatRows(rows) { + if (!rows || rows.length === 0) return ''; + const lines = rows.map((row) => { + const values = Array.isArray(row) ? row : Object.values(row); + return values.map((value) => (value == null ? 'NULL' : String(value))).join('\t'); + }); + return `${lines.join('\n')}\n`; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const sql = opts.sql || fs.readFileSync(0, 'utf8'); + const modulePath = resolveMysql2Module(); + const mysqlModule = await import(pathToFileURL(modulePath).href); + const mysql = mysqlModule.default ?? mysqlModule; + const connection = await mysql.createConnection({ + host: opts.host || process.env.CODEX_HARNESS_DB_HOST || '127.0.0.1', + user: opts.user || process.env.CODEX_HARNESS_DB_USER || 'boot', + password: process.env.MYSQL_PWD || process.env.CODEX_HARNESS_DB_PASSWORD || '', + database: opts.database || undefined, + charset: 'utf8mb4', + multipleStatements: true, + rowsAsArray: true, + }); + try { + const [result] = await connection.query({ sql, rowsAsArray: true }); + process.stdout.write(formatRows(findLastRowSet(result))); + } finally { + await connection.end(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main().catch((error) => { + process.stderr.write(`${error?.code ? `${error.code}: ` : ''}${error?.message ?? error}\n`); + process.exit(1); + }); +} diff --git a/scripts/goosed-harness-mysql2-cli.test.mjs b/scripts/goosed-harness-mysql2-cli.test.mjs new file mode 100644 index 0000000..1c0ec9e --- /dev/null +++ b/scripts/goosed-harness-mysql2-cli.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + findLastRowSet, + formatRows, + parseArgs, + resolveMysql2Module, +} from './goosed-harness-mysql2-cli.mjs'; + +test('parseArgs reads the mysql CLI subset harness uses', () => { + assert.deepEqual( + parseArgs(['-h', 'db.local', '-u', 'boot', '--default-character-set=utf8mb4', '--batch', '-N', 'codex_harness', '-e', 'SELECT 1']), + { host: 'db.local', user: 'boot', database: 'codex_harness', sql: 'SELECT 1', noHeaders: true }, + ); +}); + +test('resolveMysql2Module resolves from the Memind node_modules, not /opt/portal', () => { + const resolved = resolveMysql2Module({}); + assert.match(resolved, /node_modules[\\/]mysql2[\\/]promise\.js$/); + assert.doesNotMatch(resolved, /^\/opt\/portal/); +}); + +test('resolveMysql2Module honours MEMIND_MYSQL2_MODULE override', () => { + assert.equal(resolveMysql2Module({ MEMIND_MYSQL2_MODULE: '/x/promise.js' }), '/x/promise.js'); +}); + +test('findLastRowSet returns a single SELECT row set as-is', () => { + const rows = [['1', 'a'], ['2', null]]; + assert.equal(findLastRowSet(rows), rows); + assert.equal(formatRows(findLastRowSet(rows)), '1\ta\n2\tNULL\n'); +}); + +test('findLastRowSet skips ResultSetHeader in multi-statement results', () => { + const header = { affectedRows: 1, insertId: 7 }; + const rows = [['7']]; + assert.equal(findLastRowSet([header, rows]), rows); + assert.equal(formatRows(findLastRowSet([header, rows])), '7\n'); +}); + +test('findLastRowSet returns null for DDL-only results', () => { + assert.equal(findLastRowSet({ affectedRows: 0 }), null); + assert.equal(formatRows(findLastRowSet({ affectedRows: 0 })), ''); +});