@solcreek/cli 0.4.11 → 0.4.12

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,15 @@
1
+ /**
2
+ * Pure filter logic for `creek logs --follow`.
3
+ *
4
+ * Historical mode (`creek logs --since`) gets server-side filtering;
5
+ * live mode (`--follow`) receives ALL log events for the project's
6
+ * realtime room and must filter client-side. This module is the
7
+ * mirror of control-plane/src/modules/logs/query.ts:matchesQuery —
8
+ * if those drift, --follow shows different entries than --since
9
+ * for the same flags.
10
+ */
11
+ import type { LogEntry, LogQueryFilters } from "@solcreek/sdk";
12
+ export declare function matchesClientSide(entry: LogEntry, filters: LogQueryFilters): boolean;
13
+ export declare function describeFilters(filters: LogQueryFilters): string;
14
+ export declare function safeStringify(v: unknown): string;
15
+ //# sourceMappingURL=logs-filter.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure filter logic for `creek logs --follow`.
3
+ *
4
+ * Historical mode (`creek logs --since`) gets server-side filtering;
5
+ * live mode (`--follow`) receives ALL log events for the project's
6
+ * realtime room and must filter client-side. This module is the
7
+ * mirror of control-plane/src/modules/logs/query.ts:matchesQuery —
8
+ * if those drift, --follow shows different entries than --since
9
+ * for the same flags.
10
+ */
11
+ export function matchesClientSide(entry, filters) {
12
+ if (filters.outcomes?.length && !filters.outcomes.includes(entry.outcome))
13
+ return false;
14
+ if (filters.scriptTypes?.length && !filters.scriptTypes.includes(entry.scriptType))
15
+ return false;
16
+ if (filters.deployment && entry.deployId !== filters.deployment)
17
+ return false;
18
+ if (filters.branch && entry.branch !== filters.branch)
19
+ return false;
20
+ if (filters.levels?.length) {
21
+ const hit = entry.logs.some((l) => filters.levels.includes(l.level));
22
+ if (!hit)
23
+ return false;
24
+ }
25
+ if (filters.search) {
26
+ if (!searchMatches(entry, filters.search))
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+ function searchMatches(entry, needle) {
32
+ const n = needle.toLowerCase();
33
+ const haystack = entry.logs
34
+ .flatMap((l) => l.message.map((m) => (typeof m === "string" ? m : safeStringify(m))))
35
+ .join(" ") +
36
+ " " +
37
+ entry.exceptions.map((e) => `${e.name} ${e.message}`).join(" ") +
38
+ " " +
39
+ (entry.request?.url ?? "");
40
+ return haystack.toLowerCase().includes(n);
41
+ }
42
+ export function describeFilters(filters) {
43
+ const bits = [];
44
+ if (filters.outcomes?.length)
45
+ bits.push(`outcome=${filters.outcomes.join(",")}`);
46
+ if (filters.scriptTypes?.length)
47
+ bits.push(`scriptType=${filters.scriptTypes.join(",")}`);
48
+ if (filters.deployment)
49
+ bits.push(`deployment=${filters.deployment}`);
50
+ if (filters.branch)
51
+ bits.push(`branch=${filters.branch}`);
52
+ if (filters.levels?.length)
53
+ bits.push(`level=${filters.levels.join(",")}`);
54
+ if (filters.search)
55
+ bits.push(`search="${filters.search}"`);
56
+ return bits.length === 0 ? "(none)" : bits.join(" ");
57
+ }
58
+ export function safeStringify(v) {
59
+ try {
60
+ return JSON.stringify(v);
61
+ }
62
+ catch {
63
+ return String(v);
64
+ }
65
+ }
66
+ //# sourceMappingURL=logs-filter.js.map
@@ -1,8 +1,10 @@
1
1
  import { defineCommand } from "citty";
2
2
  import consola from "consola";
3
+ import WebSocket from "ws";
3
4
  import { CreekClient, resolveConfig, ConfigNotFoundError, } from "@solcreek/sdk";
4
5
  import { getToken, getApiUrl } from "../utils/config.js";
5
6
  import { globalArgs, resolveJsonMode, jsonOutput, AUTH_BREADCRUMBS, NO_PROJECT_BREADCRUMBS, } from "../utils/output.js";
7
+ import { matchesClientSide, describeFilters, safeStringify as ssExport } from "./logs-filter.js";
6
8
  /**
7
9
  * `creek logs` — read structured tenant logs from R2 archive.
8
10
  *
@@ -69,15 +71,11 @@ export const logsCommand = defineCommand({
69
71
  },
70
72
  follow: {
71
73
  type: "boolean",
72
- description: "(Step 7 not yet implemented) Live tail via WebSocket.",
74
+ description: "Live tail via WebSocket. Prints recent context first, then streams new entries until Ctrl+C.",
73
75
  },
74
76
  ...globalArgs,
75
77
  },
76
78
  async run({ args }) {
77
- if (args.follow) {
78
- consola.warn("--follow is not yet implemented (Phase 8 Step 7).");
79
- consola.info("This command currently returns historical entries only.");
80
- }
81
79
  const jsonMode = resolveJsonMode(args);
82
80
  const token = getToken();
83
81
  if (!token) {
@@ -128,7 +126,7 @@ export const logsCommand = defineCommand({
128
126
  }
129
127
  return;
130
128
  }
131
- if (response.entries.length === 0) {
129
+ if (response.entries.length === 0 && !args.follow) {
132
130
  consola.info("No log entries match the query.");
133
131
  return;
134
132
  }
@@ -137,11 +135,140 @@ export const logsCommand = defineCommand({
137
135
  for (const entry of ordered) {
138
136
  printEntry(entry);
139
137
  }
140
- if (response.truncated) {
138
+ if (response.truncated && !args.follow) {
141
139
  consola.warn(`Truncated to ${response.entries.length} entries — refine --since/--limit to see more.`);
142
140
  }
141
+ if (args.follow) {
142
+ // Track the newest historical timestamp so we can drop any
143
+ // duplicates that the WS would otherwise replay (R2 → realtime
144
+ // race window — the same event can appear on both within a
145
+ // ~second of being captured).
146
+ const seenAfter = response.entries.length > 0
147
+ ? Math.max(...response.entries.map((e) => e.timestamp))
148
+ : 0;
149
+ await follow(client, projectSlug, filters, seenAfter, jsonMode);
150
+ }
143
151
  },
144
152
  });
153
+ /**
154
+ * Live tail via WebSocket. Mints a 5-min token via control-plane,
155
+ * connects to realtime-worker, and prints incoming `type: "log"`
156
+ * messages through the same printEntry path used for historical mode.
157
+ *
158
+ * Reconnect: realtime-worker drops the connection on any 5xx upstream
159
+ * (DO eviction, DO error, etc.). We retry with capped exponential
160
+ * backoff, re-mint the token (it could have expired during downtime).
161
+ *
162
+ * Token refresh: a single token is good for 5 min. For long-running
163
+ * tails we re-mint at 4 min — well before expiry — to avoid a
164
+ * mid-window 401 race with the WS handshake.
165
+ *
166
+ * Filter parity: the same client-side filter we'd apply to historical
167
+ * entries is applied to live entries too. Realtime push doesn't know
168
+ * about query filters; we filter here so `--outcome exception
169
+ * --follow` shows only exceptions.
170
+ */
171
+ async function follow(client, projectSlug, filters, initialSeenAfter, jsonMode) {
172
+ if (!jsonMode) {
173
+ consola.info(`Live tail — Ctrl+C to exit. Filtering: ${describeFilters(filters)}`);
174
+ }
175
+ let seenAfter = initialSeenAfter;
176
+ let stopped = false;
177
+ process.on("SIGINT", () => {
178
+ stopped = true;
179
+ if (!jsonMode) {
180
+ process.stderr.write("\n");
181
+ consola.info("Stopped.");
182
+ }
183
+ process.exit(0);
184
+ });
185
+ const TOKEN_REFRESH_MS = 4 * 60 * 1000;
186
+ let backoffMs = 500;
187
+ const BACKOFF_MAX_MS = 15_000;
188
+ while (!stopped) {
189
+ let mintedAt;
190
+ let wsUrl;
191
+ try {
192
+ const minted = await client.getLogsWsToken(projectSlug);
193
+ mintedAt = Date.now();
194
+ wsUrl = minted.wsUrl;
195
+ }
196
+ catch (err) {
197
+ if (stopped)
198
+ return;
199
+ const msg = err instanceof Error ? err.message : String(err);
200
+ if (jsonMode)
201
+ process.stderr.write(`# ws-token failed: ${msg}\n`);
202
+ else
203
+ consola.warn(`Failed to mint subscribe token: ${msg}. Retrying in ${Math.round(backoffMs / 1000)}s.`);
204
+ await sleep(backoffMs);
205
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
206
+ continue;
207
+ }
208
+ const ws = new WebSocket(wsUrl);
209
+ let closed = false;
210
+ let refreshTimer = null;
211
+ await new Promise((resolve) => {
212
+ ws.on("open", () => {
213
+ backoffMs = 500; // reset on successful connect
214
+ // Force a clean reconnect just before token expiry.
215
+ const elapsed = Date.now() - mintedAt;
216
+ const remaining = Math.max(5_000, TOKEN_REFRESH_MS - elapsed);
217
+ refreshTimer = setTimeout(() => {
218
+ if (!closed) {
219
+ try {
220
+ ws.close(1000, "token refresh");
221
+ }
222
+ catch { /* already closed */ }
223
+ }
224
+ }, remaining);
225
+ });
226
+ ws.on("message", (data) => {
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(data.toString());
230
+ }
231
+ catch {
232
+ return;
233
+ }
234
+ if (parsed.type !== "log" || !parsed.entry)
235
+ return;
236
+ const entry = parsed.entry;
237
+ // Skip duplicates the historical R2 read already showed.
238
+ if (entry.timestamp <= seenAfter)
239
+ return;
240
+ // Apply client-side filters so flags work in --follow mode too.
241
+ if (!matchesClientSide(entry, filters))
242
+ return;
243
+ seenAfter = Math.max(seenAfter, entry.timestamp);
244
+ if (jsonMode) {
245
+ process.stdout.write(JSON.stringify(entry) + "\n");
246
+ }
247
+ else {
248
+ printEntry(entry);
249
+ }
250
+ });
251
+ ws.on("close", () => {
252
+ closed = true;
253
+ if (refreshTimer)
254
+ clearTimeout(refreshTimer);
255
+ resolve();
256
+ });
257
+ ws.on("error", () => {
258
+ // The "close" event will fire after this; resolve happens there.
259
+ });
260
+ });
261
+ if (stopped)
262
+ return;
263
+ // Brief backoff before reconnect — only meaningful if the close
264
+ // wasn't from our own token-refresh timer.
265
+ await sleep(backoffMs);
266
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
267
+ }
268
+ }
269
+ function sleep(ms) {
270
+ return new Promise((r) => setTimeout(r, ms));
271
+ }
145
272
  async function resolveProjectSlug(override, jsonMode) {
146
273
  if (override)
147
274
  return override;
@@ -222,12 +349,7 @@ function printEntry(entry) {
222
349
  process.stdout.write(` ${color("exc", "red")} ${color(ex.name, "red")}: ${ex.message}\n`);
223
350
  }
224
351
  }
225
- function safeStringify(v) {
226
- try {
227
- return JSON.stringify(v);
228
- }
229
- catch {
230
- return String(v);
231
- }
232
- }
352
+ // safeStringify lives in logs-filter.js (re-exported for nested
353
+ // console.log message rendering in printEntry below).
354
+ const safeStringify = ssExport;
233
355
  //# sourceMappingURL=logs.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solcreek/cli",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "CLI for the Creek deployment platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",