@tryghost/velo-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/index.d.ts +1 -0
- package/dist/index.js +398 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
|
|
12
|
+
// src/commands/login.ts
|
|
13
|
+
import { createServer } from "http";
|
|
14
|
+
import { URL } from "url";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import open from "open";
|
|
17
|
+
import ora from "ora";
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
21
|
+
import { homedir } from "os";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
var API_BASE_URL = "https://velo.tryghost.workers.dev";
|
|
24
|
+
var DEFAULT_CALLBACK_PORT = 9876;
|
|
25
|
+
function getConfigDir() {
|
|
26
|
+
return join(homedir(), ".config", "velo");
|
|
27
|
+
}
|
|
28
|
+
function getCredentialsPath() {
|
|
29
|
+
return join(getConfigDir(), "credentials.json");
|
|
30
|
+
}
|
|
31
|
+
function loadCredentials() {
|
|
32
|
+
const path = getCredentialsPath();
|
|
33
|
+
if (!existsSync(path)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const data = readFileSync(path, "utf-8");
|
|
38
|
+
const creds = JSON.parse(data);
|
|
39
|
+
if (new Date(creds.expires_at) < /* @__PURE__ */ new Date()) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return creds;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function saveCredentials(credentials) {
|
|
48
|
+
const dir = getConfigDir();
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
writeFileSync(getCredentialsPath(), JSON.stringify(credentials, null, 2));
|
|
53
|
+
}
|
|
54
|
+
function clearCredentials() {
|
|
55
|
+
const path = getCredentialsPath();
|
|
56
|
+
if (existsSync(path)) {
|
|
57
|
+
const { unlinkSync } = __require("fs");
|
|
58
|
+
unlinkSync(path);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/api.ts
|
|
65
|
+
var APIError = class extends Error {
|
|
66
|
+
constructor(message, statusCode, body) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.statusCode = statusCode;
|
|
69
|
+
this.body = body;
|
|
70
|
+
this.name = "APIError";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
async function apiRequest(endpoint, options = {}) {
|
|
74
|
+
const credentials = loadCredentials();
|
|
75
|
+
if (!credentials) {
|
|
76
|
+
throw new APIError("Not logged in. Run `velo login` first.", 401);
|
|
77
|
+
}
|
|
78
|
+
const url = `${API_BASE_URL}${endpoint}`;
|
|
79
|
+
const headers = new Headers(options.headers);
|
|
80
|
+
headers.set("Authorization", `Bearer ${credentials.token}`);
|
|
81
|
+
headers.set("Content-Type", "application/json");
|
|
82
|
+
const response = await fetch(url, {
|
|
83
|
+
...options,
|
|
84
|
+
headers
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = await response.json();
|
|
90
|
+
} catch {
|
|
91
|
+
body = await response.text();
|
|
92
|
+
}
|
|
93
|
+
throw new APIError(
|
|
94
|
+
`API request failed: ${response.statusText}`,
|
|
95
|
+
response.status,
|
|
96
|
+
body
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return response.json();
|
|
100
|
+
}
|
|
101
|
+
async function verifyToken(token) {
|
|
102
|
+
const url = `${API_BASE_URL}/cli/verify`;
|
|
103
|
+
const response = await fetch(url, {
|
|
104
|
+
headers: {
|
|
105
|
+
"Authorization": `Bearer ${token}`
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return response.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/commands/login.ts
|
|
112
|
+
async function login() {
|
|
113
|
+
const existing = loadCredentials();
|
|
114
|
+
if (existing) {
|
|
115
|
+
console.log(chalk.yellow(`Already logged in as ${existing.email}`));
|
|
116
|
+
console.log(chalk.gray(`Token expires: ${new Date(existing.expires_at).toLocaleDateString()}`));
|
|
117
|
+
console.log(chalk.gray(`Run ${chalk.cyan("velo logout")} to log out first.`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const port = DEFAULT_CALLBACK_PORT;
|
|
121
|
+
const loginUrl = `${API_BASE_URL}/cli/login?port=${port}`;
|
|
122
|
+
console.log(chalk.blue("Velo CLI Login"));
|
|
123
|
+
console.log(chalk.gray("Opening browser for authentication...\n"));
|
|
124
|
+
const credentialsPromise = new Promise((resolve, reject) => {
|
|
125
|
+
const server = createServer((req, res) => {
|
|
126
|
+
if (!req.url?.startsWith("/callback")) {
|
|
127
|
+
res.writeHead(404);
|
|
128
|
+
res.end("Not found");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
133
|
+
const token = url.searchParams.get("token");
|
|
134
|
+
const email = url.searchParams.get("email");
|
|
135
|
+
const expiresAt = url.searchParams.get("expires_at");
|
|
136
|
+
if (!token || !email || !expiresAt) {
|
|
137
|
+
throw new Error("Missing required parameters");
|
|
138
|
+
}
|
|
139
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
140
|
+
res.end(`
|
|
141
|
+
<!DOCTYPE html>
|
|
142
|
+
<html>
|
|
143
|
+
<head>
|
|
144
|
+
<title>Velo CLI - Login Success</title>
|
|
145
|
+
<style>
|
|
146
|
+
body {
|
|
147
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
148
|
+
display: flex;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
align-items: center;
|
|
151
|
+
height: 100vh;
|
|
152
|
+
margin: 0;
|
|
153
|
+
background: #0d1117;
|
|
154
|
+
color: #c9d1d9;
|
|
155
|
+
}
|
|
156
|
+
.container {
|
|
157
|
+
text-align: center;
|
|
158
|
+
padding: 40px;
|
|
159
|
+
}
|
|
160
|
+
.success {
|
|
161
|
+
color: #3fb950;
|
|
162
|
+
font-size: 48px;
|
|
163
|
+
margin-bottom: 16px;
|
|
164
|
+
}
|
|
165
|
+
h1 { margin-bottom: 8px; }
|
|
166
|
+
p { color: #8b949e; }
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<div class="container">
|
|
171
|
+
<div class="success">✓</div>
|
|
172
|
+
<h1>Login Successful!</h1>
|
|
173
|
+
<p>You can close this window and return to your terminal.</p>
|
|
174
|
+
</div>
|
|
175
|
+
</body>
|
|
176
|
+
</html>
|
|
177
|
+
`);
|
|
178
|
+
server.close();
|
|
179
|
+
resolve({ token, email, expires_at: expiresAt });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
182
|
+
res.end(`
|
|
183
|
+
<!DOCTYPE html>
|
|
184
|
+
<html>
|
|
185
|
+
<head><title>Login Failed</title></head>
|
|
186
|
+
<body style="font-family: sans-serif; padding: 40px; text-align: center;">
|
|
187
|
+
<h1 style="color: #f85149;">Login Failed</h1>
|
|
188
|
+
<p>Something went wrong. Please try again.</p>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
191
|
+
`);
|
|
192
|
+
server.close();
|
|
193
|
+
reject(new Error("Login failed: Invalid callback"));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
server.on("error", (error) => {
|
|
197
|
+
if (error.code === "EADDRINUSE") {
|
|
198
|
+
reject(new Error(`Port ${port} is already in use. Make sure no other velo login is running.`));
|
|
199
|
+
} else {
|
|
200
|
+
reject(error);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.listen(port, "127.0.0.1", () => {
|
|
204
|
+
open(loginUrl);
|
|
205
|
+
});
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
server.close();
|
|
208
|
+
reject(new Error("Login timed out. Please try again."));
|
|
209
|
+
}, 12e4);
|
|
210
|
+
});
|
|
211
|
+
const spinner = ora("Waiting for browser authentication...").start();
|
|
212
|
+
try {
|
|
213
|
+
const credentials = await credentialsPromise;
|
|
214
|
+
spinner.stop();
|
|
215
|
+
const verification = await verifyToken(credentials.token);
|
|
216
|
+
if (!verification.valid) {
|
|
217
|
+
console.log(chalk.red("Token verification failed. Please try again."));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
saveCredentials(credentials);
|
|
221
|
+
console.log(chalk.green(`
|
|
222
|
+
\u2713 Logged in as ${chalk.bold(credentials.email)}`));
|
|
223
|
+
console.log(chalk.gray(` Token expires: ${new Date(credentials.expires_at).toLocaleDateString()}`));
|
|
224
|
+
console.log(chalk.gray(` Credentials saved to ~/.config/velo/credentials.json`));
|
|
225
|
+
console.log(chalk.gray(`
|
|
226
|
+
Run ${chalk.cyan("velo status")} to see CI metrics.`));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
spinner.stop();
|
|
229
|
+
console.log(chalk.red(`
|
|
230
|
+
${error.message}`));
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/commands/logout.ts
|
|
236
|
+
import chalk2 from "chalk";
|
|
237
|
+
async function logout() {
|
|
238
|
+
const existing = loadCredentials();
|
|
239
|
+
if (!existing) {
|
|
240
|
+
console.log(chalk2.yellow("Not logged in."));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
clearCredentials();
|
|
244
|
+
console.log(chalk2.green(`Logged out from ${existing.email}`));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/commands/status.ts
|
|
248
|
+
import chalk3 from "chalk";
|
|
249
|
+
async function status(options) {
|
|
250
|
+
const credentials = loadCredentials();
|
|
251
|
+
if (!credentials) {
|
|
252
|
+
console.log(chalk3.red("Not logged in."));
|
|
253
|
+
console.log(chalk3.gray(`Run ${chalk3.cyan("velo login")} to authenticate.`));
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
console.log(chalk3.blue("Velo CI Status"));
|
|
257
|
+
console.log(chalk3.gray(`Logged in as ${credentials.email}
|
|
258
|
+
`));
|
|
259
|
+
const days = options.days || 7;
|
|
260
|
+
try {
|
|
261
|
+
let endpoint = `/api/metrics?period=${days}d`;
|
|
262
|
+
if (options.repo) {
|
|
263
|
+
endpoint += `&repo=${encodeURIComponent(options.repo)}`;
|
|
264
|
+
}
|
|
265
|
+
const metrics = await apiRequest(endpoint);
|
|
266
|
+
console.log(chalk3.white.bold("Last " + days + " days:"));
|
|
267
|
+
console.log(` Total runs: ${chalk3.cyan(metrics.data.total_runs.toLocaleString())}`);
|
|
268
|
+
console.log(` Success rate: ${formatRate(metrics.data.success_rate)}`);
|
|
269
|
+
console.log(` Avg duration: ${chalk3.gray(metrics.data.avg_duration_minutes.toFixed(1) + " min")}`);
|
|
270
|
+
console.log();
|
|
271
|
+
const repos = Object.entries(metrics.data.by_repo).sort((a, b) => b[1].total - a[1].total).slice(0, 5);
|
|
272
|
+
if (repos.length > 0) {
|
|
273
|
+
console.log(chalk3.white.bold("Top repos:"));
|
|
274
|
+
for (const [repo, data] of repos) {
|
|
275
|
+
const shortName = repo.split("/")[1] || repo;
|
|
276
|
+
const rateStr = formatRate(data.success_rate);
|
|
277
|
+
console.log(` ${chalk3.cyan(shortName.padEnd(20))} ${data.total.toString().padStart(5)} runs ${rateStr}`);
|
|
278
|
+
}
|
|
279
|
+
console.log();
|
|
280
|
+
}
|
|
281
|
+
const failuresEndpoint = `/api/classifications?limit=5&classification=SOFT${options.repo ? `&repo=${encodeURIComponent(options.repo)}` : ""}`;
|
|
282
|
+
try {
|
|
283
|
+
const failures = await apiRequest(failuresEndpoint);
|
|
284
|
+
if (failures.classifications?.length > 0) {
|
|
285
|
+
console.log(chalk3.white.bold("Recent SOFT failures (code issues):"));
|
|
286
|
+
for (const f of failures.classifications) {
|
|
287
|
+
const shortRepo = f.repo.split("/")[1] || f.repo;
|
|
288
|
+
const time = new Date(f.created_at).toLocaleDateString();
|
|
289
|
+
console.log(` ${chalk3.yellow(shortRepo.padEnd(20))} ${f.branch?.substring(0, 25).padEnd(25)} ${chalk3.gray(time)}`);
|
|
290
|
+
if (f.explanation) {
|
|
291
|
+
console.log(chalk3.gray(` ${f.explanation.substring(0, 80)}...`));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
console.log();
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof APIError) {
|
|
300
|
+
console.log(chalk3.red(`API Error: ${error.message}`));
|
|
301
|
+
if (error.statusCode === 401) {
|
|
302
|
+
console.log(chalk3.gray(`Your session may have expired. Run ${chalk3.cyan("velo login")} again.`));
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
console.log(chalk3.red(`Error: ${error.message}`));
|
|
306
|
+
}
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function formatRate(rate) {
|
|
311
|
+
if (rate >= 90) return chalk3.green(`${rate.toFixed(1)}%`);
|
|
312
|
+
if (rate >= 75) return chalk3.yellow(`${rate.toFixed(1)}%`);
|
|
313
|
+
return chalk3.red(`${rate.toFixed(1)}%`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/commands/api.ts
|
|
317
|
+
import chalk4 from "chalk";
|
|
318
|
+
async function api(endpoint, options) {
|
|
319
|
+
const credentials = loadCredentials();
|
|
320
|
+
if (!credentials) {
|
|
321
|
+
console.log(chalk4.red("Not logged in."));
|
|
322
|
+
console.log(chalk4.gray(`Run ${chalk4.cyan("velo login")} to authenticate.`));
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
if (!endpoint.startsWith("/")) {
|
|
326
|
+
endpoint = "/" + endpoint;
|
|
327
|
+
}
|
|
328
|
+
const url = `${API_BASE_URL}${endpoint}`;
|
|
329
|
+
const method = options.method?.toUpperCase() || "GET";
|
|
330
|
+
const headers = {
|
|
331
|
+
"Authorization": `Bearer ${credentials.token}`,
|
|
332
|
+
"Content-Type": "application/json"
|
|
333
|
+
};
|
|
334
|
+
const fetchOptions = {
|
|
335
|
+
method,
|
|
336
|
+
headers
|
|
337
|
+
};
|
|
338
|
+
if (options.data && (method === "POST" || method === "PATCH" || method === "PUT")) {
|
|
339
|
+
fetchOptions.body = options.data;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const response = await fetch(url, fetchOptions);
|
|
343
|
+
const contentType = response.headers.get("content-type") || "";
|
|
344
|
+
if (options.raw) {
|
|
345
|
+
if (contentType.includes("application/json")) {
|
|
346
|
+
const data = await response.json();
|
|
347
|
+
console.log(JSON.stringify(data, null, 2));
|
|
348
|
+
} else {
|
|
349
|
+
console.log(await response.text());
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
const statusColor = response.ok ? chalk4.green : chalk4.red;
|
|
353
|
+
console.log(`${statusColor(response.status)} ${chalk4.gray(response.statusText)} ${chalk4.cyan(url)}`);
|
|
354
|
+
console.log();
|
|
355
|
+
if (contentType.includes("application/json")) {
|
|
356
|
+
const data = await response.json();
|
|
357
|
+
console.log(JSON.stringify(data, null, 2));
|
|
358
|
+
} else {
|
|
359
|
+
console.log(await response.text());
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.log(chalk4.red(`Error: ${error.message}`));
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/index.ts
|
|
372
|
+
var program = new Command();
|
|
373
|
+
program.name("velo").description("CLI for Velo CI/CD metrics").version("0.1.0");
|
|
374
|
+
program.command("login").description("Authenticate via Ghost SSO").action(login);
|
|
375
|
+
program.command("logout").description("Clear saved credentials").action(logout);
|
|
376
|
+
program.command("status").description("Show CI health overview").option("-r, --repo <repo>", "Filter by repository (e.g., TryGhost/Ghost)").option("-d, --days <days>", "Number of days to analyze (default: 7)", "7").action((options) => {
|
|
377
|
+
status({
|
|
378
|
+
repo: options.repo,
|
|
379
|
+
days: parseInt(options.days, 10)
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
program.command("api <endpoint>").description("Make raw API calls").option("-m, --method <method>", "HTTP method (GET, POST, etc.)", "GET").option("-d, --data <json>", "Request body (JSON string)").option("--raw", "Output response only (no status line)").action((endpoint, options) => {
|
|
383
|
+
api(endpoint, {
|
|
384
|
+
method: options.method,
|
|
385
|
+
data: options.data,
|
|
386
|
+
raw: options.raw
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
program.addHelpText("after", `
|
|
390
|
+
Examples:
|
|
391
|
+
$ velo login # Authenticate via browser SSO
|
|
392
|
+
$ velo status # Quick CI health check
|
|
393
|
+
$ velo status --repo TryGhost/Ghost # Status for specific repo
|
|
394
|
+
$ velo api /api/health # Check API health
|
|
395
|
+
$ velo api /api/dora?period=30d # Get DORA metrics
|
|
396
|
+
$ velo api /api/classifications?limit=10 # Recent CI failures
|
|
397
|
+
`);
|
|
398
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tryghost/velo-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Velo CI/CD metrics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"velo": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
12
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
13
|
+
"start": "node dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"commander": "^12.1.0",
|
|
17
|
+
"chalk": "^5.3.0",
|
|
18
|
+
"open": "^10.1.0",
|
|
19
|
+
"ora": "^8.0.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.11.0",
|
|
23
|
+
"tsup": "^8.0.1",
|
|
24
|
+
"typescript": "^5.3.3"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"ci",
|
|
34
|
+
"cd",
|
|
35
|
+
"metrics",
|
|
36
|
+
"ghost",
|
|
37
|
+
"velo"
|
|
38
|
+
],
|
|
39
|
+
"author": "Ghost Foundation",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/TryGhost/Velo.git",
|
|
44
|
+
"directory": "packages/cli"
|
|
45
|
+
}
|
|
46
|
+
}
|