@neetru/cli 2.27.1 → 2.28.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.
@@ -47,6 +47,27 @@ import { safeExit } from '../utils/safe-exit.js';
47
47
  export function toCoreStack(stack) {
48
48
  return stack === 'nextjs' ? 'node' : stack;
49
49
  }
50
+ function scopedLog(json) {
51
+ if (!json)
52
+ return log;
53
+ const noop = (_msg) => { };
54
+ return { info: noop, warn: noop, dim: noop, success: noop, heading: noop, error: log.error };
55
+ }
56
+ /** Emite UMA linha JSON de erro no stdout. */
57
+ function emitDeployJsonError(code, message) {
58
+ console.log(JSON.stringify({ ok: false, error: code, message }));
59
+ }
60
+ /**
61
+ * Falha terminal: em --json imprime `{ok:false,error,message}` no stdout; senão
62
+ * mostra a mensagem humana no stderr. Sempre encerra (safeExit é `never`).
63
+ */
64
+ function deployFail(json, code, message, exitCode = 1) {
65
+ if (json)
66
+ emitDeployJsonError(code, message);
67
+ else
68
+ log.error(message);
69
+ safeExit(exitCode);
70
+ }
50
71
  /**
51
72
  * GAP-M1-8: os 3 call-sites de deploy caíam em `?? '0.0.0'` quando não havia
52
73
  * manifest de build (sem `neetru build` local, ex: redeploy direto de um
@@ -56,12 +77,11 @@ export function toCoreStack(stack) {
56
77
  * Core com uma versão sem sentido (regressão ante o histórico real do
57
78
  * produto). Agora falha alto e explícito em vez de inventar um placeholder.
58
79
  */
59
- export function resolveDeployVersion(manifestVersion, explicitVersion) {
80
+ export function resolveDeployVersion(manifestVersion, explicitVersion, json) {
60
81
  const version = manifestVersion ?? explicitVersion;
61
82
  if (!version) {
62
- log.error('Versão não detectada (sem manifest de build local) e --version não foi informado. ' +
83
+ deployFail(json, 'version_required', 'Versão não detectada (sem manifest de build local) e --version não foi informado. ' +
63
84
  'Passe --version <versão real> explicitamente — nunca assuma um placeholder.');
64
- safeExit(1);
65
85
  }
66
86
  return version;
67
87
  }
@@ -106,7 +126,10 @@ function isTty() {
106
126
  return !!process.stdout.isTTY && !!process.stdin.isTTY;
107
127
  }
108
128
  function ensureNonInteractive(opts) {
109
- return !!opts.nonInteractive || !isTty();
129
+ // SETOR 5.1: --json implica não-interativo — um consumidor de JSON não pode
130
+ // responder prompts. Isso já pula todos os blocos `if (!nonInteractive)`
131
+ // (Resumo/confirmação), mantendo o stdout limpo.
132
+ return !!opts.nonInteractive || !!opts.json || !isTty();
110
133
  }
111
134
  async function loadLocalConfig(cwd) {
112
135
  for (const filename of ['neetru.config.json', '.neetru.json']) {
@@ -195,12 +218,13 @@ const SDK_CRED_ENV_KEYS = new Set([
195
218
  'NEETRU_API_KEY',
196
219
  'NEETRU_CLIENT_ID',
197
220
  ]);
198
- async function loadEnvFile(envFile, target = 'cloud-run') {
221
+ async function loadEnvFile(envFile, target = 'cloud-run', json) {
222
+ const lg = scopedLog(json);
199
223
  const content = await fsp.readFile(envFile, 'utf8');
200
224
  const out = {};
201
225
  if (target === 'vm') {
202
226
  // (3) Lembrete único: a credencial SDK não precisa ir no --env-file.
203
- log.info('A credencial SDK é injetada automaticamente na VM pelo Core/agente — ' +
227
+ lg.info('A credencial SDK é injetada automaticamente na VM pelo Core/agente — ' +
204
228
  'você NÃO precisa passar --env-file pra ela.');
205
229
  }
206
230
  for (const line of content.split(/\r?\n/)) {
@@ -223,27 +247,27 @@ async function loadEnvFile(envFile, target = 'cloud-run') {
223
247
  }
224
248
  else if (open === '"' || open === "'") {
225
249
  // aspas sem fechamento correspondente — avisa e mantém o valor sem a aspas inicial
226
- log.warn(`Aspas sem fechamento em valor de env (variável: ${k}) — aspas inicial removida`);
250
+ lg.warn(`Aspas sem fechamento em valor de env (variável: ${k}) — aspas inicial removida`);
227
251
  v = v.slice(1);
228
252
  }
229
253
  }
230
254
  if (!k)
231
255
  continue;
232
256
  if (isDangerousEnvKey(k)) {
233
- log.warn(`Variável de ambiente ignorada (nome não permitido): ${k}`);
257
+ lg.warn(`Variável de ambiente ignorada (nome não permitido): ${k}`);
234
258
  continue;
235
259
  }
236
260
  // (2) Credencial SDK: injetada server-side pelo Core/agente. Dropar SEMPRE,
237
261
  // inclusive NEETRU_CLIENT_ID (que não bate em isSecretEnvVarKey por não ter
238
262
  // KEY/TOKEN/SECRET no nome).
239
263
  if (SDK_CRED_ENV_KEYS.has(k)) {
240
- log.warn(`Credencial SDK ignorada (${k}): a cred SDK é injetada server-side ` +
264
+ lg.warn(`Credencial SDK ignorada (${k}): a cred SDK é injetada server-side ` +
241
265
  'pelo Core/agente — ignorando do --env-file.');
242
266
  continue;
243
267
  }
244
268
  // (1) Segredos são dropados aqui (o Core rejeitaria com HTTP 400).
245
269
  if (isSecretEnvVarKey(k)) {
246
- log.warn(`Variável com aparência de segredo ignorada (${k}): segredos não podem ` +
270
+ lg.warn(`Variável com aparência de segredo ignorada (${k}): segredos não podem ` +
247
271
  'ir no deploy via env (vazariam no Firestore, legível por todo o staff). ' +
248
272
  'Use o Secret Manager e referencie por nome na VM.');
249
273
  continue;
@@ -260,8 +284,7 @@ async function pickProduct(opts, nonInteractive, cwd) {
260
284
  if (local?.slug)
261
285
  return local.slug;
262
286
  if (nonInteractive) {
263
- log.error('--product <slug> é obrigatório em modo não interativo.');
264
- safeExit(1);
287
+ deployFail(opts.json, 'product_required', '--product <slug> é obrigatório em modo não interativo.');
265
288
  }
266
289
  const spinner = ora({ text: 'Buscando catálogo…', color: 'blue' }).start();
267
290
  let catalog;
@@ -271,11 +294,10 @@ async function pickProduct(opts, nonInteractive, cwd) {
271
294
  }
272
295
  catch (err) {
273
296
  spinner.fail('Não consegui buscar o catálogo.');
274
- handleApiError(err);
297
+ handleApiError(err, opts.json);
275
298
  }
276
299
  if (catalog.products.length === 0) {
277
- log.error('Nenhum produto público encontrado. Crie um via `neetru publish` antes.');
278
- safeExit(1);
300
+ deployFail(opts.json, 'no_products', 'Nenhum produto público encontrado. Crie um via `neetru publish` antes.');
279
301
  }
280
302
  const { slug } = await inquirer.prompt([
281
303
  {
@@ -322,7 +344,7 @@ async function pickServer(opts, nonInteractive) {
322
344
  }
323
345
  catch (err) {
324
346
  spinner.fail('Não consegui listar servers.');
325
- handleApiError(err);
347
+ handleApiError(err, opts.json);
326
348
  }
327
349
  // bug_d9ee266e (CRITICAL 27/05): `--server <id>` explicito DEVE bypass
328
350
  // o filtro `?status=online`. Antes, se a list voltasse vazia (race entre
@@ -344,23 +366,26 @@ async function pickServer(opts, nonInteractive) {
344
366
  describeSpinner.stop();
345
367
  if (describe?.server)
346
368
  return describe.server;
347
- log.error(`Server "${opts.server}" nao encontrado.`);
348
- safeExit(1);
369
+ deployFail(opts.json, 'server_not_found', `Server "${opts.server}" nao encontrado.`);
349
370
  }
350
371
  catch (err) {
351
372
  describeSpinner.fail(`Server "${opts.server}" nao encontrado.`);
352
- handleApiError(err);
373
+ handleApiError(err, opts.json);
353
374
  }
354
375
  }
355
376
  if (resp.servers.length === 0) {
356
- log.error('Nenhum servidor online encontrado.');
357
- log.dim(' Provisione um com: POST /api/cli/v1/servers/provision');
358
- log.dim(' Ou passe --server <id> explicito se ja sabe o id.');
377
+ if (opts.json) {
378
+ emitDeployJsonError('no_servers_online', 'Nenhum servidor online encontrado.');
379
+ }
380
+ else {
381
+ log.error('Nenhum servidor online encontrado.');
382
+ log.dim(' Provisione um com: POST /api/cli/v1/servers/provision');
383
+ log.dim(' Ou passe --server <id> explicito se ja sabe o id.');
384
+ }
359
385
  safeExit(1);
360
386
  }
361
387
  if (nonInteractive) {
362
- log.error('--server <id> é obrigatório em modo não interativo.');
363
- safeExit(1);
388
+ deployFail(opts.json, 'server_required', '--server <id> é obrigatório em modo não interativo.');
364
389
  }
365
390
  const { serverId } = await inquirer.prompt([
366
391
  {
@@ -428,7 +453,7 @@ function shouldKeepLocalArtifact(opts) {
428
453
  *
429
454
  * @returns `gs://...` canônico pra usar como artifactUrl no POST de deploy.
430
455
  */
431
- async function uploadArtifactToGcs(tarballAbs, productSlug, envName, sizeBytes) {
456
+ async function uploadArtifactToGcs(tarballAbs, productSlug, envName, sizeBytes, json) {
432
457
  const filename = path.basename(tarballAbs);
433
458
  // Pede signed URL ao Core.
434
459
  const signSpinner = ora({
@@ -451,7 +476,7 @@ async function uploadArtifactToGcs(tarballAbs, productSlug, envName, sizeBytes)
451
476
  }
452
477
  catch (err) {
453
478
  signSpinner.fail('Falha ao obter signed URL.');
454
- handleApiError(err);
479
+ handleApiError(err, json);
455
480
  }
456
481
  // PUT direto pro GCS com 1 retry. global.fetch nativo (Node 18+) aceita
457
482
  // ReadableStream e respeita Content-Length quando duplex='half'.
@@ -494,10 +519,9 @@ async function uploadArtifactToGcs(tarballAbs, productSlug, envName, sizeBytes)
494
519
  await attempt(2);
495
520
  }
496
521
  catch (err) {
497
- log.error(`Upload do artifact falhou após 2 tentativas: ${err.message}. ` +
522
+ deployFail(json, 'artifact_upload_failed', `Upload do artifact falhou após 2 tentativas: ${err.message}. ` +
498
523
  'Verifique conectividade com storage.googleapis.com ou rode com --local-artifact ' +
499
- 'se o agente está na mesma máquina.');
500
- safeExit(4);
524
+ 'se o agente está na mesma máquina.', 4);
501
525
  }
502
526
  }
503
527
  return { artifactUrl: signed.gcsPath, deployId: signed.deployId };
@@ -523,12 +547,12 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
523
547
  }
524
548
  // Decide se vamos fazer upload pra GCS ou manter file://.
525
549
  const keepLocal = shouldKeepLocalArtifact(opts);
550
+ const lg = scopedLog(opts.json);
526
551
  // Caso 2: --artifact <file> local.
527
552
  if (opts.artifact) {
528
553
  const abs = path.resolve(cwd, opts.artifact);
529
554
  if (!fileExists(abs)) {
530
- log.error(`Artifact não encontrado: ${abs}`);
531
- safeExit(1);
555
+ deployFail(opts.json, 'artifact_not_found', `Artifact não encontrado: ${abs}`);
532
556
  }
533
557
  const { createHash } = await import('node:crypto');
534
558
  const hash = createHash('sha256');
@@ -538,14 +562,14 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
538
562
  const stat = await fsp.stat(abs);
539
563
  const sha256 = hash.digest('hex');
540
564
  if (keepLocal) {
541
- log.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
565
+ lg.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
542
566
  return {
543
567
  artifactUrl: `file://${abs.replace(/\\/g, '/')}`,
544
568
  artifactSha256: sha256,
545
569
  artifactSizeBytes: stat.size,
546
570
  };
547
571
  }
548
- const uploaded = await uploadArtifactToGcs(abs, productSlug, envName, stat.size);
572
+ const uploaded = await uploadArtifactToGcs(abs, productSlug, envName, stat.size, opts.json);
549
573
  return {
550
574
  artifactUrl: uploaded.artifactUrl,
551
575
  artifactSha256: sha256,
@@ -567,7 +591,7 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
567
591
  }
568
592
  }
569
593
  if (!manifest) {
570
- log.info('Nenhum artifact encontrado. Rodando `neetru build`…');
594
+ lg.info('Nenhum artifact encontrado. Rodando `neetru build`…');
571
595
  // bug_d80cbe84: passa --stack e --env-file do deploy pro build, para que
572
596
  // `neetru deploy --stack=nextjs` não ignore a flag ao chamar runBuild
573
597
  // internamente. Sem isso, o build sempre detectava a stack do cwd (podendo
@@ -584,11 +608,15 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
584
608
  stack: opts.stack,
585
609
  envFile: opts.envFile,
586
610
  targetEnv: deployEnvToBuildTargetEnv(envName),
611
+ // SETOR 5.1: em deploy --json, o build interno roda em silêncio (stdout
612
+ // limpo, subprocessos → stderr) e NÃO emite a linha JSON dele — o deploy
613
+ // emite a linha final única.
614
+ quiet: opts.json,
587
615
  });
588
616
  }
589
617
  const tarAbs = path.resolve(cwd, manifest.tarball);
590
618
  if (keepLocal) {
591
- log.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
619
+ lg.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
592
620
  return {
593
621
  artifactUrl: `file://${tarAbs.replace(/\\/g, '/')}`,
594
622
  artifactSha256: manifest.sha256,
@@ -596,7 +624,7 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
596
624
  manifest,
597
625
  };
598
626
  }
599
- const uploaded = await uploadArtifactToGcs(tarAbs, productSlug, envName, manifest.sizeBytes);
627
+ const uploaded = await uploadArtifactToGcs(tarAbs, productSlug, envName, manifest.sizeBytes, opts.json);
600
628
  return {
601
629
  artifactUrl: uploaded.artifactUrl,
602
630
  artifactSha256: manifest.sha256,
@@ -605,7 +633,7 @@ async function resolveArtifact(opts, productSlug, envName, cwd) {
605
633
  deployId: uploaded.deployId,
606
634
  };
607
635
  }
608
- async function pollDeploymentStatus(deploymentId) {
636
+ async function pollDeploymentStatus(deploymentId, json) {
609
637
  const start = Date.now();
610
638
  const timeoutMs = 20 * 60 * 1000; // 20min
611
639
  let lastStatus = '';
@@ -622,7 +650,7 @@ async function pollDeploymentStatus(deploymentId) {
622
650
  continue;
623
651
  }
624
652
  spinner.fail('Falha ao consultar status.');
625
- handleApiError(err);
653
+ handleApiError(err, json);
626
654
  }
627
655
  if (resp.status !== lastStatus) {
628
656
  spinner.text = `Status: ${chalk.bold(resp.status)} · ${resp.steps
@@ -659,10 +687,9 @@ async function resolveWorkspaceSource(opts, cwd) {
659
687
  };
660
688
  }
661
689
  }
662
- log.error('Nenhum arquivo de source encontrado para o deploy workspace. ' +
690
+ deployFail(opts.json, 'source_not_found', 'Nenhum arquivo de source encontrado para o deploy workspace. ' +
663
691
  'Crie um handler.ts (ou src/handler.ts), aponte com --artifact <arquivo>, ' +
664
692
  'ou defina "source" no neetru.config.json.');
665
- safeExit(1);
666
693
  }
667
694
  /**
668
695
  * bug_eae4bef6: limiar acima do qual o source é enviado via GCS (signed URL)
@@ -681,8 +708,7 @@ const WORKSPACE_SOURCE_GCS_THRESHOLD_BYTES = 1.5 * 1024 * 1024;
681
708
  */
682
709
  async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractive) {
683
710
  if (!WORKSPACE_ENVS.has(envName)) {
684
- log.error(`Ambiente inválido: "${envName}". Use dev, staging ou prod.`);
685
- safeExit(1);
711
+ deployFail(opts.json, 'invalid_env', `Ambiente inválido: "${envName}". Use dev, staging ou prod.`);
686
712
  }
687
713
  const source = await resolveWorkspaceSource(opts, cwd);
688
714
  const sourceSizeBytes = Buffer.byteLength(source.content, 'utf8');
@@ -728,7 +754,7 @@ async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractiv
728
754
  }
729
755
  catch (err) {
730
756
  uploadSpinner.fail('Falha ao obter signed URL para source.');
731
- handleApiError(err);
757
+ handleApiError(err, opts.json);
732
758
  }
733
759
  // PUT direto pra GCS com o conteúdo do source.
734
760
  // bug_bf0c8c33: usar o contentType RETORNADO pelo servidor (uploadResult.contentType)
@@ -753,11 +779,11 @@ async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractiv
753
779
  }
754
780
  catch (uploadErr) {
755
781
  uploadSpinner.fail(`Upload do source falhou: ${uploadErr.message}`);
756
- handleApiError(uploadErr);
782
+ handleApiError(uploadErr, opts.json);
757
783
  }
758
784
  deployBody = {
759
785
  productSlug,
760
- version: resolveDeployVersion(undefined, opts.version),
786
+ version: resolveDeployVersion(undefined, opts.version, opts.json),
761
787
  target: 'workspace',
762
788
  environmentId: envName,
763
789
  // gcsPath retornado pelo upload-url é o path `gs://bucket/object`.
@@ -768,7 +794,7 @@ async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractiv
768
794
  // Caminho normal (source pequeno, embutido inline no JSON).
769
795
  deployBody = {
770
796
  productSlug,
771
- version: resolveDeployVersion(undefined, opts.version),
797
+ version: resolveDeployVersion(undefined, opts.version, opts.json),
772
798
  target: 'workspace',
773
799
  environmentId: envName,
774
800
  source: source.content,
@@ -787,16 +813,41 @@ async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractiv
787
813
  }
788
814
  catch (err) {
789
815
  spinner.fail('Falha ao publicar.');
790
- handleApiError(err);
816
+ handleApiError(err, opts.json);
791
817
  }
792
818
  if (resp.status === 'live') {
793
819
  spinner.succeed(`Deploy ${chalk.bold('live')} · ${resp.deploymentId}`);
794
- if (resp.url)
820
+ if (opts.json) {
821
+ console.log(JSON.stringify({
822
+ ok: true,
823
+ status: 'live',
824
+ target: 'workspace',
825
+ deploymentId: resp.deploymentId,
826
+ environmentId: resp.environmentId,
827
+ url: resp.url ? `https://${resp.url}` : null,
828
+ }));
829
+ }
830
+ else if (resp.url) {
795
831
  log.success(`Live em https://${resp.url}`);
832
+ }
796
833
  safeExit(0);
797
834
  }
798
835
  spinner.fail(`Deploy ${chalk.bold(resp.status)}.`);
799
- log.error(resp.error ?? `Deploy terminou com status ${resp.status}`);
836
+ const wsErr = resp.error ?? `Deploy terminou com status ${resp.status}`;
837
+ if (opts.json) {
838
+ console.log(JSON.stringify({
839
+ ok: false,
840
+ error: 'deploy_failed',
841
+ message: wsErr,
842
+ status: resp.status,
843
+ target: 'workspace',
844
+ deploymentId: resp.deploymentId,
845
+ environmentId: resp.environmentId,
846
+ }));
847
+ }
848
+ else {
849
+ log.error(wsErr);
850
+ }
800
851
  safeExit(1);
801
852
  }
802
853
  /**
@@ -908,13 +959,12 @@ function buildArtifactRegistryUri(registry, productSlug, envName, opts, gcloudPr
908
959
  // Caso 3: só host (1 segmento) — precisa de projectId.
909
960
  const host = segments[0];
910
961
  if (!gcloudProject) {
911
- log.error('Não foi possível detectar o GCP project automaticamente.\n' +
962
+ deployFail(opts.json, 'gcp_project_undetected', 'Não foi possível detectar o GCP project automaticamente.\n' +
912
963
  ' Opções:\n' +
913
964
  ' 1. Configure via: gcloud config set project <SEU_PROJECT_ID>\n' +
914
965
  ' 2. Passe o registry completo:\n' +
915
966
  ` --registry=${host}/<project-id>/<repo-name>\n` +
916
967
  ' 3. Passe a imagem já pronta: --image=<uri-completa>');
917
- safeExit(1);
918
968
  }
919
969
  const repoName = opts.registryRepo ?? `${productSlug}-${envName}`;
920
970
  return `${host}/${gcloudProject}/${repoName}/${productSlug}:${tag}`;
@@ -935,13 +985,18 @@ function buildArtifactRegistryUri(registry, productSlug, envName, opts, gcloudPr
935
985
  async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
936
986
  if (opts.image)
937
987
  return opts.image;
988
+ const lg = scopedLog(opts.json);
989
+ // SETOR 5.1: em --json, o stdout do docker/gcloud é roteado pro stderr (fd 2)
990
+ // pra manter o stdout do CLI 100% JSON puro.
991
+ const crStdio = opts.json
992
+ ? ['inherit', 2, 2]
993
+ : 'inherit';
938
994
  const cfg = (await loadLocalConfig(cwd));
939
995
  const registry = opts.registry ?? cfg?.registry;
940
996
  if (!registry) {
941
- log.error('Sem imagem pra deployar. Passe --image <uri> com a imagem já pushada, ' +
997
+ deployFail(opts.json, 'image_required', 'Sem imagem pra deployar. Passe --image <uri> com a imagem já pushada, ' +
942
998
  'ou defina o registry (--registry <host|host/project/repo> ou "registry" no neetru.config.json) ' +
943
999
  'pra build automático via Cloud Build.');
944
- safeExit(1);
945
1000
  }
946
1001
  // ── Detecta project GCP (necessário para URI completa) ─────────────────────
947
1002
  const gcloudProject = getGcloudProject();
@@ -959,7 +1014,7 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
959
1014
  if (!hasDockerfile) {
960
1015
  const stack = opts.stack;
961
1016
  if (stack === 'node' || stack === 'nextjs') {
962
- log.warn(`Dockerfile não encontrado em ${cwd}.\n` +
1017
+ lg.warn(`Dockerfile não encontrado em ${cwd}.\n` +
963
1018
  ` Gerando template automático para --stack=${stack}…`);
964
1019
  const { tmpDir, dockerfilePath } = await generateDockerfileTemplate(stack, cwd);
965
1020
  generatedDockerfileDir = tmpDir;
@@ -970,13 +1025,13 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
970
1025
  try {
971
1026
  const content = await fsp.readFile(dockerfilePath, 'utf8');
972
1027
  await fsp.writeFile(destDockerfile, content, { flag: 'wx' });
973
- log.dim(` Dockerfile temporário gerado em ${cwd}/Dockerfile`);
1028
+ lg.dim(` Dockerfile temporário gerado em ${cwd}/Dockerfile`);
974
1029
  }
975
1030
  catch (err) {
976
1031
  if (err.code === 'EEXIST') {
977
1032
  // Outro processo criou um Dockerfile no intervalo assíncrono — usar o
978
1033
  // existente sem sobrescrever; não marcar como gerado (não apagar no finally).
979
- log.warn(`Dockerfile encontrado em ${cwd} (criado durante geração automática). Usando o existente.`);
1034
+ lg.warn(`Dockerfile encontrado em ${cwd} (criado durante geração automática). Usando o existente.`);
980
1035
  generatedDockerfileDir = null; // não remover no finally
981
1036
  }
982
1037
  else {
@@ -985,43 +1040,38 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
985
1040
  }
986
1041
  }
987
1042
  else if (stack === 'docker') {
988
- log.error(`Dockerfile não encontrado em ${cwd}.\n` +
1043
+ deployFail(opts.json, 'dockerfile_required', `Dockerfile não encontrado em ${cwd}.\n` +
989
1044
  ' --stack=docker exige que você forneça um Dockerfile no projeto.\n' +
990
1045
  ' Crie um Dockerfile ou use --stack=node para template automático.');
991
- safeExit(1);
992
1046
  }
993
1047
  else {
994
- log.error(`Dockerfile não encontrado em ${cwd}.\n` +
1048
+ deployFail(opts.json, 'dockerfile_required', `Dockerfile não encontrado em ${cwd}.\n` +
995
1049
  ' Opções:\n' +
996
1050
  ' 1. Adicione um Dockerfile ao projeto.\n' +
997
1051
  ' 2. Use --stack=node ou --stack=nextjs para template automático.\n' +
998
1052
  ' 3. Passe --image=<uri> com imagem já construída.');
999
- safeExit(1);
1000
1053
  }
1001
1054
  }
1002
1055
  // ── Build + Push ─────────────────────────────────────────────────────────────
1003
- log.info(`Buildando imagem ${chalk.bold(imageUri)}…`);
1056
+ lg.info(`Buildando imagem ${chalk.bold(imageUri)}…`);
1004
1057
  try {
1005
1058
  if (opts.buildLocal) {
1006
1059
  // ── Modo local: docker build + docker push (exige Docker instalado) ──────
1007
- const build = spawnSync('docker', ['build', '-t', imageUri, '.'], { cwd: buildContext, stdio: 'inherit' });
1060
+ const build = spawnSync('docker', ['build', '-t', imageUri, '.'], { cwd: buildContext, stdio: crStdio });
1008
1061
  if (build.error) {
1009
1062
  const enoent = build.error.code === 'ENOENT';
1010
- log.error(enoent
1063
+ deployFail(opts.json, 'docker_build_failed', enoent
1011
1064
  ? 'docker não encontrado no PATH. Remova --build-local pra usar Cloud Build remoto.'
1012
1065
  : `Falha ao rodar docker build: ${build.error.message}`);
1013
- safeExit(1);
1014
1066
  }
1015
1067
  if (build.status !== 0) {
1016
- log.error(`docker build falhou (exit ${build.status}).`);
1017
- safeExit(1);
1068
+ deployFail(opts.json, 'docker_build_failed', `docker build falhou (exit ${build.status}).`);
1018
1069
  }
1019
- log.info('Push da imagem pro Artifact Registry…');
1020
- const push = spawnSync('docker', ['push', imageUri], { cwd: buildContext, stdio: 'inherit' });
1070
+ lg.info('Push da imagem pro Artifact Registry…');
1071
+ const push = spawnSync('docker', ['push', imageUri], { cwd: buildContext, stdio: crStdio });
1021
1072
  if (push.error || push.status !== 0) {
1022
- log.error(`docker push falhou${push.status !== null && push.status !== 0 ? ` (exit ${push.status})` : ''}. ` +
1073
+ deployFail(opts.json, 'docker_push_failed', `docker push falhou${push.status !== null && push.status !== 0 ? ` (exit ${push.status})` : ''}. ` +
1023
1074
  'Verifique a autenticação: gcloud auth configure-docker <host>.');
1024
- safeExit(1);
1025
1075
  }
1026
1076
  }
1027
1077
  else {
@@ -1033,24 +1083,22 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
1033
1083
  if (gcloudProject)
1034
1084
  gcloudArgs.push(`--project=${gcloudProject}`);
1035
1085
  gcloudArgs.push(buildContext);
1036
- log.info(`Usando Cloud Build remoto (sem Docker local).\n` +
1086
+ lg.info(`Usando Cloud Build remoto (sem Docker local).\n` +
1037
1087
  ` Comando: gcloud ${gcloudArgs.join(' ')}`);
1038
- const submit = spawnSync('gcloud', gcloudArgs, { cwd: buildContext, stdio: 'inherit' });
1088
+ const submit = spawnSync('gcloud', gcloudArgs, { cwd: buildContext, stdio: crStdio });
1039
1089
  if (submit.error) {
1040
1090
  const enoent = submit.error.code === 'ENOENT';
1041
- log.error(enoent
1091
+ deployFail(opts.json, 'gcloud_build_failed', enoent
1042
1092
  ? 'gcloud não encontrado no PATH. Instale o Google Cloud CLI: https://cloud.google.com/sdk/docs/install\n' +
1043
1093
  ' Ou use --build-local com Docker instalado.'
1044
1094
  : `Falha ao rodar gcloud builds submit: ${submit.error.message}`);
1045
- safeExit(1);
1046
1095
  }
1047
1096
  if (submit.status !== 0) {
1048
- log.error(`gcloud builds submit falhou (exit ${submit.status}).\n` +
1097
+ deployFail(opts.json, 'gcloud_build_failed', `gcloud builds submit falhou (exit ${submit.status}).\n` +
1049
1098
  ' Verifique:\n' +
1050
1099
  ' 1. Autenticação: gcloud auth login\n' +
1051
1100
  ' 2. APIs habilitadas: Cloud Build API + Artifact Registry API\n' +
1052
1101
  ` 3. Repositório AR existe: ${imageUri.split('/').slice(0, 3).join('/')}`);
1053
- safeExit(1);
1054
1102
  }
1055
1103
  }
1056
1104
  }
@@ -1075,15 +1123,14 @@ async function resolveCloudRunImage(opts, productSlug, envName, cwd) {
1075
1123
  */
1076
1124
  async function runCloudRunDeploy(opts, productSlug, envName, cwd, nonInteractive) {
1077
1125
  if (!WORKSPACE_ENVS.has(envName)) {
1078
- log.error(`Ambiente inválido: "${envName}". Use dev, staging ou prod.`);
1079
- safeExit(1);
1126
+ deployFail(opts.json, 'invalid_env', `Ambiente inválido: "${envName}". Use dev, staging ou prod.`);
1080
1127
  }
1081
1128
  // Fix bug_ec17af083d7045a1a00f24255efb716c:
1082
1129
  // Cloud Run exige uma imagem de container — não um tarball source.
1083
1130
  // Se o dev passou --artifact (útil pra target=vm/workspace), informamos
1084
1131
  // claramente o que fazer em vez de silenciosamente ignorar a flag.
1085
1132
  if (opts.artifact || opts.artifactUrl) {
1086
- log.error('target=cloud-run não aceita --artifact / --artifact-url (tarball source).\n' +
1133
+ deployFail(opts.json, 'artifact_not_supported_cloud_run', 'target=cloud-run não aceita --artifact / --artifact-url (tarball source).\n' +
1087
1134
  ' Cloud Run precisa de uma imagem de container OCI já buildada e pushada.\n' +
1088
1135
  '\n' +
1089
1136
  ' Opções:\n' +
@@ -1096,7 +1143,6 @@ async function runCloudRunDeploy(opts, productSlug, envName, cwd, nonInteractive
1096
1143
  '\n' +
1097
1144
  ' 3. Mude o target pra vm ou workspace se quiser usar o tarball:\n' +
1098
1145
  ' --target=vm --artifact=' + (opts.artifact ?? opts.artifactUrl ?? '<file>'));
1099
- safeExit(1);
1100
1146
  }
1101
1147
  // bug_863ae5: pre-flight IAM check — abort cedo se SA Core não tiver permissão.
1102
1148
  // MASTER-HIGH-048 (2.11.0): prefere auth.json sobre Conf legado.
@@ -1107,7 +1153,7 @@ async function runCloudRunDeploy(opts, productSlug, envName, cwd, nonInteractive
1107
1153
  const imageUrl = await resolveCloudRunImage(opts, productSlug, envName, cwd);
1108
1154
  let envVars;
1109
1155
  if (opts.envFile) {
1110
- envVars = await loadEnvFile(path.resolve(cwd, opts.envFile));
1156
+ envVars = await loadEnvFile(path.resolve(cwd, opts.envFile), 'cloud-run', opts.json);
1111
1157
  }
1112
1158
  if (!nonInteractive) {
1113
1159
  log.heading('Resumo');
@@ -1130,7 +1176,7 @@ async function runCloudRunDeploy(opts, productSlug, envName, cwd, nonInteractive
1130
1176
  method: 'POST',
1131
1177
  body: {
1132
1178
  productSlug,
1133
- version: resolveDeployVersion(undefined, opts.version),
1179
+ version: resolveDeployVersion(undefined, opts.version, opts.json),
1134
1180
  target: 'cloud-run',
1135
1181
  environmentId: envName,
1136
1182
  imageUrl,
@@ -1141,17 +1187,46 @@ async function runCloudRunDeploy(opts, productSlug, envName, cwd, nonInteractive
1141
1187
  }
1142
1188
  catch (err) {
1143
1189
  spinner.fail('Falha ao publicar.');
1144
- handleApiError(err);
1190
+ handleApiError(err, opts.json);
1145
1191
  }
1146
1192
  if (resp.status === 'live') {
1147
1193
  spinner.succeed(`Deploy ${chalk.bold('live')} · ${resp.deploymentId}`);
1148
- if (resp.url)
1149
- log.success(`Live em ${resp.url}`);
1150
- log.dim(` serviço: ${resp.serviceName}${resp.revision ? ` · revision ${resp.revision}` : ''}`);
1194
+ if (opts.json) {
1195
+ console.log(JSON.stringify({
1196
+ ok: true,
1197
+ status: 'live',
1198
+ target: 'cloud-run',
1199
+ deploymentId: resp.deploymentId,
1200
+ environmentId: resp.environmentId,
1201
+ service: resp.serviceName,
1202
+ url: resp.url ?? null,
1203
+ revision: resp.revision ?? null,
1204
+ }));
1205
+ }
1206
+ else {
1207
+ if (resp.url)
1208
+ log.success(`Live em ${resp.url}`);
1209
+ log.dim(` serviço: ${resp.serviceName}${resp.revision ? ` · revision ${resp.revision}` : ''}`);
1210
+ }
1151
1211
  safeExit(0);
1152
1212
  }
1153
1213
  spinner.fail(`Deploy ${chalk.bold(resp.status)}.`);
1154
- log.error(resp.error ?? `Deploy terminou com status ${resp.status}`);
1214
+ const crErr = resp.error ?? `Deploy terminou com status ${resp.status}`;
1215
+ if (opts.json) {
1216
+ console.log(JSON.stringify({
1217
+ ok: false,
1218
+ error: 'deploy_failed',
1219
+ message: crErr,
1220
+ status: resp.status,
1221
+ target: 'cloud-run',
1222
+ deploymentId: resp.deploymentId,
1223
+ environmentId: resp.environmentId,
1224
+ service: resp.serviceName,
1225
+ }));
1226
+ }
1227
+ else {
1228
+ log.error(crErr);
1229
+ }
1155
1230
  safeExit(1);
1156
1231
  }
1157
1232
  /**
@@ -1193,13 +1268,20 @@ async function checkSchemaGate(cwd, opts) {
1193
1268
  const localFp = schemaFingerprint(schemaContent);
1194
1269
  if (localFp === lastApplied)
1195
1270
  return true;
1271
+ const lg = scopedLog(opts.json);
1196
1272
  // Divergência detectada — avisa e pede confirmação.
1197
- log.warn(`Schema local diverge do aplicado — rode \`neetru db apply\` antes de publicar.`);
1198
- log.dim(` fingerprint local: ${localFp.slice(0, 16)}…`);
1199
- log.dim(` último aplicado: ${lastApplied.slice(0, 16)}…`);
1200
- if (opts.nonInteractive) {
1201
- log.error('Deploy bloqueado: schema diverge e modo não-interativo não permite confirmação. ' +
1202
- 'Rode `neetru db apply` ou passe --force.');
1273
+ lg.warn(`Schema local diverge do aplicado — rode \`neetru db apply\` antes de publicar.`);
1274
+ lg.dim(` fingerprint local: ${localFp.slice(0, 16)}…`);
1275
+ lg.dim(` último aplicado: ${lastApplied.slice(0, 16)}…`);
1276
+ // --json implica não-interativo (via ensureNonInteractive), então a
1277
+ // divergência bloqueia aqui e emite a linha JSON de erro (o caller safeExit).
1278
+ if (opts.nonInteractive || opts.json) {
1279
+ const msg = 'Deploy bloqueado: schema diverge e modo não-interativo não permite confirmação. ' +
1280
+ 'Rode `neetru db apply` ou passe --force.';
1281
+ if (opts.json)
1282
+ emitDeployJsonError('schema_drift', msg);
1283
+ else
1284
+ log.error(msg);
1203
1285
  return false;
1204
1286
  }
1205
1287
  const { continuar } = await inquirer.prompt([
@@ -1217,7 +1299,8 @@ async function checkSchemaGate(cwd, opts) {
1217
1299
  return true;
1218
1300
  }
1219
1301
  export async function runDeploy(opts) {
1220
- log.banner();
1302
+ if (!opts.json)
1303
+ log.banner();
1221
1304
  const cwd = process.cwd();
1222
1305
  const nonInteractive = ensureNonInteractive(opts);
1223
1306
  const productSlug = await pickProduct(opts, nonInteractive, cwd);
@@ -1247,7 +1330,7 @@ export async function runDeploy(opts) {
1247
1330
  // motivou o guard antigo (Core rejeitava sem --port); o Core agora ignora
1248
1331
  // porta em deploy VM, então exigi-la aqui só atrapalharia scripts/CI.
1249
1332
  // --port continua aceito (compat) mas é ignorado com WARN — ver abaixo.
1250
- if (target === 'vm' && opts.port) {
1333
+ if (target === 'vm' && opts.port && !opts.json) {
1251
1334
  log.warn('A porta é gerenciada pelo Core; --port é ignorado em deploy staging/prod.');
1252
1335
  }
1253
1336
  const { domain } = await pickDomain(opts, nonInteractive, productSlug);
@@ -1259,7 +1342,7 @@ export async function runDeploy(opts) {
1259
1342
  // EnvVars.
1260
1343
  let envVars;
1261
1344
  if (opts.envFile) {
1262
- envVars = await loadEnvFile(path.resolve(cwd, opts.envFile), target);
1345
+ envVars = await loadEnvFile(path.resolve(cwd, opts.envFile), target, opts.json);
1263
1346
  }
1264
1347
  // Confirmação interativa.
1265
1348
  if (!nonInteractive) {
@@ -1286,7 +1369,7 @@ export async function runDeploy(opts) {
1286
1369
  }
1287
1370
  const body = {
1288
1371
  productSlug,
1289
- version: resolveDeployVersion(artifact.manifest?.version, opts.version),
1372
+ version: resolveDeployVersion(artifact.manifest?.version, opts.version, opts.json),
1290
1373
  // bug_a5786e03: mapeia nextjs→node (Core/agente não conhecem `nextjs`).
1291
1374
  stack: toCoreStack(opts.stack ?? artifact.manifest?.stack) ?? 'node',
1292
1375
  target,
@@ -1323,16 +1406,48 @@ export async function runDeploy(opts) {
1323
1406
  }
1324
1407
  catch (err) {
1325
1408
  dispatchSpinner.fail('Falha ao criar deploy.');
1326
- handleApiError(err);
1409
+ handleApiError(err, opts.json);
1327
1410
  }
1328
- const final = await pollDeploymentStatus(deploy.deploymentId);
1411
+ const final = await pollDeploymentStatus(deploy.deploymentId, opts.json);
1329
1412
  if (final.status !== 'success') {
1330
- console.log('\n' + formatDeployConsole(final.steps));
1331
- log.error(final.lastError ?? `Deploy terminou com status ${final.status}`);
1413
+ const vmErr = final.lastError ?? `Deploy terminou com status ${final.status}`;
1414
+ if (opts.json) {
1415
+ console.log(JSON.stringify({
1416
+ ok: false,
1417
+ error: 'deploy_failed',
1418
+ message: vmErr,
1419
+ status: final.status,
1420
+ target: 'vm',
1421
+ deploymentId: deploy.deploymentId,
1422
+ serverId: final.serverId,
1423
+ steps: final.steps,
1424
+ }));
1425
+ }
1426
+ else {
1427
+ console.log('\n' + formatDeployConsole(final.steps));
1428
+ log.error(vmErr);
1429
+ }
1332
1430
  safeExit(1);
1333
1431
  }
1334
- if (final.domain)
1432
+ if (opts.json) {
1433
+ console.log(JSON.stringify({
1434
+ ok: true,
1435
+ status: 'success',
1436
+ target: 'vm',
1437
+ deploymentId: deploy.deploymentId,
1438
+ product: final.productSlug ?? productSlug,
1439
+ version: final.version,
1440
+ environmentId: envName,
1441
+ serverId: final.serverId,
1442
+ domain: final.domain,
1443
+ url: final.domain ? `https://${final.domain}` : null,
1444
+ artifactUrl: artifact.artifactUrl,
1445
+ steps: final.steps,
1446
+ }));
1447
+ }
1448
+ else if (final.domain) {
1335
1449
  log.success(`Live em https://${final.domain}`);
1450
+ }
1336
1451
  safeExit(0);
1337
1452
  }
1338
1453
  //# sourceMappingURL=deploy.js.map