@neetru/cli 1.0.1 → 2.1.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 +136 -0
- package/README.md +109 -152
- package/dist/commands/add.d.ts +8 -3
- package/dist/commands/add.js +70 -143
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/agent-release.d.ts +13 -0
- package/dist/commands/agent-release.js +204 -0
- package/dist/commands/agent-release.js.map +1 -0
- package/dist/commands/agent-write.d.ts +12 -0
- package/dist/commands/agent-write.js +94 -0
- package/dist/commands/agent-write.js.map +1 -0
- package/dist/commands/ai.d.ts +4 -0
- package/dist/commands/ai.js +88 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/api-catalog.d.ts +20 -0
- package/dist/commands/api-catalog.js +126 -0
- package/dist/commands/api-catalog.js.map +1 -0
- package/dist/commands/audit.d.ts +8 -0
- package/dist/commands/audit.js +69 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/autocomplete.d.ts +7 -0
- package/dist/commands/autocomplete.js +107 -0
- package/dist/commands/autocomplete.js.map +1 -0
- package/dist/commands/billing.d.ts +6 -0
- package/dist/commands/billing.js +69 -0
- package/dist/commands/billing.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.js +295 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/cloud-run.d.ts +11 -0
- package/dist/commands/cloud-run.js +87 -0
- package/dist/commands/cloud-run.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +70 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/db.d.ts +14 -0
- package/dist/commands/db.js +187 -0
- package/dist/commands/db.js.map +1 -0
- package/dist/commands/deploy.d.ts +23 -3
- package/dist/commands/deploy.js +530 -177
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/deployments.d.ts +11 -0
- package/dist/commands/deployments.js +69 -0
- package/dist/commands/deployments.js.map +1 -0
- package/dist/commands/doctor.d.ts +27 -0
- package/dist/commands/doctor.js +211 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/dr.d.ts +11 -0
- package/dist/commands/dr.js +79 -0
- package/dist/commands/dr.js.map +1 -0
- package/dist/commands/env.d.ts +15 -0
- package/dist/commands/env.js +56 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/fn.d.ts +6 -0
- package/dist/commands/fn.js +87 -0
- package/dist/commands/fn.js.map +1 -0
- package/dist/commands/infra-read.d.ts +9 -0
- package/dist/commands/infra-read.js +113 -0
- package/dist/commands/infra-read.js.map +1 -0
- package/dist/commands/init.d.ts +10 -3
- package/dist/commands/init.js +275 -142
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +6 -3
- package/dist/commands/login.js +222 -92
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +28 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/logs.d.ts +14 -3
- package/dist/commands/logs.js +132 -106
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/mocks.d.ts +5 -0
- package/dist/commands/mocks.js +23 -0
- package/dist/commands/mocks.js.map +1 -0
- package/dist/commands/open.d.ts +4 -3
- package/dist/commands/open.js +53 -85
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/products-db.d.ts +37 -0
- package/dist/commands/products-db.js +230 -0
- package/dist/commands/products-db.js.map +1 -0
- package/dist/commands/products.d.ts +12 -0
- package/dist/commands/products.js +97 -0
- package/dist/commands/products.js.map +1 -0
- package/dist/commands/promote.d.ts +9 -0
- package/dist/commands/promote.js +114 -0
- package/dist/commands/promote.js.map +1 -0
- package/dist/commands/publish.d.ts +14 -0
- package/dist/commands/publish.js +180 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/servers.d.ts +23 -0
- package/dist/commands/servers.js +166 -0
- package/dist/commands/servers.js.map +1 -0
- package/dist/commands/status.d.ts +5 -3
- package/dist/commands/status.js +91 -93
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/support.d.ts +25 -0
- package/dist/commands/support.js +184 -0
- package/dist/commands/support.js.map +1 -0
- package/dist/commands/surface-status.d.ts +5 -0
- package/dist/commands/surface-status.js +63 -0
- package/dist/commands/surface-status.js.map +1 -0
- package/dist/commands/tenants.d.ts +34 -0
- package/dist/commands/tenants.js +179 -0
- package/dist/commands/tenants.js.map +1 -0
- package/dist/commands/upgrade.d.ts +12 -0
- package/dist/commands/upgrade.js +77 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/validate.d.ts +1 -3
- package/dist/commands/validate.js +83 -91
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/whoami.d.ts +5 -3
- package/dist/commands/whoami.js +76 -28
- package/dist/commands/whoami.js.map +1 -1
- package/dist/commands/workspaces.d.ts +15 -0
- package/dist/commands/workspaces.js +72 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +1094 -36
- package/dist/index.js.map +1 -1
- package/dist/lib/ai/context.d.ts +11 -0
- package/dist/lib/ai/context.js +112 -0
- package/dist/lib/ai/context.js.map +1 -0
- package/dist/lib/ai/orchestrator.d.ts +10 -0
- package/dist/lib/ai/orchestrator.js +92 -0
- package/dist/lib/ai/orchestrator.js.map +1 -0
- package/dist/lib/api-client.d.ts +39 -0
- package/dist/lib/api-client.js +185 -0
- package/dist/lib/api-client.js.map +1 -0
- package/dist/lib/auth.d.ts +15 -0
- package/dist/lib/auth.js +98 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/cli-read.d.ts +13 -0
- package/dist/lib/cli-read.js +103 -0
- package/dist/lib/cli-read.js.map +1 -0
- package/dist/lib/cli-write.d.ts +47 -0
- package/dist/lib/cli-write.js +137 -0
- package/dist/lib/cli-write.js.map +1 -0
- package/dist/lib/config-schema.d.ts +165 -0
- package/dist/lib/config-schema.js +57 -0
- package/dist/lib/config-schema.js.map +1 -0
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +33 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/render.d.ts +16 -0
- package/dist/lib/render.js +74 -0
- package/dist/lib/render.js.map +1 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.js +27 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +35 -33
- package/templates/auth/callback.ts +22 -0
- package/templates/auth/sign-in.tsx +41 -0
- package/templates/billing/checkout.ts +22 -0
- package/templates/billing/page.tsx +43 -0
- package/templates/support/ticket-form.tsx +68 -0
- package/templates/usage/track.ts +30 -0
- package/templates/users/profile.tsx +43 -0
- package/LICENSE +0 -21
- package/dist/commands/add.d.ts.map +0 -1
- package/dist/commands/deploy.d.ts.map +0 -1
- package/dist/commands/generate-types.d.ts +0 -3
- package/dist/commands/generate-types.d.ts.map +0 -1
- package/dist/commands/generate-types.js +0 -150
- package/dist/commands/generate-types.js.map +0 -1
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/login.d.ts.map +0 -1
- package/dist/commands/logs.d.ts.map +0 -1
- package/dist/commands/open.d.ts.map +0 -1
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/whoami.d.ts.map +0 -1
- package/dist/config.d.ts +0 -14
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -83
- package/dist/config.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/scaffold/auth.d.ts +0 -3
- package/dist/scaffold/auth.d.ts.map +0 -1
- package/dist/scaffold/auth.js +0 -228
- package/dist/scaffold/auth.js.map +0 -1
- package/dist/scaffold/billing.d.ts +0 -3
- package/dist/scaffold/billing.d.ts.map +0 -1
- package/dist/scaffold/billing.js +0 -184
- package/dist/scaffold/billing.js.map +0 -1
- package/dist/scaffold/usage.d.ts +0 -3
- package/dist/scaffold/usage.d.ts.map +0 -1
- package/dist/scaffold/usage.js +0 -173
- package/dist/scaffold/usage.js.map +0 -1
- package/dist/scaffold/users.d.ts +0 -3
- package/dist/scaffold/users.d.ts.map +0 -1
- package/dist/scaffold/users.js +0 -135
- package/dist/scaffold/users.js.map +0 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -1,196 +1,549 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.deployCommand = deployCommand;
|
|
40
|
-
const commander_1 = require("commander");
|
|
41
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
42
|
-
const ora_1 = __importDefault(require("ora"));
|
|
43
|
-
const config_1 = require("../config");
|
|
44
|
-
async function prompt(questions) {
|
|
45
|
-
const { default: inquirer } = await Promise.resolve().then(() => __importStar(require('inquirer')));
|
|
46
|
-
return inquirer.prompt(questions);
|
|
1
|
+
/**
|
|
2
|
+
* `neetru deploy` — pipeline interativo de deploy do produto.
|
|
3
|
+
*
|
|
4
|
+
* Sprint 3 (P1-1): substitui o stub vazio. Fluxo:
|
|
5
|
+
* 1. Resolve produto (lista via /api/v1/cli/catalog).
|
|
6
|
+
* 2. Detecta stack (product_config + fallback local).
|
|
7
|
+
* 3. Escolhe target (cloud-run | vm-existente | provisionar-nova).
|
|
8
|
+
* 4. Para target=vm: lista servers online (/api/v1/cli/servers) +
|
|
9
|
+
* escolha capacity-aware.
|
|
10
|
+
* 5. Domínio + porta (auto-detect ou input).
|
|
11
|
+
* 6. Build (chama runBuild se artifact ausente).
|
|
12
|
+
* 7. Upload artifact (Sprint 1: usa file:// local — owner roda em VM com
|
|
13
|
+
* acesso ao mesmo FS, OU define `NEETRU_ARTIFACT_BUCKET` e o CLI faz
|
|
14
|
+
* upload via signed URL — Sprint 2.5).
|
|
15
|
+
* 8. POST /api/v1/cli/deploy → recebe deploymentId + statusUrl.
|
|
16
|
+
* 9. Polling /api/v1/cli/deploy/status até estado terminal.
|
|
17
|
+
*
|
|
18
|
+
* NÃO bloqueia em modo `--non-interactive` quando flags suficientes vieram.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import { promises as fsp } from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import inquirer from 'inquirer';
|
|
24
|
+
import chalk from 'chalk';
|
|
25
|
+
import ora from 'ora';
|
|
26
|
+
import { log } from '../utils/logger.js';
|
|
27
|
+
import { apiRequest, CliApiError, CliNetworkError } from '../lib/api-client.js';
|
|
28
|
+
import { runBuild } from './build.js';
|
|
29
|
+
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled']);
|
|
30
|
+
function fileExists(p) {
|
|
31
|
+
try {
|
|
32
|
+
fs.statSync(p);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
47
38
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
39
|
+
function isTty() {
|
|
40
|
+
return !!process.stdout.isTTY && !!process.stdin.isTTY;
|
|
41
|
+
}
|
|
42
|
+
function ensureNonInteractive(opts) {
|
|
43
|
+
return !!opts.nonInteractive || !isTty();
|
|
44
|
+
}
|
|
45
|
+
async function loadLocalConfig(cwd) {
|
|
46
|
+
for (const filename of ['neetru.config.json', '.neetru.json']) {
|
|
47
|
+
const full = path.join(cwd, filename);
|
|
48
|
+
if (!fileExists(full))
|
|
49
|
+
continue;
|
|
58
50
|
try {
|
|
59
|
-
|
|
51
|
+
return JSON.parse(await fsp.readFile(full, 'utf8'));
|
|
60
52
|
}
|
|
61
|
-
catch
|
|
62
|
-
|
|
63
|
-
process.exit(1);
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
64
55
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
async function loadEnvFile(envFile) {
|
|
60
|
+
const content = await fsp.readFile(envFile, 'utf8');
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const line of content.split(/\r?\n/)) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
65
|
+
continue;
|
|
66
|
+
const eq = trimmed.indexOf('=');
|
|
67
|
+
if (eq === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const k = trimmed.slice(0, eq).trim();
|
|
70
|
+
let v = trimmed.slice(eq + 1).trim();
|
|
71
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
72
|
+
v = v.slice(1, -1);
|
|
70
73
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
if (k)
|
|
75
|
+
out[k] = v;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function handleApiError(error) {
|
|
80
|
+
if (error instanceof CliApiError) {
|
|
81
|
+
if (error.status === 401) {
|
|
82
|
+
log.error('Token inválido ou expirado. Execute: neetru login');
|
|
83
|
+
process.exit(2);
|
|
75
84
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
if (error.status === 403) {
|
|
86
|
+
log.error(`Permissão negada: ${error.message}`);
|
|
87
|
+
process.exit(3);
|
|
88
|
+
}
|
|
89
|
+
if (error.status === 501) {
|
|
90
|
+
log.error(`Recurso não implementado: ${error.message}`);
|
|
91
|
+
process.exit(5);
|
|
92
|
+
}
|
|
93
|
+
log.error(`Erro do servidor (${error.status}): ${error.message}`);
|
|
94
|
+
process.exit(4);
|
|
95
|
+
}
|
|
96
|
+
if (error instanceof CliNetworkError) {
|
|
97
|
+
log.error(error.message);
|
|
98
|
+
process.exit(4);
|
|
99
|
+
}
|
|
100
|
+
log.error(error.message);
|
|
101
|
+
process.exit(4);
|
|
102
|
+
}
|
|
103
|
+
async function pickProduct(opts, nonInteractive, cwd) {
|
|
104
|
+
if (opts.product)
|
|
105
|
+
return opts.product;
|
|
106
|
+
const local = await loadLocalConfig(cwd);
|
|
107
|
+
if (local?.slug)
|
|
108
|
+
return local.slug;
|
|
109
|
+
if (nonInteractive) {
|
|
110
|
+
log.error('--product <slug> é obrigatório em modo não interativo.');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const spinner = ora({ text: 'Buscando catálogo…', color: 'blue' }).start();
|
|
114
|
+
let catalog;
|
|
115
|
+
try {
|
|
116
|
+
catalog = await apiRequest('/api/v1/cli/catalog');
|
|
117
|
+
spinner.stop();
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
spinner.fail('Não consegui buscar o catálogo.');
|
|
121
|
+
handleApiError(err);
|
|
122
|
+
}
|
|
123
|
+
if (catalog.products.length === 0) {
|
|
124
|
+
log.error('Nenhum produto público encontrado. Crie um via `neetru publish` antes.');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const { slug } = await inquirer.prompt([
|
|
128
|
+
{
|
|
129
|
+
type: 'list',
|
|
130
|
+
name: 'slug',
|
|
131
|
+
message: 'Produto a deployar:',
|
|
132
|
+
choices: catalog.products.map((p) => ({
|
|
133
|
+
name: `${p.name} ${chalk.dim('(' + p.slug + ')')} ${chalk.dim('· ' + p.status)}`,
|
|
134
|
+
value: p.slug,
|
|
135
|
+
})),
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
return slug;
|
|
139
|
+
}
|
|
140
|
+
async function pickTarget(opts, nonInteractive) {
|
|
141
|
+
if (opts.target)
|
|
142
|
+
return opts.target;
|
|
143
|
+
if (nonInteractive)
|
|
144
|
+
return 'vm';
|
|
145
|
+
// `cloud-run` é aceito via flag explícita `--target=cloud-run` (staff/forward-
|
|
146
|
+
// compat), mas NÃO aparece no menu interativo — runtime ainda não está pronto
|
|
147
|
+
// e não se anuncia feature incompleta pro dev.
|
|
148
|
+
const { target } = await inquirer.prompt([
|
|
149
|
+
{
|
|
150
|
+
type: 'list',
|
|
151
|
+
name: 'target',
|
|
152
|
+
message: 'Onde deployar?',
|
|
153
|
+
default: 'vm',
|
|
154
|
+
choices: [
|
|
155
|
+
{ name: 'VM existente (custo fixo, sem cold start)', value: 'vm' },
|
|
156
|
+
{ name: 'Workspace (runtime gerenciado per-tenant — Cloud Run privado)', value: 'workspace' },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
return target;
|
|
161
|
+
}
|
|
162
|
+
async function pickServer(opts, nonInteractive) {
|
|
163
|
+
const spinner = ora({ text: 'Buscando servidores online…', color: 'blue' }).start();
|
|
164
|
+
let resp;
|
|
165
|
+
try {
|
|
166
|
+
resp = await apiRequest('/api/cli/v1/servers?status=online&capacity=true');
|
|
167
|
+
spinner.stop();
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
spinner.fail('Não consegui listar servers.');
|
|
171
|
+
handleApiError(err);
|
|
172
|
+
}
|
|
173
|
+
if (resp.servers.length === 0) {
|
|
174
|
+
log.error('Nenhum servidor online encontrado.');
|
|
175
|
+
log.dim(' Provisione um com: POST /api/v1/cli/servers/provision');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
if (opts.server) {
|
|
179
|
+
const found = resp.servers.find((s) => s.id === opts.server || s.name === opts.server);
|
|
180
|
+
if (!found) {
|
|
181
|
+
log.error(`Server "${opts.server}" não encontrado entre os online.`);
|
|
182
|
+
process.exit(1);
|
|
88
183
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
if (nonInteractive) {
|
|
187
|
+
log.error('--server <id> é obrigatório em modo não interativo.');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const { serverId } = await inquirer.prompt([
|
|
191
|
+
{
|
|
192
|
+
type: 'list',
|
|
193
|
+
name: 'serverId',
|
|
194
|
+
message: 'Server alvo:',
|
|
195
|
+
choices: resp.servers.map((s) => {
|
|
196
|
+
const ramFree = s.availableRamMb !== null && s.availableRamMb !== undefined
|
|
197
|
+
? `${s.availableRamMb}MB livres`
|
|
198
|
+
: 'capacidade desconhecida';
|
|
199
|
+
return {
|
|
200
|
+
name: `${s.name ?? s.id} ${chalk.dim('(' + (s.provider ?? '?') + '/' + (s.region ?? '?') + ')')} · ${ramFree}`,
|
|
201
|
+
value: s.id,
|
|
202
|
+
};
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
return resp.servers.find((s) => s.id === serverId);
|
|
207
|
+
}
|
|
208
|
+
async function pickDomainPort(opts, nonInteractive, productSlug) {
|
|
209
|
+
let domain = opts.domain;
|
|
210
|
+
let port = opts.port;
|
|
211
|
+
if (nonInteractive)
|
|
212
|
+
return { domain, port };
|
|
213
|
+
if (!domain) {
|
|
214
|
+
const { d } = await inquirer.prompt([
|
|
215
|
+
{
|
|
216
|
+
type: 'input',
|
|
217
|
+
name: 'd',
|
|
218
|
+
message: `Domínio (vazio = sem vhost):`,
|
|
219
|
+
default: `${productSlug}.neetru.com`,
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
222
|
+
domain = d.trim() || undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!port) {
|
|
225
|
+
const { p } = await inquirer.prompt([
|
|
226
|
+
{
|
|
227
|
+
type: 'input',
|
|
228
|
+
name: 'p',
|
|
229
|
+
message: 'Porta interna do app:',
|
|
230
|
+
default: '3000',
|
|
231
|
+
validate: (v) => {
|
|
232
|
+
const n = Number.parseInt(v, 10);
|
|
233
|
+
if (Number.isNaN(n) || n < 1 || n > 65535)
|
|
234
|
+
return 'Porta inválida (1-65535).';
|
|
235
|
+
return true;
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
port = Number.parseInt(p, 10);
|
|
240
|
+
}
|
|
241
|
+
return { domain, port };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* F-11 (2026-05-16): determina se devemos manter o artifactUrl como `file://`
|
|
245
|
+
* em vez de fazer upload pra GCS. Default = false (upload é o caminho normal).
|
|
246
|
+
*
|
|
247
|
+
* Razões pra retornar true:
|
|
248
|
+
* - `--local-artifact` explícito (dev sabe o que está fazendo).
|
|
249
|
+
* - `NEETRU_DEV_LOCAL_ARTIFACT=true` no env.
|
|
250
|
+
* - `--artifact-url` já é `file://` (caller forçou — não fazemos upload de
|
|
251
|
+
* um path que não controlamos).
|
|
252
|
+
* - `--artifact-url` já é `gs://` ou `https://` (caller fez upload sozinho).
|
|
253
|
+
*/
|
|
254
|
+
function shouldKeepLocalArtifact(opts) {
|
|
255
|
+
if (opts.localArtifact)
|
|
256
|
+
return true;
|
|
257
|
+
if (process.env.NEETRU_DEV_LOCAL_ARTIFACT === 'true')
|
|
258
|
+
return true;
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* F-11: sobe o tarball local pra GCS via signed URL V4. 1 retry em caso de
|
|
263
|
+
* falha de rede; falha persistente aborta com mensagem clara.
|
|
264
|
+
*
|
|
265
|
+
* @returns `gs://...` canônico pra usar como artifactUrl no POST de deploy.
|
|
266
|
+
*/
|
|
267
|
+
async function uploadArtifactToGcs(tarballAbs, productSlug, envName, sizeBytes) {
|
|
268
|
+
const filename = path.basename(tarballAbs);
|
|
269
|
+
// Pede signed URL ao Core.
|
|
270
|
+
const signSpinner = ora({
|
|
271
|
+
text: `Solicitando signed URL pra ${filename}…`,
|
|
272
|
+
color: 'blue',
|
|
273
|
+
}).start();
|
|
274
|
+
let signed;
|
|
275
|
+
try {
|
|
276
|
+
signed = await apiRequest('/api/cli/v1/deploy/upload-url', {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body: {
|
|
279
|
+
productId: productSlug,
|
|
280
|
+
env: envName,
|
|
281
|
+
filename,
|
|
282
|
+
contentLength: sizeBytes,
|
|
283
|
+
contentType: 'application/gzip',
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
signSpinner.succeed(`Signed URL emitida (expira em ${signed.expiresAt}). deployId=${chalk.bold(signed.deployId)}`);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
signSpinner.fail('Falha ao obter signed URL.');
|
|
290
|
+
handleApiError(err);
|
|
291
|
+
}
|
|
292
|
+
// PUT direto pro GCS com 1 retry. global.fetch nativo (Node 18+) aceita
|
|
293
|
+
// ReadableStream e respeita Content-Length quando duplex='half'.
|
|
294
|
+
const attempt = async (attemptNumber) => {
|
|
295
|
+
const stream = fs.createReadStream(tarballAbs);
|
|
296
|
+
const uploadSpinner = ora({
|
|
297
|
+
text: attemptNumber === 1
|
|
298
|
+
? `Subindo ${(sizeBytes / 1024 / 1024).toFixed(2)} MB pra GCS…`
|
|
299
|
+
: `Retry upload (${attemptNumber}/2)…`,
|
|
300
|
+
color: 'blue',
|
|
301
|
+
}).start();
|
|
94
302
|
try {
|
|
95
|
-
const
|
|
96
|
-
method: '
|
|
303
|
+
const resp = await fetch(signed.uploadUrl, {
|
|
304
|
+
method: 'PUT',
|
|
305
|
+
body: stream,
|
|
97
306
|
headers: {
|
|
98
|
-
|
|
99
|
-
'Content-
|
|
307
|
+
'Content-Type': signed.contentType,
|
|
308
|
+
'Content-Length': String(sizeBytes),
|
|
100
309
|
},
|
|
101
|
-
body
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
branch: options.branch,
|
|
105
|
-
}),
|
|
106
|
-
signal: AbortSignal.timeout(15000),
|
|
310
|
+
// Node fetch exige duplex='half' quando o body é stream legível.
|
|
311
|
+
// O TS lib.dom não conhece o campo; cast tipado.
|
|
312
|
+
...{ duplex: 'half' },
|
|
107
313
|
});
|
|
108
|
-
if (!
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
process.exit(1);
|
|
314
|
+
if (!resp.ok) {
|
|
315
|
+
const text = await resp.text().catch(() => '');
|
|
316
|
+
throw new Error(`HTTP ${resp.status}${text ? ` — ${text.slice(0, 200)}` : ''}`);
|
|
112
317
|
}
|
|
113
|
-
|
|
114
|
-
|
|
318
|
+
uploadSpinner.succeed(`Upload concluído (${signed.gcsPath}).`);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
uploadSpinner.fail(`Upload falhou: ${err.message}`);
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
await attempt(1);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
try {
|
|
330
|
+
await attempt(2);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
log.error(`Upload do artifact falhou após 2 tentativas: ${err.message}. ` +
|
|
334
|
+
'Verifique conectividade com storage.googleapis.com ou rode com --local-artifact ' +
|
|
335
|
+
'se o agente está na mesma máquina.');
|
|
336
|
+
process.exit(4);
|
|
115
337
|
}
|
|
116
|
-
|
|
117
|
-
|
|
338
|
+
}
|
|
339
|
+
return { artifactUrl: signed.gcsPath, deployId: signed.deployId };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* F-11 (2026-05-16): resolve o artifact pra o POST de deploy.
|
|
343
|
+
*
|
|
344
|
+
* Cascata:
|
|
345
|
+
* 1. `--artifact-url` + `--artifact-sha256` → usa direto (caller fez upload).
|
|
346
|
+
* 2. `--artifact` (arquivo local) → calcula sha256 e parte pra (4).
|
|
347
|
+
* 3. Sem artifact → procura manifest, senão roda `neetru build`. Parte pra (4).
|
|
348
|
+
* 4. Default: faz upload pra GCS via signed URL e devolve `gs://…`.
|
|
349
|
+
* Override: `--local-artifact` ou `NEETRU_DEV_LOCAL_ARTIFACT=true` mantém
|
|
350
|
+
* `file://` (dev em mesma máquina do agent — modo legacy).
|
|
351
|
+
*
|
|
352
|
+
* BUG-008 fix continua válido no backend: `file://` é rejeitado pelo POST de
|
|
353
|
+
* deploy a não ser que o agent rode no mesmo FS. O caminho default agora é gs://.
|
|
354
|
+
*/
|
|
355
|
+
async function resolveArtifact(opts, productSlug, envName, cwd) {
|
|
356
|
+
// Caso 1: caller já fez upload e passou URL+sha pronta.
|
|
357
|
+
if (opts.artifactUrl && opts.artifactSha256) {
|
|
358
|
+
return { artifactUrl: opts.artifactUrl, artifactSha256: opts.artifactSha256 };
|
|
359
|
+
}
|
|
360
|
+
// Decide se vamos fazer upload pra GCS ou manter file://.
|
|
361
|
+
const keepLocal = shouldKeepLocalArtifact(opts);
|
|
362
|
+
// Caso 2: --artifact <file> local.
|
|
363
|
+
if (opts.artifact) {
|
|
364
|
+
const abs = path.resolve(cwd, opts.artifact);
|
|
365
|
+
if (!fileExists(abs)) {
|
|
366
|
+
log.error(`Artifact não encontrado: ${abs}`);
|
|
118
367
|
process.exit(1);
|
|
119
368
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
console.log('\n' + chalk_1.default.red.bold(`✗ Deploy falhou (${status.status})`));
|
|
171
|
-
if (status.error)
|
|
172
|
-
console.log(chalk_1.default.dim(` ${status.error}`));
|
|
173
|
-
}
|
|
174
|
-
console.log('');
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
369
|
+
const { createHash } = await import('node:crypto');
|
|
370
|
+
const hash = createHash('sha256');
|
|
371
|
+
const stream = fs.createReadStream(abs);
|
|
372
|
+
for await (const chunk of stream)
|
|
373
|
+
hash.update(chunk);
|
|
374
|
+
const stat = await fsp.stat(abs);
|
|
375
|
+
const sha256 = hash.digest('hex');
|
|
376
|
+
if (keepLocal) {
|
|
377
|
+
log.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
|
|
378
|
+
return {
|
|
379
|
+
artifactUrl: `file://${abs.replace(/\\/g, '/')}`,
|
|
380
|
+
artifactSha256: sha256,
|
|
381
|
+
artifactSizeBytes: stat.size,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const uploaded = await uploadArtifactToGcs(abs, productSlug, envName, stat.size);
|
|
385
|
+
return {
|
|
386
|
+
artifactUrl: uploaded.artifactUrl,
|
|
387
|
+
artifactSha256: sha256,
|
|
388
|
+
artifactSizeBytes: stat.size,
|
|
389
|
+
deployId: uploaded.deployId,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// Caso 3: procura manifest existente, senão chama runBuild.
|
|
393
|
+
const manifestPath = path.join(cwd, '.neetru-build', 'manifest.json');
|
|
394
|
+
let manifest = null;
|
|
395
|
+
if (fileExists(manifestPath)) {
|
|
396
|
+
try {
|
|
397
|
+
manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
|
|
398
|
+
if (manifest.slug !== productSlug)
|
|
399
|
+
manifest = null;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
manifest = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!manifest) {
|
|
406
|
+
log.info('Nenhum artifact encontrado. Rodando `neetru build`…');
|
|
407
|
+
manifest = await runBuild({ product: productSlug, version: opts.version });
|
|
408
|
+
}
|
|
409
|
+
const tarAbs = path.resolve(cwd, manifest.tarball);
|
|
410
|
+
if (keepLocal) {
|
|
411
|
+
log.warn('Modo --local-artifact: mandando file:// pro Core. Só funciona se o agent compartilha FS com este CLI.');
|
|
412
|
+
return {
|
|
413
|
+
artifactUrl: `file://${tarAbs.replace(/\\/g, '/')}`,
|
|
414
|
+
artifactSha256: manifest.sha256,
|
|
415
|
+
artifactSizeBytes: manifest.sizeBytes,
|
|
416
|
+
manifest,
|
|
182
417
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
418
|
+
}
|
|
419
|
+
const uploaded = await uploadArtifactToGcs(tarAbs, productSlug, envName, manifest.sizeBytes);
|
|
420
|
+
return {
|
|
421
|
+
artifactUrl: uploaded.artifactUrl,
|
|
422
|
+
artifactSha256: manifest.sha256,
|
|
423
|
+
artifactSizeBytes: manifest.sizeBytes,
|
|
424
|
+
manifest,
|
|
425
|
+
deployId: uploaded.deployId,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function pollDeploymentStatus(deploymentId) {
|
|
429
|
+
const start = Date.now();
|
|
430
|
+
const timeoutMs = 20 * 60 * 1000; // 20min
|
|
431
|
+
let lastStatus = '';
|
|
432
|
+
const spinner = ora({ text: 'Aguardando agente…', color: 'blue' }).start();
|
|
433
|
+
while (Date.now() - start < timeoutMs) {
|
|
434
|
+
let resp;
|
|
435
|
+
try {
|
|
436
|
+
resp = await apiRequest(`/api/v1/cli/deploy/status?id=${encodeURIComponent(deploymentId)}`);
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
// transient — segue tentando.
|
|
440
|
+
if (err instanceof CliNetworkError) {
|
|
441
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
442
|
+
continue;
|
|
191
443
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
444
|
+
spinner.fail('Falha ao consultar status.');
|
|
445
|
+
handleApiError(err);
|
|
446
|
+
}
|
|
447
|
+
if (resp.status !== lastStatus) {
|
|
448
|
+
spinner.text = `Status: ${chalk.bold(resp.status)} · ${resp.steps
|
|
449
|
+
.map((s) => `${s.name}=${s.status}`)
|
|
450
|
+
.join(', ')}`;
|
|
451
|
+
lastStatus = resp.status;
|
|
452
|
+
}
|
|
453
|
+
if (TERMINAL_STATUSES.has(resp.status)) {
|
|
454
|
+
if (resp.status === 'success')
|
|
455
|
+
spinner.succeed(`Deploy ${chalk.bold('success')}.`);
|
|
456
|
+
else
|
|
457
|
+
spinner.fail(`Deploy ${chalk.bold(resp.status)}.`);
|
|
458
|
+
return resp;
|
|
459
|
+
}
|
|
460
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
461
|
+
}
|
|
462
|
+
spinner.fail('Timeout aguardando deploy.');
|
|
463
|
+
throw new Error('deploy_timeout');
|
|
464
|
+
}
|
|
465
|
+
export async function runDeploy(opts) {
|
|
466
|
+
log.banner();
|
|
467
|
+
const cwd = process.cwd();
|
|
468
|
+
const nonInteractive = ensureNonInteractive(opts);
|
|
469
|
+
const productSlug = await pickProduct(opts, nonInteractive, cwd);
|
|
470
|
+
const target = await pickTarget(opts, nonInteractive);
|
|
471
|
+
let serverChoice = null;
|
|
472
|
+
if (target === 'vm') {
|
|
473
|
+
serverChoice = await pickServer(opts, nonInteractive);
|
|
474
|
+
}
|
|
475
|
+
// target='workspace' não exige server — backend resolve runtime per-tenant.
|
|
476
|
+
const { domain, port } = await pickDomainPort(opts, nonInteractive, productSlug);
|
|
477
|
+
// F-11: env name pra GCS path (`gs://neetru-deploy-artifacts/<product>/<env>/…`).
|
|
478
|
+
// Default 'dev' quando não definido — alinhado com o middleware OAuth.
|
|
479
|
+
const envName = (opts.env ?? 'dev').toLowerCase();
|
|
480
|
+
// Build / artifact (default: faz upload pra GCS via signed URL).
|
|
481
|
+
const artifact = await resolveArtifact(opts, productSlug, envName, cwd);
|
|
482
|
+
// EnvVars.
|
|
483
|
+
let envVars;
|
|
484
|
+
if (opts.envFile) {
|
|
485
|
+
envVars = await loadEnvFile(path.resolve(cwd, opts.envFile));
|
|
486
|
+
}
|
|
487
|
+
// Confirmação interativa.
|
|
488
|
+
if (!nonInteractive) {
|
|
489
|
+
log.heading('Resumo');
|
|
490
|
+
log.dim(` produto: ${productSlug}`);
|
|
491
|
+
log.dim(` versão: ${artifact.manifest?.version ?? opts.version ?? '(detectada no build)'}`);
|
|
492
|
+
log.dim(` stack: ${opts.stack ?? artifact.manifest?.stack ?? '(detectada no build)'}`);
|
|
493
|
+
log.dim(` target: ${target}`);
|
|
494
|
+
if (serverChoice)
|
|
495
|
+
log.dim(` server: ${serverChoice.name ?? serverChoice.id}`);
|
|
496
|
+
if (domain)
|
|
497
|
+
log.dim(` domain: ${domain}`);
|
|
498
|
+
if (port)
|
|
499
|
+
log.dim(` port: ${port}`);
|
|
500
|
+
log.dim(` artifact: ${artifact.artifactUrl}`);
|
|
501
|
+
log.dim(` sha256: ${artifact.artifactSha256.slice(0, 16)}…`);
|
|
502
|
+
const { ok } = await inquirer.prompt([
|
|
503
|
+
{ type: 'confirm', name: 'ok', message: 'Confirmar deploy?', default: true },
|
|
504
|
+
]);
|
|
505
|
+
if (!ok) {
|
|
506
|
+
log.warn('Deploy cancelado.');
|
|
507
|
+
process.exit(0);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const body = {
|
|
511
|
+
productSlug,
|
|
512
|
+
version: artifact.manifest?.version ?? opts.version ?? '0.0.0',
|
|
513
|
+
stack: opts.stack ?? artifact.manifest?.stack ?? 'node',
|
|
514
|
+
target,
|
|
515
|
+
artifactUrl: artifact.artifactUrl,
|
|
516
|
+
artifactSha256: artifact.artifactSha256,
|
|
517
|
+
artifactSizeBytes: artifact.artifactSizeBytes,
|
|
518
|
+
};
|
|
519
|
+
if (target === 'vm') {
|
|
520
|
+
body.serverId = serverChoice.id;
|
|
521
|
+
body.port = port;
|
|
522
|
+
}
|
|
523
|
+
if (domain)
|
|
524
|
+
body.domain = domain;
|
|
525
|
+
if (envVars && Object.keys(envVars).length > 0)
|
|
526
|
+
body.envVars = envVars;
|
|
527
|
+
let deploy;
|
|
528
|
+
const dispatchSpinner = ora({ text: 'Enviando comando ao Core…', color: 'blue' }).start();
|
|
529
|
+
try {
|
|
530
|
+
deploy = await apiRequest('/api/v1/cli/deploy', {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
body,
|
|
533
|
+
});
|
|
534
|
+
dispatchSpinner.succeed(`Deploy criado: ${chalk.bold(deploy.deploymentId)}`);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
dispatchSpinner.fail('Falha ao criar deploy.');
|
|
538
|
+
handleApiError(err);
|
|
539
|
+
}
|
|
540
|
+
const final = await pollDeploymentStatus(deploy.deploymentId);
|
|
541
|
+
if (final.status !== 'success') {
|
|
542
|
+
log.error(final.lastError ?? `Deploy terminou com status ${final.status}`);
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
if (final.domain)
|
|
546
|
+
log.success(`Live em https://${final.domain}`);
|
|
547
|
+
process.exit(0);
|
|
195
548
|
}
|
|
196
549
|
//# sourceMappingURL=deploy.js.map
|