@lizard-build/cli 0.3.42 → 0.3.44
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/commands/events.d.ts +2 -0
- package/dist/commands/events.js +70 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/logs.js +103 -32
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/metrics.d.ts +2 -0
- package/dist/commands/metrics.js +280 -0
- package/dist/commands/metrics.js.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.js +143 -62
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/src/commands/events.ts +106 -0
- package/src/commands/logs.ts +110 -34
- package/src/commands/metrics.ts +390 -0
- package/src/index.ts +4 -0
- package/src/lib/api.ts +158 -70
- package/src/lib/updater.ts +1 -1
- package/test/unit/sse.test.ts +173 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { api, withScope, withQuery, APIError, type ResourceScope } from "../lib/api.js";
|
|
4
|
+
import { getProjectLink } from "../lib/config.js";
|
|
5
|
+
import { resolveProjectScope, getActiveServiceWithKind } from "../lib/resolve.js";
|
|
6
|
+
import { info, error, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
|
|
7
|
+
|
|
8
|
+
// Shapes returned by /api/apps/:id/metrics and /api/projects/:id/metrics.
|
|
9
|
+
// Units: series cpu = vCPUs, memory/disk_used/disk_total = bytes,
|
|
10
|
+
// network_rx/tx + disk_read/write = bytes/s; latest mem is in MB.
|
|
11
|
+
interface SeriesItem {
|
|
12
|
+
metric: string;
|
|
13
|
+
timestamps: number[]; // seconds
|
|
14
|
+
values: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LatestMetrics {
|
|
18
|
+
cpu: number; // vCPUs in use
|
|
19
|
+
memUsedMb: number;
|
|
20
|
+
memTotalMb: number;
|
|
21
|
+
sampledAt: number; // ms
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EntityMetricsResult {
|
|
25
|
+
series: SeriesItem[];
|
|
26
|
+
latest: LatestMetrics | null;
|
|
27
|
+
limits: { cpuMillis: number; memoryMi: number };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ProjectEntityMetrics extends EntityMetricsResult {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
type: "app" | "addon";
|
|
34
|
+
deleted: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RANGES = ["1h", "6h", "24h", "7d", "14d", "30d"];
|
|
38
|
+
|
|
39
|
+
export function registerMetrics(program: Command) {
|
|
40
|
+
program
|
|
41
|
+
.command("metrics")
|
|
42
|
+
.description("Show resource metrics (CPU, memory, network, disk) and cost")
|
|
43
|
+
.option("-s, --service <id>", "Service name or ID (defaults to linked service)")
|
|
44
|
+
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
45
|
+
.option("-r, --range <range>", `Time range: ${RANGES.join("|")}`, "1h")
|
|
46
|
+
.option("-w, --watch", "Live view, refreshed every 3s (Ctrl+C to stop)")
|
|
47
|
+
.option("--cost", "Show running resources and cost per hour instead of metrics")
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
if (!RANGES.includes(opts.range)) {
|
|
50
|
+
error(`Invalid --range "${opts.range}". Choose one of: ${RANGES.join(", ")}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (opts.watch && isJSONMode()) {
|
|
54
|
+
error("--watch is interactive and cannot be combined with --json (poll without --watch instead)");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
59
|
+
|
|
60
|
+
if (opts.cost) {
|
|
61
|
+
await showCost(projectId, scope);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Service detail when -s is given or a service is linked; otherwise
|
|
66
|
+
// an overview of every service in the project.
|
|
67
|
+
const hasService = Boolean(opts.service || getProjectLink()?.serviceId);
|
|
68
|
+
|
|
69
|
+
if (opts.watch) {
|
|
70
|
+
let serviceId: string | undefined;
|
|
71
|
+
if (hasService) {
|
|
72
|
+
serviceId = (await getActiveServiceWithKind(opts.service, projectId)).id;
|
|
73
|
+
}
|
|
74
|
+
await watchLive(projectId, scope, serviceId);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (hasService) {
|
|
79
|
+
await showServiceMetrics(opts.service, projectId, scope, opts.range);
|
|
80
|
+
} else {
|
|
81
|
+
await showProjectOverview(projectId, scope);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Formatting helpers ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function fmtBytes(b: number): string {
|
|
89
|
+
if (!isFinite(b) || b < 0) return "—";
|
|
90
|
+
if (b < 1024) return `${Math.round(b)} B`;
|
|
91
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
|
92
|
+
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
|
93
|
+
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fmtRate(b: number): string {
|
|
97
|
+
return `${fmtBytes(b)}/s`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fmtMb(mb: number): string {
|
|
101
|
+
return fmtBytes(mb * 1024 * 1024);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fmtVcpu(v: number): string {
|
|
105
|
+
return v.toFixed(2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Render values as a unicode sparkline, downsampled to `width` buckets. */
|
|
109
|
+
function sparkline(values: number[], width = 30): string {
|
|
110
|
+
if (values.length === 0) return "";
|
|
111
|
+
const blocks = "▁▂▃▄▅▆▇█";
|
|
112
|
+
const buckets: number[] = [];
|
|
113
|
+
const per = Math.max(1, Math.ceil(values.length / width));
|
|
114
|
+
for (let i = 0; i < values.length; i += per) {
|
|
115
|
+
const slice = values.slice(i, i + per);
|
|
116
|
+
buckets.push(slice.reduce((a, b) => a + b, 0) / slice.length);
|
|
117
|
+
}
|
|
118
|
+
const max = Math.max(...buckets);
|
|
119
|
+
if (max <= 0) return chalk.dim(blocks[0].repeat(buckets.length));
|
|
120
|
+
return buckets
|
|
121
|
+
.map((v) => blocks[Math.min(blocks.length - 1, Math.floor((v / max) * (blocks.length - 1) + 0.5))])
|
|
122
|
+
.join("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function seriesByName(series: SeriesItem[], name: string): number[] {
|
|
126
|
+
return series.find((s) => s.metric === name)?.values ?? [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface MetricStats {
|
|
130
|
+
now: number | null;
|
|
131
|
+
min: number;
|
|
132
|
+
avg: number;
|
|
133
|
+
max: number;
|
|
134
|
+
values: number[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** min/avg/max over a series. Rate metrics carry an artificial 0 as their
|
|
138
|
+
* first sample (no previous counter to diff against) — skip it. */
|
|
139
|
+
function stats(values: number[], isRate = false): MetricStats | null {
|
|
140
|
+
const vals = isRate && values.length > 1 ? values.slice(1) : values;
|
|
141
|
+
if (vals.length === 0) return null;
|
|
142
|
+
return {
|
|
143
|
+
now: vals[vals.length - 1],
|
|
144
|
+
min: Math.min(...vals),
|
|
145
|
+
avg: vals.reduce((a, b) => a + b, 0) / vals.length,
|
|
146
|
+
max: Math.max(...vals),
|
|
147
|
+
values: vals,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Service detail ────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
async function fetchServiceMetrics(
|
|
154
|
+
svc: { id: string; kind: "app" | "addon" },
|
|
155
|
+
projectId: string,
|
|
156
|
+
scope: ResourceScope,
|
|
157
|
+
range: string,
|
|
158
|
+
): Promise<EntityMetricsResult> {
|
|
159
|
+
const path =
|
|
160
|
+
svc.kind === "app"
|
|
161
|
+
? withQuery(`/api/apps/${svc.id}/metrics`, { range })
|
|
162
|
+
: withScope(
|
|
163
|
+
withQuery(`/api/projects/${projectId}/addons/${svc.id}/metrics`, { range }),
|
|
164
|
+
scope,
|
|
165
|
+
);
|
|
166
|
+
return api.get<EntityMetricsResult>(path);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function showServiceMetrics(
|
|
170
|
+
serviceFlag: string | undefined,
|
|
171
|
+
projectId: string,
|
|
172
|
+
scope: ResourceScope,
|
|
173
|
+
range: string,
|
|
174
|
+
) {
|
|
175
|
+
const svc = await getActiveServiceWithKind(serviceFlag, projectId);
|
|
176
|
+
const data = await fetchServiceMetrics(svc, projectId, scope, range);
|
|
177
|
+
|
|
178
|
+
if (isJSONMode()) {
|
|
179
|
+
printJSON({ service: { id: svc.id, name: svc.name, kind: svc.kind }, range, ...data });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { series, latest, limits } = data;
|
|
184
|
+
if (series.length === 0 && !latest) {
|
|
185
|
+
info(chalk.dim(`No metrics for ${svc.name} yet — the service may be new or stopped.`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sampled = latest ? timeAgo(latest.sampledAt) : chalk.dim("no live data");
|
|
190
|
+
console.log(chalk.bold(svc.name) + chalk.dim(` (${svc.kind}) — last ${range}, sampled ${sampled}`));
|
|
191
|
+
console.log();
|
|
192
|
+
|
|
193
|
+
const cpu = stats(seriesByName(series, "cpu"));
|
|
194
|
+
const mem = stats(seriesByName(series, "memory"));
|
|
195
|
+
const rx = stats(seriesByName(series, "network_rx"), true);
|
|
196
|
+
const tx = stats(seriesByName(series, "network_tx"), true);
|
|
197
|
+
const dr = stats(seriesByName(series, "disk_read"), true);
|
|
198
|
+
const dw = stats(seriesByName(series, "disk_write"), true);
|
|
199
|
+
|
|
200
|
+
const rows: string[][] = [];
|
|
201
|
+
const push = (
|
|
202
|
+
label: string,
|
|
203
|
+
s: MetricStats | null,
|
|
204
|
+
fmt: (v: number) => string,
|
|
205
|
+
now?: number | null,
|
|
206
|
+
) => {
|
|
207
|
+
if (!s) return;
|
|
208
|
+
const current = now ?? s.now;
|
|
209
|
+
rows.push([
|
|
210
|
+
label,
|
|
211
|
+
current === null || current === undefined ? chalk.dim("—") : fmt(current),
|
|
212
|
+
fmt(s.min),
|
|
213
|
+
fmt(s.avg),
|
|
214
|
+
fmt(s.max),
|
|
215
|
+
chalk.cyan(sparkline(s.values)),
|
|
216
|
+
]);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// "Now" for CPU/memory prefers the live Redis snapshot over the last
|
|
220
|
+
// (up to 90s stale) historical sample.
|
|
221
|
+
push("CPU (vCPU)", cpu, fmtVcpu, latest ? latest.cpu : undefined);
|
|
222
|
+
push("Memory", mem, fmtBytes, latest ? latest.memUsedMb * 1024 * 1024 : undefined);
|
|
223
|
+
push("Net ↓", rx, fmtRate);
|
|
224
|
+
push("Net ↑", tx, fmtRate);
|
|
225
|
+
push("Disk read", dr, fmtRate);
|
|
226
|
+
push("Disk write", dw, fmtRate);
|
|
227
|
+
|
|
228
|
+
if (rows.length > 0) {
|
|
229
|
+
table(["Metric", "Now", "Min", "Avg", "Max", "Trend"], rows);
|
|
230
|
+
} else if (latest) {
|
|
231
|
+
// Live snapshot only (brand-new VM, no history rows yet)
|
|
232
|
+
table(
|
|
233
|
+
["Metric", "Now"],
|
|
234
|
+
[
|
|
235
|
+
["CPU (vCPU)", fmtVcpu(latest.cpu)],
|
|
236
|
+
["Memory", `${fmtMb(latest.memUsedMb)} / ${fmtMb(latest.memTotalMb)}`],
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log();
|
|
242
|
+
const diskUsed = seriesByName(series, "disk_used");
|
|
243
|
+
const diskTotal = seriesByName(series, "disk_total");
|
|
244
|
+
if (diskUsed.length > 0) {
|
|
245
|
+
const used = diskUsed[diskUsed.length - 1];
|
|
246
|
+
const total = diskTotal[diskTotal.length - 1] ?? 0;
|
|
247
|
+
console.log(
|
|
248
|
+
chalk.dim("Disk used ") + fmtBytes(used) + (total > 0 ? chalk.dim(` / ${fmtBytes(total)}`) : ""),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
console.log(
|
|
252
|
+
chalk.dim("Limits ") +
|
|
253
|
+
`${(limits.cpuMillis / 1000).toFixed(limits.cpuMillis % 1000 === 0 ? 0 : 1)} vCPU · ${fmtMb(limits.memoryMi)} memory`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Project overview ──────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
async function fetchLive(projectId: string, scope: ResourceScope): Promise<ProjectEntityMetrics[]> {
|
|
260
|
+
const data = await api.get<{ services: ProjectEntityMetrics[] }>(
|
|
261
|
+
withScope(withQuery(`/api/projects/${projectId}/metrics`, { live: true }), scope),
|
|
262
|
+
);
|
|
263
|
+
return (data.services || []).filter((s) => !s.deleted);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function overviewRows(services: ProjectEntityMetrics[]): string[][] {
|
|
267
|
+
return services.map((s) => {
|
|
268
|
+
const cpuLimit = s.limits.cpuMillis / 1000;
|
|
269
|
+
return [
|
|
270
|
+
s.label,
|
|
271
|
+
s.type,
|
|
272
|
+
s.latest ? `${fmtVcpu(s.latest.cpu)} / ${cpuLimit}` : chalk.dim("—"),
|
|
273
|
+
s.latest ? `${fmtMb(s.latest.memUsedMb)} / ${fmtMb(s.latest.memTotalMb)}` : chalk.dim("—"),
|
|
274
|
+
s.latest ? timeAgo(s.latest.sampledAt) : chalk.dim("no data"),
|
|
275
|
+
];
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function showProjectOverview(projectId: string, scope: ResourceScope) {
|
|
280
|
+
const services = await fetchLive(projectId, scope);
|
|
281
|
+
|
|
282
|
+
if (isJSONMode()) {
|
|
283
|
+
printJSON({ services });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (services.length === 0) {
|
|
288
|
+
console.log("No services. Use `lizard add` or `lizard up`.");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
table(["Service", "Type", "CPU (vCPU)", "Memory", "Sampled"], overviewRows(services));
|
|
293
|
+
info(chalk.dim("\nDetails: lizard metrics -s <service> Live: lizard metrics --watch"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Watch mode ────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
async function watchLive(projectId: string, scope: ResourceScope, serviceId?: string) {
|
|
299
|
+
info(chalk.dim("Watching metrics... (Ctrl+C to stop)"));
|
|
300
|
+
// Capture console output so each refresh replaces the previous frame
|
|
301
|
+
// instead of scrolling.
|
|
302
|
+
for (;;) {
|
|
303
|
+
let services: ProjectEntityMetrics[];
|
|
304
|
+
try {
|
|
305
|
+
services = await fetchLive(projectId, scope);
|
|
306
|
+
} catch (e: any) {
|
|
307
|
+
error(e.message || String(e));
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
if (serviceId) services = services.filter((s) => s.id === serviceId);
|
|
311
|
+
|
|
312
|
+
process.stdout.write("\x1b[2J\x1b[H"); // clear screen, cursor home
|
|
313
|
+
console.log(chalk.dim(new Date().toLocaleTimeString()) + chalk.dim(" (refreshes every 3s, Ctrl+C to stop)"));
|
|
314
|
+
console.log();
|
|
315
|
+
if (services.length === 0) {
|
|
316
|
+
console.log(chalk.dim("No services."));
|
|
317
|
+
} else {
|
|
318
|
+
table(["Service", "Type", "CPU (vCPU)", "Memory", "Sampled"], overviewRows(services));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Cost ──────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
interface BillingResource {
|
|
328
|
+
projectId: string;
|
|
329
|
+
type: "app" | "addon";
|
|
330
|
+
name: string;
|
|
331
|
+
addonType?: string;
|
|
332
|
+
vcpu: number;
|
|
333
|
+
memoryGb: number;
|
|
334
|
+
storageGb: number;
|
|
335
|
+
costPerHour: number;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function showCost(projectId: string, scope: ResourceScope) {
|
|
339
|
+
if (!scope.workspaceId) {
|
|
340
|
+
error("Could not resolve the workspace for this project. Run `lizard link` first.");
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let data: { resources: BillingResource[]; costPerHour: number };
|
|
345
|
+
try {
|
|
346
|
+
data = await api.get(withQuery("/api/billing/live", { workspaceId: scope.workspaceId }));
|
|
347
|
+
} catch (e) {
|
|
348
|
+
if (e instanceof APIError && e.status === 403) {
|
|
349
|
+
error("Billing is only visible to the workspace owner.");
|
|
350
|
+
process.exit(2);
|
|
351
|
+
}
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const mine = data.resources.filter((r) => r.projectId === projectId);
|
|
356
|
+
const projectCost = mine.reduce((a, r) => a + r.costPerHour, 0);
|
|
357
|
+
|
|
358
|
+
if (isJSONMode()) {
|
|
359
|
+
printJSON({
|
|
360
|
+
projectId,
|
|
361
|
+
resources: mine,
|
|
362
|
+
projectCostPerHour: projectCost,
|
|
363
|
+
workspaceCostPerHour: data.costPerHour,
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (mine.length === 0) {
|
|
369
|
+
console.log("No running resources in this project.");
|
|
370
|
+
} else {
|
|
371
|
+
table(
|
|
372
|
+
["Resource", "Type", "vCPU", "Memory", "Storage", "$/hr"],
|
|
373
|
+
mine.map((r) => [
|
|
374
|
+
r.name,
|
|
375
|
+
r.addonType ? `addon (${r.addonType})` : r.type,
|
|
376
|
+
fmtVcpu(r.vcpu),
|
|
377
|
+
`${r.memoryGb.toFixed(2)} GB`,
|
|
378
|
+
r.storageGb > 0 ? `${r.storageGb} GB` : chalk.dim("—"),
|
|
379
|
+
`$${r.costPerHour.toFixed(4)}`,
|
|
380
|
+
]),
|
|
381
|
+
);
|
|
382
|
+
console.log();
|
|
383
|
+
console.log(
|
|
384
|
+
chalk.dim("Project ") +
|
|
385
|
+
`$${projectCost.toFixed(4)}/hr` +
|
|
386
|
+
chalk.dim(` (~$${(projectCost * 730).toFixed(2)}/mo at current usage)`),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
console.log(chalk.dim("Workspace ") + `$${data.costPerHour.toFixed(4)}/hr`);
|
|
390
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,12 +27,14 @@ import { registerAdd } from "./commands/add.js";
|
|
|
27
27
|
import { registerConfig } from "./commands/config.js";
|
|
28
28
|
import { registerDocs } from "./commands/docs.js";
|
|
29
29
|
import { registerDomain } from "./commands/domain.js";
|
|
30
|
+
import { registerEvents } from "./commands/events.js";
|
|
30
31
|
import { registerGit } from "./commands/git.js";
|
|
31
32
|
import { registerInit } from "./commands/init.js";
|
|
32
33
|
import { registerLink } from "./commands/link.js";
|
|
33
34
|
import { registerLogin } from "./commands/login.js";
|
|
34
35
|
import { registerLogout } from "./commands/logout.js";
|
|
35
36
|
import { registerLogs } from "./commands/logs.js";
|
|
37
|
+
import { registerMetrics } from "./commands/metrics.js";
|
|
36
38
|
import { registerOpen } from "./commands/open.js";
|
|
37
39
|
import { registerPort } from "./commands/port.js";
|
|
38
40
|
import { registerProjects } from "./commands/projects.js";
|
|
@@ -108,12 +110,14 @@ registerAdd(program);
|
|
|
108
110
|
registerConfig(program);
|
|
109
111
|
registerDocs(program);
|
|
110
112
|
registerDomain(program);
|
|
113
|
+
registerEvents(program);
|
|
111
114
|
registerGit(program);
|
|
112
115
|
registerInit(program);
|
|
113
116
|
registerLink(program);
|
|
114
117
|
registerLogin(program);
|
|
115
118
|
registerLogout(program);
|
|
116
119
|
registerLogs(program);
|
|
120
|
+
registerMetrics(program);
|
|
117
121
|
registerOpen(program);
|
|
118
122
|
registerPort(program);
|
|
119
123
|
registerProjects(program);
|
package/src/lib/api.ts
CHANGED
|
@@ -129,90 +129,178 @@ export const api = {
|
|
|
129
129
|
delete: <T = any>(path: string) => request<T>("DELETE", path),
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/** Compare two Redis-stream-style event ids (`<ms>-<seq>`). Returns true when
|
|
133
|
+
* `id` is at or before `last` — i.e. a replayed event we've already shown.
|
|
134
|
+
* Ids in any other format never count as replays. */
|
|
135
|
+
function isReplayedId(id: string, last: string): boolean {
|
|
136
|
+
const a = id.split("-").map(Number);
|
|
137
|
+
const b = last.split("-").map(Number);
|
|
138
|
+
if (a.length !== 2 || b.length !== 2 || a.some(Number.isNaN) || b.some(Number.isNaN)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
145
|
+
|
|
146
|
+
/** Stream SSE and call handler for each event. Return false to stop.
|
|
133
147
|
*
|
|
134
148
|
* `opts.idleTimeoutMs` — stop (resolve) when no *event* arrives for that
|
|
135
149
|
* long. Heartbeat comments don't reset the timer. Used by `--tail`-style
|
|
136
|
-
* snapshot reads that must not follow a live stream forever.
|
|
150
|
+
* snapshot reads that must not follow a live stream forever.
|
|
151
|
+
*
|
|
152
|
+
* `opts.reconnect` — re-establish the connection when the server drops it
|
|
153
|
+
* (API deploys, proxy idle timeouts). Resumes via `Last-Event-ID` and
|
|
154
|
+
* suppresses events the server replays from before the drop. Rejects after
|
|
155
|
+
* MAX_RECONNECT_ATTEMPTS consecutive failures so callers exit non-zero
|
|
156
|
+
* instead of pretending the stream ended cleanly. `opts.onReconnect` fires
|
|
157
|
+
* before each attempt. */
|
|
137
158
|
export function streamSSE(
|
|
138
159
|
path: string,
|
|
139
160
|
handler: (event: string, data: string) => boolean | void,
|
|
140
|
-
opts: {
|
|
161
|
+
opts: {
|
|
162
|
+
idleTimeoutMs?: number;
|
|
163
|
+
reconnect?: boolean;
|
|
164
|
+
onReconnect?: (attempt: number) => void;
|
|
165
|
+
/** Base backoff between reconnect attempts; scaled by attempt number. */
|
|
166
|
+
reconnectBaseDelayMs?: number;
|
|
167
|
+
} = {},
|
|
141
168
|
): Promise<void> {
|
|
142
169
|
return new Promise((resolve, reject) => {
|
|
143
170
|
const url = new URL(baseURL + path);
|
|
144
171
|
const token = _accessToken || getToken();
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
172
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
173
|
+
|
|
174
|
+
let finished = false;
|
|
175
|
+
let attempts = 0;
|
|
176
|
+
let lastEventId: string | undefined;
|
|
177
|
+
// Id of the last event handed to the handler — replay marker across reconnects.
|
|
178
|
+
let lastDispatchedId: string | undefined;
|
|
179
|
+
|
|
180
|
+
const settle = (err?: unknown) => {
|
|
181
|
+
if (finished) return;
|
|
182
|
+
finished = true;
|
|
183
|
+
if (err) reject(err);
|
|
184
|
+
else resolve();
|
|
148
185
|
};
|
|
149
|
-
if (token) reqHeaders["Authorization"] = `Bearer ${token}`;
|
|
150
186
|
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
187
|
+
// Connection dropped without the handler asking to stop.
|
|
188
|
+
const dropped = (err?: unknown) => {
|
|
189
|
+
if (finished) return;
|
|
190
|
+
if (!opts.reconnect) {
|
|
191
|
+
settle(err);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
attempts++;
|
|
195
|
+
if (attempts > MAX_RECONNECT_ATTEMPTS) {
|
|
196
|
+
settle(
|
|
197
|
+
err instanceof Error
|
|
198
|
+
? err
|
|
199
|
+
: new Error("SSE stream disconnected and reconnect attempts failed"),
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
opts.onReconnect?.(attempts);
|
|
204
|
+
const base = opts.reconnectBaseDelayMs ?? 1000;
|
|
205
|
+
setTimeout(connect, Math.min(base * attempts, base * 5));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const connect = () => {
|
|
209
|
+
if (finished) return;
|
|
210
|
+
const reqHeaders: Record<string, string> = {
|
|
211
|
+
"User-Agent": USER_AGENT,
|
|
212
|
+
Accept: "text/event-stream",
|
|
213
|
+
};
|
|
214
|
+
if (token) reqHeaders["Authorization"] = `Bearer ${token}`;
|
|
215
|
+
if (lastEventId) reqHeaders["Last-Event-ID"] = lastEventId;
|
|
216
|
+
|
|
217
|
+
const req = transport.request(
|
|
218
|
+
{ hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
219
|
+
path: url.pathname + url.search, method: "GET", headers: reqHeaders },
|
|
220
|
+
(res) => {
|
|
221
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
222
|
+
let body = "";
|
|
223
|
+
res.on("data", (c: Buffer) => body += c.toString());
|
|
224
|
+
res.on("end", () => settle(new APIError(res.statusCode!, `SSE failed: ${body}`)));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
229
|
+
const finish = () => {
|
|
230
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
231
|
+
finished = true;
|
|
232
|
+
req.destroy();
|
|
233
|
+
resolve();
|
|
234
|
+
};
|
|
235
|
+
const armIdleTimer = () => {
|
|
236
|
+
if (!opts.idleTimeoutMs) return;
|
|
237
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
238
|
+
idleTimer = setTimeout(finish, opts.idleTimeoutMs);
|
|
239
|
+
};
|
|
240
|
+
armIdleTimer();
|
|
241
|
+
|
|
242
|
+
let buffer = "";
|
|
243
|
+
let currentEvent = "";
|
|
244
|
+
let currentId: string | undefined;
|
|
245
|
+
let dataLines: string[] = [];
|
|
246
|
+
|
|
247
|
+
res.setEncoding("utf8");
|
|
248
|
+
res.on("data", (chunk: string) => {
|
|
249
|
+
buffer += chunk;
|
|
250
|
+
const lines = buffer.split("\n");
|
|
251
|
+
buffer = lines.pop() ?? "";
|
|
252
|
+
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
const trimmed = line.replace(/\r$/, "");
|
|
255
|
+
if (trimmed === "") {
|
|
256
|
+
const data = dataLines.join("\n");
|
|
257
|
+
if (data) {
|
|
258
|
+
attempts = 0; // stream is healthy — reset the reconnect budget
|
|
259
|
+
armIdleTimer();
|
|
260
|
+
// After a reconnect the app-log endpoint replays recent
|
|
261
|
+
// history; skip entries we already printed.
|
|
262
|
+
const replay =
|
|
263
|
+
opts.reconnect && currentId && lastDispatchedId
|
|
264
|
+
? isReplayedId(currentId, lastDispatchedId)
|
|
265
|
+
: false;
|
|
266
|
+
if (!replay) {
|
|
267
|
+
if (currentId) lastDispatchedId = currentId;
|
|
268
|
+
const cont = handler(currentEvent, data);
|
|
269
|
+
if (cont === false) {
|
|
270
|
+
finish();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
195
274
|
}
|
|
275
|
+
currentEvent = "";
|
|
276
|
+
currentId = undefined;
|
|
277
|
+
dataLines = [];
|
|
278
|
+
} else if (trimmed.startsWith("event:")) {
|
|
279
|
+
currentEvent = trimmed.slice(6).trim();
|
|
280
|
+
} else if (trimmed.startsWith("data:")) {
|
|
281
|
+
dataLines.push(trimmed.slice(5).trimStart());
|
|
282
|
+
} else if (trimmed.startsWith("id:")) {
|
|
283
|
+
currentId = trimmed.slice(3).trim();
|
|
284
|
+
lastEventId = currentId;
|
|
196
285
|
}
|
|
197
|
-
currentEvent = "";
|
|
198
|
-
currentData = "";
|
|
199
|
-
} else if (trimmed.startsWith("event:")) {
|
|
200
|
-
currentEvent = trimmed.slice(6).trim();
|
|
201
|
-
} else if (trimmed.startsWith("data:")) {
|
|
202
|
-
currentData = trimmed.slice(5).trimStart();
|
|
203
286
|
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
res.on("end", () => {
|
|
290
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
291
|
+
dropped();
|
|
292
|
+
});
|
|
293
|
+
res.on("error", (err) => {
|
|
294
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
295
|
+
dropped(err);
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
req.on("error", dropped);
|
|
301
|
+
req.end();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
connect();
|
|
217
305
|
});
|
|
218
306
|
}
|
package/src/lib/updater.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
|
|
8
|
-
export const CURRENT_VERSION = "0.3.
|
|
8
|
+
export const CURRENT_VERSION = "0.3.44";
|
|
9
9
|
const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
|
|
10
10
|
const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
|
|
11
11
|
|