@h1veframework/cli 0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/dist/index.js +1133 -0
  4. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 H1VE Framework
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @h1veframework/cli — `nf`
2
+
3
+ CLI de terminal do **H1VE Flow**. Opera o fluxo da feature **da própria branch**, sem abrir o painel: ver estado, mover stage, abrir blocker, ler a spec, enviar a AI declaration e consultar a saúde técnica.
4
+
5
+ É um **cliente fino**: toda regra (transições, ownership, papéis) é decidida no servidor. O `nf` resolve a branch (via `git`), chama a API e formata a saída. O bundle é **auto-contido** (sem dependências de runtime além do Node).
6
+
7
+ ## Instalação
8
+
9
+ ```bash
10
+ npm install -g @h1veframework/cli # disponibiliza o comando `nf` globalmente
11
+ ```
12
+
13
+ > Requer Node ≥ 18.18 e `git` no PATH.
14
+
15
+ <details><summary>A partir do código-fonte (contribuidores)</summary>
16
+
17
+ ```bash
18
+ npm install # na raiz do monorepo
19
+ npm run build -w @h1veframework/cli
20
+ node packages/cli/dist/index.js --help # ou: npm i -g ./packages/cli
21
+ ```
22
+ </details>
23
+
24
+ ## Configuração
25
+
26
+ Duas variáveis de ambiente:
27
+
28
+ | Variável | Descrição |
29
+ |---|---|
30
+ | `NEXUS_FLOW_API_URL` | URL do H1VE Flow (ex.: `https://app.h1ve.org`) |
31
+ | `NEXUS_FLOW_API_KEY` | Um **PAT** (`nf_pat_…`, criado em `/api-tokens`) **ou** a chave de serviço |
32
+
33
+ ```bash
34
+ export NEXUS_FLOW_API_URL=https://app.h1ve.org
35
+ export NEXUS_FLOW_API_KEY=nf_pat_xxxxxxxx
36
+ ```
37
+
38
+ > Nota: os nomes das env vars ainda usam o prefixo `NEXUS_FLOW_` (nome interno original). O rebrand desses nomes p/ `H1VE_` toca código de servidor + config de deploy — fica p/ uma fatia futura.
39
+
40
+ - **PAT** (`nf_pat_…`): age com a **sua identidade e papel** — habilita escrita (`move`, `blocker`, `done`).
41
+ - **Chave de serviço**: **só leitura** — `move`/`blocker`/`done` respondem `SERVICE_CANNOT_WRITE`. `status`/`spec`/`health` funcionam.
42
+
43
+ ## Comandos
44
+
45
+ ```bash
46
+ nf status # estado da feature da branch (stage, dias ativos, blockers, sign-offs)
47
+ nf spec # imprime a spec (spec_content) da feature
48
+ nf move <stage> [--note "..."] # move a feature de stage
49
+ nf blocker "<descrição>" # abre um blocker
50
+ nf done [--from <arq>] [--no-move] # envia a AI declaration (JSON) e move dev → pr
51
+ nf health # últimos snapshots de saúde técnica (founder/architect, ou serviço)
52
+ ```
53
+
54
+ Flags globais: `--json` (saída crua), `-h/--help`, `-v/--version`.
55
+
56
+ ### `nf done`
57
+
58
+ Recebe a AI declaration como **JSON** — de um arquivo ou do stdin:
59
+
60
+ ```bash
61
+ nf done --from ai-declaration.json
62
+ nf done < ai-declaration.json
63
+ ```
64
+
65
+ Formato do JSON (validado no servidor):
66
+
67
+ ```json
68
+ {
69
+ "generated_files": [{ "file": "src/x.ts", "pct_generated": 80 }],
70
+ "reviewed_files": [{ "file": "src/x.ts", "reviewed_by_human": true }],
71
+ "github_pr_number": 42,
72
+ "out_of_scope": "nenhum"
73
+ }
74
+ ```
75
+
76
+ `nf done` **envia a declaration e depois move `dev → pr`**. Com `--no-move`, só envia. Se o move falhar (ex.: a feature não está em `dev`), a declaration **já foi enviada** — o CLI reporta o parcial e sai com código ≠ 0.
77
+
78
+ ## Saída e exit codes
79
+
80
+ - Sucesso → **stdout**, exit `0`.
81
+ - Erro → **stderr** (mensagem amigável, sem stack), exit `≠ 0` (config faltando = `2`).
82
+
83
+ Compõe bem em scripts/CI:
84
+
85
+ ```bash
86
+ nf status --json | jq .stage
87
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,1133 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { parseArgs } from "util";
5
+ import { readFile } from "fs/promises";
6
+
7
+ // ../core/src/client.ts
8
+ var NexusApiError = class extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "NexusApiError";
13
+ }
14
+ code;
15
+ };
16
+ function errorForStatus(status2) {
17
+ switch (status2) {
18
+ case 401:
19
+ return new NexusApiError("UNAUTHENTICATED", "NEXUS_FLOW_API_KEY ausente ou inv\xE1lida.");
20
+ case 403:
21
+ return new NexusApiError("FORBIDDEN", "Sem permiss\xE3o para ver esta feature.");
22
+ case 404:
23
+ return new NexusApiError("FEATURE_NOT_FOUND", "Nenhuma feature corresponde a esta branch.");
24
+ case 422:
25
+ return new NexusApiError("INVALID_INPUT", "Branch inv\xE1lida.");
26
+ default:
27
+ return new NexusApiError("API_ERROR", `Falha ao consultar o Nexus Flow (HTTP ${status2}).`);
28
+ }
29
+ }
30
+ function mapWriteError(status2, code) {
31
+ if (code === "SERVICE_CANNOT_WRITE") {
32
+ return new NexusApiError(
33
+ code,
34
+ "A chave de servi\xE7o \xE9 s\xF3-leitura. Use um PAT de usu\xE1rio (crie em /api-tokens)."
35
+ );
36
+ }
37
+ if (code === "INVALID_TRANSITION") {
38
+ return new NexusApiError(code, "Transi\xE7\xE3o de stage inv\xE1lida a partir do stage atual.");
39
+ }
40
+ if (code === "OWNER_REQUIRED") {
41
+ return new NexusApiError(
42
+ code,
43
+ "A feature precisa de um owner antes de iniciar. Pe\xE7a ao arquiteto para atribuir."
44
+ );
45
+ }
46
+ switch (status2) {
47
+ case 401:
48
+ return new NexusApiError("UNAUTHENTICATED", "NEXUS_FLOW_API_KEY ausente ou inv\xE1lida.");
49
+ case 403:
50
+ return new NexusApiError(code ?? "FORBIDDEN", "Sem permiss\xE3o para esta a\xE7\xE3o (papel ou ownership).");
51
+ case 404:
52
+ return new NexusApiError("FEATURE_NOT_FOUND", "Feature n\xE3o encontrada.");
53
+ case 422:
54
+ return new NexusApiError(code ?? "INVALID_INPUT", "Entrada inv\xE1lida para esta a\xE7\xE3o.");
55
+ default:
56
+ return new NexusApiError("API_ERROR", `Falha ao escrever no Nexus Flow (HTTP ${status2}).`);
57
+ }
58
+ }
59
+ async function readErrorCode(res) {
60
+ try {
61
+ return (await res.json()).code;
62
+ } catch {
63
+ return void 0;
64
+ }
65
+ }
66
+ function rootUrl(baseUrl) {
67
+ return baseUrl.replace(/\/+$/, "");
68
+ }
69
+ function createClient(config) {
70
+ const doFetch = config.fetchImpl ?? fetch;
71
+ const authHeaders = { "x-api-key": config.apiKey, accept: "application/json" };
72
+ async function postAction(featureId, action, body) {
73
+ const url = `${rootUrl(config.baseUrl)}/api/features/${encodeURIComponent(featureId)}/${action}`;
74
+ const res = await doFetch(url, {
75
+ method: "POST",
76
+ headers: { ...authHeaders, "content-type": "application/json" },
77
+ body: JSON.stringify(body)
78
+ });
79
+ if (!res.ok) throw mapWriteError(res.status, await readErrorCode(res));
80
+ return await res.json();
81
+ }
82
+ return {
83
+ async getContext(branch) {
84
+ const url = `${rootUrl(config.baseUrl)}/api/context?branch=${encodeURIComponent(branch)}`;
85
+ const res = await doFetch(url, { headers: authHeaders });
86
+ if (!res.ok) throw errorForStatus(res.status);
87
+ return await res.json();
88
+ },
89
+ async listHealth() {
90
+ const url = `${rootUrl(config.baseUrl)}/api/health`;
91
+ const res = await doFetch(url, { headers: authHeaders });
92
+ if (!res.ok) throw errorForStatus(res.status);
93
+ return await res.json();
94
+ },
95
+ async listFeatures() {
96
+ const url = `${rootUrl(config.baseUrl)}/api/features`;
97
+ const res = await doFetch(url, { headers: authHeaders });
98
+ if (!res.ok) throw errorForStatus(res.status);
99
+ return await res.json();
100
+ },
101
+ startFeature(featureId, slug) {
102
+ return postAction(featureId, "start", slug ? { slug } : {});
103
+ },
104
+ moveStage(featureId, toStage, note) {
105
+ return postAction(featureId, "move", { to_stage: toStage, note });
106
+ },
107
+ createBlocker(featureId, description) {
108
+ return postAction(featureId, "blockers", { description });
109
+ },
110
+ submitAiDeclaration(featureId, input) {
111
+ return postAction(featureId, "ai-declaration", input);
112
+ },
113
+ async listProjects() {
114
+ const url = `${rootUrl(config.baseUrl)}/api/projects`;
115
+ const res = await doFetch(url, { headers: authHeaders });
116
+ if (!res.ok) throw errorForStatus(res.status);
117
+ return await res.json();
118
+ },
119
+ async createConnection(projectId, input) {
120
+ const url = `${rootUrl(config.baseUrl)}/api/projects/${encodeURIComponent(projectId)}/connections`;
121
+ const res = await doFetch(url, {
122
+ method: "POST",
123
+ headers: { ...authHeaders, "content-type": "application/json" },
124
+ body: JSON.stringify(input)
125
+ });
126
+ if (!res.ok) throw mapWriteError(res.status, await readErrorCode(res));
127
+ return await res.json();
128
+ }
129
+ };
130
+ }
131
+
132
+ // ../core/src/branch.ts
133
+ import { execFile } from "child_process";
134
+ import { promisify } from "util";
135
+ var execFileAsync = promisify(execFile);
136
+ var defaultGitRunner = async (args) => {
137
+ const { stdout } = await execFileAsync("git", args, { cwd: process.cwd() });
138
+ return stdout;
139
+ };
140
+ async function currentBranch(runner = defaultGitRunner) {
141
+ let out;
142
+ try {
143
+ out = await runner(["rev-parse", "--abbrev-ref", "HEAD"]);
144
+ } catch {
145
+ throw new Error("NOT_A_GIT_BRANCH");
146
+ }
147
+ const branch = out.trim();
148
+ if (!branch || branch === "HEAD") throw new Error("NOT_A_GIT_BRANCH");
149
+ return branch;
150
+ }
151
+ function gitErrText(err) {
152
+ if (err && typeof err === "object" && "stderr" in err) {
153
+ return String(err.stderr ?? "");
154
+ }
155
+ return err instanceof Error ? err.message : "";
156
+ }
157
+ async function createBranch(name, runner = defaultGitRunner) {
158
+ try {
159
+ await runner(["switch", "-c", name]);
160
+ } catch (err) {
161
+ const text = gitErrText(err);
162
+ if (/already exists/i.test(text)) throw new Error("BRANCH_EXISTS");
163
+ if (/not a git repository/i.test(text)) throw new Error("NOT_A_GIT_REPO");
164
+ throw new Error("GIT_BRANCH_FAILED");
165
+ }
166
+ }
167
+
168
+ // ../core/src/env.ts
169
+ import { readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
170
+ import { join } from "path";
171
+ var defaultFs = {
172
+ readFile: (p) => fsReadFile(p, "utf8"),
173
+ writeFile: (p, c) => fsWriteFile(p, c, "utf8")
174
+ };
175
+ async function isGitIgnored(file, git) {
176
+ try {
177
+ await git(["check-ignore", "-q", file]);
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+ function mergeEnv(existing, pairs) {
184
+ const remaining = new Set(Object.keys(pairs));
185
+ const body = existing.replace(/\n+$/, "");
186
+ const lines = body.length ? body.split("\n") : [];
187
+ const out = lines.map((line) => {
188
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
189
+ if (key !== void 0 && remaining.has(key)) {
190
+ remaining.delete(key);
191
+ return `${key}=${pairs[key]}`;
192
+ }
193
+ return line;
194
+ });
195
+ for (const k of Object.keys(pairs)) if (remaining.has(k)) out.push(`${k}=${pairs[k]}`);
196
+ return out.join("\n") + "\n";
197
+ }
198
+ function removeEnvKeys(existing, keys) {
199
+ const drop = new Set(keys);
200
+ const body = existing.replace(/\n+$/, "");
201
+ const lines = body.length ? body.split("\n") : [];
202
+ const out = lines.filter((line) => {
203
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
204
+ return key === void 0 || !drop.has(key);
205
+ });
206
+ return out.length ? out.join("\n") + "\n" : "";
207
+ }
208
+ async function removeEnvVars(keys, opts = {}) {
209
+ const file = opts.file ?? ".env.local";
210
+ const cwd = opts.cwd ?? process.cwd();
211
+ const git = opts.git ?? defaultGitRunner;
212
+ const fs = opts.fs ?? defaultFs;
213
+ if (keys.length === 0) throw new Error("NO_ENV_VARS");
214
+ if (!await isGitIgnored(file, git)) throw new Error("ENV_FILE_NOT_GITIGNORED");
215
+ const path = join(cwd, file);
216
+ let existing = "";
217
+ try {
218
+ existing = await fs.readFile(path);
219
+ } catch {
220
+ return { file, removed: [] };
221
+ }
222
+ const present = /* @__PURE__ */ new Set();
223
+ for (const line of existing.split("\n")) {
224
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
225
+ if (key !== void 0) present.add(key);
226
+ }
227
+ const removed = keys.filter((k) => present.has(k));
228
+ await fs.writeFile(path, removeEnvKeys(existing, keys));
229
+ return { file, removed };
230
+ }
231
+ async function writeEnvVars(pairs, opts = {}) {
232
+ const file = opts.file ?? ".env.local";
233
+ const cwd = opts.cwd ?? process.cwd();
234
+ const git = opts.git ?? defaultGitRunner;
235
+ const fs = opts.fs ?? defaultFs;
236
+ if (Object.keys(pairs).length === 0) throw new Error("NO_ENV_VARS");
237
+ if (!await isGitIgnored(file, git)) throw new Error("ENV_FILE_NOT_GITIGNORED");
238
+ const path = join(cwd, file);
239
+ let existing = "";
240
+ try {
241
+ existing = await fs.readFile(path);
242
+ } catch {
243
+ existing = "";
244
+ }
245
+ await fs.writeFile(path, mergeEnv(existing, pairs));
246
+ return { file, keys: Object.keys(pairs) };
247
+ }
248
+
249
+ // src/errors.ts
250
+ var CliError = class extends Error {
251
+ constructor(message, exitCode = 1) {
252
+ super(message);
253
+ this.exitCode = exitCode;
254
+ this.name = "CliError";
255
+ }
256
+ exitCode;
257
+ };
258
+
259
+ // src/config.ts
260
+ function readConfig(env = process.env) {
261
+ const baseUrl = env.NEXUS_FLOW_API_URL?.trim();
262
+ const apiKey = env.NEXUS_FLOW_API_KEY?.trim();
263
+ if (!baseUrl || !apiKey) {
264
+ throw new CliError(
265
+ "Defina NEXUS_FLOW_API_URL e NEXUS_FLOW_API_KEY no ambiente.\n export NEXUS_FLOW_API_URL=https://seu-nexus.example.com\n export NEXUS_FLOW_API_KEY=nf_pat_... (crie um token em /api-tokens)",
266
+ 2
267
+ );
268
+ }
269
+ return { baseUrl, apiKey };
270
+ }
271
+ function clientFromConfig(cfg) {
272
+ return createClient({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
273
+ }
274
+
275
+ // src/format.ts
276
+ var STAGE_LABEL = {
277
+ backlog: "Backlog",
278
+ spec: "Spec",
279
+ dev: "Dev",
280
+ pr: "PR",
281
+ ci: "CI",
282
+ qa_data: "QA/Data",
283
+ architect: "Arquiteto",
284
+ main: "Main",
285
+ blocked: "Bloqueada"
286
+ };
287
+ function stageLabel(stage) {
288
+ return STAGE_LABEL[stage] ?? stage;
289
+ }
290
+ function formatStatus(d) {
291
+ const f = d.feature;
292
+ const lines = [
293
+ `${f.name} [${f.priority}]`,
294
+ `Stage: ${stageLabel(d.stage)}`,
295
+ `Dias ativos: ${d.days_active}`,
296
+ `Branch: ${f.branch_slug ?? "\u2014"}`
297
+ ];
298
+ if (f.github_pr_url) lines.push(`PR: ${f.github_pr_url}`);
299
+ lines.push(`Sign-offs: QA ${d.sign_offs.qa} \xB7 Data ${d.sign_offs.data}`);
300
+ if (d.active_blockers.length) {
301
+ lines.push(`Blockers ativos (${d.active_blockers.length}):`);
302
+ for (const b of d.active_blockers) lines.push(` \u26A0 ${b.description}`);
303
+ } else {
304
+ lines.push("Blockers ativos: nenhum");
305
+ }
306
+ if (d.ai_declaration?.submitted) lines.push("AI declaration: enviada");
307
+ return lines.join("\n");
308
+ }
309
+ function formatSpec(d) {
310
+ if (!d.spec_content) return `${d.feature.name}: sem spec registrada.`;
311
+ return d.spec_content;
312
+ }
313
+ function formatMove(prevStage, r) {
314
+ return `\u2713 ${r.name}: ${stageLabel(prevStage)} \u2192 ${stageLabel(r.stage)}`;
315
+ }
316
+ function formatBlocker(r) {
317
+ return `\u2713 Blocker aberto: ${r.description}`;
318
+ }
319
+ function formatStartList(list) {
320
+ const header = "Features atribu\xEDdas a iniciar \u2014 escolha: nf start <n\xBA>";
321
+ const rows = list.map(
322
+ (f, i) => ` ${String(i + 1).padStart(2)}. ${f.name} [${stageLabel(f.stage)}]`
323
+ );
324
+ return [header, ...rows].join("\n");
325
+ }
326
+ function formatStart(r) {
327
+ return [
328
+ `\u2713 Iniciada: ${r.feature.name}`,
329
+ `Branch: ${r.branch_name} (criada e ativa)`,
330
+ "Pr\xF3ximo: codar \u2192 'nf status' \u2192 'nf done' (move dev \u2192 pr)"
331
+ ].join("\n");
332
+ }
333
+ function formatHealth(snaps) {
334
+ if (!snaps.length) return "Nenhum snapshot de sa\xFAde t\xE9cnica registrado.";
335
+ const header = "Semana CI% Deploys Bugs D\xEDvida";
336
+ const rows = snaps.slice(0, 8).map(
337
+ (s) => [
338
+ s.week.padEnd(9),
339
+ String(s.ci_pass_rate).padStart(4),
340
+ String(s.deployments).padStart(8),
341
+ String(s.open_bugs).padStart(5),
342
+ String(s.debt_items).padStart(7)
343
+ ].join(" ")
344
+ );
345
+ return [header, ...rows].join("\n");
346
+ }
347
+ function formatConnect(r) {
348
+ return [
349
+ `\u2713 Conectado "${r.label}" ao projeto ${r.project}.`,
350
+ ` ${r.keys.length} vari\xE1vel(is) escrita(s) em ${r.file}: ${r.keys.join(", ")}`,
351
+ ` A credencial ficou s\xF3 no seu ${r.file} (gitignored) \u2014 o servidor guarda apenas o invent\xE1rio.`
352
+ ].join("\n");
353
+ }
354
+
355
+ // src/commands/status.ts
356
+ async function status(io) {
357
+ const branch = await io.resolveBranch();
358
+ const digest = await io.client.getContext(branch);
359
+ return { human: formatStatus(digest), data: digest };
360
+ }
361
+
362
+ // src/commands/spec.ts
363
+ async function spec(io) {
364
+ const branch = await io.resolveBranch();
365
+ const digest = await io.client.getContext(branch);
366
+ return { human: formatSpec(digest), data: { spec_content: digest.spec_content } };
367
+ }
368
+
369
+ // src/commands/move.ts
370
+ var STAGES = [
371
+ "backlog",
372
+ "spec",
373
+ "dev",
374
+ "pr",
375
+ "ci",
376
+ "qa_data",
377
+ "architect",
378
+ "main",
379
+ "blocked"
380
+ ];
381
+ function isStage(s) {
382
+ return STAGES.includes(s);
383
+ }
384
+ async function move(io, args) {
385
+ const stage = args.positional;
386
+ if (!stage) throw new CliError("Uso: nf move <stage>");
387
+ if (!isStage(stage)) {
388
+ throw new CliError(`Stage inv\xE1lido: ${stage}. V\xE1lidos: ${STAGES.join(", ")}`);
389
+ }
390
+ const branch = await io.resolveBranch();
391
+ const digest = await io.client.getContext(branch);
392
+ const result = await io.client.moveStage(digest.feature.id, stage, args.note);
393
+ return { human: formatMove(digest.stage, result), data: result };
394
+ }
395
+
396
+ // src/commands/blocker.ts
397
+ async function blocker(io, args) {
398
+ const description = args.positional;
399
+ if (!description) throw new CliError('Uso: nf blocker "<descri\xE7\xE3o>"');
400
+ const branch = await io.resolveBranch();
401
+ const digest = await io.client.getContext(branch);
402
+ const result = await io.client.createBlocker(digest.feature.id, description);
403
+ return { human: formatBlocker(result), data: result };
404
+ }
405
+
406
+ // src/commands/done.ts
407
+ async function readDeclaration(io, from) {
408
+ const raw = from ? await io.readFile(from) : await io.readStdin();
409
+ if (!raw.trim()) {
410
+ throw new CliError("Forne\xE7a a AI declaration via --from <arquivo> ou pelo stdin.");
411
+ }
412
+ try {
413
+ return JSON.parse(raw);
414
+ } catch {
415
+ throw new CliError("JSON inv\xE1lido na AI declaration.");
416
+ }
417
+ }
418
+ async function done(io, args) {
419
+ const input = await readDeclaration(io, args.from);
420
+ const branch = await io.resolveBranch();
421
+ const digest = await io.client.getContext(branch);
422
+ const featureId = digest.feature.id;
423
+ const declaration = await io.client.submitAiDeclaration(featureId, input);
424
+ const sent = `\u2713 AI declaration enviada (${declaration.id}).`;
425
+ if (args.noMove) {
426
+ return { human: `${sent}
427
+ Move pulado (--no-move).`, data: { declaration } };
428
+ }
429
+ try {
430
+ const moved = await io.client.moveStage(featureId, "pr");
431
+ const movedLine = `\u2713 ${moved.name}: ${stageLabel(digest.stage)} \u2192 ${stageLabel(moved.stage)}`;
432
+ return { human: `${sent}
433
+ ${movedLine}`, data: { declaration, move: moved } };
434
+ } catch (err) {
435
+ const reason = err instanceof Error ? err.message : "erro ao mover";
436
+ throw new CliError(
437
+ `${sent}
438
+ \u2717 Move dev \u2192 pr falhou: ${reason}
439
+ (a declaration J\xC1 foi enviada \u2014 rode \`nf move pr\` para concluir; N\xC3O repita \`nf done\`, pois enviaria a declaration de novo.)`
440
+ );
441
+ }
442
+ }
443
+
444
+ // src/commands/health.ts
445
+ async function health(io) {
446
+ const snapshots = await io.client.listHealth();
447
+ return { human: formatHealth(snapshots), data: snapshots };
448
+ }
449
+
450
+ // src/commands/start.ts
451
+ function startable(features) {
452
+ return features.filter((f) => f.branch_slug === null && f.stage !== "main");
453
+ }
454
+ function pick(list, arg) {
455
+ if (!arg) return list.length === 1 ? list[0] ?? null : null;
456
+ const byId = list.find((f) => f.id === arg);
457
+ if (byId) return byId;
458
+ const idx = Number(arg);
459
+ if (Number.isInteger(idx) && idx >= 1 && idx <= list.length) return list[idx - 1] ?? null;
460
+ throw new CliError(`Feature n\xE3o encontrada: ${arg}. Rode 'nf start' para ver a lista.`);
461
+ }
462
+ async function start(io, args) {
463
+ const list = startable(await io.client.listFeatures());
464
+ if (list.length === 0) {
465
+ throw new CliError("Nenhuma feature atribu\xEDda a iniciar (sem branch e fora de main).");
466
+ }
467
+ const selected = pick(list, args.positional);
468
+ if (!selected) {
469
+ return { human: formatStartList(list), data: { features: list } };
470
+ }
471
+ const result = await io.client.startFeature(selected.id, args.slug);
472
+ try {
473
+ await io.createBranch(result.branch_name);
474
+ } catch (err) {
475
+ const code = err instanceof Error ? err.message : "";
476
+ if (code === "BRANCH_EXISTS") {
477
+ throw new CliError(
478
+ `branch_slug gravado, mas a branch '${result.branch_name}' j\xE1 existe. Rode: git switch ${result.branch_name}`
479
+ );
480
+ }
481
+ throw err;
482
+ }
483
+ return { human: formatStart(result), data: result };
484
+ }
485
+
486
+ // src/commands/connect.ts
487
+ var KINDS = ["database", "deploy", "observability", "api", "auth", "other"];
488
+ function parseEnvPairs(env) {
489
+ const pairs = {};
490
+ for (const item of env ?? []) {
491
+ const eq = item.indexOf("=");
492
+ if (eq <= 0) throw new CliError(`--env inv\xE1lido: "${item}" (esperado KEY=valor)`);
493
+ const key = item.slice(0, eq);
494
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new CliError(`Nome de env var inv\xE1lido: "${key}"`);
495
+ pairs[key] = item.slice(eq + 1);
496
+ }
497
+ return pairs;
498
+ }
499
+ function resolveProject(projects, arg) {
500
+ if (arg) {
501
+ const found = projects.find((p) => p.id === arg || p.name.toLowerCase() === arg.toLowerCase());
502
+ if (!found) throw new CliError(`Projeto "${arg}" n\xE3o encontrado. Dispon\xEDveis: ${projects.map((p) => p.name).join(", ") || "(nenhum)"}`);
503
+ return found;
504
+ }
505
+ const [only, ...rest] = projects;
506
+ if (!only) throw new CliError("Voc\xEA n\xE3o \xE9 membro de nenhum projeto.");
507
+ if (rest.length > 0) {
508
+ throw new CliError(`V\xE1rios projetos \u2014 informe --project <nome>. Op\xE7\xF5es: ${projects.map((p) => p.name).join(", ")}`);
509
+ }
510
+ return only;
511
+ }
512
+ async function connect(io, args) {
513
+ const kind = args.kind;
514
+ if (!kind || !KINDS.includes(kind)) throw new CliError(`--kind \xE9 obrigat\xF3rio (${KINDS.join(" | ")})`);
515
+ if (!args.label) throw new CliError("--label \xE9 obrigat\xF3rio");
516
+ const pairs = parseEnvPairs(args.env);
517
+ if (Object.keys(pairs).length === 0) throw new CliError("Informe ao menos um --env KEY=valor");
518
+ const project = resolveProject(await io.client.listProjects(), args.project);
519
+ let written;
520
+ try {
521
+ written = await io.writeEnvVars(pairs, { file: args.file });
522
+ } catch (e) {
523
+ const msg = e instanceof Error ? e.message : "";
524
+ if (msg === "ENV_FILE_NOT_GITIGNORED") {
525
+ throw new CliError(`${args.file ?? ".env.local"} n\xE3o est\xE1 no .gitignore \u2014 adicione-o antes (o segredo n\xE3o pode virar commit).`, 2);
526
+ }
527
+ throw new CliError("N\xE3o consegui escrever o arquivo .env local.");
528
+ }
529
+ const conn = await io.client.createConnection(project.id, {
530
+ kind,
531
+ label: args.label,
532
+ url: args.url,
533
+ notes: args.notes
534
+ });
535
+ return {
536
+ human: formatConnect({ project: project.name, file: written.file, keys: written.keys, label: args.label }),
537
+ data: { id: conn.id, project_id: project.id, file: written.file, keys: written.keys }
538
+ };
539
+ }
540
+
541
+ // src/commands/serve.ts
542
+ import { createServer } from "http";
543
+ import { randomBytes } from "crypto";
544
+
545
+ // src/agent.ts
546
+ import { timingSafeEqual } from "crypto";
547
+ function tokenEq(got, expected) {
548
+ if (!got) return false;
549
+ const a = Buffer.from(got);
550
+ const b = Buffer.from(expected);
551
+ return a.length === b.length && timingSafeEqual(a, b);
552
+ }
553
+ var ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
554
+ function isLoopbackHost(host) {
555
+ const name = (host.split(":")[0] ?? "").toLowerCase();
556
+ return name === "127.0.0.1" || name === "localhost";
557
+ }
558
+ function isSafeFile(file) {
559
+ return typeof file === "string" && file.length > 0 && !/[/\\]/.test(file) && !file.includes("..") && !file.startsWith("~");
560
+ }
561
+ function normalizeConfig(v) {
562
+ if (v === void 0 || v === null) return {};
563
+ if (typeof v !== "object" || Array.isArray(v)) return null;
564
+ const entries = Object.entries(v);
565
+ if (entries.length > 20) return null;
566
+ const out = {};
567
+ for (const [k, val] of entries) {
568
+ if (typeof val !== "string" || val.length > 200) return null;
569
+ out[k] = val;
570
+ }
571
+ return out;
572
+ }
573
+ async function handleAgent(req, deps) {
574
+ if (req.origin !== null && req.origin !== deps.origin) return { status: 403, body: { error: "forbidden_origin" } };
575
+ if (req.host !== null && !isLoopbackHost(req.host)) return { status: 403, body: { error: "forbidden_host" } };
576
+ if (req.method === "OPTIONS") return { status: 204, body: null };
577
+ if (req.method === "GET" && req.path === "/health") return { status: 200, body: { ok: true, agent: "nexus-flow" } };
578
+ if (req.method === "POST" && req.path === "/apply") {
579
+ if (!tokenEq(req.token, deps.pairingToken)) return { status: 401, body: { error: "unpaired" } };
580
+ const b = req.body ?? null;
581
+ const env = b?.env;
582
+ if (!env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).length === 0) {
583
+ return { status: 422, body: { error: "no_env" } };
584
+ }
585
+ for (const k of Object.keys(env)) if (!ENV_NAME.test(k)) return { status: 422, body: { error: "bad_env_name" } };
586
+ if (b?.file !== void 0 && !isSafeFile(b.file)) return { status: 422, body: { error: "bad_file" } };
587
+ try {
588
+ const res = await deps.writeEnv(env, { file: b?.file });
589
+ return { status: 200, body: { file: res.file, keys: res.keys } };
590
+ } catch (e) {
591
+ const msg = e instanceof Error ? e.message : "";
592
+ if (msg === "ENV_FILE_NOT_GITIGNORED") return { status: 409, body: { error: "not_gitignored" } };
593
+ return { status: 500, body: { error: "write_failed" } };
594
+ }
595
+ }
596
+ if (req.method === "POST" && req.path === "/remove") {
597
+ if (!tokenEq(req.token, deps.pairingToken)) return { status: 401, body: { error: "unpaired" } };
598
+ const b = req.body ?? null;
599
+ const keys = b?.keys;
600
+ if (!Array.isArray(keys) || keys.length === 0) return { status: 422, body: { error: "no_keys" } };
601
+ for (const k of keys) if (typeof k !== "string" || !ENV_NAME.test(k)) return { status: 422, body: { error: "bad_env_name" } };
602
+ if (b?.file !== void 0 && !isSafeFile(b.file)) return { status: 422, body: { error: "bad_file" } };
603
+ try {
604
+ const res = await deps.removeEnv(keys, { file: b?.file });
605
+ return { status: 200, body: { file: res.file, removed: res.removed } };
606
+ } catch (e) {
607
+ const msg = e instanceof Error ? e.message : "";
608
+ if (msg === "ENV_FILE_NOT_GITIGNORED") return { status: 409, body: { error: "not_gitignored" } };
609
+ return { status: 500, body: { error: "remove_failed" } };
610
+ }
611
+ }
612
+ if (req.method === "POST" && req.path === "/apply-deploy") {
613
+ if (!tokenEq(req.token, deps.pairingToken)) return { status: 401, body: { error: "unpaired" } };
614
+ const b = req.body ?? null;
615
+ if (!b || typeof b.provider !== "string" || !b.provider) return { status: 422, body: { error: "no_provider" } };
616
+ if (typeof b.token !== "string" || !b.token) return { status: 422, body: { error: "missing_credentials" } };
617
+ const config = normalizeConfig(b.config);
618
+ if (config === null) return { status: 422, body: { error: "bad_config" } };
619
+ const env = b.env;
620
+ if (!env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).length === 0) {
621
+ return { status: 422, body: { error: "no_env" } };
622
+ }
623
+ for (const k of Object.keys(env)) if (!ENV_NAME.test(k)) return { status: 422, body: { error: "bad_env_name" } };
624
+ let result;
625
+ try {
626
+ result = await deps.deployApply(b.provider, { token: b.token, config, env });
627
+ } catch {
628
+ return { status: 502, body: { error: "deploy_failed" } };
629
+ }
630
+ if (result.ok) return { status: 200, body: { ok: true } };
631
+ const status2 = result.code === "unsupported_provider" ? 422 : 502;
632
+ return { status: status2, body: { error: result.code ?? "deploy_failed" } };
633
+ }
634
+ if (req.method === "POST" && req.path === "/remove-deploy") {
635
+ if (!tokenEq(req.token, deps.pairingToken)) return { status: 401, body: { error: "unpaired" } };
636
+ const b = req.body ?? null;
637
+ if (!b || typeof b.provider !== "string" || !b.provider) return { status: 422, body: { error: "no_provider" } };
638
+ if (typeof b.token !== "string" || !b.token) return { status: 422, body: { error: "missing_credentials" } };
639
+ const config = normalizeConfig(b.config);
640
+ if (config === null) return { status: 422, body: { error: "bad_config" } };
641
+ const keys = b.keys;
642
+ if (!Array.isArray(keys) || keys.length === 0) return { status: 422, body: { error: "no_keys" } };
643
+ for (const k of keys) if (typeof k !== "string" || !ENV_NAME.test(k)) return { status: 422, body: { error: "bad_env_name" } };
644
+ let result;
645
+ try {
646
+ result = await deps.deployRemove(b.provider, { token: b.token, config, keys });
647
+ } catch {
648
+ return { status: 502, body: { error: "deploy_failed" } };
649
+ }
650
+ if (result.ok) return { status: 200, body: { removed: result.removed ?? [], failed: result.failed ?? [] } };
651
+ const status2 = result.code === "unsupported_provider" ? 422 : 502;
652
+ return { status: status2, body: { error: result.code ?? "deploy_failed" } };
653
+ }
654
+ return { status: 404, body: { error: "not_found" } };
655
+ }
656
+
657
+ // src/vercel.ts
658
+ var VERCEL_API = "https://api.vercel.com";
659
+ var TIMEOUT_MS = 15e3;
660
+ async function applyVercelEnv(input) {
661
+ const { project, team, target: cfgTarget } = input.config;
662
+ if (!project) return { ok: false, code: "missing_config" };
663
+ const target = cfgTarget ? [cfgTarget] : ["production"];
664
+ const body = Object.entries(input.env).map(([key, value]) => ({ key, value, type: "encrypted", target }));
665
+ const query = team ? `?upsert=true&teamId=${encodeURIComponent(team)}` : "?upsert=true";
666
+ const url = `${VERCEL_API}/v10/projects/${encodeURIComponent(project)}/env${query}`;
667
+ const controller = new AbortController();
668
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
669
+ try {
670
+ const res = await fetch(url, {
671
+ method: "POST",
672
+ headers: { Authorization: `Bearer ${input.token}`, "content-type": "application/json" },
673
+ body: JSON.stringify(body),
674
+ signal: controller.signal
675
+ });
676
+ if (res.status === 401 || res.status === 403) return { ok: false, code: "unauthorized" };
677
+ if (res.status === 404) return { ok: false, code: "project_not_found" };
678
+ if (res.status >= 200 && res.status < 300) return { ok: true };
679
+ return { ok: false, code: "vercel_error" };
680
+ } catch {
681
+ return { ok: false, code: "request_failed" };
682
+ } finally {
683
+ clearTimeout(timer);
684
+ }
685
+ }
686
+ function mapVercelError(status2) {
687
+ if (status2 === 401 || status2 === 403) return "unauthorized";
688
+ if (status2 === 404) return "project_not_found";
689
+ return "vercel_error";
690
+ }
691
+ var teamQuery = (teamId) => teamId ? `?teamId=${encodeURIComponent(teamId)}` : "";
692
+ function matchesTarget(env, target) {
693
+ if (!target) return true;
694
+ const t = env.target;
695
+ return Array.isArray(t) ? t.includes(target) : t === target;
696
+ }
697
+ async function vercelReq(url, method, token) {
698
+ const controller = new AbortController();
699
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
700
+ try {
701
+ return await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, signal: controller.signal });
702
+ } catch {
703
+ return null;
704
+ } finally {
705
+ clearTimeout(timer);
706
+ }
707
+ }
708
+ async function removeVercelEnv(input) {
709
+ const { project, team, target } = input.config;
710
+ if (!project) return { ok: false, code: "missing_config" };
711
+ const base = `${VERCEL_API}/v9/projects/${encodeURIComponent(project)}/env`;
712
+ const listRes = await vercelReq(`${base}${teamQuery(team)}`, "GET", input.token);
713
+ if (!listRes) return { ok: false, code: "request_failed" };
714
+ if (!(listRes.status >= 200 && listRes.status < 300)) return { ok: false, code: mapVercelError(listRes.status) };
715
+ let envs;
716
+ try {
717
+ const data = await listRes.json();
718
+ envs = Array.isArray(data.envs) ? data.envs : [];
719
+ } catch {
720
+ return { ok: false, code: "vercel_error" };
721
+ }
722
+ const want = new Set(input.keys);
723
+ const matches = envs.filter((e) => want.has(e.key) && matchesTarget(e, target));
724
+ const removed = [];
725
+ const failed = [];
726
+ for (const e of matches) {
727
+ const del = await vercelReq(`${base}/${encodeURIComponent(e.id)}${teamQuery(team)}`, "DELETE", input.token);
728
+ (del && del.status >= 200 && del.status < 300 ? removed : failed).push(e.key);
729
+ }
730
+ return { ok: true, removed, failed };
731
+ }
732
+
733
+ // src/netlify.ts
734
+ var NETLIFY_API = "https://api.netlify.com";
735
+ var TIMEOUT_MS2 = 15e3;
736
+ function mapNetlifyError(status2) {
737
+ if (status2 === 401 || status2 === 403) return "unauthorized";
738
+ if (status2 === 404) return "project_not_found";
739
+ return "netlify_error";
740
+ }
741
+ var siteQuery = (siteId) => siteId ? `?site_id=${encodeURIComponent(siteId)}` : "";
742
+ async function netlifyReq(url, method, token, body) {
743
+ const controller = new AbortController();
744
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
745
+ try {
746
+ return await fetch(url, {
747
+ method,
748
+ headers: { Authorization: `Bearer ${token}`, ...body !== void 0 ? { "content-type": "application/json" } : {} },
749
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
750
+ signal: controller.signal
751
+ });
752
+ } catch {
753
+ return null;
754
+ } finally {
755
+ clearTimeout(timer);
756
+ }
757
+ }
758
+ async function applyNetlifyEnv(input) {
759
+ const { account_id, site_id } = input.config;
760
+ if (!account_id) return { ok: false, code: "missing_config" };
761
+ const base = `${NETLIFY_API}/api/v1/accounts/${encodeURIComponent(account_id)}/env`;
762
+ for (const [key, value] of Object.entries(input.env)) {
763
+ const url = `${base}/${encodeURIComponent(key)}${siteQuery(site_id)}`;
764
+ const res = await netlifyReq(url, "PATCH", input.token, { context: "all", value });
765
+ if (!res) return { ok: false, code: "request_failed" };
766
+ if (!(res.status >= 200 && res.status < 300)) return { ok: false, code: mapNetlifyError(res.status) };
767
+ }
768
+ return { ok: true };
769
+ }
770
+ async function removeNetlifyEnv(input) {
771
+ const { account_id, site_id } = input.config;
772
+ if (!account_id) return { ok: false, code: "missing_config" };
773
+ const base = `${NETLIFY_API}/api/v1/accounts/${encodeURIComponent(account_id)}/env`;
774
+ const removed = [];
775
+ const failed = [];
776
+ for (const key of input.keys) {
777
+ const res = await netlifyReq(`${base}/${encodeURIComponent(key)}${siteQuery(site_id)}`, "DELETE", input.token);
778
+ if (res && (res.status >= 200 && res.status < 300 || res.status === 404)) removed.push(key);
779
+ else failed.push(key);
780
+ }
781
+ return { ok: true, removed, failed };
782
+ }
783
+
784
+ // src/gql.ts
785
+ var GQL_TIMEOUT_MS = 15e3;
786
+ async function gqlRequest(url, token, query, variables, errPrefix) {
787
+ const controller = new AbortController();
788
+ const timer = setTimeout(() => controller.abort(), GQL_TIMEOUT_MS);
789
+ try {
790
+ const res = await fetch(url, {
791
+ method: "POST",
792
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
793
+ body: JSON.stringify({ query, variables }),
794
+ signal: controller.signal
795
+ });
796
+ if (res.status === 401 || res.status === 403) return { ok: false, code: "unauthorized" };
797
+ if (!(res.status >= 200 && res.status < 300)) return { ok: false, code: `${errPrefix}_error` };
798
+ const json = await res.json().catch(() => null);
799
+ if (!json || Array.isArray(json.errors) && json.errors.length > 0) return { ok: false, code: `${errPrefix}_error` };
800
+ return { ok: true, data: json.data };
801
+ } catch {
802
+ return { ok: false, code: "request_failed" };
803
+ } finally {
804
+ clearTimeout(timer);
805
+ }
806
+ }
807
+
808
+ // src/fly.ts
809
+ var FLY_API = "https://api.fly.io/graphql";
810
+ var SET = "mutation($input: SetSecretsInput!) { setSecrets(input: $input) { release { id } } }";
811
+ var UNSET = "mutation($input: UnsetSecretsInput!) { unsetSecrets(input: $input) { release { id } } }";
812
+ async function applyFlyEnv(input) {
813
+ const { app } = input.config;
814
+ if (!app) return { ok: false, code: "missing_config" };
815
+ const secrets = Object.entries(input.env).map(([key, value]) => ({ key, value }));
816
+ const r = await gqlRequest(FLY_API, input.token, SET, { input: { appId: app, replaceAll: false, secrets } }, "fly");
817
+ return r.ok ? { ok: true } : { ok: false, code: r.code };
818
+ }
819
+ async function removeFlyEnv(input) {
820
+ const { app } = input.config;
821
+ if (!app) return { ok: false, code: "missing_config" };
822
+ const r = await gqlRequest(FLY_API, input.token, UNSET, { input: { appId: app, keys: input.keys } }, "fly");
823
+ if (!r.ok) return { ok: false, code: r.code };
824
+ return { ok: true, removed: input.keys, failed: [] };
825
+ }
826
+
827
+ // src/railway.ts
828
+ var RAILWAY_API = "https://backboard.railway.com/graphql/v2";
829
+ var UPSERT = "mutation($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }";
830
+ var DELETE = "mutation($input: VariableDeleteInput!) { variableDelete(input: $input) }";
831
+ async function applyRailwayEnv(input) {
832
+ const { projectId, environmentId, serviceId } = input.config;
833
+ if (!projectId || !environmentId) return { ok: false, code: "missing_config" };
834
+ const inp = { projectId, environmentId, ...serviceId ? { serviceId } : {}, variables: input.env, replace: false };
835
+ const r = await gqlRequest(RAILWAY_API, input.token, UPSERT, { input: inp }, "railway");
836
+ return r.ok ? { ok: true } : { ok: false, code: r.code };
837
+ }
838
+ async function removeRailwayEnv(input) {
839
+ const { projectId, environmentId, serviceId } = input.config;
840
+ if (!projectId || !environmentId) return { ok: false, code: "missing_config" };
841
+ const removed = [];
842
+ const failed = [];
843
+ for (const name of input.keys) {
844
+ const inp = { projectId, environmentId, ...serviceId ? { serviceId } : {}, name };
845
+ const r = await gqlRequest(RAILWAY_API, input.token, DELETE, { input: inp }, "railway");
846
+ (r.ok ? removed : failed).push(name);
847
+ }
848
+ return { ok: true, removed, failed };
849
+ }
850
+
851
+ // src/render.ts
852
+ var RENDER_API = "https://api.render.com";
853
+ var TIMEOUT_MS3 = 15e3;
854
+ function mapRenderError(status2) {
855
+ if (status2 === 401 || status2 === 403) return "unauthorized";
856
+ if (status2 === 404) return "project_not_found";
857
+ return "render_error";
858
+ }
859
+ async function renderReq(url, method, token, body) {
860
+ const controller = new AbortController();
861
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS3);
862
+ try {
863
+ return await fetch(url, {
864
+ method,
865
+ headers: { Authorization: `Bearer ${token}`, ...body !== void 0 ? { "content-type": "application/json" } : {} },
866
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
867
+ signal: controller.signal
868
+ });
869
+ } catch {
870
+ return null;
871
+ } finally {
872
+ clearTimeout(timer);
873
+ }
874
+ }
875
+ async function applyRenderEnv(input) {
876
+ const { serviceId } = input.config;
877
+ if (!serviceId) return { ok: false, code: "missing_config" };
878
+ const base = `${RENDER_API}/v1/services/${encodeURIComponent(serviceId)}/env-vars`;
879
+ for (const [key, value] of Object.entries(input.env)) {
880
+ const res = await renderReq(`${base}/${encodeURIComponent(key)}`, "PUT", input.token, { value });
881
+ if (!res) return { ok: false, code: "request_failed" };
882
+ if (!(res.status >= 200 && res.status < 300)) return { ok: false, code: mapRenderError(res.status) };
883
+ }
884
+ return { ok: true };
885
+ }
886
+ async function removeRenderEnv(input) {
887
+ const { serviceId } = input.config;
888
+ if (!serviceId) return { ok: false, code: "missing_config" };
889
+ const base = `${RENDER_API}/v1/services/${encodeURIComponent(serviceId)}/env-vars`;
890
+ const removed = [];
891
+ const failed = [];
892
+ for (const key of input.keys) {
893
+ const res = await renderReq(`${base}/${encodeURIComponent(key)}`, "DELETE", input.token);
894
+ if (res && (res.status >= 200 && res.status < 300 || res.status === 404)) removed.push(key);
895
+ else failed.push(key);
896
+ }
897
+ return { ok: true, removed, failed };
898
+ }
899
+
900
+ // src/commands/serve.ts
901
+ var DEFAULT_AGENT_PORT = 7391;
902
+ var DEPLOY_REGISTRY = {
903
+ vercel: applyVercelEnv,
904
+ netlify: applyNetlifyEnv,
905
+ fly: applyFlyEnv,
906
+ railway: applyRailwayEnv,
907
+ render: applyRenderEnv
908
+ };
909
+ var DEPLOY_REMOVE_REGISTRY = {
910
+ vercel: removeVercelEnv,
911
+ netlify: removeNetlifyEnv,
912
+ fly: removeFlyEnv,
913
+ railway: removeRailwayEnv,
914
+ render: removeRenderEnv
915
+ };
916
+ async function deployApply(provider, input) {
917
+ const adapter = Object.hasOwn(DEPLOY_REGISTRY, provider) ? DEPLOY_REGISTRY[provider] : void 0;
918
+ if (!adapter) return { ok: false, code: "unsupported_provider" };
919
+ return adapter(input);
920
+ }
921
+ async function deployRemove(provider, input) {
922
+ const adapter = Object.hasOwn(DEPLOY_REMOVE_REGISTRY, provider) ? DEPLOY_REMOVE_REGISTRY[provider] : void 0;
923
+ if (!adapter) return { ok: false, code: "unsupported_provider" };
924
+ return adapter(input);
925
+ }
926
+ function corsHeaders(origin) {
927
+ return {
928
+ "Access-Control-Allow-Origin": origin,
929
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
930
+ "Access-Control-Allow-Headers": "content-type, x-pairing-token",
931
+ "content-type": "application/json"
932
+ };
933
+ }
934
+ var MAX_BODY = 64 * 1024;
935
+ async function readJsonBody(req) {
936
+ const chunks = [];
937
+ let size = 0;
938
+ for await (const c of req) {
939
+ size += c.length;
940
+ if (size > MAX_BODY) {
941
+ req.destroy();
942
+ return null;
943
+ }
944
+ chunks.push(c);
945
+ }
946
+ if (!chunks.length) return null;
947
+ try {
948
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
949
+ } catch {
950
+ return null;
951
+ }
952
+ }
953
+ async function serve(io, args) {
954
+ const port = Number(args.port ?? DEFAULT_AGENT_PORT);
955
+ const origin = args.origin ?? "http://localhost:3000";
956
+ const pairingToken = randomBytes(18).toString("base64url");
957
+ const deps = { pairingToken, origin, writeEnv: io.writeEnvVars, removeEnv: io.removeEnvVars, deployApply, deployRemove };
958
+ const server = createServer((req, res) => {
959
+ void (async () => {
960
+ const body = req.method === "POST" ? await readJsonBody(req) : null;
961
+ const result = await handleAgent(
962
+ {
963
+ method: req.method ?? "GET",
964
+ path: (req.url ?? "/").split("?")[0] ?? "/",
965
+ origin: req.headers.origin ?? null,
966
+ host: req.headers.host ?? null,
967
+ token: req.headers["x-pairing-token"] ?? null,
968
+ body
969
+ },
970
+ deps
971
+ );
972
+ res.writeHead(result.status, corsHeaders(origin));
973
+ res.end(result.body === null ? "" : JSON.stringify(result.body));
974
+ })().catch(() => {
975
+ res.writeHead(500, corsHeaders(origin));
976
+ res.end("{}");
977
+ });
978
+ });
979
+ await new Promise((resolve, reject) => {
980
+ server.on("error", reject);
981
+ server.listen(port, "127.0.0.1", resolve);
982
+ });
983
+ process.stdout.write(
984
+ `Agente H1VE ouvindo em http://127.0.0.1:${port}
985
+ Origem permitida: ${origin}
986
+ C\xF3digo de pareamento: ${pairingToken}
987
+ Cole o c\xF3digo no painel /connections. Ctrl+C para parar.
988
+ `
989
+ );
990
+ await new Promise(() => {
991
+ });
992
+ return { human: "", data: null };
993
+ }
994
+
995
+ // src/index.ts
996
+ var VERSION = "0.1.0";
997
+ var HELP = `nf \u2014 CLI do Nexus Flow
998
+
999
+ Uso:
1000
+ nf start [<n\xBA|id>] [--slug <s>]
1001
+ inicia uma feature atribu\xEDda: cria a branch e grava o slug
1002
+ nf status estado da feature da branch atual
1003
+ nf spec imprime a spec da feature
1004
+ nf move <stage> [--note] move a feature de stage
1005
+ nf blocker "<desc>" abre um blocker na feature
1006
+ nf done [--from <arq>] [--no-move]
1007
+ envia a AI declaration (JSON) e move dev \u2192 pr
1008
+ nf health \xFAltimos snapshots de sa\xFAde t\xE9cnica
1009
+ nf connect --kind <k> --label <l> --env KEY=VAL [...]
1010
+ aplica a credencial no .env.local LOCAL (nunca ao servidor)
1011
+ e registra o invent\xE1rio da conex\xE3o (Jeito B)
1012
+ nf serve [--port 7391] [--origin http://localhost:3000]
1013
+ sobe o agente local (127.0.0.1) p/ o menu visual aplicar
1014
+ credenciais pelo navegador \u2014 imprime o c\xF3digo de pareamento
1015
+
1016
+ Flags:
1017
+ --json sa\xEDda em JSON cru (comp\xF5e em scripts)
1018
+ --slug <s> slug custom para 'nf start' (default = derivado do nome)
1019
+ --kind <k> tipo da conex\xE3o (database|deploy|observability|api|auth|other)
1020
+ --label <l> nome da conex\xE3o (ex.: "Neon (prod)")
1021
+ --env KEY=VAL vari\xE1vel a escrever no .env.local (repet\xEDvel)
1022
+ --project <p> projeto (nome|id) \u2014 se voc\xEA \xE9 membro de mais de um
1023
+ --url/--notes metadado opcional da conex\xE3o
1024
+ -h, --help esta ajuda
1025
+ -v, --version vers\xE3o do CLI
1026
+
1027
+ Ambiente:
1028
+ NEXUS_FLOW_API_URL URL do Nexus Flow
1029
+ NEXUS_FLOW_API_KEY PAT (nf_pat_\u2026) p/ escrever, ou a chave de servi\xE7o (s\xF3 leitura)
1030
+ `;
1031
+ var COMMANDS = { start, status, spec, move, blocker, done, health, connect, serve };
1032
+ async function readStdin() {
1033
+ if (process.stdin.isTTY) return "";
1034
+ const chunks = [];
1035
+ for await (const chunk of process.stdin) chunks.push(chunk);
1036
+ return Buffer.concat(chunks).toString("utf8");
1037
+ }
1038
+ function parse(argv) {
1039
+ try {
1040
+ return parseArgs({
1041
+ args: argv,
1042
+ allowPositionals: true,
1043
+ options: {
1044
+ note: { type: "string" },
1045
+ from: { type: "string" },
1046
+ slug: { type: "string" },
1047
+ "no-move": { type: "boolean" },
1048
+ json: { type: "boolean" },
1049
+ help: { type: "boolean", short: "h" },
1050
+ version: { type: "boolean", short: "v" },
1051
+ // nf connect (SPEC-050)
1052
+ kind: { type: "string" },
1053
+ label: { type: "string" },
1054
+ url: { type: "string" },
1055
+ notes: { type: "string" },
1056
+ project: { type: "string" },
1057
+ file: { type: "string" },
1058
+ env: { type: "string", multiple: true },
1059
+ // nf serve (SPEC-051)
1060
+ port: { type: "string" },
1061
+ origin: { type: "string" }
1062
+ }
1063
+ });
1064
+ } catch {
1065
+ throw new CliError("Argumentos inv\xE1lidos. Veja `nf --help`.", 2);
1066
+ }
1067
+ }
1068
+ async function main(argv) {
1069
+ const { values, positionals } = parse(argv);
1070
+ if (values.version) {
1071
+ process.stdout.write(`${VERSION}
1072
+ `);
1073
+ return 0;
1074
+ }
1075
+ const command = positionals[0];
1076
+ if (values.help || !command) {
1077
+ process.stdout.write(HELP);
1078
+ return command || values.help ? 0 : 1;
1079
+ }
1080
+ const run = COMMANDS[command];
1081
+ if (!run) {
1082
+ process.stderr.write(`Comando desconhecido: ${command}
1083
+
1084
+ ${HELP}`);
1085
+ return 1;
1086
+ }
1087
+ const cfg = command === "serve" ? void 0 : readConfig();
1088
+ const io = {
1089
+ client: cfg ? clientFromConfig(cfg) : {},
1090
+ resolveBranch: () => currentBranch(defaultGitRunner),
1091
+ createBranch: (name) => createBranch(name, defaultGitRunner),
1092
+ readFile: (path) => readFile(path, "utf8"),
1093
+ readStdin,
1094
+ writeEnvVars: (pairs, opts) => writeEnvVars(pairs, { file: opts?.file }),
1095
+ removeEnvVars: (keys, opts) => removeEnvVars(keys, { file: opts?.file })
1096
+ };
1097
+ const args = {
1098
+ positional: positionals[1],
1099
+ note: values.note,
1100
+ from: values.from,
1101
+ slug: values.slug,
1102
+ noMove: values["no-move"] === true,
1103
+ kind: values.kind,
1104
+ label: values.label,
1105
+ url: values.url,
1106
+ notes: values.notes,
1107
+ project: values.project,
1108
+ file: values.file,
1109
+ env: values.env,
1110
+ port: values.port,
1111
+ origin: values.origin
1112
+ };
1113
+ const result = await run(io, args);
1114
+ process.stdout.write((values.json ? JSON.stringify(result.data, null, 2) : result.human) + "\n");
1115
+ return 0;
1116
+ }
1117
+ function messageFor(err) {
1118
+ if (err instanceof CliError || err instanceof NexusApiError) return err.message;
1119
+ if (err instanceof Error && err.message === "NOT_A_GIT_BRANCH") {
1120
+ return "N\xE3o estou numa branch git. Rode dentro do reposit\xF3rio da feature.";
1121
+ }
1122
+ if (err instanceof Error && (err.message === "NOT_A_GIT_REPO" || err.message === "GIT_BRANCH_FAILED")) {
1123
+ return "N\xE3o consegui criar a branch git. Rode dentro do reposit\xF3rio (git) da feature.";
1124
+ }
1125
+ return "Erro inesperado ao falar com o Nexus Flow.";
1126
+ }
1127
+ main(process.argv.slice(2)).then((code) => {
1128
+ process.exitCode = code;
1129
+ }).catch((err) => {
1130
+ process.stderr.write(`${messageFor(err)}
1131
+ `);
1132
+ process.exitCode = err instanceof CliError ? err.exitCode : 1;
1133
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@h1veframework/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI `nf` do H1VE Flow: opera o fluxo da feature (status/move/blocker/spec/done/health/start) da própria branch, via PAT.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "nf": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/GusHartz/nexus-flow.git",
20
+ "directory": "packages/cli"
21
+ },
22
+ "homepage": "https://github.com/GusHartz/nexus-flow/tree/main/packages/cli#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/GusHartz/nexus-flow/issues"
25
+ },
26
+ "keywords": [
27
+ "h1ve",
28
+ "h1ve-flow",
29
+ "cli",
30
+ "nf",
31
+ "dev-workflow",
32
+ "governance"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run"
41
+ },
42
+ "devDependencies": {
43
+ "@nexus-flow/core": "*",
44
+ "@types/node": "^20.19.43",
45
+ "tsup": "^8.3.0",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^2.1.0"
48
+ }
49
+ }