@hadcloud/deploy-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/dist/credentials.js +98 -0
- package/dist/index.js +156 -0
- package/package.json +21 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
const service = "com.hadcloud.deploy-cli";
|
|
6
|
+
const account = "default";
|
|
7
|
+
function configDirectory() {
|
|
8
|
+
if (process.env.HAD_DEPLOY_CONFIG_DIR)
|
|
9
|
+
return process.env.HAD_DEPLOY_CONFIG_DIR;
|
|
10
|
+
if (platform() === "win32")
|
|
11
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "HAD Deploy");
|
|
12
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "had-deploy");
|
|
13
|
+
}
|
|
14
|
+
const configPath = () => join(configDirectory(), "config.json");
|
|
15
|
+
const tokenPath = () => join(configDirectory(), "token.dpapi");
|
|
16
|
+
async function writeConfig(apiUrl) {
|
|
17
|
+
await mkdir(configDirectory(), { recursive: true, mode: 0o700 });
|
|
18
|
+
await writeFile(configPath(), `${JSON.stringify({ apiUrl, version: 1 }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
async function readConfig() {
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(await readFile(configPath(), "utf8"));
|
|
23
|
+
return typeof value.apiUrl === "string" ? { apiUrl: value.apiUrl } : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function powershell(script, input) {
|
|
30
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", input, windowsHide: true });
|
|
31
|
+
if (result.status !== 0)
|
|
32
|
+
throw new Error("Não foi possível acessar o armazenamento protegido do Windows.");
|
|
33
|
+
return result.stdout.trim();
|
|
34
|
+
}
|
|
35
|
+
async function saveWindowsToken(token) {
|
|
36
|
+
const encrypted = powershell("$plain=[Console]::In.ReadToEnd(); $secure=ConvertTo-SecureString -String $plain -AsPlainText -Force; ConvertFrom-SecureString -SecureString $secure", token);
|
|
37
|
+
await writeFile(tokenPath(), `${encrypted}\n`, { encoding: "utf8", mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
async function readWindowsToken() {
|
|
40
|
+
try {
|
|
41
|
+
const encrypted = await readFile(tokenPath(), "utf8");
|
|
42
|
+
return powershell("$cipher=[Console]::In.ReadToEnd().Trim(); $secure=ConvertTo-SecureString -String $cipher; $bstr=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure); try {[Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)} finally {[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)}", encrypted) || null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function secretTool(args, input) {
|
|
49
|
+
const result = spawnSync("secret-tool", args, { encoding: "utf8", input, windowsHide: true });
|
|
50
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
51
|
+
}
|
|
52
|
+
async function saveToken(token) {
|
|
53
|
+
if (platform() === "win32")
|
|
54
|
+
return saveWindowsToken(token);
|
|
55
|
+
if (platform() === "linux") {
|
|
56
|
+
if (secretTool(["store", "--label=HAD Deploy CLI", "service", service, "account", account], token) === null)
|
|
57
|
+
throw new Error("Secret Service indisponível. Instale secret-tool/libsecret ou use HAD_DEPLOY_TOKEN na sessão.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
throw new Error("Armazenamento de credenciais ainda não está disponível neste sistema. Use HAD_DEPLOY_TOKEN na sessão.");
|
|
61
|
+
}
|
|
62
|
+
async function readStoredToken() {
|
|
63
|
+
if (platform() === "win32")
|
|
64
|
+
return readWindowsToken();
|
|
65
|
+
if (platform() === "linux")
|
|
66
|
+
return secretTool(["lookup", "service", service, "account", account]);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export async function saveCredentials(credentials) {
|
|
70
|
+
await writeConfig(credentials.apiUrl);
|
|
71
|
+
try {
|
|
72
|
+
await saveToken(credentials.token);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await rm(configPath(), { force: true });
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function loadCredentials() {
|
|
80
|
+
const apiUrl = process.env.HAD_DEPLOY_API_URL?.replace(/\/$/, "") ?? (await readConfig())?.apiUrl;
|
|
81
|
+
const token = process.env.HAD_DEPLOY_TOKEN ?? await readStoredToken();
|
|
82
|
+
if (!apiUrl || !token)
|
|
83
|
+
throw new Error("Execute 'had login' ou defina HAD_DEPLOY_TOKEN e HAD_DEPLOY_API_URL para esta sessão.");
|
|
84
|
+
return { apiUrl, token };
|
|
85
|
+
}
|
|
86
|
+
export async function removeCredentials() {
|
|
87
|
+
await rm(configPath(), { force: true });
|
|
88
|
+
if (platform() === "win32")
|
|
89
|
+
await rm(tokenPath(), { force: true });
|
|
90
|
+
if (platform() === "linux")
|
|
91
|
+
secretTool(["clear", "service", service, "account", account]);
|
|
92
|
+
}
|
|
93
|
+
export async function credentialStatus() {
|
|
94
|
+
const config = await readConfig();
|
|
95
|
+
return { apiUrl: process.env.HAD_DEPLOY_API_URL ?? config?.apiUrl ?? null, storage: platform() === "win32" ? "Windows DPAPI" : platform() === "linux" ? "Linux Secret Service" : "Variável de ambiente" };
|
|
96
|
+
}
|
|
97
|
+
export function isToken(value) { return /^hd_cli_[A-Za-z0-9_-]{24,}$/.test(value); }
|
|
98
|
+
export function normalizeApiUrl(value) { return new URL(value).origin; }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { credentialStatus, isToken, loadCredentials, normalizeApiUrl, removeCredentials, saveCredentials, } from "./credentials.js";
|
|
5
|
+
function usage() {
|
|
6
|
+
console.error(`HAD Deploy CLI
|
|
7
|
+
|
|
8
|
+
Uso:
|
|
9
|
+
had login [--api-url https://api.exemplo.com] [--token-stdin]
|
|
10
|
+
had logout
|
|
11
|
+
had config
|
|
12
|
+
had projects list
|
|
13
|
+
had apps list <project-id>
|
|
14
|
+
had deploy <project-id> <app-id>
|
|
15
|
+
had status <deployment-id>
|
|
16
|
+
had logs <deployment-id> [tail]
|
|
17
|
+
|
|
18
|
+
Variáveis opcionais (inclusive para CI):
|
|
19
|
+
HAD_DEPLOY_TOKEN
|
|
20
|
+
HAD_DEPLOY_API_URL`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
async function request(path, init) {
|
|
24
|
+
const credentials = await loadCredentials();
|
|
25
|
+
const response = await fetch(`${credentials.apiUrl}${path}`, {
|
|
26
|
+
...init,
|
|
27
|
+
headers: { authorization: `Bearer ${credentials.token}`, accept: "application/json", ...init?.headers },
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const payload = (await response.json().catch(() => ({})));
|
|
31
|
+
throw new Error(payload.error?.message ?? `A API retornou HTTP ${response.status}.`);
|
|
32
|
+
}
|
|
33
|
+
return response.json();
|
|
34
|
+
}
|
|
35
|
+
async function prompt(question) {
|
|
36
|
+
const readline = createInterface({ input, output });
|
|
37
|
+
try {
|
|
38
|
+
return (await readline.question(question)).trim();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
readline.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function readStdin() {
|
|
45
|
+
let value = "";
|
|
46
|
+
for await (const chunk of input)
|
|
47
|
+
value += chunk.toString();
|
|
48
|
+
return value.trim();
|
|
49
|
+
}
|
|
50
|
+
async function promptSecret(question) {
|
|
51
|
+
if (!input.isTTY)
|
|
52
|
+
return prompt(question);
|
|
53
|
+
output.write(question);
|
|
54
|
+
input.setRawMode(true);
|
|
55
|
+
input.resume();
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let value = "";
|
|
58
|
+
const onData = (chunk) => {
|
|
59
|
+
const character = chunk.toString("utf8");
|
|
60
|
+
if (character === "\r" || character === "\n") {
|
|
61
|
+
cleanup();
|
|
62
|
+
output.write("\n");
|
|
63
|
+
resolve(value.trim());
|
|
64
|
+
}
|
|
65
|
+
else if (character === "\u0003") {
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(new Error("Login cancelado."));
|
|
68
|
+
}
|
|
69
|
+
else if (character === "\u007f" || character === "\b") {
|
|
70
|
+
value = value.slice(0, -1);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
value += character;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
input.off("data", onData);
|
|
78
|
+
input.setRawMode(false);
|
|
79
|
+
input.pause();
|
|
80
|
+
};
|
|
81
|
+
input.on("data", onData);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function login(args) {
|
|
85
|
+
const apiUrlIndex = args.indexOf("--api-url");
|
|
86
|
+
const apiInput = apiUrlIndex >= 0 ? args[apiUrlIndex + 1] : await prompt("URL da API HAD Deploy: ");
|
|
87
|
+
if (!apiInput)
|
|
88
|
+
throw new Error("A URL da API é obrigatória.");
|
|
89
|
+
const fromStdin = args.includes("--token-stdin");
|
|
90
|
+
const token = fromStdin
|
|
91
|
+
? await readStdin()
|
|
92
|
+
: await promptSecret("Token CLI: ");
|
|
93
|
+
if (!isToken(token))
|
|
94
|
+
throw new Error("Token CLI inválido. Crie um token do tipo CLI no painel HAD Deploy.");
|
|
95
|
+
await saveCredentials({ apiUrl: normalizeApiUrl(apiInput), token });
|
|
96
|
+
console.log("Login concluído. O token foi armazenado no armazenamento protegido deste usuário.");
|
|
97
|
+
}
|
|
98
|
+
function printRows(rows) {
|
|
99
|
+
if (!rows.length) {
|
|
100
|
+
console.log("Nenhum recurso encontrado.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
104
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
105
|
+
console.log(columns.map((column, index) => column.toUpperCase().padEnd(widths[index] ?? column.length)).join(" "));
|
|
106
|
+
console.log(widths.map((width) => "-".repeat(width)).join(" "));
|
|
107
|
+
for (const row of rows)
|
|
108
|
+
console.log(columns.map((column, index) => (row[column] ?? "").padEnd(widths[index] ?? 0)).join(" "));
|
|
109
|
+
}
|
|
110
|
+
export async function main(args) {
|
|
111
|
+
const [group, action, resourceId, extra] = args;
|
|
112
|
+
if (group === "login")
|
|
113
|
+
return login(args.slice(1));
|
|
114
|
+
if (group === "logout" && !action) {
|
|
115
|
+
await removeCredentials();
|
|
116
|
+
console.log("Credenciais locais removidas.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (group === "config" && !action) {
|
|
120
|
+
const status = await credentialStatus();
|
|
121
|
+
console.log(`API: ${status.apiUrl ?? "não configurada"}\nArmazenamento: ${status.storage}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (group === "projects" && action === "list" && !resourceId) {
|
|
125
|
+
const { projects } = await request("/api/v1/automation/projects");
|
|
126
|
+
printRows(projects.map((project) => ({ id: project.id, name: project.name, status: project.status })));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (group === "apps" && action === "list" && resourceId) {
|
|
130
|
+
const { applications } = await request(`/api/v1/automation/projects/${encodeURIComponent(resourceId)}/apps`);
|
|
131
|
+
printRows(applications.map((application) => ({ id: application.id, name: application.name, framework: application.framework, status: application.status })));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (group === "deploy" && action && resourceId && !extra) {
|
|
135
|
+
const response = await request(`/api/v1/automation/projects/${encodeURIComponent(action)}/apps/${encodeURIComponent(resourceId)}/deployments`, { method: "POST", headers: { "idempotency-key": crypto.randomUUID() } });
|
|
136
|
+
console.log(`Deploy ${response.deployment.id} solicitado: ${response.deployment.status}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (group === "status" && action && !resourceId) {
|
|
140
|
+
const { deployment } = await request(`/api/v1/automation/deployments/${encodeURIComponent(action)}`);
|
|
141
|
+
printRows([{ id: deployment.id, status: deployment.status, trigger: deployment.trigger, queued: deployment.queuedAt, started: deployment.startedAt ?? "-", finished: deployment.finishedAt ?? "-", error: deployment.errorCode ?? "-" }]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (group === "logs" && action && (!resourceId || /^\d+$/.test(resourceId))) {
|
|
145
|
+
const tail = resourceId ?? "200";
|
|
146
|
+
const { logs } = await request(`/api/v1/automation/deployments/${encodeURIComponent(action)}/logs?tail=${encodeURIComponent(tail)}`);
|
|
147
|
+
for (const log of logs)
|
|
148
|
+
console.log(`[${log.stream}] ${log.message}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
usage();
|
|
152
|
+
}
|
|
153
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
154
|
+
console.error(error instanceof Error ? error.message : "Não foi possível concluir o comando.");
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hadcloud/deploy-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"had": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
18
|
+
"test": "node -e \"process.exit(0)\"",
|
|
19
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
20
|
+
}
|
|
21
|
+
}
|