@neetru/cli 2.32.1 → 2.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +38 -3
  3. package/dist/commands/admin/sdk-credential.js +3 -1
  4. package/dist/commands/ai-models.d.ts +2 -1
  5. package/dist/commands/ai-models.js +4 -5
  6. package/dist/commands/ai.d.ts +12 -2
  7. package/dist/commands/ai.js +47 -38
  8. package/dist/commands/auth.d.ts +3 -2
  9. package/dist/commands/auth.js +96 -57
  10. package/dist/commands/build.js +35 -4
  11. package/dist/commands/chat.d.ts +24 -6
  12. package/dist/commands/chat.js +43 -7
  13. package/dist/commands/code.js +11 -2
  14. package/dist/commands/config.js +15 -13
  15. package/dist/commands/db-frontdoor.d.ts +116 -0
  16. package/dist/commands/db-frontdoor.js +572 -0
  17. package/dist/commands/db-move.d.ts +5 -0
  18. package/dist/commands/db-move.js +27 -0
  19. package/dist/commands/db.d.ts +8 -2
  20. package/dist/commands/db.js +13 -6
  21. package/dist/commands/deploy.d.ts +14 -5
  22. package/dist/commands/deploy.js +376 -55
  23. package/dist/commands/dev-context.js +1 -1
  24. package/dist/commands/environments-repoint.d.ts +49 -0
  25. package/dist/commands/environments-repoint.js +219 -0
  26. package/dist/commands/init.js +7 -1
  27. package/dist/commands/new.js +14 -0
  28. package/dist/commands/secrets.d.ts +6 -4
  29. package/dist/commands/secrets.js +14 -7
  30. package/dist/commands/servers.d.ts +29 -0
  31. package/dist/commands/servers.js +108 -1
  32. package/dist/commands/ui.js +1 -1
  33. package/dist/commands/workspaces.js +16 -1
  34. package/dist/index.js +103 -7
  35. package/dist/lib/ai/auth-discovery.d.ts +9 -12
  36. package/dist/lib/ai/auth-discovery.js +43 -145
  37. package/dist/lib/ai/orchestrator.d.ts +13 -6
  38. package/dist/lib/ai/orchestrator.js +46 -246
  39. package/dist/lib/ai/provedores.d.ts +57 -0
  40. package/dist/lib/ai/provedores.js +66 -0
  41. package/dist/lib/api-client.d.ts +8 -1
  42. package/dist/lib/api-client.js +18 -2
  43. package/dist/lib/build-targets.d.ts +48 -10
  44. package/dist/lib/build-targets.js +64 -10
  45. package/dist/lib/cli-write.js +6 -3
  46. package/dist/lib/code-agent-gateway.d.ts +2 -2
  47. package/dist/lib/code-agent-gateway.js +2 -2
  48. package/dist/lib/code-agent-settings.d.ts +2 -2
  49. package/dist/lib/code-agent-settings.js +2 -4
  50. package/dist/lib/code-agent-types.d.ts +3 -1
  51. package/dist/lib/config.d.ts +4 -7
  52. package/dist/lib/config.js +9 -6
  53. package/dist/lib/deploy-recusa-de-destino.d.ts +9 -0
  54. package/dist/lib/deploy-recusa-de-destino.js +98 -0
  55. package/dist/lib/deploy-render-probe.d.ts +117 -0
  56. package/dist/lib/deploy-render-probe.js +475 -0
  57. package/dist/lib/deploy-vm-um-app.d.ts +62 -0
  58. package/dist/lib/deploy-vm-um-app.js +83 -0
  59. package/dist/lib/segredo-produto.d.ts +25 -5
  60. package/dist/lib/segredo-produto.js +45 -10
  61. package/dist/lib/sse.d.ts +4 -5
  62. package/dist/lib/sse.js +4 -5
  63. package/package.json +1 -2
@@ -14,6 +14,8 @@
14
14
  * upload via signed URL — Sprint 2.5).
15
15
  * 8. POST /api/cli/v1/deploy → recebe deploymentId + statusUrl.
16
16
  * 9. Polling /api/cli/v1/deploy/status até estado terminal.
17
+ * 10. (VM) Saúde em https://<domain>/api/health?deep=1 e, com ela ok, a
18
+ * página inicial: a CSP deixa rodar os scripts embutidos? Só então "Live".
17
19
  *
18
20
  * NÃO bloqueia em modo `--non-interactive` quando flags suficientes vieram.
19
21
  */
@@ -33,15 +35,22 @@ import { DEPLOY_ENVIRONMENT_IDS, deployEnvParaRuntime, } from '../lib/envs.js';
33
35
  import { resolveStepUp } from '../lib/cli-write.js';
34
36
  import { runBuild } from './build.js';
35
37
  import { formatDeployConsole, isDeployUnverified, deployStatusLabel } from './deployments.js';
38
+ import { buildRenderProbeUrl, describeRenderBlock, runRenderProbe, RENDER_BLOCKED_FIX, } from '../lib/deploy-render-probe.js';
36
39
  import { readDbJson, schemaFingerprint } from '../lib/db-local/index.js';
37
40
  // bug_863ae5: pre-flight IAM check.
38
41
  import { assertIamReady } from '../lib/iam-preflight.js';
39
42
  import { ALLOW_PUBLIC_SECRET_FLAG, MIN_OVERRIDE_REASON_LENGTH, buildOverrideRecord, collectPublicEnvSecrets, formatFindingsMessage, scanDirectoryForPublicSecrets, scanTarballForPublicSecrets, } from '../lib/public-secret-scan.js';
40
43
  import { getActiveTokenSync } from '../lib/token-resolver.js';
41
44
  import { BYTES_FAREJADOS, checkDeployTargetContract, farejarConteudoDePacote, mensagemSourceAcimaDoTeto, mensagemSourceNaoEhHandler, pareceArquivoDePacote, WORKSPACE_SOURCE_MAX_BYTES, } from '../lib/deploy-target-contract.js';
45
+ import { AVISO_PORTA_IGNORADA_NA_VM } from '../lib/deploy-vm-um-app.js';
46
+ import { explicarRecusaDeDestino } from '../lib/deploy-recusa-de-destino.js';
42
47
  import { safeExit } from '../utils/safe-exit.js';
43
- import { AVISO_AGENTE_PENDENTE, CHAVES_DO_CARREGADOR, CHAVE_SEGREDO_RE, chaveInjetadaPeloCore, comandoParaRegistrarSegredo, problemaNaChaveReservada, } from '../lib/segredo-produto.js';
48
+ import { CHAVES_DO_CARREGADOR, CHAVE_SEGREDO_RE, chaveInjetadaPeloCore, comandoParaRegistrarSegredo, notaSegredoDescartadoNoDeploy, problemaNaChaveReservada, } from '../lib/segredo-produto.js';
44
49
  import { avisoEnvDoBundlerForaDeUtf8, CodificacaoInvalida, decodificarUtf8, semBomInicial, } from '../lib/texto-utf8.js';
50
+ // bug_1dcf8e03: o template de Dockerfile do cloud-run usa a MESMA detecção de
51
+ // gerenciador de pacote do cli#106 (build-targets.ts) pra não supor npm/app
52
+ // único num monorepo pnpm/yarn/bun.
53
+ import { detectPackageManager } from '../lib/build-targets.js';
45
54
  /**
46
55
  * bug_a5786e03 (2026-05-31): o Core/agente só conhecem os stacks canônicos
47
56
  * `node|docker|php-apache|static` (PRODUCT_STACKS). `nextjs` é um alias de
@@ -187,9 +196,16 @@ export async function runPublicSecretGate(source, ctx) {
187
196
  * Também não mandamos para stack não-node: ali `entrypoint` é `index.html` /
188
197
  * `index.php` / referência de imagem, que não é "arquivo para o `node` rodar".
189
198
  *
190
- * ATENÇÃO — a cadeia ainda NÃO fecha só com isto: o Core descarta o campo hoje
191
- * (`deployRequestSchema` não o declara, e objeto zod sem `.passthrough()` remove
192
- * chave desconhecida em silêncio). Ver "o que falta no Core" na descrição do PR.
199
+ * Do lado do Core o campo chega: desde o d6cfda64 (2026-10-11) o
200
+ * `deployRequestSchema` declara `entrypoint` e o `entrypointToStartCommand` o
201
+ * traduz para o `startCommand` do agente (`node apps/web/server.js`).
202
+ *
203
+ * O que ainda NÃO fecha é do agente (medido na main dele, 1.6.80): a decisão de
204
+ * instalar dependências (`resolveNodeInstallPlan` em `deploy-plan.ts`) também
205
+ * olha só a raiz do pacote. Sem `server.js` nem `.next/` na raiz, o pacote de
206
+ * monorepo não é reconhecido como standalone e o agente roda `npm rebuild` (e,
207
+ * se falhar, `npm install`) na raiz do pacote, em vez de pular a instalação.
208
+ * O conserto é no `neetru-agent-service-vm` (bug_bc72fe7e).
193
209
  */
194
210
  export function entrypointParaDeploy(manifest) {
195
211
  if (!manifest)
@@ -447,15 +463,18 @@ export function avisoSegredoDescartadoNaVm(chave, env) {
447
463
  '(letras, dígitos e underscore, começando por letra, de 2 a 64 caracteres). Renomeie a ' +
448
464
  `variável no app e registre-a com \`${comandoParaRegistrarSegredo('<NOME>', env)}\`.`);
449
465
  }
466
+ // bug_89d2497e (etapa 1): o "use --generate" solto era lido pelo usuário do
467
+ // `neetru deploy`, que não declara a flag. O texto cita o comando inteiro.
468
+ const registrar = comandoParaRegistrarSegredo(chave, env);
450
469
  return (inicio +
451
470
  'segredo não vai no deploy por variável de ambiente (ficaria gravado no Firestore, que toda ' +
452
- `a equipe lê). Registre-o com \`${comandoParaRegistrarSegredo(chave, env)}\` — o valor vai ` +
453
- 'direto para o Secret Manager; use --generate quando o Core puder sortear o valor.');
471
+ `a equipe lê). Registre-o com \`${registrar}\` — o valor vai direto para o Secret Manager. ` +
472
+ `Para o Core sortear o valor, use \`${registrar} --generate\`.`);
454
473
  }
455
474
  /**
456
475
  * O aviso desta chave manda para `neetru secrets set`? Não manda quando o
457
- * Core reserva a chave — aí o aviso de que o agente ainda não entrega
458
- * segredos do produto também não cabe.
476
+ * Core reserva a chave — aí a nota de como o segredo registrado chega ao app
477
+ * (`notaSegredoDescartadoNoDeploy`) também não cabe.
459
478
  */
460
479
  export function segredoDescartadoApontaSecretsSet(chave) {
461
480
  return problemaNaChaveReservada(chave) === null;
@@ -548,9 +567,11 @@ env) {
548
567
  }
549
568
  out[k] = v;
550
569
  }
551
- // Uma vez só, depois da lista: o registro ainda não chega ao app na VM.
570
+ // Uma vez só, depois da lista: como o segredo descartado chega ao app (pelo
571
+ // registro, num deploy). Até a 2.33.0 aqui saía que o agente "ainda não
572
+ // entrega" — falso desde a 1.6.80 (bug_8b85353b, comentário da vistoria 22/10).
552
573
  if (descartouSegredoNaVm)
553
- lg.warn(AVISO_AGENTE_PENDENTE);
574
+ lg.info(notaSegredoDescartadoNoDeploy(env));
554
575
  return out;
555
576
  }
556
577
  // handleApiError unificado em ../lib/api-error.js (auditoria qualidade 2026-06-20).
@@ -1170,6 +1191,55 @@ async function probeDeployedHealth(domain) {
1170
1191
  }
1171
1192
  return { finalVerdict: last.verdict, attempts, lastAttempt: last, url };
1172
1193
  }
1194
+ /**
1195
+ * bug_0d2e0087 (caso Zelo 22/10): a saúde 200 não prova que a página aparece.
1196
+ * Com a saúde já confirmada, abre `https://<domain>/` e confere se a CSP deixa
1197
+ * rodar os `<script>` embutidos do Next (regra em `lib/deploy-render-probe.ts`,
1198
+ * espelho do portão do Core). Uma tentativa só; nunca lança — o que não deu pra
1199
+ * conferir volta `unverifiable` e não muda o veredito da saúde.
1200
+ */
1201
+ async function probeDeployedRender(domain) {
1202
+ const url = buildRenderProbeUrl(domain);
1203
+ if (!url) {
1204
+ return {
1205
+ verdict: 'unverifiable',
1206
+ url: `https://${domain}/`,
1207
+ httpStatus: null,
1208
+ inlineScripts: 0,
1209
+ blockedInlineScripts: 0,
1210
+ blockingDirective: null,
1211
+ reason: 'domínio não forma URL',
1212
+ };
1213
+ }
1214
+ const spinner = ora({ text: `Conferindo se a página inicial renderiza (${url})…`, color: 'blue' }).start();
1215
+ const render = await runRenderProbe(url, {
1216
+ fetchImpl: (u, init) => fetch(u, init),
1217
+ timeoutMs: healthProbeTiming().requestTimeoutMs,
1218
+ });
1219
+ if (render.verdict === 'ok') {
1220
+ spinner.succeed(`Página inicial: nenhum dos ${render.inlineScripts} script(s) embutido(s) bloqueado pela CSP`);
1221
+ }
1222
+ else if (render.verdict === 'inline_scripts_blocked') {
1223
+ spinner.fail(`Página inicial EM BRANCO no navegador: ${describeRenderBlock(render)}`);
1224
+ }
1225
+ else {
1226
+ // Não é falha nem prova: quem chama diz "não conferida" junto do "Live".
1227
+ spinner.stop();
1228
+ }
1229
+ return render;
1230
+ }
1231
+ /** Resumo da checagem da página que vai no `health.render` do `--json`. */
1232
+ function renderJson(render) {
1233
+ return {
1234
+ verdict: render.verdict,
1235
+ url: render.url,
1236
+ httpStatus: render.httpStatus,
1237
+ inlineScripts: render.inlineScripts,
1238
+ blockedInlineScripts: render.blockedInlineScripts,
1239
+ blockingDirective: render.blockingDirective,
1240
+ reason: render.reason,
1241
+ };
1242
+ }
1173
1243
  /**
1174
1244
  * Resolve o arquivo de source `.ts` de um deploy workspace. Ordem de busca:
1175
1245
  * `--artifact <arquivo>` → campo `source` do neetru.config.json → candidatos
@@ -1443,32 +1513,208 @@ function getGcloudProject() {
1443
1513
  return null;
1444
1514
  }
1445
1515
  }
1516
+ /**
1517
+ * Lockfile CONCRETO na raiz do gerenciador (`pm.rootDir`), se houver.
1518
+ *
1519
+ * `detectPackageManager` não garante que `pm.lockfile` venha preenchido: com
1520
+ * `packageManager` declarado no package.json, ele some do retorno mesmo que um
1521
+ * lockfile físico exista (ver `build-targets.ts`). Aqui checamos o disco direto
1522
+ * — é o que decide `npm ci` vs `npm install` (bug_1dcf8e03: `npm ci` exige
1523
+ * lockfile e falha sem ele; era esse o `npm ci` cego do template antigo).
1524
+ */
1525
+ function lockfileConcretoNaRaiz(pm) {
1526
+ const candidatos = {
1527
+ npm: ['package-lock.json', 'npm-shrinkwrap.json'],
1528
+ pnpm: ['pnpm-lock.yaml'],
1529
+ yarn: ['yarn.lock'],
1530
+ bun: ['bun.lockb', 'bun.lock'],
1531
+ };
1532
+ for (const f of candidatos[pm.name]) {
1533
+ if (fileExists(path.join(pm.rootDir, f)))
1534
+ return f;
1535
+ }
1536
+ return null;
1537
+ }
1538
+ /**
1539
+ * Linhas de setup do gerenciador, ANTES do install — necessárias porque a
1540
+ * imagem `node:22-slim` só traz npm. pnpm/yarn vêm pelo corepack (já embutido
1541
+ * no Node 22, só falta habilitar); bun não tem corepack, então instala pelo
1542
+ * script oficial.
1543
+ */
1544
+ function linhasDeSetupDoGerenciador(pm) {
1545
+ switch (pm) {
1546
+ case 'pnpm':
1547
+ case 'yarn':
1548
+ return ['RUN corepack enable'];
1549
+ case 'bun':
1550
+ return [
1551
+ 'RUN apt-get update \\',
1552
+ ' && apt-get install -y --no-install-recommends curl unzip ca-certificates \\',
1553
+ ' && curl -fsSL https://bun.sh/install | bash \\',
1554
+ ' && ln -s /root/.bun/bin/bun /usr/local/bin/bun \\',
1555
+ ' && apt-get purge -y curl unzip \\',
1556
+ ' && rm -rf /var/lib/apt/lists/*',
1557
+ ];
1558
+ case 'npm':
1559
+ default:
1560
+ return [];
1561
+ }
1562
+ }
1563
+ /**
1564
+ * Comando de install SÓ-PRODUÇÃO (`--omit=dev`/`--production`), pro caso de
1565
+ * app único (sem build dentro do Dockerfile — stack=node) e pro `deps` stage
1566
+ * do stack=nextjs de app único (o builder reusa esses node_modules).
1567
+ *
1568
+ * bug_1dcf8e03: sem lockfile, `npm ci`/`--frozen-lockfile` falham de cara —
1569
+ * cai pro install não-travado (mesma régua do `comandoInstall` de
1570
+ * build-targets.ts, adaptada pra Dockerfile: aqui, diferente do build local,
1571
+ * reprodutibilidade é bem-vinda, então com lockfile preferimos ci/frozen).
1572
+ */
1573
+ function instalarSoProducao(pm, temLockfile) {
1574
+ switch (pm) {
1575
+ case 'pnpm':
1576
+ return temLockfile ? 'pnpm install --prod --frozen-lockfile' : 'pnpm install --prod --no-frozen-lockfile';
1577
+ case 'yarn':
1578
+ return temLockfile ? 'yarn install --production --frozen-lockfile' : 'yarn install --production';
1579
+ case 'bun':
1580
+ return 'bun install --production';
1581
+ case 'npm':
1582
+ default:
1583
+ return temLockfile ? 'npm ci --omit=dev' : 'npm install --omit=dev';
1584
+ }
1585
+ }
1586
+ /**
1587
+ * Comando de install COMPLETO (com devDependencies) — usado só no builder do
1588
+ * stack=nextjs em MONOREPO, porque o `next build` roda DENTRO do container e
1589
+ * pode precisar de devDependencies do workspace (typescript, plugins de
1590
+ * build). Sem risco pro tamanho da imagem final: o builder é descartado; o
1591
+ * runner só copia o `.next/standalone` (que já vem só com o necessário em
1592
+ * runtime, tracejado pelo próprio Next.js).
1593
+ */
1594
+ function instalarCompleto(pm, temLockfile) {
1595
+ switch (pm) {
1596
+ case 'pnpm':
1597
+ return temLockfile ? 'pnpm install --frozen-lockfile' : 'pnpm install --no-frozen-lockfile';
1598
+ case 'yarn':
1599
+ return temLockfile ? 'yarn install --frozen-lockfile' : 'yarn install';
1600
+ case 'bun':
1601
+ return 'bun install';
1602
+ case 'npm':
1603
+ default:
1604
+ return temLockfile ? 'npm ci' : 'npm install';
1605
+ }
1606
+ }
1607
+ function comandoDeBuild(pm) {
1608
+ switch (pm) {
1609
+ case 'pnpm':
1610
+ return 'pnpm run build';
1611
+ case 'yarn':
1612
+ return 'yarn run build';
1613
+ case 'bun':
1614
+ return 'bun run build';
1615
+ case 'npm':
1616
+ default:
1617
+ return 'npm run build';
1618
+ }
1619
+ }
1620
+ /**
1621
+ * Manifests a copiar no estágio `deps` (cache de layer) do caminho de APP
1622
+ * ÚNICO. Só entra o lockfile que REALMENTE existe no disco — listar um que
1623
+ * não existe faz o `COPY` falhar o build inteiro (ao contrário do glob
1624
+ * `package*.json`, que sempre casa com pelo menos um arquivo, porque
1625
+ * `package.json` é garantido).
1626
+ */
1627
+ function manifestsParaCache(pmName, lockfileEncontrado) {
1628
+ if (pmName === 'npm')
1629
+ return ['package*.json'];
1630
+ return lockfileEncontrado ? ['package.json', lockfileEncontrado] : ['package.json'];
1631
+ }
1446
1632
  /**
1447
1633
  * Gera um Dockerfile temporário para a stack informada e retorna o path do
1448
- * diretório temporário. O caller é responsável por apagar o dir após o uso.
1634
+ * diretório temporário, junto do diretório onde o build deve rodar
1635
+ * (`buildContext`). O caller é responsável por apagar o `tmpDir` após o uso.
1449
1636
  *
1450
1637
  * Fix bug_1904ba55: quando --stack=node|nextjs e Dockerfile ausente, o CLI
1451
1638
  * gera um template adequado em vez de falhar com erro pouco acionável.
1639
+ *
1640
+ * Fix bug_1dcf8e03: o template supunha app único com npm — `npm ci` sem
1641
+ * lockfile (pnpm/yarn/bun não têm package-lock.json) falhava de cara, e em
1642
+ * monorepo o contexto era só o `cwd` (as deps `workspace:*` não resolvem) e o
1643
+ * `CMD`/`COPY` do standalone assumiam `server.js` na RAIZ de `/app`.
1644
+ *
1645
+ * `appDir` é o `cwd` de onde `neetru deploy` roda — a mesma convenção que
1646
+ * `neetru build --app`/`--cwd` usa (build-targets.ts): `detectPackageManager`
1647
+ * sobe a árvore a partir dele até achar o lockfile do workspace (ou o próprio
1648
+ * `appDir`, se for app único). O `relativeAppDir` daí (`path.relative` entre
1649
+ * a raiz do workspace e `appDir`) é o MESMO prefixo que o `next build` usa pra
1650
+ * aninhar o app dentro do `.next/standalone` (`outputFileTracingRoot` por
1651
+ * padrão é a raiz do workspace) — é por isso que o `COPY`/`CMD` do runner
1652
+ * apontam pra `<relativeAppDir>/.next/standalone/<relativeAppDir>/server.js`:
1653
+ * a MESMA regra de aninhamento que `resolveStandaloneLayout` usa pro pacote
1654
+ * da VM (cli#106), só que calculada ANTES do build (o Dockerfile builda do
1655
+ * zero) em vez de lida do `required-server-files.json` depois dele.
1656
+ *
1657
+ * App único (`relativeAppDir === ''`) preserva o shape de hoje (3 estágios
1658
+ * deps/builder/runner com cache de layer) — só o gerenciador muda.
1452
1659
  */
1453
- async function generateDockerfileTemplate(stack, cwd) {
1660
+ async function generateDockerfileTemplate(stack, appDir) {
1454
1661
  const os = await import('node:os');
1455
1662
  const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'neetru-dockerfile-'));
1663
+ const pm = detectPackageManager(appDir);
1664
+ const buildContext = pm.rootDir;
1665
+ const relativeAppDir = path.relative(pm.rootDir, appDir).split(path.sep).join('/');
1666
+ const lockfileEncontrado = lockfileConcretoNaRaiz(pm);
1667
+ const temLockfile = lockfileEncontrado !== null;
1668
+ const setupLinhas = linhasDeSetupDoGerenciador(pm.name);
1456
1669
  let content;
1457
- if (stack === 'nextjs') {
1670
+ if (stack === 'nextjs' && relativeAppDir) {
1671
+ // ── Monorepo (bug_1dcf8e03): contexto = raiz do workspace, instala tudo
1672
+ // (workspace:* precisa dos outros pacotes), builda dentro do prefixo do
1673
+ // app e o runner copia só o standalone aninhado (padrão igual ao cli#106).
1674
+ content = [
1675
+ `# Dockerfile gerado automaticamente por neetru deploy --stack=nextjs`,
1676
+ `# Monorepo detectado: app em ${relativeAppDir}/, gerenciador ${pm.name} (${pm.source}${lockfileEncontrado ? `: ${lockfileEncontrado}` : ''})`,
1677
+ '# Next.js standalone output (output: "standalone" no next.config.*)',
1678
+ 'FROM node:22-slim AS base',
1679
+ 'WORKDIR /app',
1680
+ '',
1681
+ 'FROM base AS builder',
1682
+ ...setupLinhas,
1683
+ // Contexto inteiro: workspace:* só resolve com o monorepo completo.
1684
+ 'COPY . .',
1685
+ `RUN ${instalarCompleto(pm.name, temLockfile)}`,
1686
+ `RUN cd ${relativeAppDir} && ${comandoDeBuild(pm.name)}`,
1687
+ '',
1688
+ 'FROM base AS runner',
1689
+ 'ENV NODE_ENV=production',
1690
+ 'ENV PORT=8080',
1691
+ 'ENV HOSTNAME=0.0.0.0',
1692
+ `COPY --from=builder /app/${relativeAppDir}/.next/standalone ./`,
1693
+ `COPY --from=builder /app/${relativeAppDir}/.next/static ./${relativeAppDir}/.next/static`,
1694
+ `COPY --from=builder /app/${relativeAppDir}/public ./${relativeAppDir}/public`,
1695
+ 'EXPOSE 8080',
1696
+ `CMD ["node", "${relativeAppDir}/server.js"]`,
1697
+ ].join('\n');
1698
+ }
1699
+ else if (stack === 'nextjs') {
1700
+ const manifests = manifestsParaCache(pm.name, lockfileEncontrado);
1458
1701
  content = [
1459
1702
  '# Dockerfile gerado automaticamente por neetru deploy --stack=nextjs',
1703
+ `# Gerenciador detectado: ${pm.name} (${pm.source}${lockfileEncontrado ? `: ${lockfileEncontrado}` : ''})`,
1460
1704
  '# Next.js standalone output (output: "standalone" no next.config.*)',
1461
1705
  'FROM node:22-slim AS base',
1462
1706
  'WORKDIR /app',
1463
1707
  '',
1464
1708
  'FROM base AS deps',
1465
- 'COPY package*.json ./',
1466
- 'RUN npm ci --omit=dev',
1709
+ ...setupLinhas,
1710
+ `COPY ${manifests.join(' ')} ./`,
1711
+ `RUN ${instalarSoProducao(pm.name, temLockfile)}`,
1467
1712
  '',
1468
1713
  'FROM base AS builder',
1714
+ ...setupLinhas,
1469
1715
  'COPY --from=deps /app/node_modules ./node_modules',
1470
1716
  'COPY . .',
1471
- 'RUN npm run build',
1717
+ `RUN ${comandoDeBuild(pm.name)}`,
1472
1718
  '',
1473
1719
  'FROM base AS runner',
1474
1720
  'ENV NODE_ENV=production',
@@ -1481,14 +1727,31 @@ async function generateDockerfileTemplate(stack, cwd) {
1481
1727
  'CMD ["node", "server.js"]',
1482
1728
  ].join('\n');
1483
1729
  }
1730
+ else if (relativeAppDir) {
1731
+ // stack === 'node', monorepo: sem passo de build — o server.js já vem
1732
+ // pronto no repositório; só precisa do workspace inteiro instalado.
1733
+ content = [
1734
+ '# Dockerfile gerado automaticamente por neetru deploy --stack=node',
1735
+ `# Monorepo detectado: app em ${relativeAppDir}/, gerenciador ${pm.name} (${pm.source}${lockfileEncontrado ? `: ${lockfileEncontrado}` : ''})`,
1736
+ 'FROM node:22-slim',
1737
+ 'WORKDIR /app',
1738
+ ...setupLinhas,
1739
+ 'COPY . .',
1740
+ `RUN ${instalarSoProducao(pm.name, temLockfile)}`,
1741
+ 'EXPOSE 8080',
1742
+ `CMD ["node", "${relativeAppDir}/server.js"]`,
1743
+ ].join('\n');
1744
+ }
1484
1745
  else {
1485
- // stack === 'node'
1746
+ // stack === 'node', app único (caso de hoje — não muda)
1747
+ const manifests = manifestsParaCache(pm.name, lockfileEncontrado);
1486
1748
  content = [
1487
1749
  '# Dockerfile gerado automaticamente por neetru deploy --stack=node',
1488
1750
  'FROM node:22-slim',
1489
1751
  'WORKDIR /app',
1490
- 'COPY package*.json ./',
1491
- 'RUN npm ci --omit=dev',
1752
+ ...setupLinhas,
1753
+ `COPY ${manifests.join(' ')} ./`,
1754
+ `RUN ${instalarSoProducao(pm.name, temLockfile)}`,
1492
1755
  'COPY . .',
1493
1756
  'EXPOSE 8080',
1494
1757
  'CMD ["node", "server.js"]',
@@ -1498,8 +1761,8 @@ async function generateDockerfileTemplate(stack, cwd) {
1498
1761
  await fsp.writeFile(dockerfilePath, content, 'utf8');
1499
1762
  // Copia o contexto do projeto para o tmpDir para permitir gcloud builds submit
1500
1763
  // usando --source=cwd mas com Dockerfile injetado.
1501
- // Na prática copiamos só o Dockerfile; gcloud usa --source=cwd separado.
1502
- return { tmpDir, dockerfilePath };
1764
+ // Na prática copiamos só o Dockerfile; gcloud usa --source=<buildContext> separado.
1765
+ return { tmpDir, dockerfilePath, buildContext };
1503
1766
  }
1504
1767
  /**
1505
1768
  * Constrói a URI completa do Artifact Registry a partir das opções fornecidas.
@@ -1587,22 +1850,27 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
1587
1850
  if (stack === 'node' || stack === 'nextjs') {
1588
1851
  lg.warn(`Dockerfile não encontrado em ${cwd}.\n` +
1589
1852
  ` Gerando template automático para --stack=${stack}…`);
1590
- const { tmpDir, dockerfilePath } = await generateDockerfileTemplate(stack, cwd);
1853
+ // bug_1dcf8e03: em monorepo, o build context tem que ser a RAIZ do
1854
+ // workspace (onde mora o lockfile) — é lá que o Dockerfile gerado
1855
+ // também é escrito, porque `gcloud builds submit`/`docker build`
1856
+ // procuram o `Dockerfile` na raiz do próprio contexto.
1857
+ const { tmpDir, dockerfilePath, buildContext: generatedBuildContext } = await generateDockerfileTemplate(stack, cwd);
1591
1858
  generatedDockerfileDir = tmpDir;
1592
- // Copia o Dockerfile gerado para o cwd temporariamente usando flag 'wx'
1593
- // (exclusive create) para evitar sobrescrever um Dockerfile que possa ter
1594
- // aparecido entre o check fileExists() acima e agora (race condition).
1595
- const destDockerfile = path.join(cwd, 'Dockerfile');
1859
+ buildContext = generatedBuildContext;
1860
+ // Copia o Dockerfile gerado pro build context temporariamente usando flag
1861
+ // 'wx' (exclusive create) para evitar sobrescrever um Dockerfile que possa
1862
+ // ter aparecido entre o check fileExists() acima e agora (race condition).
1863
+ const destDockerfile = path.join(buildContext, 'Dockerfile');
1596
1864
  try {
1597
1865
  const content = await fsp.readFile(dockerfilePath, 'utf8');
1598
1866
  await fsp.writeFile(destDockerfile, content, { flag: 'wx' });
1599
- lg.dim(` Dockerfile temporário gerado em ${cwd}/Dockerfile`);
1867
+ lg.dim(` Dockerfile temporário gerado em ${buildContext}/Dockerfile`);
1600
1868
  }
1601
1869
  catch (err) {
1602
1870
  if (err.code === 'EEXIST') {
1603
1871
  // Outro processo criou um Dockerfile no intervalo assíncrono — usar o
1604
1872
  // existente sem sobrescrever; não marcar como gerado (não apagar no finally).
1605
- lg.warn(`Dockerfile encontrado em ${cwd} (criado durante geração automática). Usando o existente.`);
1873
+ lg.warn(`Dockerfile encontrado em ${buildContext} (criado durante geração automática). Usando o existente.`);
1606
1874
  generatedDockerfileDir = null; // não remover no finally
1607
1875
  }
1608
1876
  else {
@@ -1676,7 +1944,10 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
1676
1944
  finally {
1677
1945
  // ── Limpa Dockerfile temporário gerado ────────────────────────────────────
1678
1946
  if (generatedDockerfileDir) {
1679
- const tmpDockerfile = path.join(cwd, 'Dockerfile');
1947
+ // bug_1dcf8e03: o Dockerfile foi escrito em `buildContext` (raiz do
1948
+ // workspace em monorepo), não necessariamente em `cwd` — limpa do
1949
+ // mesmo lugar onde foi gravado.
1950
+ const tmpDockerfile = path.join(buildContext, 'Dockerfile');
1680
1951
  try {
1681
1952
  await fsp.rm(tmpDockerfile, { force: true });
1682
1953
  await fsp.rm(generatedDockerfileDir, { recursive: true, force: true });
@@ -1760,7 +2031,9 @@ envFileTexto) {
1760
2031
  }
1761
2032
  catch (err) {
1762
2033
  spinner.fail('Falha ao publicar.');
1763
- handleApiError(err, opts.json);
2034
+ // bug_6d92ef71 / bug_fe30f035: recusa "ambiente tem destino X" explicada
2035
+ // (ver `lib/deploy-recusa-de-destino.ts`); qualquer outro erro passa intacto.
2036
+ handleApiError(explicarRecusaDeDestino(err, productSlug), opts.json);
1764
2037
  }
1765
2038
  if (resp.status === 'live') {
1766
2039
  spinner.succeed(`Deploy ${chalk.bold('live')} · ${resp.deploymentId}`);
@@ -2019,6 +2292,34 @@ export async function runDeploy(opts) {
2019
2292
  await runCloudRunDeploy(opts, productSlug, (opts.env ?? 'dev').toLowerCase(), cwd, nonInteractive, envFileTexto);
2020
2293
  return;
2021
2294
  }
2295
+ // P6 (2026-07-19): a porta interna do app deixou de ser exigida em deploy
2296
+ // VM — o Core/agente aloca por (produto,env,VM). bug_a9c0bc3 (HIGH, 27/05)
2297
+ // motivou o guard antigo (Core rejeitava sem --port); o Core agora ignora
2298
+ // porta em deploy VM, então exigi-la aqui só atrapalharia scripts/CI.
2299
+ // --port continua aceito (compat) mas é ignorado com aviso.
2300
+ //
2301
+ // bug_6d92ef71 (low), 1ª metade: o `!opts.json` calava o aviso POR COMPLETO
2302
+ // em modo machine-readable. Em --json o aviso vai pro stderr (fora do
2303
+ // contrato de 1 linha JSON do stdout); no modo humano, `log.warn`.
2304
+ //
2305
+ // bug_6d92ef71, 2ª metade (vistoria de 22/10): o aviso confirmava a
2306
+ // limitação e não dava saída. Quem passa --port na VM quase sempre quer um
2307
+ // SEGUNDO app do mesmo produto — e um 2º deploy do mesmo produto e ambiente
2308
+ // SUBSTITUI o primeiro (o Core reconhece o app por produto+ambiente). O texto
2309
+ // agora diz isso e o caminho que existe (ver `lib/deploy-vm-um-app.ts`).
2310
+ //
2311
+ // Roda ANTES do `pickServer` (NIT da mesma vistoria): num `--json
2312
+ // --non-interactive` sem `--server`, a CLI morria em `server_required`
2313
+ // antes de avisar. E `!== undefined`: `--port abc` vira NaN e também é
2314
+ // descartado na VM, então também avisa.
2315
+ if (target === 'vm' && opts.port !== undefined) {
2316
+ if (opts.json) {
2317
+ console.error(AVISO_PORTA_IGNORADA_NA_VM);
2318
+ }
2319
+ else {
2320
+ log.warn(AVISO_PORTA_IGNORADA_NA_VM);
2321
+ }
2322
+ }
2022
2323
  // F-11: env name pra GCS path (`gs://neetru-deploy-artifacts/<product>/<env>/…`).
2023
2324
  // Default 'dev' quando não definido — alinhado com o middleware OAuth.
2024
2325
  const envName = (opts.env ?? 'dev').toLowerCase();
@@ -2036,29 +2337,6 @@ export async function runDeploy(opts) {
2036
2337
  serverChoice = await pickServer(opts, nonInteractive);
2037
2338
  }
2038
2339
  // target='workspace' não exige server — backend resolve runtime per-tenant.
2039
- // P6 (2026-07-19): a porta interna do app deixou de ser exigida em deploy
2040
- // VM — o Core/agente aloca por (produto,env,VM). bug_a9c0bc3 (HIGH, 27/05)
2041
- // motivou o guard antigo (Core rejeitava sem --port); o Core agora ignora
2042
- // porta em deploy VM, então exigi-la aqui só atrapalharia scripts/CI.
2043
- // --port continua aceito (compat) mas é ignorado com WARN — ver abaixo.
2044
- //
2045
- // bug_6d92ef71 (low): o `!opts.json` calava o aviso POR COMPLETO em modo
2046
- // machine-readable — nem no stdout (certo, quebraria o contrato de 1 linha
2047
- // JSON do --json), nem em canal nenhum. Quem roda via script nunca
2048
- // descobria que --port foi descartado. Mantém o aviso humano em stdout no
2049
- // modo interativo e manda a MESMA informação pro stderr em --json — stderr
2050
- // não faz parte do contrato de saída parseável, então não quebra nada.
2051
- // Mensagem também deixou de dizer "staging/prod": o guard dispara pra
2052
- // QUALQUER ambiente em target=vm (inclusive dev).
2053
- if (target === 'vm' && opts.port) {
2054
- const portIgnoredMsg = 'A porta é gerenciada pelo Core em deploy VM (aloca por produto/ambiente/servidor); --port é ignorado.';
2055
- if (opts.json) {
2056
- console.error(portIgnoredMsg);
2057
- }
2058
- else {
2059
- log.warn(portIgnoredMsg);
2060
- }
2061
- }
2062
2340
  const { domain } = await pickDomain(opts, nonInteractive, productSlug);
2063
2341
  // Build / artifact (default: faz upload pra GCS via signed URL).
2064
2342
  const artifact = await resolveArtifact(opts, productSlug, envName, cwd);
@@ -2122,7 +2400,7 @@ export async function runDeploy(opts) {
2122
2400
  };
2123
2401
  // bug_678ddc98: standalone aninhado de monorepo — o agente não tem como
2124
2402
  // inferir `apps/web/server.js` olhando a raiz do tarball. Ver
2125
- // `entrypointParaDeploy` (e, no PR, o que ainda falta do lado do Core).
2403
+ // `entrypointParaDeploy` (e, lá, o que ainda falta do lado do agente).
2126
2404
  const entrypointAninhado = entrypointParaDeploy(artifact.manifest);
2127
2405
  if (entrypointAninhado) {
2128
2406
  body.entrypoint = entrypointAninhado;
@@ -2157,7 +2435,12 @@ export async function runDeploy(opts) {
2157
2435
  }
2158
2436
  catch (err) {
2159
2437
  dispatchSpinner.fail('Falha ao criar deploy.');
2160
- handleApiError(err, opts.json);
2438
+ // bug_6d92ef71 / bug_fe30f035: o produto novo (o caminho "dois apps = dois
2439
+ // produtos") nasce com os ambientes no destino workspace, e o Core recusa o
2440
+ // 1º deploy na VM com um conselho que a CLI não tem como seguir. A recusa
2441
+ // sai explicada (ver `lib/deploy-recusa-de-destino.ts`); qualquer outro erro
2442
+ // passa intacto.
2443
+ handleApiError(explicarRecusaDeDestino(err, productSlug), opts.json);
2161
2444
  }
2162
2445
  const final = await pollDeploymentStatus(deploy.deploymentId, opts.json);
2163
2446
  if (final.status !== 'success') {
@@ -2226,6 +2509,40 @@ export async function runDeploy(opts) {
2226
2509
  }
2227
2510
  const probe = await probeDeployedHealth(final.domain);
2228
2511
  if (probe.finalVerdict === 'healthy') {
2512
+ // bug_0d2e0087 (caso Zelo 22/10): health 200 com JSON saudável e a página
2513
+ // inicial EM BRANCO — a CSP do produto bloqueava os <script> embutidos do
2514
+ // Next. O `deploy/status` do Core diz só que o DEPLOYMENT terminou; quem
2515
+ // confere a página no Core é o gatilho das functions (core#793), depois,
2516
+ // e o resultado dele não volta por essa rota. Então a CLI confere aqui
2517
+ // antes de afirmar "Live".
2518
+ const render = await probeDeployedRender(final.domain);
2519
+ if (render.verdict === 'inline_scripts_blocked') {
2520
+ const blockDetail = describeRenderBlock(render);
2521
+ const renderMsg = `Deploy aceito pelo Core e a saúde passou, mas a página inicial fica EM BRANCO no navegador: ` +
2522
+ `${blockDetail}.`;
2523
+ if (opts.json) {
2524
+ console.log(JSON.stringify({
2525
+ ...base,
2526
+ ok: false,
2527
+ error: 'render_blocked',
2528
+ message: `${renderMsg} ${RENDER_BLOCKED_FIX}`,
2529
+ health: {
2530
+ verified: false,
2531
+ verdict: 'render_blocked',
2532
+ httpStatus: probe.lastAttempt.httpStatus ?? null,
2533
+ attempts: probe.attempts,
2534
+ url: probe.url,
2535
+ detail: blockDetail,
2536
+ render: renderJson(render),
2537
+ },
2538
+ }));
2539
+ }
2540
+ else {
2541
+ log.error(renderMsg);
2542
+ log.warn(`Não marcando como "Live". ${RENDER_BLOCKED_FIX}`);
2543
+ }
2544
+ safeExit(1);
2545
+ }
2229
2546
  if (opts.json) {
2230
2547
  console.log(JSON.stringify({
2231
2548
  ...base,
@@ -2235,11 +2552,15 @@ export async function runDeploy(opts) {
2235
2552
  httpStatus: probe.lastAttempt.httpStatus ?? null,
2236
2553
  attempts: probe.attempts,
2237
2554
  url: probe.url,
2555
+ render: renderJson(render),
2238
2556
  },
2239
2557
  }));
2240
2558
  }
2241
2559
  else {
2242
2560
  log.success(`Live em https://${final.domain}`);
2561
+ if (render.verdict === 'unverifiable') {
2562
+ log.dim(` página inicial não conferida (${render.reason ?? 'motivo desconhecido'}) — vale abrir no navegador.`);
2563
+ }
2243
2564
  }
2244
2565
  safeExit(0);
2245
2566
  }
@@ -72,7 +72,7 @@ function mergeDevDatabases(current, productId, databases) {
72
72
  async function resolveProductForSync(token, opts) {
73
73
  const explicit = opts.productId ?? opts.product ?? opts.context?.productId ?? opts.context?.productSlug;
74
74
  if (!explicit) {
75
- log.error('Produto não definido. Use `neetru dev use --product <slug-ou-id>` ou `--prototype <slug>`.');
75
+ log.error('Produto não definido. Use `neetru dev use --product <slug-ou-id>` ou `neetru dev use --prototype <slug>`.');
76
76
  safeExit(1);
77
77
  }
78
78
  const productId = opts.productId ?? await resolveProductIdentifier(token, explicit);