From 5940c4c61e490eeedba64c365a644a1295503d26 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 19:41:16 -0300 Subject: [PATCH] =?UTF-8?q?fix(OPS40):=20pnpm=20dev=20honra=20o=20PORT=20d?= =?UTF-8?q?o=20.env.local=20=E2=80=94=20next=20dev=20-p=20via=20scripts/de?= =?UTF-8?q?v.mjs=20(Closes=20#682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next dev resolve a porta antes de @next/env carregar env-files (commander .env('PORT') no bin/next), então o PORT=3100+slot do provisionador de worktree era ignorado e todo dev server subia em 3000. pnpm dev/devsafe passam por scripts/dev.mjs: preflight do guard-dev-db inline, env com a mesma precedência (real > .env.local > .env, override:false) e porta como flag CLI -p (resolveDevPort/nextDevArgs em scripts/lib/cli.mjs, unit-testados). Sem PORT → nenhum -p → default 3000 + allowRetry intactos. --- AGENTS.md | 1 + docs/CHANGELOG-AGENTS.md | 1 + .../worktree-dev-port-nao-honrado-impl.md | 75 +++++++++++++++++++ package.json | 4 +- scripts/dev.mjs | 64 ++++++++++++++++ scripts/lib/cli.mjs | 30 ++++++++ tests/unit/devPort.unit.spec.ts | 46 ++++++++++++ 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 docs/plans/worktree-dev-port-nao-honrado-impl.md create mode 100644 scripts/dev.mjs create mode 100644 tests/unit/devPort.unit.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 5cd9cd00..3e6e711a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ These are product/architecture constraints — not a ban on editing existing cod **Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):** - Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (`pnpm db:start`); `pnpm dev` refuses a non-local `DATABASE_URL` (guard: `scripts/guard-dev-db.mjs`) unless `ALLOW_REMOTE_DB=true`. Tests load `.env.test` (`teqo_test`) and refuse any database whose name doesn't end in `_test` (`tests/helpers/assertTestDatabase.ts`). Never repoint `.env`/`.env.local`/`.env.test` `DATABASE_URL` at Neon. Full workflow lives in the `local-database` skill (`.agents/skills/local-database`). +- **`pnpm dev` honra o `PORT` do `.env.local` (OPS40):** `next dev` resolve a porta ANTES de `@next/env` carregar os env-files (commander `.env('PORT')` no `bin/next`), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000. `pnpm dev` hoje passa por `scripts/dev.mjs`: roda o mesmo preflight do `guard-dev-db`, carrega `.env.local` → `.env` (`override:false` — env real do processo vence, como o `PORT` que o webServer do Playwright injeta) e entrega a porta ao `next dev` como **flag CLI `-p`** (`resolveDevPort`/`nextDevArgs` em `scripts/lib/cli.mjs`, unit-testados); sem `PORT` em lugar nenhum, nenhum `-p` é passado e o default 3000 (com `allowRetry`) do Next segue intacto no main repo. - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 7503e4b2..fa7b14a1 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -5,6 +5,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- **Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects//canvases/plan--ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `
` (ex. `mobile`, `desktop-vazio`) → `docs/plans/-ui-draft-.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`. +**Recently resolved (2026-08-11):** **OPS40** (Issue #682) — `pnpm dev` honra o `PORT` do `.env.local`: `next dev` resolve a porta **antes** de `@next/env` carregar env-files (commander `.env('PORT')` no `bin/next` — causa confirmada no fonte 15.4.11), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta `PORT` no env real). **`pnpm dev`/`devsafe` agora passam por `scripts/dev.mjs`** (wrapper): importa o `guard-dev-db.mjs` (preflight fail-closed inline, mesma saída), `loadCliEnv()` (`.env.local` → `.env`, `override:false`) e entrega a porta ao `next dev` como **flag CLI `-p`** — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → `.env.local` (worktree) → `.env` → sem `-p` (default 3000 + `allowRetry` do Next intactos no main repo). Derivação pura unit-testada em `scripts/lib/cli.mjs` (`resolveDevPort` — null quando ausente, throw fail-closed em PORT inválida; `nextDevArgs`); spawn `node next/dist/bin/next` com `NODE_OPTIONS=--no-deprecation` (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (`--turbopack`) repassados após o `-p` derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode `campaignAuth` 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: `AGENTS.md` em `main` carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado **fora** da região conflitada. Intenção [`worktree-dev-port-nao-honrado.md`](plans/worktree-dev-port-nao-honrado.md); impl [`worktree-dev-port-nao-honrado-impl.md`](plans/worktree-dev-port-nao-honrado-impl.md). **Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md). **Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md). diff --git a/docs/plans/worktree-dev-port-nao-honrado-impl.md b/docs/plans/worktree-dev-port-nao-honrado-impl.md new file mode 100644 index 00000000..82dc80d6 --- /dev/null +++ b/docs/plans/worktree-dev-port-nao-honrado-impl.md @@ -0,0 +1,75 @@ +# Impl: worktree dev — `PORT` do `.env.local` honrado pelo `pnpm dev` + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #682 (OPS40) +Intenção: docs/plans/worktree-dev-port-nao-honrado.md +Appetite restante: herdado (~2–4h; a entrega é pequena, sobra verificação) + +## Leitura da intenção + +- **Outcome:** `pnpm dev` num worktree provisionado sobe em `3100+slot` (a porta do `.env.local`); dois worktrees em `pnpm dev` simultâneos não colidem; e2e (webServer com `PORT` explícito) e main repo (`pnpm dev` → 3000) intactos; ninguém precisa lembrar `-p`. +- **O que NÃO negociar:** env do processo do webServer do Playwright continua sendo a fonte que manda no e2e (injeção explícita de `PORT`); main repo sem `PORT` em lugar nenhum continua em 3000; guard de DB local (fail-closed) continua rodando antes do dev server. +- **O que reavaliar:** a hipótese "o problema é o `next dev` ignorar env-file" está **confirmada e com causa exata** — o CLI do Next 15.4.11 resolve a porta via `commander` `.option('-p, --port').env('PORT')` em `next/dist/bin/next` (linha 92), **antes** de `@next/env` carregar `.env.local`; o `env('PORT')` do commander só enxerga o env real do processo. A porta precisa ir como **flag CLI** `-p`. O wrapper do candidato (a) é o caminho certo; refinei os detalhes (guard inline, helpers puros unit-testados, spawn direto do bin do Next). + +## Abordagem recomendada + +```mermaid +flowchart LR + dev["pnpm dev"] --> wrap["node scripts/dev.mjs"] + wrap --> guard["import scripts/guard-dev-db.mjs
(preflight DB local, fail-closed)"] + wrap --> env["loadCliEnv()
.env.local › .env, override:false"] + env --> port["resolveDevPort(process.env)
real env › .env.local › .env › default"] + port --> spawn["spawn node next/dist/bin/next
dev [-p N] (stdio inherit)"] + spawn --> next["next dev na porta certa
(flag CLI ganha do env)"] +``` + +**Opções consideradas:** A (wrapper `scripts/dev.mjs`) | B (`next.config` `port`) | C (shell one-liner com `grep` no `.env.local`) | D (upstream Next) +**Recomendação:** A — causa raiz é o parse da porta antes do env-file; a única alavanca que o repo controla é entregar a porta como flag CLI. O wrapper é um ponto único, testável e não exige que ninguém lembre de nada. +**Rejeitadas:** + +- **B** — `next.config` também é avaliado dentro do mesmo bootstrap que já ignorou o env-file para a porta; `port` no config lê `process.env` na mesma ordem problemática (e nem existe `port` de dev no config do Next 15.4 que valha). Mesma classe de bug, sem ganho de evidência. +- **C** — duplica parsing de `.env.local` (semântica de aspas/comentários do dotenv), quebra precedência real-env-vs-arquivo, frágil no shell. +- **D** — upstream, fora de controle; o wrapper sobrevive a mudanças do Next (se um dia `PORT` de env-file for honrado, o wrapper vira no-op correta). + +### Componentes / mudanças + +- **`resolveDevPort` / `nextDevArgs`** (`scripts/lib/cli.mjs`, novos exports puros): `resolveDevPort(env)` → `number | null` (`null` = sem `-p`, Next decide o default 3000; inválido não-vazio → `Error` fail-closed, ex.: `PORT=abc`); `nextDevArgs(port)` → `['dev']` ou `['dev', '-p', String(port)]`. Reusa o padrão de "derivação pura unit-testada" de `scripts/lib/worktree-env.mjs`. +- **`scripts/dev.mjs`** (novo, wrapper): importa `guard-dev-db.mjs` (o preflight roda inline; `process.exit(1)` do guard derruba tudo — mesmo fail-closed de hoje), chama `loadCliEnv()`, resolve a porta, e dá `spawn(process.execPath, [require.resolve('next/dist/bin/next'), ...nextDevArgs(port), ...extras], { stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: '--no-deprecation' } })` — paridade exata com o `cross-env NODE_OPTIONS=--no-deprecation` atual. Forward de SIGINT/SIGTERM/SIGHUP + propagação de exit code. `process.argv.slice(2)` passa extras (ex.: `--turbopack`) depois do `-p` derivado (último vence no parser do commander; contrato preservado). +- **`package.json`**: `dev` → `node scripts/dev.mjs`; `devsafe` → `rm -rf .next && node scripts/dev.mjs`. `cross-env` continua em uso por build/lint/etc. +- **Migration:** sem migration (nenhum schema). +- **Access / Consent:** N/A. +- **UI:** N/A (Impeccable A). + +### Precedência da porta (contrato) + +1. `process.env.PORT` real (webServer do Playwright injeta — e2e continua dono da porta); +2. `PORT` do `.env.local` (provisionador do worktree); +3. `PORT` do `.env` (sem uso hoje, mas simétrico); +4. nenhum → sem `-p` → Next default 3000 (main repo intacto; `allowRetry` do Next continua ativo só neste caso — comportamento atual preservado). + +## Fases verificáveis + +1. **Helpers puros** — `resolveDevPort`/`nextDevArgs` em `scripts/lib/cli.mjs` + `tests/unit/devPort.unit.spec.ts` (casos: ausente→null, `3140`→3140, espaço em volta→trim, `0`/`abc`/`65536`→throw, `65535`→ok; args com/sem `-p`). `pnpm test:unit`. +2. **Wrapper + scripts** — `scripts/dev.mjs` + package.json. Verificação manual: `pnpm dev` neste worktree (`.env.local` `PORT=3140`) → log "Local: http://localhost:3140"; e2e dev-mode smoke (`pnpm test:e2e` com `--grep` curto) provando o caminho webServer→wrapper→porta certa. +3. **Docs + gates** — AGENTS.md (menção ao wrapper no contrato de worktrees + no bullet do guard), entrada curta no `docs/CHANGELOG-AGENTS.md`, status do plano de intenção. Gates: `gate:fast` + `format:check` + `knip` + `check:cycles`. + +## Rabbit holes / Não escopo (engenharia) + +- Não carregar `.env.local` duas vezes com ordens conflitantes — `loadCliEnv` já é idempotente (`override:false`); o wrapper usa a MESMA fonte do provisionador. +- Não dar prioridade ao `.env.local` sobre o env real — e2e perderia a porta forçada (rabbit hole apontado na intenção; a precedência acima o elimina estruturalmente). +- Não mexer no guard (`guard-dev-db.mjs` continua standalone e é quem decide o DB). +- Fora de escopo: `next start`/`pnpm start` (e2e prod-mode usa 3000 e `PORT` real — intocado), re-provisionamento de worktrees existentes, e os marcadores de conflito não resolvidos no `AGENTS.md` em `main` (pré-existentes — ver Riscos). + +## Riscos e mitigação + +- **Colisão de porta real (EADDRINUSE) vira erro alto em vez de retry silencioso** — desejado: worktree colidindo deve falhar alto com o número da porta, não migrar sozinho (contrato `3100+slot`). `allowRetry` do Next só ativa no source `default` (sem `-p`), exatamente o caso do main repo. Mitigação: mensagem de erro clara do Next basta. +- **`AGENTS.md` em main tem conflito commitado (OPS33/OPS37)**: não é deste escopo e há Issue de main-mess (OPS38) em aberto para a área — registrar como débito no capture-review-debts, não resolver aqui. +- **NODE_OPTIONS** — paridade com o cross-env atual (substitui, não concatena). +- **Windows** — não é alvo do repo; `spawn` via `process.execPath` + bin do Next é neutro de plataforma de qualquer forma. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (porta do worktree honrada; e2e e main intactos) +- [x] Invariantes AGENTS/engineering-standards (guard fail-closed preservado; helpers puros unit-testados) +- [x] Testes de domínio previstos (unit `devPort.unit.spec.ts`; verificação manual do caminho real) diff --git a/package.json b/package.json index d084e744..01757eb0 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", "build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs", "build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs", - "dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", - "devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", + "dev": "node scripts/dev.mjs", + "devsafe": "rm -rf .next && node scripts/dev.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "gate:fast": "pnpm lint && pnpm typecheck && pnpm test:unit", diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 00000000..b8f55730 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,64 @@ +/** + * `pnpm dev` launcher (OPS40). `next dev` resolves its port from the `-p` flag + * or the REAL process env BEFORE `@next/env` loads `.env.local`, so a worktree + * provisioner's `PORT=3100+slot` was silently ignored and every worktree bound + * 3000. The wrapper loads the env files itself (same precedence as the guard), + * hands the port to Next as a CLI flag, and spawns the dev server in-process. + * + * Precedence (exactly what `loadCliEnv` merges, override:false): + * 1. real `process.env.PORT` (Playwright's webServer injects it — e2e wins) + * 2. `.env.local` (the worktree provisioner) + * 3. `.env` + * 4. none → no `-p` → Next default 3000 + its free-port retry (main repo) + */ +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' + +import './guard-dev-db.mjs' +import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs' + +const require = createRequire(import.meta.url) +const die = dieWithLabel('dev') + +loadCliEnv() + +let port +try { + port = resolveDevPort(process.env) +} catch (error) { + die(error.message) +} + +const extraArgs = process.argv.slice(2) +const nextArgs = [...nextDevArgs(port), ...extraArgs] +const nextBin = require.resolve('next/dist/bin/next') + +if (port === null) { + console.log(`[dev] next dev ${nextArgs.join(' ')} (sem PORT — porta padrão 3000)`) +} else { + console.log(`[dev] dev server na porta ${port} (PORT do env) — next dev -p ${port}`) +} + +const child = spawn(process.execPath, [nextBin, ...nextArgs], { + stdio: 'inherit', + // Paridade com o cross-env NODE_OPTIONS=--no-deprecation anterior. + env: { ...process.env, NODE_OPTIONS: '--no-deprecation' }, +}) + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => child.kill(signal)) +} + +child.on('error', (error) => die(`falha ao subir o next dev: ${error.message}`)) + +child.on('exit', (code, signal) => { + if (signal) { + // O listener acima re-forwardaria o sinal para um filho já morto e o + // kill(process.pid) re-dispararia este próprio handler — remove antes de + // re-sinalizar para deixar a disposição padrão encerrar o processo. + process.removeAllListeners(signal) + process.kill(process.pid, signal) + } else { + process.exit(code ?? 1) + } +}) diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 847300e3..144a4e9d 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -36,6 +36,36 @@ export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB' export const isRemoteDbOverrideSet = () => process.env[ALLOW_REMOTE_DB_FLAG] === 'true' || process.env[ALLOW_REMOTE_DB_FLAG] === '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} [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` diff --git a/tests/unit/devPort.unit.spec.ts b/tests/unit/devPort.unit.spec.ts new file mode 100644 index 00000000..2adbf4e8 --- /dev/null +++ b/tests/unit/devPort.unit.spec.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { nextDevArgs, resolveDevPort } from '../../scripts/lib/cli.mjs' + +describe('resolveDevPort (OPS40 — next dev ignores env-file PORT)', () => { + it('no PORT anywhere → null (caller omits -p, Next defaults to 3000)', () => { + expect(resolveDevPort({})).toBeNull() + expect(resolveDevPort({ DATABASE_URL: 'x' })).toBeNull() + }) + + it('an empty/whitespace PORT is treated as absent', () => { + expect(resolveDevPort({ PORT: '' })).toBeNull() + expect(resolveDevPort({ PORT: ' ' })).toBeNull() + }) + + it('reads the worktree PORT from the merged env (.env.local)', () => { + expect(resolveDevPort({ PORT: '3140' })).toBe(3140) + }) + + it('trims whitespace around the value', () => { + expect(resolveDevPort({ PORT: ' 3140 ' })).toBe(3140) + }) + + it('accepts the port range bounds', () => { + expect(resolveDevPort({ PORT: '1' })).toBe(1) + expect(resolveDevPort({ PORT: '65535' })).toBe(65535) + }) + + it('fails closed on a non-empty invalid PORT instead of falling back to 3000', () => { + for (const bad of ['abc', '0', '-1', '65536', '31.5', '12a']) { + expect(() => resolveDevPort({ PORT: bad })).toThrow(/PORT inválida/) + } + }) +}) + +describe('nextDevArgs (port must go as a CLI flag — commander never sees env files)', () => { + it('passes -p when a port is resolved', () => { + expect(nextDevArgs(3140)).toEqual(['dev', '-p', '3140']) + }) + + it('omits -p when there is no port (Next default 3000 + allowRetry)', () => { + expect(nextDevArgs(null)).toEqual(['dev']) + }) +})