@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.js +1133 -0
- 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
|
+
}
|