@hadcloud/deploy-cli 0.1.2 → 0.1.3

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 +202 -0
  2. package/dist/index.js +249 -2
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # HAD Deploy CLI
2
+
3
+ > Crie, publique e acompanhe aplicações no HAD Deploy diretamente pelo terminal — sem expor o provider de infraestrutura.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@hadcloud/deploy-cli?color=6d28d9&label=npm)](https://www.npmjs.com/package/@hadcloud/deploy-cli)
6
+ [![Node.js](https://img.shields.io/node/v/@hadcloud/deploy-cli?color=16a34a&label=node)](https://nodejs.org/)
7
+
8
+ ## O que você pode fazer
9
+
10
+ - Gerenciar projetos: listar, criar, editar e excluir.
11
+ - Criar, editar e excluir aplicações Git, Docker, Dockerfile e templates aprovados.
12
+ - Listar templates públicos e serviços provisionados.
13
+ - Publicar aplicações, iniciar/parar/reiniciar serviços e acompanhar execuções.
14
+ - Consultar status e logs de deploys de aplicações e de templates.
15
+ - Usar tokens com escopo mínimo, inclusive por projeto, em estações locais ou CI/CD.
16
+
17
+ ## Instalação
18
+
19
+ ```bash
20
+ npm install -g @hadcloud/deploy-cli
21
+ ```
22
+
23
+ Confira se a instalação está disponível:
24
+
25
+ ```bash
26
+ had config
27
+ ```
28
+
29
+ ## Começo rápido
30
+
31
+ 1. No painel HAD Deploy, abra **CLI & MCP** e gere um token do tipo **CLI**.
32
+ 2. Para acesso completo da CLI, selecione os escopos recomendados:
33
+
34
+ - `projects:read`, `projects:write`
35
+ - `apps:read`, `apps:write`
36
+ - `deployments:read`, `deployments:create`
37
+ - `logs:read`
38
+ - `env:write` somente se precisar editar variáveis de templates
39
+
40
+ 3. Faça login:
41
+
42
+ ```bash
43
+ had login --api-url https://api.seu-dominio.com
44
+ ```
45
+
46
+ O token é solicitado sem aparecer no terminal e fica armazenado no cofre do sistema operacional. No Windows, a CLI usa Windows DPAPI.
47
+
48
+ 4. Confira os projetos disponíveis:
49
+
50
+ ```bash
51
+ had projects list
52
+ ```
53
+
54
+ ## Atualizar token
55
+
56
+ Não é preciso executar logout para trocar um token expirado ou criar um token com novos escopos:
57
+
58
+ ```bash
59
+ had token update
60
+ ```
61
+
62
+ A URL de API já configurada é mantida. Você também pode executar `had login` novamente para substituir o token.
63
+
64
+ Para automação sem prompt:
65
+
66
+ ```bash
67
+ echo "$HAD_DEPLOY_TOKEN" | had token update --token-stdin
68
+ ```
69
+
70
+ > No PowerShell, use: `$env:NOVO_TOKEN | had token update --token-stdin`.
71
+
72
+ ## Projetos
73
+
74
+ ```bash
75
+ # Listar
76
+ had projects list
77
+
78
+ # Criar
79
+ had projects create "Plataforma de produção" --description "Serviços do cliente"
80
+
81
+ # Editar
82
+ had projects edit prj_... --name "Produção"
83
+
84
+ # Excluir — requer confirmação explícita
85
+ had projects delete prj_... --yes
86
+ ```
87
+
88
+ ## Aplicações
89
+
90
+ ### Aplicação de um template aprovado
91
+
92
+ ```bash
93
+ # Ver templates disponíveis
94
+ had templates list
95
+
96
+ # Provisionar n8n em um projeto
97
+ had apps create prj_... --template n8n --region BR-SP-01
98
+
99
+ # Forma equivalente
100
+ had templates create prj_... n8n --region BR-SP-01
101
+ ```
102
+
103
+ O comando mostra o ID do serviço (`tpl_...`) e, quando o provider já o registrou, o ID da execução. Para acompanhar um deploy de template:
104
+
105
+ ```bash
106
+ had templates services prj_...
107
+ had templates deployments prj_... tpl_...
108
+ had templates status prj_... tpl_... <deployment-id>
109
+ had templates logs prj_... tpl_... <deployment-id> 300
110
+ ```
111
+
112
+ ### Aplicação de repositório GitHub
113
+
114
+ Primeiro conecte sua conta GitHub no painel HAD Deploy. A CLI recebe apenas o identificador da conexão segura já cadastrada — nunca token, senha ou URL Git com credenciais.
115
+
116
+ ```bash
117
+ had apps create prj_... \
118
+ --name minha-api \
119
+ --source github \
120
+ --git-connection git_... \
121
+ --repository https://github.com/minha-org/minha-api.git \
122
+ --branch main \
123
+ --build-type railpack
124
+ ```
125
+
126
+ Fontes aceitas: `github`, `git`, `docker` e `dockerfile`.
127
+
128
+ ```bash
129
+ # Listar aplicações de um projeto
130
+ had apps list prj_...
131
+
132
+ # Alterar configurações suportadas
133
+ had apps edit prj_... app_... --branch release --build-type dockerfile
134
+
135
+ # Solicitar deploy de uma aplicação
136
+ had deploy prj_... app_...
137
+
138
+ # Consultar o deploy interno da aplicação
139
+ had status dep_...
140
+ had logs dep_... 200
141
+
142
+ # Excluir a aplicação
143
+ had apps delete prj_... app_... --yes
144
+ ```
145
+
146
+ ## Gerenciar um serviço de template
147
+
148
+ ```bash
149
+ # Reexecutar um deploy
150
+ had templates deploy prj_... tpl_...
151
+
152
+ # Atualizar variáveis pelo arquivo local
153
+ had templates edit prj_... tpl_... --env-file .env.production
154
+
155
+ # Excluir o serviço
156
+ had templates delete prj_... tpl_... --yes
157
+ ```
158
+
159
+ Os IDs de deploy de template pertencem ao provider e não são `dep_...`. Por isso, use `had templates status` e `had templates logs` para eles; `had status` e `had logs` são destinados a aplicações que utilizam a fila interna do HAD Deploy.
160
+
161
+ ## Uso em CI/CD
162
+
163
+ Prefira variáveis protegidas do provedor de CI. Não grave tokens em `.env` versionado, código-fonte ou logs.
164
+
165
+ ```bash
166
+ export HAD_DEPLOY_API_URL="https://api.seu-dominio.com"
167
+ export HAD_DEPLOY_TOKEN="hd_cli_..."
168
+
169
+ had projects list
170
+ had deploy prj_... app_...
171
+ ```
172
+
173
+ Quando `HAD_DEPLOY_TOKEN` e `HAD_DEPLOY_API_URL` estão definidos, a CLI não precisa de login interativo nem persiste credenciais locais.
174
+
175
+ ## Segurança
176
+
177
+ - Gere tokens com o menor conjunto de escopos possível.
178
+ - Para um único projeto, crie um token limitado ao projeto no painel.
179
+ - Tokens limitados a projeto não podem criar novos projetos nem acessar recursos de outro projeto.
180
+ - Comandos destrutivos exigem `--yes`.
181
+ - O manifesto Compose de templates permanece controlado pela plataforma; a CLI não aceita Compose arbitrário.
182
+ - Revogue tokens não utilizados pelo painel HAD Deploy.
183
+
184
+ ## Referência rápida
185
+
186
+ ```text
187
+ had login [--api-url <url>] [--token-stdin]
188
+ had token update [--token-stdin]
189
+ had logout
190
+ had config
191
+
192
+ had projects list|create|edit|delete
193
+ had apps list|create|edit|delete
194
+ had templates list|create|services|deploy|deployments|status|logs|edit|delete
195
+ had deploy <project-id> <app-id>
196
+ had status <deployment-id>
197
+ had logs <deployment-id> [tail]
198
+ ```
199
+
200
+ ## Suporte
201
+
202
+ Abra o painel HAD Deploy para criar conexões Git, emitir ou revogar tokens e acompanhar a infraestrutura. Ao solicitar suporte, envie o ID público do recurso (`prj_`, `app_`, `tpl_` ou `dep_`) e o horário aproximado da operação — nunca envie o token.
package/dist/index.js CHANGED
@@ -1,19 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
+ import { readFile } from "node:fs/promises";
4
5
  import { credentialStatus, isToken, loadCredentials, normalizeApiUrl, removeCredentials, saveCredentials, } from "./credentials.js";
5
6
  function usage() {
6
7
  console.error(`HAD Deploy CLI
7
8
 
8
9
  Uso:
9
10
  had login [--api-url https://api.exemplo.com] [--token-stdin]
11
+ had token update [--token-stdin]
10
12
  had logout
11
13
  had config
12
14
  had projects list
13
15
  had projects create <name> [--description <text>]
16
+ had projects edit <project-id> [--name <name>] [--description <text>]
17
+ had projects delete <project-id> --yes
14
18
  had apps list <project-id>
15
- had templates list
16
19
  had apps create <project-id> --template <template-id> [--region <region-code>]
20
+ had apps create <project-id> --name <name> --source github --git-connection <id> --repository <url> [--branch <branch>] [--build-type <type>]
21
+ had apps edit <project-id> <app-id> [--name <name>] [--branch <branch>] [--build-type <type>]
22
+ had apps delete <project-id> <app-id> --yes
23
+ had templates list
24
+ had templates create <project-id> <template-id> [--region <region-code>]
25
+ had templates services <project-id>
26
+ had templates deploy <project-id> <service-id>
27
+ had templates deployments <project-id> <service-id>
28
+ had templates status <project-id> <service-id> <deployment-id>
29
+ had templates logs <project-id> <service-id> <deployment-id> [tail]
30
+ had templates edit <project-id> <service-id> --env-file <path>
31
+ had templates delete <project-id> <service-id> --yes
17
32
  had deploy <project-id> <app-id>
18
33
  had status <deployment-id>
19
34
  had logs <deployment-id> [tail]
@@ -37,6 +52,10 @@ async function request(path, init) {
37
52
  const payload = (await response.json().catch(() => ({})));
38
53
  throw new Error(payload.error?.message ?? `A API retornou HTTP ${response.status}.`);
39
54
  }
55
+ if (response.status === 204 ||
56
+ response.headers.get("content-length") === "0") {
57
+ return undefined;
58
+ }
40
59
  return response.json();
41
60
  }
42
61
  async function prompt(question) {
@@ -90,9 +109,10 @@ async function promptSecret(question) {
90
109
  }
91
110
  async function login(args) {
92
111
  const apiUrlIndex = args.indexOf("--api-url");
112
+ const configuredApiUrl = (await credentialStatus()).apiUrl;
93
113
  const apiInput = apiUrlIndex >= 0
94
114
  ? args[apiUrlIndex + 1]
95
- : await prompt("URL da API HAD Deploy: ");
115
+ : (configuredApiUrl ?? (await prompt("URL da API HAD Deploy: ")));
96
116
  if (!apiInput)
97
117
  throw new Error("A URL da API é obrigatória.");
98
118
  const fromStdin = args.includes("--token-stdin");
@@ -134,10 +154,34 @@ function optionValue(args, option) {
134
154
  throw new Error(`Informe um valor para ${option}.`);
135
155
  return value;
136
156
  }
157
+ function requireOption(args, option) {
158
+ const value = optionValue(args, option);
159
+ if (!value)
160
+ throw new Error(`Informe ${option}.`);
161
+ return value;
162
+ }
163
+ function requireConfirmation(args) {
164
+ if (!args.includes("--yes")) {
165
+ throw new Error("Esta operação é destrutiva. Confirme explicitamente com --yes.");
166
+ }
167
+ }
168
+ function integerOption(args, option) {
169
+ const value = optionValue(args, option);
170
+ if (!value)
171
+ return undefined;
172
+ const number = Number(value);
173
+ if (!Number.isInteger(number) || number < 1 || number > 65_535) {
174
+ throw new Error(`${option} deve ser um número inteiro válido.`);
175
+ }
176
+ return number;
177
+ }
137
178
  export async function main(args) {
138
179
  const [group, action, resourceId, extra] = args;
139
180
  if (group === "login")
140
181
  return login(args.slice(1));
182
+ if (group === "token" && action === "update" && !resourceId) {
183
+ return login(args.slice(2));
184
+ }
141
185
  if (group === "logout" && !action) {
142
186
  await removeCredentials();
143
187
  console.log("Credenciais locais removidas.");
@@ -173,6 +217,31 @@ export async function main(args) {
173
217
  console.log(`Projeto ${response.project.name} (${response.project.id}) criado: ${response.project.status}`);
174
218
  return;
175
219
  }
220
+ if (group === "projects" && action === "edit" && resourceId) {
221
+ const name = optionValue(args, "--name");
222
+ const description = optionValue(args, "--description");
223
+ if (!name && !description) {
224
+ throw new Error("Informe pelo menos --name ou --description.");
225
+ }
226
+ const response = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}`, {
227
+ method: "PATCH",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ ...(name ? { name } : {}),
231
+ ...(description ? { description } : {}),
232
+ }),
233
+ });
234
+ console.log(`Projeto ${response.project.name} (${response.project.id}) atualizado: ${response.project.status}`);
235
+ return;
236
+ }
237
+ if (group === "projects" && action === "delete" && resourceId) {
238
+ requireConfirmation(args);
239
+ await request(`/api/v1/projects/${encodeURIComponent(resourceId)}`, {
240
+ method: "DELETE",
241
+ });
242
+ console.log(`Projeto ${resourceId} excluído.`);
243
+ return;
244
+ }
176
245
  if (group === "apps" && action === "list" && resourceId) {
177
246
  const { applications } = await request(`/api/v1/automation/projects/${encodeURIComponent(resourceId)}/apps`);
178
247
  printRows(applications.map((application) => ({
@@ -193,9 +262,146 @@ export async function main(args) {
193
262
  })));
194
263
  return;
195
264
  }
265
+ if (group === "templates" && action === "create" && resourceId && extra) {
266
+ return main([
267
+ "apps",
268
+ "create",
269
+ resourceId,
270
+ "--template",
271
+ extra,
272
+ ...args.slice(4),
273
+ ]);
274
+ }
275
+ if (group === "templates" && action === "services" && resourceId) {
276
+ const { services } = await request(`/api/v1/automation/projects/${encodeURIComponent(resourceId)}/templates`);
277
+ printRows(services.map((service) => ({
278
+ id: service.id,
279
+ name: service.name,
280
+ template: service.templateId,
281
+ version: service.templateVersion ?? "latest",
282
+ status: service.status,
283
+ })));
284
+ return;
285
+ }
286
+ if (group === "templates" && action === "deploy" && resourceId && extra) {
287
+ const response = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}/deploy`, { method: "POST" });
288
+ if (response.deployment) {
289
+ console.log(`Deploy ${response.deployment.id} solicitado: ${response.deployment.status}`);
290
+ }
291
+ else {
292
+ console.log("Deploy solicitado. A execução ainda está sendo registrada pelo provider.");
293
+ }
294
+ return;
295
+ }
296
+ if (group === "templates" &&
297
+ action === "deployments" &&
298
+ resourceId &&
299
+ extra) {
300
+ const { deployments } = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}/deployments`);
301
+ printRows(deployments.map((deployment) => ({
302
+ id: deployment.id,
303
+ status: deployment.status,
304
+ trigger: deployment.trigger,
305
+ created: deployment.createdAt,
306
+ title: deployment.title ?? "-",
307
+ })));
308
+ return;
309
+ }
310
+ if (group === "templates" &&
311
+ action === "status" &&
312
+ resourceId &&
313
+ extra &&
314
+ args[4]) {
315
+ const deploymentId = args[4];
316
+ const { deployments } = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}/deployments`);
317
+ const deployment = deployments.find((candidate) => candidate.id === deploymentId);
318
+ if (!deployment) {
319
+ throw new Error("Deployment de template não encontrado para este serviço.");
320
+ }
321
+ printRows([
322
+ {
323
+ id: deployment.id,
324
+ status: deployment.status,
325
+ trigger: deployment.trigger,
326
+ created: deployment.createdAt,
327
+ title: deployment.title ?? "-",
328
+ },
329
+ ]);
330
+ return;
331
+ }
332
+ if (group === "templates" &&
333
+ action === "logs" &&
334
+ resourceId &&
335
+ extra &&
336
+ args[4]) {
337
+ const deploymentId = args[4];
338
+ const tail = args[5] ?? "200";
339
+ if (!/^\d+$/.test(tail))
340
+ throw new Error("tail deve ser numérico.");
341
+ const { logs } = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}/deployments/${encodeURIComponent(deploymentId)}/logs?tail=${encodeURIComponent(tail)}`);
342
+ for (const log of logs)
343
+ console.log(`[${log.stream}] ${log.message}`);
344
+ return;
345
+ }
346
+ if (group === "templates" && action === "edit" && resourceId && extra) {
347
+ const envFile = requireOption(args, "--env-file");
348
+ const env = await readFile(envFile, "utf8");
349
+ await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}/env`, {
350
+ method: "PUT",
351
+ headers: { "content-type": "application/json" },
352
+ body: JSON.stringify({ env }),
353
+ });
354
+ console.log(`Variáveis do serviço ${extra} atualizadas.`);
355
+ return;
356
+ }
357
+ if (group === "templates" && action === "delete" && resourceId && extra) {
358
+ requireConfirmation(args);
359
+ await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/templates/${encodeURIComponent(extra)}`, { method: "DELETE" });
360
+ console.log(`Serviço de template ${extra} excluído.`);
361
+ return;
362
+ }
196
363
  if (group === "apps" && action === "create" && resourceId) {
197
364
  const templateId = optionValue(args, "--template");
198
365
  const regionCode = optionValue(args, "--region");
366
+ if (!templateId && optionValue(args, "--source")) {
367
+ const name = requireOption(args, "--name");
368
+ const source = requireOption(args, "--source").toUpperCase();
369
+ const repositoryUrl = optionValue(args, "--repository");
370
+ const gitConnectionId = optionValue(args, "--git-connection");
371
+ if (!new Set(["GITHUB", "GIT", "DOCKER", "DOCKERFILE"]).has(source)) {
372
+ throw new Error("--source deve ser github, git, docker ou dockerfile.");
373
+ }
374
+ if (source === "GITHUB" && !gitConnectionId) {
375
+ throw new Error("Aplicações GitHub requerem --git-connection com uma conexão já cadastrada no painel.");
376
+ }
377
+ if (["GITHUB", "GIT", "DOCKERFILE"].includes(source) && !repositoryUrl) {
378
+ throw new Error("Informe --repository para esta origem.");
379
+ }
380
+ const repositoryBranch = optionValue(args, "--branch");
381
+ const buildType = optionValue(args, "--build-type");
382
+ const description = optionValue(args, "--description");
383
+ const runtimePort = integerOption(args, "--port");
384
+ const response = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/apps`, {
385
+ method: "POST",
386
+ headers: {
387
+ "content-type": "application/json",
388
+ "idempotency-key": crypto.randomUUID(),
389
+ },
390
+ body: JSON.stringify({
391
+ name,
392
+ sourceType: source,
393
+ ...(regionCode ? { regionCode } : {}),
394
+ ...(repositoryUrl ? { repositoryUrl } : {}),
395
+ ...(gitConnectionId ? { gitConnectionId } : {}),
396
+ ...(repositoryBranch ? { repositoryBranch } : {}),
397
+ ...(buildType ? { buildType: buildType.toUpperCase() } : {}),
398
+ ...(description ? { description } : {}),
399
+ ...(runtimePort ? { runtimePort } : {}),
400
+ }),
401
+ });
402
+ console.log(`Aplicação ${response.application.name} (${response.application.id}) criada: ${response.application.status}`);
403
+ return;
404
+ }
199
405
  if (!templateId) {
200
406
  throw new Error("Use --template <template-id>. Consulte os modelos com 'had templates list'.");
201
407
  }
@@ -211,6 +417,47 @@ export async function main(args) {
211
417
  }),
212
418
  });
213
419
  console.log(`Serviço ${response.service.name} (${response.service.id}) criado: ${response.service.status}`);
420
+ if (response.deployment) {
421
+ console.log(`Deployment do template: ${response.deployment.id} (${response.deployment.status})`);
422
+ console.log(`Status: had templates status ${resourceId} ${response.service.id} ${response.deployment.id}`);
423
+ console.log(`Logs: had templates logs ${resourceId} ${response.service.id} ${response.deployment.id}`);
424
+ }
425
+ else {
426
+ console.log(`O provider ainda está registrando a execução. Consulte com: had templates deployments ${resourceId} ${response.service.id}`);
427
+ }
428
+ return;
429
+ }
430
+ if (group === "apps" && action === "edit" && resourceId && extra) {
431
+ const name = optionValue(args, "--name");
432
+ const description = optionValue(args, "--description");
433
+ const repositoryBranch = optionValue(args, "--branch");
434
+ const buildType = optionValue(args, "--build-type");
435
+ const runtimePort = integerOption(args, "--port");
436
+ if (!name &&
437
+ !description &&
438
+ !repositoryBranch &&
439
+ !buildType &&
440
+ !runtimePort) {
441
+ throw new Error("Informe ao menos um campo para atualizar a aplicação.");
442
+ }
443
+ const response = await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/apps/${encodeURIComponent(extra)}`, {
444
+ method: "PATCH",
445
+ headers: { "content-type": "application/json" },
446
+ body: JSON.stringify({
447
+ ...(name ? { name } : {}),
448
+ ...(description ? { description } : {}),
449
+ ...(repositoryBranch ? { repositoryBranch } : {}),
450
+ ...(buildType ? { buildType: buildType.toUpperCase() } : {}),
451
+ ...(runtimePort ? { runtimePort } : {}),
452
+ }),
453
+ });
454
+ console.log(`Aplicação ${response.application.name} (${response.application.id}) atualizada.`);
455
+ return;
456
+ }
457
+ if (group === "apps" && action === "delete" && resourceId && extra) {
458
+ requireConfirmation(args);
459
+ await request(`/api/v1/projects/${encodeURIComponent(resourceId)}/apps/${encodeURIComponent(extra)}`, { method: "DELETE" });
460
+ console.log(`Aplicação ${extra} excluída.`);
214
461
  return;
215
462
  }
216
463
  if (group === "deploy" && action && resourceId && !extra) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadcloud/deploy-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {