@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.
@@ -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
- /** Stream SSE and call handler for each data line. Return false to stop.
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: { idleTimeoutMs?: number } = {},
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 reqHeaders: Record<string, string> = {
146
- "User-Agent": USER_AGENT,
147
- Accept: "text/event-stream",
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
- const transport = url.protocol === "https:" ? https : http;
152
- const req = transport.request(
153
- { hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80),
154
- path: url.pathname + url.search, method: "GET", headers: reqHeaders },
155
- (res) => {
156
- if (res.statusCode && res.statusCode >= 400) {
157
- let body = "";
158
- res.on("data", (c: Buffer) => body += c.toString());
159
- res.on("end", () => reject(new APIError(res.statusCode!, `SSE failed: ${body}`)));
160
- return;
161
- }
162
-
163
- let idleTimer: NodeJS.Timeout | undefined;
164
- const finish = () => {
165
- if (idleTimer) clearTimeout(idleTimer);
166
- req.destroy();
167
- resolve();
168
- };
169
- const armIdleTimer = () => {
170
- if (!opts.idleTimeoutMs) return;
171
- if (idleTimer) clearTimeout(idleTimer);
172
- idleTimer = setTimeout(finish, opts.idleTimeoutMs);
173
- };
174
- armIdleTimer();
175
-
176
- let buffer = "";
177
- let currentEvent = "";
178
- let currentData = "";
179
-
180
- res.setEncoding("utf8");
181
- res.on("data", (chunk: string) => {
182
- buffer += chunk;
183
- const lines = buffer.split("\n");
184
- buffer = lines.pop() ?? "";
185
-
186
- for (const line of lines) {
187
- const trimmed = line.replace(/\r$/, "");
188
- if (trimmed === "") {
189
- if (currentData) {
190
- armIdleTimer();
191
- const cont = handler(currentEvent, currentData);
192
- if (cont === false) {
193
- finish();
194
- return;
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
- res.on("end", () => {
208
- if (idleTimer) clearTimeout(idleTimer);
209
- resolve();
210
- });
211
- res.on("error", reject);
212
- },
213
- );
214
-
215
- req.on("error", reject);
216
- req.end();
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
  }
@@ -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.42";
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