teqo-backup/scripts/lib/cli.mjs
Francisco Solla c15f09531a feat(fotos): miniaturas AVIF pré-geradas do álbum público (C248)
Derivada AVIF 720px q60 armazenada como objeto-irmão determinístico de cada
foto aprovada (flickr-<id>-grade.avif), servida direto quando o cliente
negocia Accept: image/avif; fallback JPEG on-the-fly em ausência/falha, heal
preguiçoso via after(), warm no afterChange da aprovação e backfill
pnpm archive:thumbnails idempotente com recibo e guardas *_CONFIRM.

Sem migration, sem UI, sem mudança de URL pública ou pull-down.
Closes #1424
2026-10-02 17:40:41 -03:00

553 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string | boolean>, 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_<worktree>_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<string, string | undefined>} [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<R>} worker
* @returns {Promise<R[]>}
*/
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
}