@h1veframework/cli 0.3.0 → 0.5.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 (3) hide show
  1. package/README.md +24 -12
  2. package/dist/index.js +274 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,9 +9,7 @@ Parte do tooling do H1VE junto com o **[servidor MCP](https://www.npmjs.com/pack
9
9
  ## Pré-requisitos
10
10
 
11
11
  - **Node.js 18.18+** (`node --version`).
12
- - Uma conta no H1VE Flow e um **PAT** (`nf_pat_…`), criado em **`app.h1ve.org/api-tokens`**.
13
- - PAT autentica **como você**: leitura **e escrita**. A chave de serviço é **só leitura**.
14
- - Guarde o PAT com cuidado — é um segredo, mostrado uma vez.
12
+ - Uma conta no H1VE Flow. **Não precisa criar/colar token** — o `nf login` cuida disso.
15
13
 
16
14
  ## Instalação
17
15
 
@@ -20,26 +18,40 @@ npm i -g @h1veframework/cli
20
18
  nf --version
21
19
  ```
22
20
 
23
- ## Configuração
21
+ ## Login (recomendado — sem colar nada)
24
22
 
25
23
  ```bash
26
- export H1VE_API_URL="https://app.h1ve.org"
27
- export H1VE_API_KEY="nf_pat_..." # seu PAT
28
- export H1VE_PROJECT_ID="..." # OBRIGATÓRIO só se você tem 2+ projetos (o id do projeto)
29
- nf health # testa a conexão
24
+ nf login # abre o navegador → você autoriza → pronto
25
+ nf health # funciona
30
26
  ```
31
27
 
32
- > **`H1VE_PROJECT_ID`** (SPEC-076): com **um** projeto, não precisa o servidor resolve sozinho.
33
- > Com **2+ projetos** (plano pago), defina o id do projeto — senão o `nf start`/`nf health` responde
34
- > *"Você tem mais de um projeto. Defina H1VE_PROJECT_ID."* (o id fica na app, no projeto).
28
+ O `nf login` abre o navegador (onde você está logado no H1VE), você clica **Autorizar**, e o CLI **guarda
29
+ a credencial sozinho** em `~/.config/h1ve/credentials.json` (permissão 600). O **[MCP](https://www.npmjs.com/package/@h1veframework/mcp)**
30
+ a mesma credencial um `nf login` configura os dois. `nf logout` remove. **100% automático** (SPEC-078):
31
+ o projeto também é resolvido sozinho pelo repo (SPEC-077), então nada de `H1VE_PROJECT_ID`.
35
32
 
36
- > Coloque os `export` no seu `~/.zshrc` / `~/.bashrc` para não repetir a cada sessão.
37
33
  > "Nenhum snapshot registrado" no `nf health` é **sucesso** (conectou; projeto sem métricas ainda).
38
34
 
35
+ ## Alternativa: variáveis de ambiente (CI / headless)
36
+
37
+ Sem navegador (ex.: CI), configure por env — tem **precedência** sobre o `nf login`:
38
+
39
+ ```bash
40
+ export H1VE_API_URL="https://app.h1ve.org" # opcional (default já é este)
41
+ export H1VE_API_KEY="nf_pat_..." # PAT criado em app.h1ve.org/api-tokens
42
+ # export H1VE_PROJECT_ID="..." # OPCIONAL — o CLI resolve o projeto pelo repo; use só p/ forçar
43
+ ```
44
+
45
+ > **`H1VE_PROJECT_ID`** (SPEC-076/077): **opcional**. Rodando dentro de um repo conectado a um projeto, o
46
+ > CLI **resolve o projeto sozinho** pelo `git remote` — zero config, mesmo com 2+ projetos. Use só pra
47
+ > **forçar** um projeto (override).
48
+
39
49
  ## Comandos
40
50
 
41
51
  | Comando | O que faz |
42
52
  |---|---|
53
+ | `nf login` | Autoriza no navegador e salva a credencial local (sem colar nada) |
54
+ | `nf logout` | Remove a credencial local |
43
55
  | `nf health` | Últimos snapshots de saúde técnica do projeto |
44
56
  | `nf status` | Estado da feature da branch atual (stage, dias ativo, blockers, sign-offs) |
45
57
  | `nf start [<nº\|id>] [--slug <s>]` | Inicia uma feature atribuída: cria a branch `feat/{você}/{slug}` e grava o slug |
package/dist/index.js CHANGED
@@ -4,6 +4,53 @@
4
4
  import { parseArgs } from "util";
5
5
  import { readFile } from "fs/promises";
6
6
 
7
+ // ../core/src/branch.ts
8
+ import { execFile } from "child_process";
9
+ import { promisify } from "util";
10
+ var execFileAsync = promisify(execFile);
11
+ var defaultGitRunner = async (args) => {
12
+ const { stdout } = await execFileAsync("git", args, { cwd: process.cwd() });
13
+ return stdout;
14
+ };
15
+ async function currentBranch(runner = defaultGitRunner) {
16
+ let out;
17
+ try {
18
+ out = await runner(["rev-parse", "--abbrev-ref", "HEAD"]);
19
+ } catch {
20
+ throw new Error("NOT_A_GIT_BRANCH");
21
+ }
22
+ const branch = out.trim();
23
+ if (!branch || branch === "HEAD") throw new Error("NOT_A_GIT_BRANCH");
24
+ return branch;
25
+ }
26
+ async function gitRemoteRepo(runner = defaultGitRunner) {
27
+ let out;
28
+ try {
29
+ out = await runner(["remote", "get-url", "origin"]);
30
+ } catch {
31
+ throw new Error("NO_GIT_REMOTE");
32
+ }
33
+ const m = out.trim().match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
34
+ if (!m) throw new Error("NO_GIT_REMOTE");
35
+ return `${m[1]}/${m[2]}`;
36
+ }
37
+ function gitErrText(err) {
38
+ if (err && typeof err === "object" && "stderr" in err) {
39
+ return String(err.stderr ?? "");
40
+ }
41
+ return err instanceof Error ? err.message : "";
42
+ }
43
+ async function createBranch(name, runner = defaultGitRunner) {
44
+ try {
45
+ await runner(["switch", "-c", name]);
46
+ } catch (err) {
47
+ const text = gitErrText(err);
48
+ if (/already exists/i.test(text)) throw new Error("BRANCH_EXISTS");
49
+ if (/not a git repository/i.test(text)) throw new Error("NOT_A_GIT_REPO");
50
+ throw new Error("GIT_BRANCH_FAILED");
51
+ }
52
+ }
53
+
7
54
  // ../core/src/client.ts
8
55
  var NexusApiError = class extends Error {
9
56
  constructor(code, message) {
@@ -140,42 +187,38 @@ function createClient(config) {
140
187
  }
141
188
  };
142
189
  }
143
-
144
- // ../core/src/branch.ts
145
- import { execFile } from "child_process";
146
- import { promisify } from "util";
147
- var execFileAsync = promisify(execFile);
148
- var defaultGitRunner = async (args) => {
149
- const { stdout } = await execFileAsync("git", args, { cwd: process.cwd() });
150
- return stdout;
151
- };
152
- async function currentBranch(runner = defaultGitRunner) {
153
- let out;
190
+ async function resolveProjectId(config, repo) {
191
+ const doFetch = config.fetchImpl ?? fetch;
154
192
  try {
155
- out = await runner(["rev-parse", "--abbrev-ref", "HEAD"]);
193
+ const url = `${rootUrl(config.baseUrl)}/api/projects/resolve-by-repo?repo=${encodeURIComponent(repo)}`;
194
+ const res = await doFetch(url, { headers: { "x-api-key": config.apiKey, accept: "application/json" } });
195
+ if (!res.ok) return null;
196
+ const body = await res.json();
197
+ return body.project_id ?? null;
156
198
  } catch {
157
- throw new Error("NOT_A_GIT_BRANCH");
158
- }
159
- const branch = out.trim();
160
- if (!branch || branch === "HEAD") throw new Error("NOT_A_GIT_BRANCH");
161
- return branch;
162
- }
163
- function gitErrText(err) {
164
- if (err && typeof err === "object" && "stderr" in err) {
165
- return String(err.stderr ?? "");
199
+ return null;
166
200
  }
167
- return err instanceof Error ? err.message : "";
168
201
  }
169
- async function createBranch(name, runner = defaultGitRunner) {
202
+ async function autoResolveProjectId(config, runner) {
170
203
  try {
171
- await runner(["switch", "-c", name]);
172
- } catch (err) {
173
- const text = gitErrText(err);
174
- if (/already exists/i.test(text)) throw new Error("BRANCH_EXISTS");
175
- if (/not a git repository/i.test(text)) throw new Error("NOT_A_GIT_REPO");
176
- throw new Error("GIT_BRANCH_FAILED");
204
+ return await resolveProjectId(config, await gitRemoteRepo(runner));
205
+ } catch {
206
+ return null;
177
207
  }
178
208
  }
209
+ async function exchangeCliLoginCode(config, code) {
210
+ const doFetch = config.fetchImpl ?? fetch;
211
+ const url = `${rootUrl(config.baseUrl)}/api/cli-login/exchange`;
212
+ const res = await doFetch(url, {
213
+ method: "POST",
214
+ headers: { "content-type": "application/json", accept: "application/json" },
215
+ body: JSON.stringify({ code })
216
+ });
217
+ if (!res.ok) throw errorForStatus(res.status, await readErrorCode(res));
218
+ const body = await res.json();
219
+ if (!body.api_key) throw new NexusApiError("LOGIN_FAILED", "Resposta de login inv\xE1lida do servidor.");
220
+ return { apiKey: body.api_key };
221
+ }
179
222
 
180
223
  // ../core/src/env.ts
181
224
  import { readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
@@ -258,6 +301,60 @@ async function writeEnvVars(pairs, opts = {}) {
258
301
  return { file, keys: Object.keys(pairs) };
259
302
  }
260
303
 
304
+ // ../core/src/credentials.ts
305
+ import {
306
+ readFile as fsReadFile2,
307
+ writeFile as fsWriteFile2,
308
+ mkdir as fsMkdir,
309
+ rm as fsRm,
310
+ chmod as fsChmod
311
+ } from "fs/promises";
312
+ import { homedir } from "os";
313
+ import { join as join2 } from "path";
314
+ function credentialDir(home = homedir()) {
315
+ return join2(home, ".config", "h1ve");
316
+ }
317
+ function credentialPath(home = homedir()) {
318
+ return join2(credentialDir(home), "credentials.json");
319
+ }
320
+ function parseCredential(raw) {
321
+ try {
322
+ const v = JSON.parse(raw);
323
+ if (v && typeof v === "object") {
324
+ const o = v;
325
+ if (typeof o.apiKey === "string" && o.apiKey && typeof o.baseUrl === "string" && o.baseUrl) {
326
+ return { apiKey: o.apiKey, baseUrl: o.baseUrl };
327
+ }
328
+ }
329
+ } catch {
330
+ }
331
+ return null;
332
+ }
333
+ function serializeCredential(cred) {
334
+ return JSON.stringify({ apiKey: cred.apiKey, baseUrl: cred.baseUrl }, null, 2) + "\n";
335
+ }
336
+ var defaultCredentialStore = {
337
+ read: async () => {
338
+ try {
339
+ return parseCredential(await fsReadFile2(credentialPath(), "utf8"));
340
+ } catch {
341
+ return null;
342
+ }
343
+ },
344
+ write: async (cred) => {
345
+ const path = credentialPath();
346
+ await fsMkdir(credentialDir(), { recursive: true, mode: 448 });
347
+ await fsWriteFile2(path, serializeCredential(cred), { mode: 384 });
348
+ await fsChmod(path, 384);
349
+ },
350
+ clear: async () => {
351
+ try {
352
+ await fsRm(credentialPath());
353
+ } catch {
354
+ }
355
+ }
356
+ };
357
+
261
358
  // src/errors.ts
262
359
  var CliError = class extends Error {
263
360
  constructor(message, exitCode = 1) {
@@ -269,20 +366,35 @@ var CliError = class extends Error {
269
366
  };
270
367
 
271
368
  // src/config.ts
272
- function readConfig(env = process.env) {
273
- const baseUrl = env.H1VE_API_URL?.trim() || env.NEXUS_FLOW_API_URL?.trim();
274
- const apiKey = env.H1VE_API_KEY?.trim() || env.NEXUS_FLOW_API_KEY?.trim();
369
+ var DEFAULT_BASE_URL = "https://app.h1ve.org";
370
+ function resolveBaseUrl(env = process.env) {
371
+ return env.H1VE_API_URL?.trim() || env.NEXUS_FLOW_API_URL?.trim() || DEFAULT_BASE_URL;
372
+ }
373
+ async function resolveConfig(env = process.env, store = defaultCredentialStore) {
275
374
  const projectId = env.H1VE_PROJECT_ID?.trim() || env.NEXUS_FLOW_PROJECT_ID?.trim();
276
- if (!baseUrl || !apiKey) {
375
+ const envKey = env.H1VE_API_KEY?.trim() || env.NEXUS_FLOW_API_KEY?.trim();
376
+ const envUrl = env.H1VE_API_URL?.trim() || env.NEXUS_FLOW_API_URL?.trim();
377
+ let apiKey;
378
+ let baseUrl;
379
+ if (envKey) {
380
+ apiKey = envKey;
381
+ baseUrl = envUrl || DEFAULT_BASE_URL;
382
+ } else {
383
+ const stored = await store.read();
384
+ apiKey = stored?.apiKey;
385
+ baseUrl = stored?.baseUrl || DEFAULT_BASE_URL;
386
+ }
387
+ if (!apiKey) {
277
388
  throw new CliError(
278
- "Defina H1VE_API_URL e H1VE_API_KEY no ambiente.\n export H1VE_API_URL=https://app.h1ve.org\n export H1VE_API_KEY=nf_pat_... (crie um token em /api-tokens)\n (os nomes legados NEXUS_FLOW_API_URL/KEY ainda s\xE3o aceitos)",
389
+ "Voc\xEA n\xE3o est\xE1 logado. Rode `nf login` (abre o navegador) \u2014 ou defina H1VE_API_KEY.\n (o nome legado NEXUS_FLOW_API_KEY tamb\xE9m \xE9 aceito)",
279
390
  2
280
391
  );
281
392
  }
282
393
  return { baseUrl, apiKey, projectId };
283
394
  }
284
- function clientFromConfig(cfg) {
285
- return createClient({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, projectId: cfg.projectId });
395
+ async function clientFromConfig(cfg) {
396
+ const projectId = cfg.projectId ?? await autoResolveProjectId({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey }) ?? void 0;
397
+ return createClient({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, projectId });
286
398
  }
287
399
 
288
400
  // src/format.ts
@@ -1005,11 +1117,127 @@ Cole o c\xF3digo no painel /connections. Ctrl+C para parar.
1005
1117
  return { human: "", data: null };
1006
1118
  }
1007
1119
 
1120
+ // src/commands/login.ts
1121
+ import { createServer as createServer2 } from "http";
1122
+ import { randomBytes as randomBytes2 } from "crypto";
1123
+ import { execFile as execFile2 } from "child_process";
1124
+ var LOGIN_TIMEOUT_MS = 3 * 60 * 1e3;
1125
+ function buildAuthorizeUrl(baseUrl, port, state) {
1126
+ const base = baseUrl.replace(/\/+$/, "");
1127
+ return `${base}/cli-login?port=${port}&state=${encodeURIComponent(state)}`;
1128
+ }
1129
+ function parseCallbackUrl(reqUrl, expectedState) {
1130
+ let u;
1131
+ try {
1132
+ u = new URL(reqUrl, "http://127.0.0.1");
1133
+ } catch {
1134
+ return { error: "bad_url" };
1135
+ }
1136
+ if (u.pathname !== "/cb") return { error: "not_cb" };
1137
+ const code = u.searchParams.get("code");
1138
+ const state = u.searchParams.get("state");
1139
+ if (!code || !state) return { error: "missing" };
1140
+ if (state !== expectedState) return { error: "state_mismatch" };
1141
+ return { code };
1142
+ }
1143
+ var SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>H1VE</title></head>
1144
+ <body style="font-family:system-ui;background:#0f1115;color:#e7e9ee;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0">
1145
+ <div style="text-align:center"><div style="font-size:22px;font-weight:800;letter-spacing:.12em">H<span style="color:#f5a623">1</span>VE</div>
1146
+ <p style="color:#9aa0aa;margin-top:12px">\u2713 Autorizado. Pode fechar esta aba e voltar ao terminal.</p></div></body></html>`;
1147
+ function openBrowser(url) {
1148
+ const platform = process.platform;
1149
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1150
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1151
+ try {
1152
+ const child = execFile2(cmd, args, () => {
1153
+ });
1154
+ child.on("error", () => {
1155
+ });
1156
+ child.unref();
1157
+ } catch {
1158
+ }
1159
+ }
1160
+ async function startCallbackServer(expectedState) {
1161
+ let resolveCode;
1162
+ const codePromise = new Promise((resolve) => {
1163
+ resolveCode = resolve;
1164
+ });
1165
+ const server = createServer2((req, res) => {
1166
+ const parsed = parseCallbackUrl(req.url ?? "/", expectedState);
1167
+ if ("code" in parsed) {
1168
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1169
+ res.end(SUCCESS_HTML);
1170
+ resolveCode(parsed.code);
1171
+ } else {
1172
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
1173
+ res.end("aguardando autorizacao");
1174
+ }
1175
+ });
1176
+ await new Promise((resolve, reject) => {
1177
+ server.on("error", reject);
1178
+ server.listen(0, "127.0.0.1", resolve);
1179
+ });
1180
+ const addr = server.address();
1181
+ const port = addr ? addr.port : 0;
1182
+ const waitForCode = () => new Promise((resolve, reject) => {
1183
+ const timer = setTimeout(() => {
1184
+ server.close();
1185
+ reject(new CliError("Tempo esgotado esperando a autoriza\xE7\xE3o. Rode `nf login` de novo."));
1186
+ }, LOGIN_TIMEOUT_MS);
1187
+ void codePromise.then((code) => {
1188
+ clearTimeout(timer);
1189
+ server.close();
1190
+ resolve(code);
1191
+ });
1192
+ });
1193
+ return { port, waitForCode, close: () => server.close() };
1194
+ }
1195
+ async function login(io, _args) {
1196
+ const baseUrl = resolveBaseUrl();
1197
+ const state = randomBytes2(18).toString("base64url");
1198
+ const cb = await startCallbackServer(state);
1199
+ const authorizeUrl = buildAuthorizeUrl(baseUrl, cb.port, state);
1200
+ openBrowser(authorizeUrl);
1201
+ process.stderr.write(
1202
+ `Abrindo o navegador para autorizar em ${baseUrl} \u2026
1203
+ Se n\xE3o abrir, acesse:
1204
+ ${authorizeUrl}
1205
+
1206
+ Aguardando autoriza\xE7\xE3o\u2026
1207
+ `
1208
+ );
1209
+ let code;
1210
+ try {
1211
+ code = await cb.waitForCode();
1212
+ } catch (err) {
1213
+ cb.close();
1214
+ throw err;
1215
+ }
1216
+ const { apiKey } = await exchangeCliLoginCode({ baseUrl }, code);
1217
+ await io.credentials.write({ apiKey, baseUrl });
1218
+ return {
1219
+ human: `\u2713 Logado. Credencial salva em ${credentialPath()} (o CLI e o MCP j\xE1 a usam).`,
1220
+ data: { baseUrl, saved: true }
1221
+ };
1222
+ }
1223
+
1224
+ // src/commands/logout.ts
1225
+ async function logout(io, _args) {
1226
+ await io.credentials.clear();
1227
+ return {
1228
+ human: `\u2713 Deslogado localmente (credencial removida de ${credentialPath()}).
1229
+ O token segue v\xE1lido no servidor at\xE9 expirar (90 dias) \u2014 revogue j\xE1 em /api-tokens se precisar.`,
1230
+ data: { ok: true }
1231
+ };
1232
+ }
1233
+
1008
1234
  // src/index.ts
1009
- var VERSION = "0.2.0";
1235
+ var VERSION = "0.5.0";
1010
1236
  var HELP = `nf \u2014 CLI do Nexus Flow
1011
1237
 
1012
1238
  Uso:
1239
+ nf login autoriza no navegador e salva a credencial local (sem colar nada)
1240
+ nf logout remove a credencial local
1013
1241
  nf start [<n\xBA|id>] [--slug <s>]
1014
1242
  inicia uma feature atribu\xEDda: cria a branch e grava o slug
1015
1243
  nf status estado da feature da branch atual
@@ -1037,12 +1265,13 @@ Flags:
1037
1265
  -h, --help esta ajuda
1038
1266
  -v, --version vers\xE3o do CLI
1039
1267
 
1040
- Ambiente:
1041
- H1VE_API_URL URL do H1VE Flow (ex.: https://app.h1ve.org)
1268
+ Ambiente (opcional \u2014 'nf login' dispensa):
1269
+ H1VE_API_URL URL do H1VE Flow (default https://app.h1ve.org)
1042
1270
  H1VE_API_KEY PAT (nf_pat_\u2026) p/ escrever, ou a chave de servi\xE7o (s\xF3 leitura)
1043
1271
  (nomes legados aceitos: NEXUS_FLOW_API_URL / NEXUS_FLOW_API_KEY)
1044
1272
  `;
1045
- var COMMANDS = { start, status, spec, move, blocker, done, health, connect, serve };
1273
+ var COMMANDS = { start, status, spec, move, blocker, done, health, connect, serve, login, logout };
1274
+ var NO_CONFIG = /* @__PURE__ */ new Set(["serve", "login", "logout"]);
1046
1275
  async function readStdin() {
1047
1276
  if (process.stdin.isTTY) return "";
1048
1277
  const chunks = [];
@@ -1098,15 +1327,17 @@ async function main(argv) {
1098
1327
  ${HELP}`);
1099
1328
  return 1;
1100
1329
  }
1101
- const cfg = command === "serve" ? void 0 : readConfig();
1330
+ const cfg = NO_CONFIG.has(command) ? void 0 : await resolveConfig();
1331
+ const client = cfg ? await clientFromConfig(cfg) : {};
1102
1332
  const io = {
1103
- client: cfg ? clientFromConfig(cfg) : {},
1333
+ client,
1104
1334
  resolveBranch: () => currentBranch(defaultGitRunner),
1105
1335
  createBranch: (name) => createBranch(name, defaultGitRunner),
1106
1336
  readFile: (path) => readFile(path, "utf8"),
1107
1337
  readStdin,
1108
1338
  writeEnvVars: (pairs, opts) => writeEnvVars(pairs, { file: opts?.file }),
1109
- removeEnvVars: (keys, opts) => removeEnvVars(keys, { file: opts?.file })
1339
+ removeEnvVars: (keys, opts) => removeEnvVars(keys, { file: opts?.file }),
1340
+ credentials: defaultCredentialStore
1110
1341
  };
1111
1342
  const args = {
1112
1343
  positional: positionals[1],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@h1veframework/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",