@neetru/cli 2.33.0 → 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.
- package/CHANGELOG.md +60 -0
- package/README.md +38 -3
- package/dist/commands/admin/sdk-credential.js +3 -1
- package/dist/commands/ai-models.d.ts +2 -1
- package/dist/commands/ai-models.js +4 -5
- package/dist/commands/ai.d.ts +12 -2
- package/dist/commands/ai.js +47 -38
- package/dist/commands/auth.d.ts +3 -2
- package/dist/commands/auth.js +96 -57
- package/dist/commands/build.js +35 -4
- package/dist/commands/chat.d.ts +24 -6
- package/dist/commands/chat.js +43 -7
- package/dist/commands/code.js +11 -2
- package/dist/commands/config.js +15 -13
- package/dist/commands/db-frontdoor.js +10 -5
- package/dist/commands/db.d.ts +8 -2
- package/dist/commands/db.js +13 -6
- package/dist/commands/deploy.d.ts +14 -5
- package/dist/commands/deploy.js +376 -55
- package/dist/commands/dev-context.js +1 -1
- package/dist/commands/environments-repoint.d.ts +49 -0
- package/dist/commands/environments-repoint.js +219 -0
- package/dist/commands/init.js +7 -1
- package/dist/commands/new.js +14 -0
- package/dist/commands/secrets.d.ts +6 -4
- package/dist/commands/secrets.js +14 -7
- package/dist/commands/ui.js +1 -1
- package/dist/commands/workspaces.js +16 -1
- package/dist/index.js +47 -7
- package/dist/lib/ai/auth-discovery.d.ts +9 -12
- package/dist/lib/ai/auth-discovery.js +43 -145
- package/dist/lib/ai/orchestrator.d.ts +13 -6
- package/dist/lib/ai/orchestrator.js +46 -246
- package/dist/lib/ai/provedores.d.ts +57 -0
- package/dist/lib/ai/provedores.js +66 -0
- package/dist/lib/build-targets.d.ts +48 -10
- package/dist/lib/build-targets.js +64 -10
- package/dist/lib/cli-write.js +6 -3
- package/dist/lib/code-agent-gateway.d.ts +2 -2
- package/dist/lib/code-agent-gateway.js +2 -2
- package/dist/lib/code-agent-settings.d.ts +2 -2
- package/dist/lib/code-agent-settings.js +2 -4
- package/dist/lib/code-agent-types.d.ts +3 -1
- package/dist/lib/config.d.ts +4 -7
- package/dist/lib/config.js +9 -6
- package/dist/lib/deploy-recusa-de-destino.d.ts +9 -0
- package/dist/lib/deploy-recusa-de-destino.js +98 -0
- package/dist/lib/deploy-render-probe.d.ts +117 -0
- package/dist/lib/deploy-render-probe.js +475 -0
- package/dist/lib/deploy-vm-um-app.d.ts +62 -0
- package/dist/lib/deploy-vm-um-app.js +83 -0
- package/dist/lib/segredo-produto.d.ts +25 -5
- package/dist/lib/segredo-produto.js +45 -10
- package/dist/lib/sse.d.ts +4 -5
- package/dist/lib/sse.js +4 -5
- package/package.json +1 -2
package/dist/commands/deploy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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 \`${
|
|
453
|
-
|
|
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í
|
|
458
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
1466
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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);
|