teqo-backup/scripts/lib/cli.mjs
Francisco Solla 446a873c84 feat(C231): ingerir o acervo de fotos do Flickr no storage privado
Collection privada archivePhoto (flickrId unique+index, metadados do Flickr +
EXIF, leitura pela comunicação, mutação admin, entrada no mapa S3) com migration
aditiva add_archive_photo; ingestão idempotente (transação + upload) e CLI
pnpm flickr:import (plano dry-run por padrão, --apply com intenção/TEQO_ENV/S3,
--verify inventário read-only, recibos JSON em data/flickr/reports). Guard de
alvo extraído de reel-ingest para scripts/lib/cli.mjs; downloadUrlToFile ganha
headers (User-Agent do Flickr).
2026-09-26 04:19:40 -03:00

405 lines
15 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}` : ''),
)
}
/**
* 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)`)
}