teqo/scripts/lib/cli.mjs
Francisco Solla 228d5da9c8
Some checks failed
CI (PR) / scope (pull_request) Successful in 32s
CI (PR) / lint (pull_request) Successful in 46s
CI (PR) / docs-guards (pull_request) Successful in 9s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
CI (PR) / format (pull_request) Successful in 47s
CI (PR) / typecheck (pull_request) Successful in 28s
CI (PR) / knip (pull_request) Successful in 32s
CI (PR) / cycles (pull_request) Successful in 34s
CI (PR) / unit (pull_request) Successful in 45s
CI (PR) / build (pull_request) Has been skipped
CI (PR) / e2e (1) (pull_request) Has been skipped
CI (PR) / e2e (2) (pull_request) Has been skipped
CI (PR) / int (pull_request) Successful in 1m34s
CI (PR) / checks (pull_request) Successful in 1s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 19m21s
CI / checks (push) Blocked by required conditions
CI / deploy homeserver (teqo-1313) (push) Blocked by required conditions
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Agent pool supervisor / tick (pull_request) Successful in 29s
CI / static (push) Successful in 2m15s
CI / int (push) Successful in 2m48s
CI / build (push) Successful in 2m37s
CI / e2e (2) (push) Successful in 5m56s
CI / e2e (1) (push) Failing after 17m21s
ops(OPS52-media-guard): reconcile do media:recover exige MEDIA_RECOVER_CONFIRM=1 (guard de intenção explícita)
2026-08-18 12:45:32 -03:00

167 lines
6.3 KiB
JavaScript
Raw Permalink 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')
/** 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 — including the docker-compose service name. THE one list. */
export const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
/**
* Gateway of the default route, read from /proc/net/route (Linux). The
* self-hosted Forgejo runner publishes each job's `services:` Postgres on the
* bridge gateway IP (192.168.x.1 — differs per job), so a job reaches it via
* that IP instead of localhost. 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)
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])
/**
* 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)`)
}