@lizard-build/cli 0.3.42 → 0.3.43

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.
@@ -2,8 +2,9 @@ import chalk from "chalk";
2
2
  import * as p from "@clack/prompts";
3
3
  import { Command } from "commander";
4
4
  import { streamSSE, api, withScope, withQuery, type ResourceScope } from "../lib/api.js";
5
- import { resolveProjectScope, resolveService, getActiveService } from "../lib/resolve.js";
6
- import { info, error, isTTY, isJSONMode, printJSON, table, statusColor, timeAgo } from "../lib/format.js";
5
+ import { resolveProjectScope, resolveService, getActiveServiceWithKind } from "../lib/resolve.js";
6
+ import { getProjectLink } from "../lib/config.js";
7
+ import { info, error, warn, isTTY, isJSONMode, printJSON, table, statusColor, timeAgo } from "../lib/format.js";
7
8
 
8
9
  export function registerLogs(program: Command) {
9
10
  program
@@ -20,6 +21,10 @@ export function registerLogs(program: Command) {
20
21
  error("Use --restarts (list) or --restart <id> (detail), not both");
21
22
  process.exit(1);
22
23
  }
24
+ if (opts.tail !== undefined && (opts.restarts !== undefined || opts.restart !== undefined)) {
25
+ error("--tail cannot be combined with --restarts/--restart");
26
+ process.exit(1);
27
+ }
23
28
 
24
29
  const { projectId, scope } = await resolveProjectScope(opts.project);
25
30
 
@@ -50,14 +55,22 @@ export function registerLogs(program: Command) {
50
55
  // branch below talks to the API with a real service ID.
51
56
  let serviceId: string | undefined;
52
57
  let serviceName: string | undefined;
58
+ let serviceKind: "app" | "addon" | undefined;
53
59
  if (opts.service) {
54
60
  const svc = await resolveService(projectId, opts.service);
55
61
  serviceId = svc.id;
56
62
  serviceName = svc.name;
63
+ serviceKind = svc.kind;
57
64
  }
58
65
 
59
66
  // --tail: fetch historical logs and exit
60
67
  if (tailN !== undefined) {
68
+ if (opts.service && !serviceName) {
69
+ // An empty name would be dropped from the query string and the
70
+ // filter would silently match every service.
71
+ error(`Service "${opts.service}" has no name to filter logs by`);
72
+ process.exit(1);
73
+ }
61
74
  // The project log stream tags entries with the service *name*
62
75
  // (not ID), so the historical filter must use the name too.
63
76
  const entries = await api.get<any[]>(
@@ -75,12 +88,13 @@ export function registerLogs(program: Command) {
75
88
 
76
89
  if (!serviceId && isTTY() && !isJSONMode()) {
77
90
  // Offer to pick a specific service or stream all
78
- const data = await api.get<{ apps: any[] }>(
91
+ const data = await api.get<{ apps: any[]; addons?: any[] }>(
79
92
  withScope(`/api/projects/${projectId}/services`, scope),
80
93
  );
81
94
  const apps = data.apps || [];
95
+ const addons = data.addons || [];
82
96
 
83
- if (apps.length > 1) {
97
+ if (apps.length + addons.length > 1) {
84
98
  const choices = [
85
99
  { value: "all", label: "All services", hint: "stream combined logs" },
86
100
  ...apps.map((a: any) => ({
@@ -88,24 +102,42 @@ export function registerLogs(program: Command) {
88
102
  label: a.name || a.id,
89
103
  hint: a.status,
90
104
  })),
105
+ ...addons.map((a: any) => ({
106
+ value: a.id,
107
+ label: a.name || a.addonType || a.id,
108
+ hint: a.status,
109
+ })),
91
110
  ];
92
111
  const selected = await p.select({ message: "Show logs for", options: choices });
93
112
  if (p.isCancel(selected)) process.exit(5);
94
- if (selected !== "all") serviceId = selected as string;
113
+ if (selected !== "all") {
114
+ serviceId = selected as string;
115
+ serviceKind = addons.some((a: any) => a.id === selected) ? "addon" : "app";
116
+ }
95
117
  }
96
118
  }
97
119
 
120
+ const onReconnect = (attempt: number) =>
121
+ warn(`log stream lost — reconnecting (attempt ${attempt}/5)`);
122
+ const streamHandler = (event: string, data: string) => {
123
+ if (event === "error") {
124
+ error(data);
125
+ process.exitCode = 1;
126
+ return false;
127
+ }
128
+ printLogLine(data);
129
+ return true;
130
+ };
131
+
98
132
  if (serviceId) {
99
- // Stream logs for a specific app
133
+ // Stream logs for a specific service. Addons live on a different
134
+ // endpoint — the app one 404s for them.
135
+ const streamPath =
136
+ serviceKind === "addon"
137
+ ? withScope(`/api/projects/${projectId}/addons/${serviceId}/logs`, scope)
138
+ : `/api/apps/${serviceId}/logs`;
100
139
  info(chalk.dim("Streaming logs... (Ctrl+C to stop)\n"));
101
- await streamSSE(`/api/apps/${serviceId}/logs`, (event, data) => {
102
- if (event === "error") {
103
- error(data);
104
- return false;
105
- }
106
- printLogLine(data);
107
- return true;
108
- });
140
+ await streamSSE(streamPath, streamHandler, { reconnect: true, onReconnect });
109
141
  return;
110
142
  }
111
143
 
@@ -113,14 +145,8 @@ export function registerLogs(program: Command) {
113
145
  info(chalk.dim("Streaming project logs... (Ctrl+C to stop)\n"));
114
146
  await streamSSE(
115
147
  withScope(`/api/projects/${projectId}/logs/stream`, scope),
116
- (event, data) => {
117
- if (event === "error") {
118
- error(data);
119
- return false;
120
- }
121
- printLogLine(data);
122
- return true;
123
- },
148
+ streamHandler,
149
+ { reconnect: true, onReconnect },
124
150
  );
125
151
  });
126
152
  }
@@ -174,6 +200,19 @@ type FlatEvent = DeployEvent["events"][number] & {
174
200
 
175
201
  async function fetchFlatRestarts(appId: string): Promise<FlatEvent[]> {
176
202
  const builds = await api.get<DeployEvent[]>(`/api/apps/${appId}/deploy-events`);
203
+ // The plain listing is DB-only; crash events still held by vm-agent are
204
+ // merged in by the server only when a buildId is given. Refetch the newest
205
+ // build so `--restart latest` sees crashes that haven't been persisted yet.
206
+ if (builds.length > 0) {
207
+ try {
208
+ const [withLive] = await api.get<DeployEvent[]>(
209
+ withQuery(`/api/apps/${appId}/deploy-events`, { buildId: builds[0].buildId }),
210
+ );
211
+ if (withLive && withLive.buildId === builds[0].buildId) builds[0] = withLive;
212
+ } catch {
213
+ // node unreachable — the DB-only list is still useful
214
+ }
215
+ }
177
216
  const flat: FlatEvent[] = [];
178
217
  for (const b of builds) {
179
218
  for (const e of b.events) {
@@ -228,12 +267,27 @@ function printLogLine(data: string) {
228
267
  }
229
268
  }
230
269
 
270
+ /** Resolve the target for app-only subcommands; addons have no builds or
271
+ * restart events, so fail with a clear message instead of a server 404. */
272
+ async function getActiveApp(
273
+ serviceRef: string | undefined,
274
+ projectId: string,
275
+ what: string,
276
+ ): Promise<{ id: string; name: string }> {
277
+ const svc = await getActiveServiceWithKind(serviceRef, projectId);
278
+ if (svc.kind === "addon") {
279
+ error(`${what} are only available for apps — "${svc.name}" is an addon`);
280
+ process.exit(1);
281
+ }
282
+ return { id: svc.id, name: svc.name };
283
+ }
284
+
231
285
  async function showRestartList(
232
286
  serviceRef: string | undefined,
233
287
  projectId: string,
234
288
  n: number,
235
289
  ) {
236
- const svc = await getActiveService(serviceRef, projectId);
290
+ const svc = await getActiveApp(serviceRef, projectId, "Restart events");
237
291
  const events = await fetchFlatRestarts(svc.id);
238
292
  const slice = events.slice(0, n);
239
293
 
@@ -272,7 +326,7 @@ async function showRestartLogTail(
272
326
  projectId: string,
273
327
  ref: string,
274
328
  ) {
275
- const svc = await getActiveService(serviceRef, projectId);
329
+ const svc = await getActiveApp(serviceRef, projectId, "Restart events");
276
330
  const events = await fetchFlatRestarts(svc.id);
277
331
 
278
332
  let evt: FlatEvent | undefined;
@@ -319,13 +373,11 @@ async function showBuildLogs(
319
373
  tailN?: number,
320
374
  ) {
321
375
  let appId: string | undefined;
322
- if (serviceRef) {
323
- const svc = await resolveService(projectId, serviceRef);
376
+ if (serviceRef || getProjectLink()?.serviceId) {
377
+ // Explicit -s flag, or the service linked to this cwd.
378
+ const svc = await getActiveApp(serviceRef, projectId, "Build logs");
324
379
  appId = svc.id;
325
- }
326
-
327
- if (!appId) {
328
- // Get first app in project
380
+ } else {
329
381
  const data = await api.get<{ apps: Array<{ id: string; name: string }> }>(
330
382
  withScope(`/api/projects/${projectId}/services`, scope),
331
383
  );
@@ -335,6 +387,9 @@ async function showBuildLogs(
335
387
  );
336
388
  }
337
389
  appId = data.apps[0].id;
390
+ if (data.apps.length > 1) {
391
+ info(chalk.dim(`Multiple apps — showing ${data.apps[0].name}. Use -s to pick another.`));
392
+ }
338
393
  }
339
394
 
340
395
  // Get latest build
@@ -354,24 +409,45 @@ async function showBuildLogs(
354
409
  // Snapshot semantics: the server replays history immediately; if the
355
410
  // build is still running the stream would otherwise follow it forever.
356
411
  // Stop after 3s without new events and print what we have.
357
- const lines: string[] = [];
412
+ //
413
+ // Each SSE event carries a multi-line *chunk* (a logSnippet delta), not a
414
+ // single line — for a finished build the whole log arrives as one event.
415
+ // Reassemble the text first, then tail by line.
416
+ const chunks: string[] = [];
358
417
  await streamSSE(
359
418
  `/api/builds/${buildId}/logs`,
360
419
  (event, data) => {
361
420
  if (event === "done" || event === "error") return false;
362
- lines.push(data);
421
+ try {
422
+ const parsed = JSON.parse(data);
423
+ chunks.push(typeof parsed === "string" ? parsed : data);
424
+ } catch {
425
+ chunks.push(data);
426
+ }
363
427
  return true;
364
428
  },
365
429
  { idleTimeoutMs: 3000 },
366
430
  );
367
- for (const line of lines.slice(-tailN)) printLogLine(line);
431
+ const lines = chunks.join("").split("\n");
432
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
433
+ for (const line of lines.slice(-tailN)) {
434
+ if (isJSONMode()) {
435
+ process.stdout.write(JSON.stringify({ message: line }) + "\n");
436
+ } else {
437
+ process.stdout.write(line + "\n");
438
+ }
439
+ }
368
440
  return;
369
441
  }
370
442
 
371
443
  await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
372
- if (event === "done" || event === "error") {
444
+ if (event === "error") {
445
+ // The human-readable "--- Build failed ---" line already arrived as a
446
+ // data event; just make the failure visible to scripts.
447
+ process.exitCode = 1;
373
448
  return false;
374
449
  }
450
+ if (event === "done") return false;
375
451
  printLogLine(data);
376
452
  return true;
377
453
  });
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.43";
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