@neetru/cli 2.27.0 → 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.
- package/CHANGELOG.md +45 -0
- package/dist/commands/build.d.ts +14 -0
- package/dist/commands/build.js +127 -62
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/db.js +108 -38
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.d.ts +1 -1
- package/dist/commands/deploy.js +217 -102
- package/dist/commands/deploy.js.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1017
|
-
safeExit(1);
|
|
1068
|
+
deployFail(opts.json, 'docker_build_failed', `docker build falhou (exit ${build.status}).`);
|
|
1018
1069
|
}
|
|
1019
|
-
|
|
1020
|
-
const push = spawnSync('docker', ['push', imageUri], { cwd: buildContext, stdio:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1088
|
+
const submit = spawnSync('gcloud', gcloudArgs, { cwd: buildContext, stdio: crStdio });
|
|
1039
1089
|
if (submit.error) {
|
|
1040
1090
|
const enoent = submit.error.code === 'ENOENT';
|
|
1041
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1149
|
-
log.
|
|
1150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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 (
|
|
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
|