/** * Shared CLI skeleton for scripts/*.mjs (Pass 3 P3-H; the `scripts/lib/` home * exists since B5 F2). `die`, `sha256`, the download→cache→sha256 prologue, * `writeText`, the dotenv preamble and the local-host list were each spelled * 5–7 times — the newest script copied the OLD shape, i.e. live drift. The * convention guard in `codebaseConventions.unit.spec.ts` fails the build on * any of these re-spelled outside this module. */ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' const require = createRequire(import.meta.url) /** Labelled `die` factory: `dieWithLabel('seed:tse')` → the script's `die`. */ export const dieWithLabel = (label) => (message) => { console.error(`\n[${label}] ${message}\n`) process.exit(1) } export const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex') /** * `--flag=value` / `--flag` parser for the self-contained CLIs that use the * equals form (the auto-unblock family). The historical `--flag value` parser * lives in `agent-forgejo.mjs` (`parseArgs`); keeping the equals form here * means an absent flag can never swallow the next argv as its value. * * @param {string[]} argv * @returns {{ flags: Record, positional: string[] }} */ export const parseEqualsFlags = (argv) => { const flags = {} const positional = [] for (const arg of Array.isArray(argv) ? argv : []) { if (typeof arg !== 'string' || !arg.startsWith('--')) { positional.push(arg) continue } const body = arg.slice(2) const equals = body.indexOf('=') if (equals === -1) flags[body] = true else flags[body.slice(0, equals)] = body.slice(equals + 1) } return { flags, positional } } /** The dotenv preamble every DB-touching script needs (.env.local wins over .env). */ export const loadCliEnv = () => { const { config: loadEnv } = require('dotenv') loadEnv({ path: '.env.local' }) loadEnv({ path: '.env' }) } /** * Local database hosts — docker-compose service name, the job-network service * names of the Forgejo-era runner (OPS62 X1: services by name, no host * publish — the GitHub hosted CI reaches services on published localhost * ports, OPS71; `postgres-int` remains active, `postgres-build` is a * defensive legacy entry since OPS88 removed the second container), and the * bridge gateway (OPS50: legacy per-port publish kept in the allowlist). * THE one list. */ export const LOCAL_HOSTS = new Set([ 'localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres', 'postgres-int', 'postgres-build', ]) /** * Gateway of the default route, read from /proc/net/route (Linux). In the * Forgejo-era CI the self-hosted runner published each job's `services:` * Postgres on the bridge gateway IP (192.168.x.1 — differs per job), so a job * reached it via that IP instead of localhost; the GitHub hosted CI (OPS71) * reaches services on published localhost ports instead, and the bridge * gateway stays in the allowlist for Docker-based runners. The same octet * order the ci-pr.yml awk produces (the /proc fields are little-endian). * Returns null when there is no default route or the file is unreadable * (macOS/Windows dev boxes). */ export const defaultGatewayHost = () => { try { const routes = readFileSync('/proc/net/route', 'utf8').split('\n') for (const line of routes) { const fields = line.trim().split(/\s+/) if (fields[1] !== '00000000') continue const hex = fields[2] ?? '' if (hex === '00000000' || hex.length < 8) return null const octets = [ parseInt(hex.slice(6, 8), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(0, 2), 16), ] const isRfc1918 = octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168) if (!isRfc1918) return null return octets.join('.') } } catch { // /proc/net/route is Linux-only; fall through to the static list. } return null } const gatewayHost = defaultGatewayHost() if (gatewayHost) LOCAL_HOSTS.add(gatewayHost) /** * The one test-database name contract: `teqo_test` or `teqo__test`. * Shared by `tests/helpers/assertTestDatabase.ts` (test suite guard) and * `scripts/db-reset.mjs` (OPS88) so a DROP-SCHEMA script can never accept the * dev database `teqo` or any non-test name. */ export const TEST_DATABASE_NAME_RE = /^teqo(_[a-z0-9]+)?_test$/ export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB' /** * Truthy intent-flag check (the one spelling of the `true`/`1` semantics). * Deliberately exact: no trim, no case-folding — `TRUE` or `yes` stay refused * (relaxing a guard is a product decision, not a refactor byproduct). */ export const isTruthyEnv = (value) => value === 'true' || value === '1' export const isRemoteDbOverrideSet = () => isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]) /** * Hostname of a connection string, or null when it is missing/unparseable. * * @param {unknown} databaseUrl * @returns {string | null} */ export const databaseHostname = (databaseUrl) => { try { return new URL(String(databaseUrl ?? '')).hostname } catch { return null } } /** * Database name of a connection string (percent-decoded), or null when the URL * is missing/unparseable or its name carries invalid percent-encoding. THE one * spelling of the name extraction shared by the fail-closed DB-name guards * (`db-reset`, `assertTestDatabase`, staging test-account) — a decode bug here * would silently misdirect a guard, so it is unit-pinned. * * @param {unknown} databaseUrl * @returns {string | null} */ export const databaseName = (databaseUrl) => { try { const url = new URL(String(databaseUrl ?? '')) return decodeURIComponent(url.pathname.replace(/^\//, '')) } catch { return null } } /** * True when the connection string targets one of the LOCAL_HOSTS. * * @param {unknown} databaseUrl * @returns {boolean} */ export const isLocalDatabaseUrl = (databaseUrl) => { const host = databaseHostname(databaseUrl) return host !== null && LOCAL_HOSTS.has(host) } /** * C155 write guard: any write run whose target is not provably local — or that * runs under `NODE_ENV=production` (the homeserver env file) or with the * `ALLOW_REMOTE_DB` escape — requires an explicit intent flag. The proxy socat * used on the homeserver rewrites the host to 127.0.0.1, so the host check * alone cannot see production; NODE_ENV is the honest signal there. Read-only * modes never call this. * * @param {{ nodeEnv?: string | null, databaseUrl?: string | null, allowRemoteDb?: boolean }} [options] * @returns {boolean} */ export const requiresWriteConfirm = ({ nodeEnv = process.env.NODE_ENV, databaseUrl = process.env.DATABASE_URL, allowRemoteDb = isRemoteDbOverrideSet(), } = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl) export const mirroredMediaRequired = ({ nodeEnv = process.env.NODE_ENV, databaseUrl = process.env.DATABASE_URL, allowRemoteDb = isRemoteDbOverrideSet(), s3Enabled = false, } = {}) => requiresWriteConfirm({ nodeEnv, databaseUrl, allowRemoteDb }) && s3Enabled !== true /** Human label of the write target for the guard messages (`host/name`). */ export const databaseTarget = () => { const url = process.env.DATABASE_URL const host = databaseHostname(url) if (host === null) return '(DATABASE_URL ausente ou inválida)' return `${host}${new URL(url).pathname}` } /** * Declared environment → exact database name. The name is the honest * discriminator on the homeserver: the socat proxy rewrites the host to * 127.0.0.1 and `NODE_ENV=production` is set in both env files, so the host * check alone cannot tell staging from production (C195/C231). */ export const TEQO_ENV_DATABASE_BY_ENV = Object.freeze({ staging: 'teqo_staging', production: 'teqo_1313', }) /** * Fail-closed write-target guard shared by the ingest CLIs (C195/C231). * Outside a test run the declared `TEQO_ENV` (`staging`|`production`) is * mandatory and must match the exact database name; inside vitest only a * `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is always refused — * an ingest never runs from a workstation against a remote database. * * @param {{ * databaseUrl?: string | null, * teqoEnv?: string | null, * allowRemoteDb?: boolean, * isTest?: boolean, * }} [options] * @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }} */ export const assertEnvironmentDatabaseTarget = ({ databaseUrl = process.env.DATABASE_URL, teqoEnv = process.env.TEQO_ENV, allowRemoteDb = isRemoteDbOverrideSet(), isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST), } = {}) => { if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') { throw new Error( 'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.', ) } const targetDatabaseName = databaseName(databaseUrl) let protocol = null try { protocol = new URL(String(databaseUrl ?? '')).protocol } catch { protocol = null } if (protocol !== 'postgresql:') { throw new Error( `protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — a ingestão só escreve num alvo PostgreSQL.`, ) } if (!isLocalDatabaseUrl(databaseUrl)) { // Never echo the URL — it carries the DB password. throw new Error( `host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — a ingestão nunca escreve em alvo remoto.`, ) } if (allowRemoteDb) { throw new Error( 'ALLOW_REMOTE_DB está setado — remova; a ingestão não precisa do override remoto.', ) } if (isTest) { if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) { throw new Error( `banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — a ingestão em teste só escreve em "teqo*_test".`, ) } return { environment: 'test', databaseName: targetDatabaseName } } if (teqoEnv !== 'staging' && teqoEnv !== 'production') { throw new Error( `TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`, ) } const expected = TEQO_ENV_DATABASE_BY_ENV[teqoEnv] if (targetDatabaseName !== expected) { throw new Error( `banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` + 'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.', ) } return { environment: teqoEnv, databaseName: targetDatabaseName } } /** * C155/C215 write guard shared by the import CLIs: a non-local/production * target (or the `ALLOW_REMOTE_DB` escape) refuses the run unless the command's * own intent flag is set. The homeserver env sets `NODE_ENV=production` and the * proxy rewrites the DB host, so the explicit flag is what makes the write * deliberate. * * @param {{ label: string, flag: string, command: string, hint?: string }} options */ export const assertWriteConfirm = ({ label, flag, command, hint = '' }) => { if (!requiresWriteConfirm()) return if (isTruthyEnv(process.env[flag])) return dieWithLabel(label)( `alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` + `Confirme a intenção com:\n ${flag}=1 ${command} …` + (hint ? `\n${hint}` : ''), ) } /** * True when the host is reachable only from the private network: loopback, * RFC1918, CGNAT/tailnet (`100.64/10`), link-local/ULA IPv6, `.local`/`.internal` * suffixes or a single-label name (docker network). A public IP or domain is * NOT private — it is exactly what the C232 guard refuses by default. * * @param {unknown} hostname * @returns {boolean} */ export const isPrivateVisionHost = (hostname) => { const host = String(hostname ?? '') .trim() .toLowerCase() .replace(/^\[|\]$/g, '') if (host === '') return false // IPv6 literal (loopback/link-local/ULA). The `includes(':')` gate is what // keeps a NAME starting with "fc"/"fd" (e.g. fdic.gov) from passing here — // it must fall through to the domain checks below. if ( host.includes(':') && (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) ) { return true } if (!host.includes('.')) return true if (host.endsWith('.local') || host.endsWith('.internal')) return true const parts = host.split('.') if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false const octets = parts.map(Number) if (octets.some((octet) => octet > 255)) return false const [first, second] = octets return ( first === 10 || first === 127 || (first === 192 && second === 168) || (first === 172 && second >= 16 && second <= 31) || (first === 100 && second >= 64 && second <= 127) ) } /** * C232 — the vision engine reads the citizens' photos, so it must live on the * private network: a public host is refused unless the run declares the * `ARCHIVE_VISION_ALLOW_REMOTE=1` escape (the product guardrail is "PII/rostos * não vão a terceiro sem decisão explícita e contrato"). Returns the scope so * the receipt records which mode ran — the escape is never invisible. * * @param {{ baseUrl?: string | null, allowRemote?: boolean, label?: string }} [options] * @returns {{ scope: 'local' | 'remote', host: string }} */ export const assertLocalVisionEndpoint = ({ baseUrl = process.env.ARCHIVE_VISION_BASE_URL, allowRemote = isTruthyEnv(process.env.ARCHIVE_VISION_ALLOW_REMOTE), label = 'archive:catalog', } = {}) => { const raw = String(baseUrl ?? '').trim() if (raw === '') { dieWithLabel(label)( 'ARCHIVE_VISION_BASE_URL ausente — configure o endpoint do engine de visão (ex.: http://100.94.122.26:11434/v1).', ) } let url try { url = new URL(raw) } catch { dieWithLabel(label)(`ARCHIVE_VISION_BASE_URL inválida: ${raw}`) } if (url.protocol !== 'http:' && url.protocol !== 'https:') { dieWithLabel(label)(`ARCHIVE_VISION_BASE_URL deve ser http(s), não "${url.protocol}".`) } const host = url.hostname if (isPrivateVisionHost(host)) return { scope: 'local', host } if (allowRemote) return { scope: 'remote', host } dieWithLabel(label)( `ARCHIVE_VISION_BASE_URL aponta para host público "${host}" — as fotos do acervo (PII/rostos de cidadãos) não vão a terceiro sem decisão explícita e contrato.\n` + ' Rode o engine na rede privada (LAN/tailnet) ou, ciente do risco, declare ARCHIVE_VISION_ALLOW_REMOTE=1.', ) } /** * Port for `next dev` (OPS40). Next's CLI resolves its port via commander's * `.env('PORT')` BEFORE `@next/env` loads `.env.local`, so a `PORT` written by * the worktree provisioner was silently ignored and every `pnpm dev` bound * 3000. `loadCliEnv()` has already merged real env > `.env.local` > `.env` * (override:false) into `process.env`, so reading `env.PORT` gives exactly the * precedence the repo wants: real env (Playwright's webServer) wins, then the * worktree's file, then no port at all (Next's 3000 default + allowRetry). * Returns null when absent (caller omits `-p`); throws fail-closed on a * non-empty invalid value (PORT=abc must not silently fall back to 3000). * * @param {Record} [env] */ export const resolveDevPort = (env = process.env) => { const raw = (env.PORT ?? '').trim() if (raw === '') return null const port = Number(raw) if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`PORT inválida: ${JSON.stringify(raw)} (esperado 1..65535)`) } return port } /** * `next dev` argv for a resolved port. The port MUST go as a CLI flag — the * env-file value never reaches commander's `env('PORT')`. A null port omits * `-p`, keeping Next's default 3000 and its "retry a free port" behavior. */ export const nextDevArgs = (port) => (port === null ? ['dev'] : ['dev', '-p', String(port)]) /** * download → cache → sha256 with provenance logging. `ext` picks the cache * filename; when it is 'json' the parsed body rides along. `expectedSha256` * fails hard on mismatch (provenance-pinned sources, e.g. TSE zips). * `download` is injected (`downloadToBuffer` from src/lib/electionResultsZip.ts * needs the script's tsx loader, so it cannot be imported here). */ export const ensureCachedDownload = async ({ label, key, url, ext, cacheDir, expectedSha256, download, }) => { await mkdir(cacheDir, { recursive: true }) const path = join(cacheDir, `${key}.${ext}`) let buffer try { await access(path) console.log(`[${label}] cache hit ${path}`) buffer = await readFile(path) } catch (error) { if (error?.code !== 'ENOENT') throw error console.log(`[${label}] downloading ${url}`) buffer = await download(url) await writeFile(path, buffer) console.log(`[${label}] saved ${path} (${buffer.length} bytes)`) } const hash = sha256Hex(buffer) if (expectedSha256 && hash !== expectedSha256) { dieWithLabel(label)( `SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`, ) } return { url, hash, buffer, // 'json' and 'geojson' both carry a JSON body. ...(ext.endsWith('json') ? { json: JSON.parse(buffer.toString('utf8')) } : {}), } } /** Write a repo file with the directory walk and the byte-count log line. */ export const writeRepoFile = async ({ label, root, relativePath, body }) => { const path = join(root, relativePath) await mkdir(dirname(path), { recursive: true }) await writeFile(path, body) console.log(`[${label}] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) } /** * `--only 80,83` → `[80, 83]` (numeric row ids). Exact digits only: a typo * must refuse the run, never silently widen or empty the selection. The two * archive plan-libs (C246/C248) share this one copy; the caller passes its * flag and usage line so the error message stays domain-specific. * * @param {{ value: string, flag?: string, usage?: string }} options * @returns {number[]} */ export const parseIdList = ({ value, flag = '--only', usage = '' }) => { const text = String(value ?? '').trim() const suffix = usage ? ` — ${usage}` : '' if (text === '') { throw new Error(`${flag} vazio — informe ao menos um id (ex.: ${flag} 80,83)${suffix}.`) } const ids = [] for (const part of text.split(',')) { const item = part.trim() if (!/^\d+$/.test(item)) { throw new Error( `${flag} espera ids numéricos separados por vírgula (recebi "${value}")${suffix}.`, ) } const id = Number(item) if (!Number.isSafeInteger(id) || id < 1) { throw new Error(`${flag} recebeu um id fora do intervalo: "${item}"${suffix}.`) } if (!ids.includes(id)) ids.push(id) } return ids } /** * Bounded-concurrency map that preserves input order (C248 — moved here from * `archiveIntegrityPlan.mjs` on the debt's trigger: the second consumer landed; * the C246 sweep and the C248 thumbnail backfill share this one copy). The * sweeps download and decode thousands of photos, so unbounded `Promise.all` * would be reckless; the pool keeps at most `concurrency` workers alive and * returns the results in the same order as the input (receipts stay * deterministic). * * @template T, R * @param {readonly T[]} items * @param {number} concurrency * @param {(item: T, index: number) => Promise} worker * @returns {Promise} */ export const mapWithConcurrency = async (items, concurrency, worker) => { const results = new Array(items.length) const size = Math.max(1, Math.min(Math.trunc(Number(concurrency)) || 1, items.length)) let next = 0 const runner = async () => { for (;;) { const index = next next += 1 if (index >= items.length) return results[index] = await worker(items[index], index) } } await Promise.all(Array.from({ length: size }, runner)) return results }