mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
/**
|
|
* 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 <diretório> [--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 <diretório> --apply\n` +
|
|
' (sem --apply o comando só confere e mostra o que seria criado ou atualizado).',
|
|
)
|
|
}
|
|
|
|
/**
|
|
* `pnpm reels:ingest <diretório> [--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<string> }} 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<string, unknown>,
|
|
* 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,
|
|
}
|
|
}
|