/** * C195 — pure rules of `pnpm reels:ingest`: CLI parsing, the fail-closed target * guard and the package reader/validator. No Payload and no `server-only`: the * CLI (`scripts/reels-ingest.mjs`) and the int spec share it, and the unit pin * covers the refusal matrix. * * The package contract itself (filenames, kinds, required flag, metadata keys, * stored names/mimetypes/alts) lives in `src/lib/reel.ts`, the owner of the * reel vocabulary that the production skill and the writer also read. */ import { readFile, readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { REEL_FEATURES, REEL_PACKAGE_ARTIFACTS, REEL_PACKAGE_METADATA_FILENAME, REEL_PACKAGE_METADATA_KEYS, REEL_PACKAGE_TRANSCRIPT_FILENAME, REEL_TITLE_MAX_LENGTH, reelMediaFieldByKind, } from '../../src/lib/reel.ts' import { ALLOW_REMOTE_DB_FLAG, TEST_DATABASE_NAME_RE, databaseHostname, databaseName, isLocalDatabaseUrl, isTruthyEnv, parseEqualsFlags, } from './cli.mjs' /** Explicit write-intent flag; dry-run (the default) never needs it. */ export const REELS_INGEST_CONFIRM_FLAG = 'REELS_INGEST_CONFIRM' const USAGE = 'uso: `pnpm reels:ingest [--apply]`' /** * 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 * `assertLocalDatabase` alone cannot tell staging from production. */ const REEL_INGEST_DATABASE_BY_ENV = Object.freeze({ staging: 'teqo_staging', production: 'teqo_1313', }) /** Identity hash of the shot list written by the production skill (sha256 hex). */ const REEL_INGEST_SHOT_LIST_HASH_RE = /^[a-f0-9]{64}$/ /** * Fail-closed write gate: `--apply` only proceeds with the explicit intent * flag. Pure and injectable so the unit pin covers the refusal. * * @param {{ apply?: boolean, confirm?: boolean }} [options] */ export const assertReelIngestApplyConfirm = ({ apply = false, confirm = isTruthyEnv(process.env[REELS_INGEST_CONFIRM_FLAG]), } = {}) => { if (!apply || confirm) return throw new Error( 'modo --apply ESCREVE na biblioteca — exige confirmação explícita de intenção.\n' + ` Re-rodar com: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest --apply\n` + ' (sem --apply o comando só confere e mostra o que seria criado ou atualizado).', ) } /** * `pnpm reels:ingest [--apply]` argv parser. The directory is the * one positional; `--apply` is the only flag (bare, no value). Errors carry the * usage so the CLI can `die` with them. * * @param {string[]} [argv] * @returns {{ directory: string, apply: boolean }} */ export const parseReelIngestArgs = (argv = process.argv.slice(2)) => { const { flags, positional } = parseEqualsFlags(argv) const unknown = Object.keys(flags).filter((flag) => flag !== 'apply') if (unknown.length > 0) { throw new Error( `argumento desconhecido: ${unknown.map((flag) => `--${flag}`).join(', ')} — ${USAGE}.`, ) } if (flags.apply !== undefined && flags.apply !== true) { throw new Error(`--apply não aceita valor — ${USAGE}.`) } if (positional.length === 0) { throw new Error(`informe o diretório do pacote — ${USAGE}.`) } if (positional.length > 1) { throw new Error( `argumento posicional extra: ${positional.slice(1).join(', ')} — o pacote é um único diretório; ${USAGE}.`, ) } return { directory: positional[0], apply: flags.apply === true } } /** * Fail-closed target guard. 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 — the 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 assertReelIngestTarget = ({ databaseUrl = process.env.DATABASE_URL, teqoEnv = process.env.TEQO_ENV, allowRemoteDb = isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]), 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:" — o ingest 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 — o ingest nunca escreve em alvo remoto.`, ) } if (allowRemoteDb) { throw new Error( 'ALLOW_REMOTE_DB está setado — remova; o ingest 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 — o ingest 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 = REEL_INGEST_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 } } /** * All reasons a parsed metadata + file listing is not a valid package. Empty * array means valid; the reader joins the list into one error. * * @param {{ metadata: unknown, fileNames: Set }} input * @returns {string[]} */ export const reelPackageIssues = ({ metadata, fileNames }) => { if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { return [`${REEL_PACKAGE_METADATA_FILENAME} deve conter um objeto JSON.`] } const issues = [] const keys = REEL_PACKAGE_METADATA_KEYS const title = metadata[keys.title] if (typeof title !== 'string' || title.trim() === '') { issues.push(`${keys.title} ausente ou vazio.`) } else if (title.length > REEL_TITLE_MAX_LENGTH) { issues.push(`${keys.title} excede ${REEL_TITLE_MAX_LENGTH} caracteres.`) } const feature = metadata[keys.feature] if (!REEL_FEATURES.includes(feature)) { issues.push( `${keys.feature} "${String(feature)}" fora do vocabulário (${REEL_FEATURES.join(', ')}).`, ) } const shotListHash = metadata[keys.shotListHash] if (typeof shotListHash !== 'string' || !REEL_INGEST_SHOT_LIST_HASH_RE.test(shotListHash)) { issues.push(`${keys.shotListHash} deve ser um sha256 hex (64 caracteres minúsculos).`) } const coverAlt = metadata[keys.coverAlt] if (typeof coverAlt !== 'string' || coverAlt.trim() === '') { issues.push(`${keys.coverAlt} ausente ou vazio.`) } const durationSeconds = metadata[keys.durationSeconds] if ( durationSeconds !== undefined && durationSeconds !== null && (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) ) { issues.push(`${keys.durationSeconds} inválido (segundos positivos).`) } const createdAt = metadata[keys.createdAt] if ( createdAt !== undefined && createdAt !== null && (typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt))) ) { issues.push(`${keys.createdAt} inválido (data ISO 8601).`) } for (const artifact of REEL_PACKAGE_ARTIFACTS) { if (artifact.required && !fileNames.has(artifact.filename)) { issues.push(`artefato obrigatório ausente: ${artifact.filename}.`) } } return issues } /** * Reads and validates one package directory. Throws with every pending item so * the operator fixes the package in one pass; never touches the database. * * @param {string} directory * @returns {Promise<{ * directory: string, * metadata: Record, * artifacts: Array<{ * filename: string, * kind: string, * field: string, * required: boolean, * present: boolean, * path: string, * }>, * transcriptPath: string | null, * }>} */ export const readReelPackage = async (directory) => { const directoryStat = await stat(directory).catch(() => null) if (!directoryStat?.isDirectory()) { throw new Error(`diretório do pacote inválido: ${directory}`) } const entries = await readdir(directory, { withFileTypes: true }) const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)) if (!fileNames.has(REEL_PACKAGE_METADATA_FILENAME)) { throw new Error(`pacote incompleto: ${REEL_PACKAGE_METADATA_FILENAME} não encontrado.`) } let metadata try { metadata = JSON.parse(await readFile(join(directory, REEL_PACKAGE_METADATA_FILENAME), 'utf8')) } catch (error) { throw new Error( `${REEL_PACKAGE_METADATA_FILENAME} ilegível: ${error instanceof Error ? error.message : String(error)}`, ) } const issues = reelPackageIssues({ metadata, fileNames }) if (issues.length > 0) { throw new Error(`pacote inválido:\n - ${issues.join('\n - ')}`) } const artifacts = REEL_PACKAGE_ARTIFACTS.map((artifact) => ({ ...artifact, field: reelMediaFieldByKind[artifact.kind], present: fileNames.has(artifact.filename), path: join(directory, artifact.filename), })) const emptyArtifacts = [] for (const artifact of artifacts) { if (!artifact.present) continue const artifactStat = await stat(artifact.path).catch(() => null) if (artifactStat === null || !artifactStat.isFile() || artifactStat.size === 0) { emptyArtifacts.push(artifact.filename) } } if (emptyArtifacts.length > 0) { throw new Error( `pacote inválido:\n - artefato vazio ou ilegível: ${emptyArtifacts.join(', ')}.`, ) } return { directory, metadata, artifacts, transcriptPath: fileNames.has(REEL_PACKAGE_TRANSCRIPT_FILENAME) ? join(directory, REEL_PACKAGE_TRANSCRIPT_FILENAME) : null, } }