@hadcloud/deploy-cli 0.1.1 → 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 +266 -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,18 +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
15
+ had projects create <name> [--description <text>]
16
+ had projects edit <project-id> [--name <name>] [--description <text>]
17
+ had projects delete <project-id> --yes
13
18
  had apps list <project-id>
14
- had templates list
15
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
16
32
  had deploy <project-id> <app-id>
17
33
  had status <deployment-id>
18
34
  had logs <deployment-id> [tail]
@@ -36,6 +52,10 @@ async function request(path, init) {
36
52
  const payload = (await response.json().catch(() => ({})));
37
53
  throw new Error(payload.error?.message ?? `A API retornou HTTP ${response.status}.`);
38
54
  }
55
+ if (response.status === 204 ||
56
+ response.headers.get("content-length") === "0") {
57
+ return undefined;
58
+ }
39
59
  return response.json();
40
60
  }
41
61
  async function prompt(question) {
@@ -89,9 +109,10 @@ async function promptSecret(question) {
89
109
  }
90
110
  async function login(args) {
91
111
  const apiUrlIndex = args.indexOf("--api-url");
112
+ const configuredApiUrl = (await credentialStatus()).apiUrl;
92
113
  const apiInput = apiUrlIndex >= 0
93
114
  ? args[apiUrlIndex + 1]
94
- : await prompt("URL da API HAD Deploy: ");
115
+ : (configuredApiUrl ?? (await prompt("URL da API HAD Deploy: ")));
95
116
  if (!apiInput)
96
117
  throw new Error("A URL da API é obrigatória.");
97
118
  const fromStdin = args.includes("--token-stdin");
@@ -133,10 +154,34 @@ function optionValue(args, option) {
133
154
  throw new Error(`Informe um valor para ${option}.`);
134
155
  return value;
135
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
+ }
136
178
  export async function main(args) {
137
179
  const [group, action, resourceId, extra] = args;
138
180
  if (group === "login")
139
181
  return login(args.slice(1));
182
+ if (group === "token" && action === "update" && !resourceId) {
183
+ return login(args.slice(2));
184
+ }
140
185
  if (group === "logout" && !action) {
141
186
  await removeCredentials();
142
187
  console.log("Credenciais locais removidas.");
@@ -156,6 +201,47 @@ export async function main(args) {
156
201
  })));
157
202
  return;
158
203
  }
204
+ if (group === "projects" && action === "create" && resourceId) {
205
+ const description = optionValue(args, "--description");
206
+ const response = await request("/api/v1/projects", {
207
+ method: "POST",
208
+ headers: {
209
+ "content-type": "application/json",
210
+ "idempotency-key": crypto.randomUUID(),
211
+ },
212
+ body: JSON.stringify({
213
+ name: resourceId,
214
+ ...(description ? { description } : {}),
215
+ }),
216
+ });
217
+ console.log(`Projeto ${response.project.name} (${response.project.id}) criado: ${response.project.status}`);
218
+ return;
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
+ }
159
245
  if (group === "apps" && action === "list" && resourceId) {
160
246
  const { applications } = await request(`/api/v1/automation/projects/${encodeURIComponent(resourceId)}/apps`);
161
247
  printRows(applications.map((application) => ({
@@ -176,9 +262,146 @@ export async function main(args) {
176
262
  })));
177
263
  return;
178
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
+ }
179
363
  if (group === "apps" && action === "create" && resourceId) {
180
364
  const templateId = optionValue(args, "--template");
181
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
+ }
182
405
  if (!templateId) {
183
406
  throw new Error("Use --template <template-id>. Consulte os modelos com 'had templates list'.");
184
407
  }
@@ -194,6 +417,47 @@ export async function main(args) {
194
417
  }),
195
418
  });
196
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.`);
197
461
  return;
198
462
  }
199
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.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {