@neetru/cli 2.33.0 → 2.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +38 -3
- package/dist/commands/admin/sdk-credential.js +3 -1
- package/dist/commands/ai-models.d.ts +2 -1
- package/dist/commands/ai-models.js +4 -5
- package/dist/commands/ai.d.ts +12 -2
- package/dist/commands/ai.js +47 -38
- package/dist/commands/auth.d.ts +3 -2
- package/dist/commands/auth.js +96 -57
- package/dist/commands/build.js +35 -4
- package/dist/commands/chat.d.ts +24 -6
- package/dist/commands/chat.js +43 -7
- package/dist/commands/code.js +11 -2
- package/dist/commands/config.js +15 -13
- package/dist/commands/db-frontdoor.js +10 -5
- package/dist/commands/db.d.ts +8 -2
- package/dist/commands/db.js +13 -6
- package/dist/commands/deploy.d.ts +14 -5
- package/dist/commands/deploy.js +376 -55
- package/dist/commands/dev-context.js +1 -1
- package/dist/commands/environments-repoint.d.ts +49 -0
- package/dist/commands/environments-repoint.js +219 -0
- package/dist/commands/init.js +7 -1
- package/dist/commands/new.js +14 -0
- package/dist/commands/secrets.d.ts +6 -4
- package/dist/commands/secrets.js +14 -7
- package/dist/commands/ui.js +1 -1
- package/dist/commands/workspaces.js +16 -1
- package/dist/index.js +47 -7
- package/dist/lib/ai/auth-discovery.d.ts +9 -12
- package/dist/lib/ai/auth-discovery.js +43 -145
- package/dist/lib/ai/orchestrator.d.ts +13 -6
- package/dist/lib/ai/orchestrator.js +46 -246
- package/dist/lib/ai/provedores.d.ts +57 -0
- package/dist/lib/ai/provedores.js +66 -0
- package/dist/lib/build-targets.d.ts +48 -10
- package/dist/lib/build-targets.js +64 -10
- package/dist/lib/cli-write.js +6 -3
- package/dist/lib/code-agent-gateway.d.ts +2 -2
- package/dist/lib/code-agent-gateway.js +2 -2
- package/dist/lib/code-agent-settings.d.ts +2 -2
- package/dist/lib/code-agent-settings.js +2 -4
- package/dist/lib/code-agent-types.d.ts +3 -1
- package/dist/lib/config.d.ts +4 -7
- package/dist/lib/config.js +9 -6
- package/dist/lib/deploy-recusa-de-destino.d.ts +9 -0
- package/dist/lib/deploy-recusa-de-destino.js +98 -0
- package/dist/lib/deploy-render-probe.d.ts +117 -0
- package/dist/lib/deploy-render-probe.js +475 -0
- package/dist/lib/deploy-vm-um-app.d.ts +62 -0
- package/dist/lib/deploy-vm-um-app.js +83 -0
- package/dist/lib/segredo-produto.d.ts +25 -5
- package/dist/lib/segredo-produto.js +45 -10
- package/dist/lib/sse.d.ts +4 -5
- package/dist/lib/sse.js +4 -5
- package/package.json +1 -2
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy-render-probe.ts — a página inicial do produto RENDERIZA no navegador?
|
|
3
|
+
* (bug_0d2e0087, lado da CLI).
|
|
4
|
+
*
|
|
5
|
+
* DEFEITO (caso Zelo, 22/10): `neetru deploy --target vm` imprimia "Live em
|
|
6
|
+
* https://zelo.neetru.com" com `health.verified:true`, e a página ficava EM
|
|
7
|
+
* BRANCO para todo visitante. O `next.config` do produto mandava
|
|
8
|
+
* `Content-Security-Policy: ... script-src 'self'` (sem `'unsafe-inline'`, sem
|
|
9
|
+
* nonce). O App Router do Next põe o payload RSC em `<script>` embutidos
|
|
10
|
+
* (`self.__next_f.push(...)`); o navegador bloqueia todos, a hidratação falha
|
|
11
|
+
* na raiz e o React 19 desmonta a árvore. A CLI não tinha como ver:
|
|
12
|
+
* - `GET /api/cli/v1/deploy/status` devolve o status do DEPLOYMENT (`success`),
|
|
13
|
+
* não o do ambiente;
|
|
14
|
+
* - a sonda de saúde da CLI (`probeDeployedHealth`) lê só o JSON de
|
|
15
|
+
* `/api/health`, que respondia 200 saudável.
|
|
16
|
+
*
|
|
17
|
+
* O QUE ESTE MÓDULO FAZ: depois que a saúde PASSA, a CLI abre a página inicial
|
|
18
|
+
* (`https://<domínio>/`), lê as políticas CSP que valem (cabeçalho
|
|
19
|
+
* `Content-Security-Policy`, o mesmo que o navegador recebe, e
|
|
20
|
+
* `<meta http-equiv>`) e confere, script embutido por script embutido, se a
|
|
21
|
+
* política deixa ele rodar (nonce, hash ou `'unsafe-inline'` — este último
|
|
22
|
+
* ignorado quando há nonce/hash/`'strict-dynamic'`, como no navegador). Algum
|
|
23
|
+
* bloqueado → `inline_scripts_blocked`, e o deploy NÃO sai como "Live".
|
|
24
|
+
*
|
|
25
|
+
* SÓ ACUSA O QUE MEDIU: página que não é HTML 2xx, rede caída, redirect pra
|
|
26
|
+
* outra origem → `unverifiable`, e o veredito da saúde fica como estava.
|
|
27
|
+
* `Content-Security-Policy-Report-Only` não bloqueia nada no navegador, então
|
|
28
|
+
* não conta.
|
|
29
|
+
*
|
|
30
|
+
* ESPELHO: a regra é a mesma de `functions/src/lib/deploy-render-probe.ts` do
|
|
31
|
+
* Core (core#793), que roda no portão de saúde das functions — mas ele roda
|
|
32
|
+
* DEPOIS do `success` do deployment, num gatilho assíncrono, e grava o
|
|
33
|
+
* resultado no ambiente, que a rota que a CLI consulta não devolve. Por isso a
|
|
34
|
+
* CLI confere por conta própria: não depende de deploy do Core nem das
|
|
35
|
+
* functions. Divergiu do Core? Corrija nos dois.
|
|
36
|
+
*
|
|
37
|
+
* Classificação e análise são PURAS; o `fetch` entra injetado em
|
|
38
|
+
* `runRenderProbe` (o deploy passa o `fetch` global).
|
|
39
|
+
*/
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
/** Teto de bytes lidos do HTML (os scripts do Next aparecem já no começo do body). */
|
|
42
|
+
export const RENDER_BODY_MAX_BYTES = 512 * 1024;
|
|
43
|
+
/** Redirects seguidos dentro da MESMA origem (ex.: `/` → `/pt-BR`). */
|
|
44
|
+
export const RENDER_MAX_REDIRECTS = 2;
|
|
45
|
+
/** Teto do texto da diretiva guardada no resultado. */
|
|
46
|
+
const DIRECTIVE_MAX_CHARS = 200;
|
|
47
|
+
/**
|
|
48
|
+
* Lê UMA política serializada (`a b; c d`). Diretiva repetida: vale a primeira
|
|
49
|
+
* (CSP3 §2.2.1). Nome de diretiva inválido é ignorado. PURA.
|
|
50
|
+
*/
|
|
51
|
+
export function parseCspPolicy(text) {
|
|
52
|
+
const directives = new Map();
|
|
53
|
+
for (const part of text.split(';')) {
|
|
54
|
+
const tokens = part.trim().split(/[\t\n\f\r ]+/).filter(Boolean);
|
|
55
|
+
if (tokens.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
const name = tokens[0].toLowerCase();
|
|
58
|
+
if (!/^[a-z0-9-]+$/.test(name))
|
|
59
|
+
continue;
|
|
60
|
+
if (directives.has(name))
|
|
61
|
+
continue;
|
|
62
|
+
directives.set(name, tokens.slice(1));
|
|
63
|
+
}
|
|
64
|
+
return { directives };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Lê o valor de um cabeçalho `Content-Security-Policy`: várias políticas vêm
|
|
68
|
+
* separadas por vírgula (cabeçalhos repetidos também chegam assim, juntados
|
|
69
|
+
* pelo fetch). Política vazia é descartada. PURA.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCspHeader(value) {
|
|
72
|
+
return value
|
|
73
|
+
.split(',')
|
|
74
|
+
.map((p) => parseCspPolicy(p))
|
|
75
|
+
.filter((p) => p.directives.size > 0);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Lista de fontes que governa um `<script>` embutido: `script-src-elem`, senão
|
|
79
|
+
* `script-src`, senão `default-src`. `undefined` = a política não restringe
|
|
80
|
+
* script. PURA.
|
|
81
|
+
*/
|
|
82
|
+
export function scriptElementSources(policy) {
|
|
83
|
+
for (const name of ['script-src-elem', 'script-src', 'default-src']) {
|
|
84
|
+
const sources = policy.directives.get(name);
|
|
85
|
+
if (sources)
|
|
86
|
+
return { name, sources };
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Normaliza base64/base64url pra comparar hash (`-`→`+`, `_`→`/`, sem `=`). */
|
|
91
|
+
function normB64(v) {
|
|
92
|
+
return v.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Este `<script>` embutido roda sob esta lista de fontes? Regras do navegador
|
|
96
|
+
* (CSP3 "Does element match source list for type and source"):
|
|
97
|
+
* - nonce do elemento igual a um `'nonce-…'` da lista → roda;
|
|
98
|
+
* - hash do conteúdo igual a um `'sha256|384|512-…'` → roda;
|
|
99
|
+
* - `'unsafe-inline'` → roda, MAS só se a lista não tem nonce, hash nem
|
|
100
|
+
* `'strict-dynamic'` (com qualquer um deles o navegador ignora o unsafe-inline);
|
|
101
|
+
* - o resto (`'self'`, hosts, `'none'`, lista vazia) NÃO libera script embutido.
|
|
102
|
+
* PURA.
|
|
103
|
+
*/
|
|
104
|
+
export function inlineScriptAllowed(script, sources) {
|
|
105
|
+
let hasNonceOrHash = false;
|
|
106
|
+
let unsafeInline = false;
|
|
107
|
+
let strictDynamic = false;
|
|
108
|
+
for (const raw of sources) {
|
|
109
|
+
const lower = raw.toLowerCase();
|
|
110
|
+
if (lower === "'unsafe-inline'") {
|
|
111
|
+
unsafeInline = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (lower === "'strict-dynamic'") {
|
|
115
|
+
strictDynamic = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const nonce = /^'nonce-([^']+)'$/i.exec(raw);
|
|
119
|
+
if (nonce) {
|
|
120
|
+
hasNonceOrHash = true;
|
|
121
|
+
if (script.nonce !== null && script.nonce === nonce[1])
|
|
122
|
+
return true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const hash = /^'(sha256|sha384|sha512)-([^']+)'$/i.exec(raw);
|
|
126
|
+
if (hash) {
|
|
127
|
+
hasNonceOrHash = true;
|
|
128
|
+
const digest = createHash(hash[1].toLowerCase()).update(script.content, 'utf8').digest('base64');
|
|
129
|
+
if (normB64(digest) === normB64(hash[2]))
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return unsafeInline && !hasNonceOrHash && !strictDynamic;
|
|
134
|
+
}
|
|
135
|
+
// ─── HTML ────────────────────────────────────────────────────────────────────
|
|
136
|
+
function decodeEntities(v) {
|
|
137
|
+
return v.replace(/&(#x[0-9a-f]+|#\d+|quot|apos|amp|lt|gt);/gi, (m, code) => {
|
|
138
|
+
const c = code.toLowerCase();
|
|
139
|
+
if (c === 'quot')
|
|
140
|
+
return '"';
|
|
141
|
+
if (c === 'apos')
|
|
142
|
+
return "'";
|
|
143
|
+
if (c === 'amp')
|
|
144
|
+
return '&';
|
|
145
|
+
if (c === 'lt')
|
|
146
|
+
return '<';
|
|
147
|
+
if (c === 'gt')
|
|
148
|
+
return '>';
|
|
149
|
+
const n = c.startsWith('#x') ? parseInt(c.slice(2), 16) : parseInt(c.slice(1), 10);
|
|
150
|
+
return Number.isFinite(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** Atributos de uma tag (nome minúsculo → valor decodificado; sem valor → ''). */
|
|
154
|
+
function parseAttributes(attrText) {
|
|
155
|
+
const attrs = new Map();
|
|
156
|
+
const re = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
157
|
+
let m;
|
|
158
|
+
while ((m = re.exec(attrText)) !== null) {
|
|
159
|
+
const name = m[1].toLowerCase();
|
|
160
|
+
if (attrs.has(name))
|
|
161
|
+
continue;
|
|
162
|
+
attrs.set(name, decodeEntities(m[2] ?? m[3] ?? m[4] ?? ''));
|
|
163
|
+
}
|
|
164
|
+
return attrs;
|
|
165
|
+
}
|
|
166
|
+
/** MIME de JavaScript (HTML "JavaScript MIME type essence match"). */
|
|
167
|
+
const JS_MIME_ESSENCES = new Set([
|
|
168
|
+
'application/ecmascript',
|
|
169
|
+
'application/javascript',
|
|
170
|
+
'application/x-ecmascript',
|
|
171
|
+
'application/x-javascript',
|
|
172
|
+
'text/ecmascript',
|
|
173
|
+
'text/javascript',
|
|
174
|
+
'text/javascript1.0',
|
|
175
|
+
'text/javascript1.1',
|
|
176
|
+
'text/javascript1.2',
|
|
177
|
+
'text/javascript1.3',
|
|
178
|
+
'text/javascript1.4',
|
|
179
|
+
'text/javascript1.5',
|
|
180
|
+
'text/jscript',
|
|
181
|
+
'text/livescript',
|
|
182
|
+
'text/x-ecmascript',
|
|
183
|
+
'text/x-javascript',
|
|
184
|
+
]);
|
|
185
|
+
/**
|
|
186
|
+
* O `type` deste `<script>` é executado (e por isso passa pela CSP)? Sem type,
|
|
187
|
+
* MIME de JS, `module` ou `importmap` → sim. `application/json`,
|
|
188
|
+
* `application/ld+json` e afins são blocos de dados: o navegador não executa
|
|
189
|
+
* nem confere na CSP. PURA.
|
|
190
|
+
*/
|
|
191
|
+
function isExecutableScriptType(type) {
|
|
192
|
+
if (type === undefined)
|
|
193
|
+
return true;
|
|
194
|
+
const t = type.trim().toLowerCase();
|
|
195
|
+
if (t === '' || t === 'module' || t === 'importmap')
|
|
196
|
+
return true;
|
|
197
|
+
return JS_MIME_ESSENCES.has(t.split(';')[0].trim());
|
|
198
|
+
}
|
|
199
|
+
/** Scripts embutidos executáveis do HTML (sem `src`, conteúdo não vazio). PURA. */
|
|
200
|
+
export function extractInlineScripts(html) {
|
|
201
|
+
const noComments = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
202
|
+
const out = [];
|
|
203
|
+
const re = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
204
|
+
let m;
|
|
205
|
+
while ((m = re.exec(noComments)) !== null) {
|
|
206
|
+
const attrs = parseAttributes(m[1]);
|
|
207
|
+
if (attrs.has('src'))
|
|
208
|
+
continue;
|
|
209
|
+
if (!isExecutableScriptType(attrs.get('type')))
|
|
210
|
+
continue;
|
|
211
|
+
const content = m[2];
|
|
212
|
+
if (content.trim() === '')
|
|
213
|
+
continue;
|
|
214
|
+
out.push({ nonce: attrs.has('nonce') ? attrs.get('nonce') : null, content });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
/** Políticas de `<meta http-equiv="Content-Security-Policy" content="…">`. PURA. */
|
|
219
|
+
export function extractMetaCspPolicies(html) {
|
|
220
|
+
const noComments = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
221
|
+
const out = [];
|
|
222
|
+
const re = /<meta\b([^>]*)>/gi;
|
|
223
|
+
let m;
|
|
224
|
+
while ((m = re.exec(noComments)) !== null) {
|
|
225
|
+
const attrs = parseAttributes(m[1]);
|
|
226
|
+
if ((attrs.get('http-equiv') ?? '').trim().toLowerCase() !== 'content-security-policy')
|
|
227
|
+
continue;
|
|
228
|
+
const content = attrs.get('content');
|
|
229
|
+
if (!content)
|
|
230
|
+
continue;
|
|
231
|
+
// Meta traz UMA política (CSP3 §3.3): vírgula não separa políticas aqui.
|
|
232
|
+
const policy = parseCspPolicy(content);
|
|
233
|
+
if (policy.directives.size > 0)
|
|
234
|
+
out.push(policy);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Quantos scripts embutidos a CSP efetiva bloqueia. Um script só roda se TODAS
|
|
240
|
+
* as políticas em vigor (cabeçalhos + meta) deixarem. PURA.
|
|
241
|
+
*/
|
|
242
|
+
export function analyzeInlineScriptCsp(input) {
|
|
243
|
+
const policies = [
|
|
244
|
+
...input.cspHeaderValues.flatMap((v) => parseCspHeader(v)),
|
|
245
|
+
...extractMetaCspPolicies(input.html),
|
|
246
|
+
];
|
|
247
|
+
const scripts = extractInlineScripts(input.html);
|
|
248
|
+
let blocked = 0;
|
|
249
|
+
let blockingDirective = null;
|
|
250
|
+
for (const script of scripts) {
|
|
251
|
+
for (const policy of policies) {
|
|
252
|
+
const governing = scriptElementSources(policy);
|
|
253
|
+
if (!governing)
|
|
254
|
+
continue;
|
|
255
|
+
if (inlineScriptAllowed(script, governing.sources))
|
|
256
|
+
continue;
|
|
257
|
+
blocked += 1;
|
|
258
|
+
if (blockingDirective === null) {
|
|
259
|
+
const text = [governing.name, ...governing.sources].join(' ');
|
|
260
|
+
blockingDirective =
|
|
261
|
+
text.length > DIRECTIVE_MAX_CHARS ? `${text.slice(0, DIRECTIVE_MAX_CHARS)}…` : text;
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { inlineScripts: scripts.length, blockedInlineScripts: blocked, blockingDirective };
|
|
267
|
+
}
|
|
268
|
+
function looksLikeHtml(contentType, body) {
|
|
269
|
+
if (contentType) {
|
|
270
|
+
const essence = contentType.split(';')[0].trim().toLowerCase();
|
|
271
|
+
return essence === 'text/html' || essence === 'application/xhtml+xml';
|
|
272
|
+
}
|
|
273
|
+
return /^\s*(<!doctype html|<html[\s>])/i.test(body);
|
|
274
|
+
}
|
|
275
|
+
function unverifiable(url, httpStatus, reason) {
|
|
276
|
+
return {
|
|
277
|
+
verdict: 'unverifiable',
|
|
278
|
+
url,
|
|
279
|
+
httpStatus,
|
|
280
|
+
inlineScripts: 0,
|
|
281
|
+
blockedInlineScripts: 0,
|
|
282
|
+
blockingDirective: null,
|
|
283
|
+
reason,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Classifica a resposta da página inicial. PURA.
|
|
288
|
+
* - rede/não-2xx/não-HTML → `unverifiable` (não é prova de nada);
|
|
289
|
+
* - HTML com script embutido que a CSP bloqueia → `inline_scripts_blocked`;
|
|
290
|
+
* - o resto (sem CSP, CSP que libera, sem script embutido) → `ok`.
|
|
291
|
+
*/
|
|
292
|
+
export function classifyRenderProbe(url, input) {
|
|
293
|
+
if (input.kind === 'network_error')
|
|
294
|
+
return unverifiable(url, null, input.error);
|
|
295
|
+
const { httpStatus } = input;
|
|
296
|
+
if (httpStatus < 200 || httpStatus >= 300)
|
|
297
|
+
return unverifiable(url, httpStatus, `HTTP ${httpStatus}`);
|
|
298
|
+
if (!looksLikeHtml(input.contentType, input.html)) {
|
|
299
|
+
return unverifiable(url, httpStatus, `não é HTML (${input.contentType ?? 'sem content-type'})`);
|
|
300
|
+
}
|
|
301
|
+
const analysis = analyzeInlineScriptCsp({ cspHeaderValues: input.cspHeaderValues, html: input.html });
|
|
302
|
+
return {
|
|
303
|
+
verdict: analysis.blockedInlineScripts > 0 ? 'inline_scripts_blocked' : 'ok',
|
|
304
|
+
url,
|
|
305
|
+
httpStatus,
|
|
306
|
+
inlineScripts: analysis.inlineScripts,
|
|
307
|
+
blockedInlineScripts: analysis.blockedInlineScripts,
|
|
308
|
+
blockingDirective: analysis.blockingDirective,
|
|
309
|
+
reason: null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// ─── I/O (injetado) ──────────────────────────────────────────────────────────
|
|
313
|
+
/**
|
|
314
|
+
* Lê o corpo até `maxBytes` (cancela o stream acima disso). Mesmo leitor do
|
|
315
|
+
* Core (`readBodyTextCapped`). Lança se o stream falhar — quem chama trata.
|
|
316
|
+
*/
|
|
317
|
+
async function readBodyTextCapped(body, maxBytes) {
|
|
318
|
+
if (!body)
|
|
319
|
+
return '';
|
|
320
|
+
const reader = body.getReader();
|
|
321
|
+
const parts = [];
|
|
322
|
+
let total = 0;
|
|
323
|
+
let truncated = false;
|
|
324
|
+
try {
|
|
325
|
+
for (;;) {
|
|
326
|
+
const { done, value } = await reader.read();
|
|
327
|
+
if (done)
|
|
328
|
+
break;
|
|
329
|
+
if (!value || value.byteLength === 0)
|
|
330
|
+
continue;
|
|
331
|
+
const room = maxBytes - total;
|
|
332
|
+
if (value.byteLength > room) {
|
|
333
|
+
if (room > 0)
|
|
334
|
+
parts.push(value.subarray(0, room));
|
|
335
|
+
total += Math.max(0, room);
|
|
336
|
+
truncated = true;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
parts.push(value);
|
|
340
|
+
total += value.byteLength;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
if (truncated) {
|
|
345
|
+
try {
|
|
346
|
+
await reader.cancel();
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// stream já encerrado — nada a fazer
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const joined = new Uint8Array(total);
|
|
354
|
+
let offset = 0;
|
|
355
|
+
for (const part of parts) {
|
|
356
|
+
joined.set(part, offset);
|
|
357
|
+
offset += part.byteLength;
|
|
358
|
+
}
|
|
359
|
+
return new TextDecoder().decode(joined);
|
|
360
|
+
}
|
|
361
|
+
/** URL da página inicial do domínio publicado. `null` se o domínio não forma URL. PURA. */
|
|
362
|
+
export function buildRenderProbeUrl(domain) {
|
|
363
|
+
try {
|
|
364
|
+
const u = new URL(`https://${domain}/`);
|
|
365
|
+
return `${u.origin}/`;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Abre a página inicial e classifica. Uma tentativa por salto (a saúde acabou
|
|
373
|
+
* de passar — não é hora de esperar app subir). `redirect:'manual'`: segue no
|
|
374
|
+
* máximo `RENDER_MAX_REDIRECTS` saltos e só na MESMA origem; redirect pra fora
|
|
375
|
+
* (login de terceiro, outro host) → `unverifiable`. Nunca lança.
|
|
376
|
+
*/
|
|
377
|
+
export async function runRenderProbe(url, deps) {
|
|
378
|
+
const maxRedirects = deps.maxRedirects ?? RENDER_MAX_REDIRECTS;
|
|
379
|
+
let current = url;
|
|
380
|
+
const origin = (() => {
|
|
381
|
+
try {
|
|
382
|
+
return new URL(url).origin;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
if (!origin)
|
|
389
|
+
return unverifiable(url, null, 'URL inválida');
|
|
390
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
391
|
+
// `null` = este salto foi redirect (segue em `location`).
|
|
392
|
+
let input = null;
|
|
393
|
+
let location = null;
|
|
394
|
+
try {
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
|
|
397
|
+
try {
|
|
398
|
+
const res = await deps.fetchImpl(current, {
|
|
399
|
+
method: 'GET',
|
|
400
|
+
redirect: 'manual',
|
|
401
|
+
signal: controller.signal,
|
|
402
|
+
headers: {
|
|
403
|
+
'cache-control': 'no-cache',
|
|
404
|
+
accept: 'text/html,application/xhtml+xml',
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
if (!res || typeof res.status !== 'number') {
|
|
408
|
+
return unverifiable(current, null, 'resposta vazia');
|
|
409
|
+
}
|
|
410
|
+
if (res.status >= 300 && res.status < 400) {
|
|
411
|
+
location = res.headers.get('location');
|
|
412
|
+
try {
|
|
413
|
+
await res.body?.cancel();
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// corpo já fechado
|
|
417
|
+
}
|
|
418
|
+
if (!location)
|
|
419
|
+
return unverifiable(current, res.status, `HTTP ${res.status} sem Location`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
const csp = res.headers.get('content-security-policy');
|
|
423
|
+
const html = await readBodyTextCapped(res.body, RENDER_BODY_MAX_BYTES);
|
|
424
|
+
input = {
|
|
425
|
+
kind: 'response',
|
|
426
|
+
httpStatus: res.status,
|
|
427
|
+
contentType: res.headers.get('content-type'),
|
|
428
|
+
cspHeaderValues: csp ? [csp] : [],
|
|
429
|
+
html,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
439
|
+
return classifyRenderProbe(current, {
|
|
440
|
+
kind: 'network_error',
|
|
441
|
+
error: msg.toLowerCase().includes('abort') ? `timeout após ${deps.timeoutMs}ms` : msg,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (input !== null)
|
|
445
|
+
return classifyRenderProbe(current, input);
|
|
446
|
+
if (location === null)
|
|
447
|
+
return unverifiable(current, null, 'resposta sem corpo nem Location');
|
|
448
|
+
let next;
|
|
449
|
+
try {
|
|
450
|
+
next = new URL(location, current);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return unverifiable(current, null, 'Location inválido');
|
|
454
|
+
}
|
|
455
|
+
if (next.origin !== origin)
|
|
456
|
+
return unverifiable(current, null, `redirect para outra origem (${next.origin})`);
|
|
457
|
+
current = next.toString();
|
|
458
|
+
}
|
|
459
|
+
return unverifiable(current, null, `mais de ${maxRedirects} redirects`);
|
|
460
|
+
}
|
|
461
|
+
// ─── texto ───────────────────────────────────────────────────────────────────
|
|
462
|
+
/** O que foi bloqueado, em uma frase (vai no `detail` do JSON e na tela). PURA. */
|
|
463
|
+
export function describeRenderBlock(render) {
|
|
464
|
+
return (`${render.blockedInlineScripts} de ${render.inlineScripts} script(s) embutido(s) da página ` +
|
|
465
|
+
`${render.url} bloqueado(s) pela Content-Security-Policy` +
|
|
466
|
+
(render.blockingDirective ? ` (${render.blockingDirective})` : ''));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* O efeito e onde se conserta — mesma frase do alerta do Core
|
|
470
|
+
* (`renderBlockedExplanation`), sem o prefixo da saúde. PURA.
|
|
471
|
+
*/
|
|
472
|
+
export const RENDER_BLOCKED_FIX = 'Sem esses scripts o Next não hidrata e a tela some para todo visitante. ' +
|
|
473
|
+
'Conserto no produto: no cabeçalho Content-Security-Policy (next.config, headers()), o script-src ' +
|
|
474
|
+
"precisa de nonce por requisição (recomendado) ou 'unsafe-inline'.";
|
|
475
|
+
//# sourceMappingURL=deploy-render-probe.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy na VM: um app por produto e ambiente — o que a CLI diz ao dev.
|
|
3
|
+
*
|
|
4
|
+
* ORIGEM — bug_6d92ef71. A descrição tinha duas metades:
|
|
5
|
+
*
|
|
6
|
+
* 1. `neetru deploy --target vm --port 3001` descartava a porta sem avisar.
|
|
7
|
+
* Consertado antes (P6, f3a9611, d327d30): a CLI avisa que o Core escolhe
|
|
8
|
+
* a porta.
|
|
9
|
+
* 2. "não fica claro como subir dois processos do mesmo produto (ex.: web e
|
|
10
|
+
* API em subdomínios diferentes) na mesma VM: dois deploys --product=X
|
|
11
|
+
* --env prod provavelmente se substituem."
|
|
12
|
+
*
|
|
13
|
+
* A metade 2 foi MEDIDA no Core (vistoria de 22/10) e é verdadeira:
|
|
14
|
+
* - `appKeyOf(productId, environmentId)` (src/lib/ports/port-registry.ts) é a
|
|
15
|
+
* identidade do app na VM — produto + ambiente, nada mais;
|
|
16
|
+
* - `ensureStableProxyPort` (src/lib/actions/_vm-deploy-core.ts) reusa a
|
|
17
|
+
* MESMA porta para o mesmo par, e o deploy novo troca o app que está nela;
|
|
18
|
+
* - `syncEnvironmentSubdomainPortCore` (src/lib/actions/_product-subdomains-cores.ts)
|
|
19
|
+
* guarda UM endereço por (produto, ambiente) e o aponta para a porta e o
|
|
20
|
+
* servidor do último deploy;
|
|
21
|
+
* - o deploy da CLI não tem flag para nomear um segundo app do mesmo produto.
|
|
22
|
+
*
|
|
23
|
+
* O aviso do conserto 1 confirmava a limitação e não dava saída: quem passa
|
|
24
|
+
* `--port` quase sempre quer subir um SEGUNDO app, e depois do aviso seguia
|
|
25
|
+
* sem caminho. Aqui fica a resposta, escrita uma vez e usada nos dois lugares
|
|
26
|
+
* onde o dev a encontra: o aviso do `--port` (`runDeploy`) e a ajuda do
|
|
27
|
+
* `neetru deploy --help` (`index.ts`).
|
|
28
|
+
*
|
|
29
|
+
* O caminho que existe hoje, medido no Core:
|
|
30
|
+
* - dois apps = dois produtos. `POST /api/cli/v1/products`
|
|
31
|
+
* (`neetru products create`) exige papel admin ou operator; cada produto tem
|
|
32
|
+
* endereço próprio (um por produto e ambiente), banco próprio (banco por
|
|
33
|
+
* produto) e segredos próprios;
|
|
34
|
+
* - o deploy do produto novo exige que a CHAVE da CLI o alcance. A rota de
|
|
35
|
+
* deploy (`/api/cli/v1/deploy`) devolve 403 `forbidden_product` quando
|
|
36
|
+
* `cliPrincipalCanAccessProduct` nega: admin passa sempre; as outras chaves
|
|
37
|
+
* só passam com `'*'` ou o produto em `allowedProducts`. Chave de operator
|
|
38
|
+
* nasce com escopo de produto EXPLÍCITO (`createCliApiKey` recusa operator
|
|
39
|
+
* sem `allowedProducts`), o `neetru auth login` só emite chave (`'*'`) para
|
|
40
|
+
* admin, e nenhuma ação de `cli-keys.ts` (criar, listar, revogar) muda o
|
|
41
|
+
* escopo de uma chave existente. Logo o texto diz o que é certo — admin
|
|
42
|
+
* alcança todos; chave com escopo, só os dela, e o deploy do novo volta 403
|
|
43
|
+
* e fica com um admin — sem prometer um jeito de ampliar a chave que o Core
|
|
44
|
+
* não tem. Sem esta linha, um operator criava o produto e batia no 403 no
|
|
45
|
+
* primeiro deploy dele, longe da causa (rodada 3, medido no Core dc45be28);
|
|
46
|
+
* - se os dois precisam do MESMO banco, separar em dois produtos não serve
|
|
47
|
+
* (o banco é isolado por produto): a API fica dentro do próprio app.
|
|
48
|
+
* - `--target cloud-run` NÃO é saída para o mesmo produto e ambiente: o
|
|
49
|
+
* ambiente tem um destino só (`_vm-deploy-core.ts` recusa ambiente cujo
|
|
50
|
+
* destino não é `vm`), então não aparece no texto.
|
|
51
|
+
*
|
|
52
|
+
* Módulo sem dependência: o `index.ts` o importa no topo para a ajuda, e o
|
|
53
|
+
* resto do deploy continua carregado sob demanda.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Aviso de `neetru deploy --target vm --port <n>`: a porta é descartada, e o
|
|
57
|
+
* dev fica sabendo por que e o que fazer se queria um segundo app.
|
|
58
|
+
* Continuação recuada para alinhar com o `⚠` do `log.warn`.
|
|
59
|
+
*/
|
|
60
|
+
export declare const AVISO_PORTA_IGNORADA_NA_VM: string;
|
|
61
|
+
/** Bloco da ajuda de `neetru deploy --help` (`addHelpText('after', …)`). */
|
|
62
|
+
export declare const AJUDA_DEPLOY_NA_VM: string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy na VM: um app por produto e ambiente — o que a CLI diz ao dev.
|
|
3
|
+
*
|
|
4
|
+
* ORIGEM — bug_6d92ef71. A descrição tinha duas metades:
|
|
5
|
+
*
|
|
6
|
+
* 1. `neetru deploy --target vm --port 3001` descartava a porta sem avisar.
|
|
7
|
+
* Consertado antes (P6, f3a9611, d327d30): a CLI avisa que o Core escolhe
|
|
8
|
+
* a porta.
|
|
9
|
+
* 2. "não fica claro como subir dois processos do mesmo produto (ex.: web e
|
|
10
|
+
* API em subdomínios diferentes) na mesma VM: dois deploys --product=X
|
|
11
|
+
* --env prod provavelmente se substituem."
|
|
12
|
+
*
|
|
13
|
+
* A metade 2 foi MEDIDA no Core (vistoria de 22/10) e é verdadeira:
|
|
14
|
+
* - `appKeyOf(productId, environmentId)` (src/lib/ports/port-registry.ts) é a
|
|
15
|
+
* identidade do app na VM — produto + ambiente, nada mais;
|
|
16
|
+
* - `ensureStableProxyPort` (src/lib/actions/_vm-deploy-core.ts) reusa a
|
|
17
|
+
* MESMA porta para o mesmo par, e o deploy novo troca o app que está nela;
|
|
18
|
+
* - `syncEnvironmentSubdomainPortCore` (src/lib/actions/_product-subdomains-cores.ts)
|
|
19
|
+
* guarda UM endereço por (produto, ambiente) e o aponta para a porta e o
|
|
20
|
+
* servidor do último deploy;
|
|
21
|
+
* - o deploy da CLI não tem flag para nomear um segundo app do mesmo produto.
|
|
22
|
+
*
|
|
23
|
+
* O aviso do conserto 1 confirmava a limitação e não dava saída: quem passa
|
|
24
|
+
* `--port` quase sempre quer subir um SEGUNDO app, e depois do aviso seguia
|
|
25
|
+
* sem caminho. Aqui fica a resposta, escrita uma vez e usada nos dois lugares
|
|
26
|
+
* onde o dev a encontra: o aviso do `--port` (`runDeploy`) e a ajuda do
|
|
27
|
+
* `neetru deploy --help` (`index.ts`).
|
|
28
|
+
*
|
|
29
|
+
* O caminho que existe hoje, medido no Core:
|
|
30
|
+
* - dois apps = dois produtos. `POST /api/cli/v1/products`
|
|
31
|
+
* (`neetru products create`) exige papel admin ou operator; cada produto tem
|
|
32
|
+
* endereço próprio (um por produto e ambiente), banco próprio (banco por
|
|
33
|
+
* produto) e segredos próprios;
|
|
34
|
+
* - o deploy do produto novo exige que a CHAVE da CLI o alcance. A rota de
|
|
35
|
+
* deploy (`/api/cli/v1/deploy`) devolve 403 `forbidden_product` quando
|
|
36
|
+
* `cliPrincipalCanAccessProduct` nega: admin passa sempre; as outras chaves
|
|
37
|
+
* só passam com `'*'` ou o produto em `allowedProducts`. Chave de operator
|
|
38
|
+
* nasce com escopo de produto EXPLÍCITO (`createCliApiKey` recusa operator
|
|
39
|
+
* sem `allowedProducts`), o `neetru auth login` só emite chave (`'*'`) para
|
|
40
|
+
* admin, e nenhuma ação de `cli-keys.ts` (criar, listar, revogar) muda o
|
|
41
|
+
* escopo de uma chave existente. Logo o texto diz o que é certo — admin
|
|
42
|
+
* alcança todos; chave com escopo, só os dela, e o deploy do novo volta 403
|
|
43
|
+
* e fica com um admin — sem prometer um jeito de ampliar a chave que o Core
|
|
44
|
+
* não tem. Sem esta linha, um operator criava o produto e batia no 403 no
|
|
45
|
+
* primeiro deploy dele, longe da causa (rodada 3, medido no Core dc45be28);
|
|
46
|
+
* - se os dois precisam do MESMO banco, separar em dois produtos não serve
|
|
47
|
+
* (o banco é isolado por produto): a API fica dentro do próprio app.
|
|
48
|
+
* - `--target cloud-run` NÃO é saída para o mesmo produto e ambiente: o
|
|
49
|
+
* ambiente tem um destino só (`_vm-deploy-core.ts` recusa ambiente cujo
|
|
50
|
+
* destino não é `vm`), então não aparece no texto.
|
|
51
|
+
*
|
|
52
|
+
* Módulo sem dependência: o `index.ts` o importa no topo para a ajuda, e o
|
|
53
|
+
* resto do deploy continua carregado sob demanda.
|
|
54
|
+
*/
|
|
55
|
+
/** A explicação do modelo e da saída, em linhas. Fonte única dos dois textos abaixo. */
|
|
56
|
+
const UM_APP_POR_AMBIENTE = [
|
|
57
|
+
'Cada produto tem UM app por ambiente na VM: um novo deploy do mesmo produto no mesmo ambiente',
|
|
58
|
+
'substitui o app que está no ar (o endereço é o mesmo), em vez de subir um segundo ao lado.',
|
|
59
|
+
'Dois apps (ex.: site e API em endereços diferentes) são dois produtos: cadastre o segundo com',
|
|
60
|
+
'`neetru products create` (exige papel admin ou operator) e faça o deploy dele com --product <slug>.',
|
|
61
|
+
'A chave da CLI precisa alcançar o produto novo: a de admin alcança todos; uma chave com escopo',
|
|
62
|
+
'de produto, só os dela — o deploy do novo volta 403 (forbidden_product) e fica com um admin.',
|
|
63
|
+
'Cada produto tem endereço, banco e segredos próprios. Se os dois precisam do mesmo banco,',
|
|
64
|
+
'sirva a API pelo próprio app (ex.: rotas /api), no mesmo endereço.',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Aviso de `neetru deploy --target vm --port <n>`: a porta é descartada, e o
|
|
68
|
+
* dev fica sabendo por que e o que fazer se queria um segundo app.
|
|
69
|
+
* Continuação recuada para alinhar com o `⚠` do `log.warn`.
|
|
70
|
+
*/
|
|
71
|
+
export const AVISO_PORTA_IGNORADA_NA_VM = [
|
|
72
|
+
'--port é ignorado em deploy na VM: quem escolhe a porta é o Core.',
|
|
73
|
+
...UM_APP_POR_AMBIENTE,
|
|
74
|
+
].join('\n ');
|
|
75
|
+
/** Bloco da ajuda de `neetru deploy --help` (`addHelpText('after', …)`). */
|
|
76
|
+
export const AJUDA_DEPLOY_NA_VM = [
|
|
77
|
+
'',
|
|
78
|
+
'Na VM (--target vm):',
|
|
79
|
+
' A porta é escolhida pelo Core; --port é ignorado.',
|
|
80
|
+
...UM_APP_POR_AMBIENTE.map((linha) => ` ${linha}`),
|
|
81
|
+
'',
|
|
82
|
+
].join('\n');
|
|
83
|
+
//# sourceMappingURL=deploy-vm-um-app.js.map
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
export { CARACTERE_DE_TROCA, CodificacaoInvalida, decodificarUtf8, semBomInicial, type OrigemDosBytes, } from './texto-utf8.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Primeira versão PUBLICADA do agente da VM que entrega os segredos do produto
|
|
4
|
+
* ao app (Parte 2 de bug_8b85353b, agent#87): com `productEnv: true` no
|
|
5
|
+
* `agent.deploy`, ele busca os valores no Core e grava
|
|
6
|
+
* `/etc/neetru/<slug>.product.env` (root, 0600), lido pelo unit do app.
|
|
7
|
+
*
|
|
8
|
+
* Por que 1.6.80 e não 1.6.79: o CHANGELOG do agente põe o recurso na 1.6.79,
|
|
9
|
+
* mas essa versão foi declarada e nunca publicada; `agent_releases/1.6.80`
|
|
10
|
+
* (stable) saiu em 2026-10-20 e as duas VMs operacionais (snee-002-multi e
|
|
11
|
+
* snee-003-db) reportam 1.6.80 desde 2026-10-22 (medido na vistoria de 22/10).
|
|
12
|
+
* Agente mais velho ignora `productEnv` calado — por isso a versão vai na nota.
|
|
13
|
+
*/
|
|
14
|
+
export declare const AGENTE_MINIMO_SEGREDO_DO_PRODUTO = "1.6.80";
|
|
15
|
+
/** Nota depois de `neetru secrets set` gravar: o app só vê a gravação no PRÓXIMO deploy. */
|
|
16
|
+
export declare function notaEntregaDoSegredo(ambiente: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Nota do `neetru deploy --target vm` depois de descartar do `--env-file`
|
|
19
|
+
* chave(s) com cara de segredo que o registro aceita. `env` é o `--env` cru do
|
|
20
|
+
* deploy: sai no nome canônico que o Core aceita, ou `<ambiente>`.
|
|
6
21
|
*/
|
|
7
|
-
export declare
|
|
22
|
+
export declare function notaSegredoDescartadoNoDeploy(env: string | undefined): string;
|
|
8
23
|
/**
|
|
9
24
|
* O comando que registra `chave` no ambiente do deploy. `env` é o `--env` cru
|
|
10
25
|
* do deploy (`dev`, `production`…): sai no nome canônico que o Core aceita, ou
|
|
@@ -57,8 +72,13 @@ export declare function problemaNaChaveReservada(chave: string): string | null;
|
|
|
57
72
|
* Por que a chave é recusada, ou `null` se serve. NUNCA repete o que foi
|
|
58
73
|
* digitado: `KEY=valor` e o valor colado no lugar do nome são os dois erros
|
|
59
74
|
* mais prováveis, e ecoar a entrada imprimiria o segredo.
|
|
75
|
+
*
|
|
76
|
+
* `comoMandarOValor`: por onde o valor entra NO COMANDO que chamou (o `set`
|
|
77
|
+
* passa "por --stdin, --from-file ou pelo pedido na tela"). bug_89d2497e
|
|
78
|
+
* (etapa 1): o texto citava essas flags a todo chamador, e o `secrets archive`
|
|
79
|
+
* — que não recebe valor nem declara as flags — também o mostrava.
|
|
60
80
|
*/
|
|
61
|
-
export declare function problemaNaChave(chave: string | undefined): string | null;
|
|
81
|
+
export declare function problemaNaChave(chave: string | undefined, comoMandarOValor?: string): string | null;
|
|
62
82
|
/**
|
|
63
83
|
* Por que o valor é recusado, ou `null` se serve. Mesma regra do Core e do
|
|
64
84
|
* agente: o valor vira uma linha de `EnvironmentFile` do systemd, e o agente
|