peekable 0.1.4 → 0.2.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.
@@ -0,0 +1,297 @@
1
+ import { ApiError, postDebugEvent, USER_AGENT } from "./api.js";
2
+ import { tryReadConfig, DEFAULT_SERVER_URL, ConfigError } from "./config.js";
3
+ import { CLI_VERSION } from "./version.js";
4
+ import { CommandFailure } from "./command-error.js";
5
+ const SUCCESS_TIMEOUT_MS = 1000;
6
+ const FAILURE_TIMEOUT_MS = 2000;
7
+ const MAX_ERROR_MESSAGE_LEN = 280;
8
+ const ALLOWED_BEACON_EVENTS = new Set([
9
+ "cli_register_failed",
10
+ "cli_config_corrupt",
11
+ "cli_connect_failed",
12
+ "cli_preauth_failed",
13
+ "cli_crash",
14
+ ]);
15
+ export function isTelemetryDisabled() {
16
+ return process.env.PEEKABLE_NO_TELEMETRY === "1";
17
+ }
18
+ function baseMetadata() {
19
+ return {
20
+ os: process.platform,
21
+ arch: process.arch,
22
+ node: process.version,
23
+ };
24
+ }
25
+ function truncate(input, max) {
26
+ return input.length <= max ? input : input.slice(0, max);
27
+ }
28
+ // Commands wrap their real error in CommandFailure before it escapes to
29
+ // runWithTelemetry; classification (ApiError request_id, ConfigError reason)
30
+ // must look at the underlying error, not the wrapper.
31
+ function unwrapCause(err) {
32
+ let current = err;
33
+ while (current instanceof CommandFailure && current.cause !== undefined) {
34
+ current = current.cause;
35
+ }
36
+ return current;
37
+ }
38
+ // Stack traces and fs error messages embed absolute paths under the user's
39
+ // home directory (which carries the OS username). Redact before transmission.
40
+ function redactHomePaths(text) {
41
+ const home = process.env.HOME || process.env.USERPROFILE;
42
+ if (!home || home.length < 4)
43
+ return text;
44
+ return text.split(home).join("~");
45
+ }
46
+ function extractErrorCode(err) {
47
+ if (err instanceof CommandFailure && err.cause !== undefined) {
48
+ return extractErrorCode(err.cause);
49
+ }
50
+ if (err instanceof ApiError) {
51
+ return String(err.status);
52
+ }
53
+ if (err instanceof ConfigError) {
54
+ return err.reason === "corrupt" ? "config_corrupt" : "config_missing";
55
+ }
56
+ if (err && typeof err === "object") {
57
+ const anyErr = err;
58
+ if (typeof anyErr.code === "string" && anyErr.code)
59
+ return anyErr.code;
60
+ if (anyErr.cause && typeof anyErr.cause.code === "string")
61
+ return anyErr.cause.code;
62
+ if (typeof anyErr.name === "string" && anyErr.name && anyErr.name !== "Error") {
63
+ return anyErr.name;
64
+ }
65
+ }
66
+ return "unknown";
67
+ }
68
+ function extractErrorMessage(err) {
69
+ if (err instanceof CommandFailure && err.cause instanceof Error) {
70
+ return truncate(err.cause.message ?? "", MAX_ERROR_MESSAGE_LEN);
71
+ }
72
+ if (err instanceof Error)
73
+ return truncate(err.message ?? "", MAX_ERROR_MESSAGE_LEN);
74
+ return truncate(String(err), MAX_ERROR_MESSAGE_LEN);
75
+ }
76
+ function looksLikeConnectFailure(err) {
77
+ const code = extractErrorCode(err);
78
+ const connCodes = new Set([
79
+ "ECONNREFUSED",
80
+ "ENOTFOUND",
81
+ "EAI_AGAIN",
82
+ "ETIMEDOUT",
83
+ "ECONNRESET",
84
+ "EHOSTUNREACH",
85
+ "ENETUNREACH",
86
+ "UND_ERR_CONNECT_TIMEOUT",
87
+ "UND_ERR_SOCKET",
88
+ ]);
89
+ return connCodes.has(code);
90
+ }
91
+ function resolveBeaconTarget() {
92
+ if (process.env.SHARE_URL)
93
+ return process.env.SHARE_URL;
94
+ const cfg = tryReadConfig();
95
+ if (cfg.state === "ok")
96
+ return cfg.config.url;
97
+ return DEFAULT_SERVER_URL;
98
+ }
99
+ async function raceTimeout(promise, ms) {
100
+ await Promise.race([
101
+ promise.then(() => undefined, () => undefined),
102
+ new Promise((resolve) => {
103
+ const t = setTimeout(resolve, ms);
104
+ // don't hold the loop open beyond needed
105
+ if (typeof t === "object" && t && typeof t.unref === "function") {
106
+ t.unref();
107
+ }
108
+ }),
109
+ ]);
110
+ }
111
+ async function beaconPost(eventName, extraMetadata) {
112
+ if (!ALLOWED_BEACON_EVENTS.has(eventName))
113
+ return;
114
+ const url = resolveBeaconTarget();
115
+ const payload = {
116
+ event_name: eventName,
117
+ cli_version: CLI_VERSION,
118
+ ...baseMetadata(),
119
+ };
120
+ if (typeof extraMetadata.error_code === "string") {
121
+ payload.error_code = extraMetadata.error_code;
122
+ }
123
+ if (typeof extraMetadata.command === "string") {
124
+ payload.command = extraMetadata.command;
125
+ }
126
+ const controller = new AbortController();
127
+ const timeout = setTimeout(() => controller.abort(), FAILURE_TIMEOUT_MS);
128
+ try {
129
+ await fetch(`${url}/api/cli/beacon`, {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ "User-Agent": USER_AGENT,
134
+ },
135
+ body: JSON.stringify(payload),
136
+ signal: controller.signal,
137
+ });
138
+ }
139
+ catch {
140
+ // fire and forget
141
+ }
142
+ finally {
143
+ clearTimeout(timeout);
144
+ }
145
+ }
146
+ function completedEventName(commandName) {
147
+ return `cli_${commandName}_completed`;
148
+ }
149
+ function failedEventName(commandName) {
150
+ return `cli_${commandName}_failed`;
151
+ }
152
+ async function sendAuthenticatedDebugEvent(eventName, status, commandName, durationMs, metadata, errorCode, sessionId) {
153
+ const cfg = tryReadConfig();
154
+ if (cfg.state !== "ok")
155
+ return false;
156
+ try {
157
+ await postDebugEvent(cfg.config, {
158
+ event_name: eventName,
159
+ session_id: sessionId,
160
+ source: "cli",
161
+ command: commandName,
162
+ cli_version: CLI_VERSION,
163
+ status,
164
+ duration_ms: durationMs,
165
+ error_code: errorCode,
166
+ metadata,
167
+ });
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ /**
175
+ * Wrap a short-lived command action so success/failure telemetry is emitted.
176
+ * The command's original error (if any) is rethrown after telemetry settles.
177
+ */
178
+ export async function runWithTelemetry(commandName, fn, context) {
179
+ if (isTelemetryDisabled()) {
180
+ await fn();
181
+ return;
182
+ }
183
+ const start = Date.now();
184
+ let caught;
185
+ try {
186
+ await fn();
187
+ }
188
+ catch (err) {
189
+ caught = err;
190
+ }
191
+ const durationMs = Date.now() - start;
192
+ // Context is read after fn() so the command can populate it as it learns
193
+ // things (file size, fragment hints). session_id rides the envelope's
194
+ // dedicated column; everything else lands in metadata.
195
+ const { session_id: contextSessionId, ...contextMetadata } = context ?? {};
196
+ const sessionId = typeof contextSessionId === "string" ? contextSessionId : undefined;
197
+ if (caught === undefined) {
198
+ const p = sendAuthenticatedDebugEvent(completedEventName(commandName), "ok", commandName, durationMs, { ...baseMetadata(), ...contextMetadata }, undefined, sessionId);
199
+ await raceTimeout(p, SUCCESS_TIMEOUT_MS);
200
+ return;
201
+ }
202
+ const errorCode = extractErrorCode(caught);
203
+ const unwrapped = unwrapCause(caught);
204
+ const metadata = {
205
+ ...baseMetadata(),
206
+ ...contextMetadata,
207
+ error_message: redactHomePaths(extractErrorMessage(caught)),
208
+ };
209
+ const cfgForServerUrl = tryReadConfig();
210
+ if (cfgForServerUrl.state === "ok") {
211
+ metadata.server_url = cfgForServerUrl.config.url;
212
+ }
213
+ // Prefer the underlying error's stack — CommandFailure's own stack only
214
+ // points at the command's catch block.
215
+ const stackSource = unwrapped instanceof Error && unwrapped.stack ? unwrapped : caught;
216
+ if (stackSource instanceof Error && stackSource.stack) {
217
+ metadata.stack_summary = redactHomePaths(stackSource.stack.split("\n").slice(0, 9).join("\n").slice(0, 2000));
218
+ }
219
+ if (unwrapped instanceof ApiError && typeof unwrapped.details.request_id === "string") {
220
+ metadata.request_id = unwrapped.details.request_id;
221
+ }
222
+ const p = (async () => {
223
+ const sent = await sendAuthenticatedDebugEvent(failedEventName(commandName), "error", commandName, durationMs, metadata, errorCode, sessionId);
224
+ if (sent)
225
+ return;
226
+ // Fall back to anonymous beacon for pre-auth failures. Specific names
227
+ // first; cli_preauth_failed is the catch-all so no failure class is
228
+ // completely dark just because the user has no config yet.
229
+ let beaconEvent;
230
+ if (unwrapped instanceof ConfigError && unwrapped.reason === "corrupt") {
231
+ beaconEvent = "cli_config_corrupt";
232
+ }
233
+ else if (commandName === "register") {
234
+ beaconEvent = "cli_register_failed";
235
+ }
236
+ else if (looksLikeConnectFailure(caught)) {
237
+ beaconEvent = "cli_connect_failed";
238
+ }
239
+ else {
240
+ beaconEvent = "cli_preauth_failed";
241
+ }
242
+ await beaconPost(beaconEvent, { error_code: errorCode, command: commandName });
243
+ })();
244
+ await raceTimeout(p, FAILURE_TIMEOUT_MS);
245
+ throw caught;
246
+ }
247
+ /**
248
+ * Fire-and-forget start event for long-running commands (watch, proxy).
249
+ * The returned promise resolves once telemetry settles (or times out); callers
250
+ * may ignore it.
251
+ */
252
+ export function postStartEvent(eventName, metadata = {}) {
253
+ if (isTelemetryDisabled())
254
+ return;
255
+ const cfg = tryReadConfig();
256
+ if (cfg.state !== "ok")
257
+ return;
258
+ const started = postDebugEvent(cfg.config, {
259
+ event_name: eventName,
260
+ source: "cli",
261
+ cli_version: CLI_VERSION,
262
+ metadata: { ...baseMetadata(), ...metadata },
263
+ }).catch(() => undefined);
264
+ // fire-and-forget: don't await
265
+ void started;
266
+ }
267
+ /**
268
+ * Fire-and-forget best-effort debug event with a 1s cap. Used for WS
269
+ * close/error handlers in long-running commands.
270
+ */
271
+ export function postFireAndForgetDebugEvent(eventName, metadata = {}, opts = {}) {
272
+ if (isTelemetryDisabled())
273
+ return;
274
+ const cfg = tryReadConfig();
275
+ if (cfg.state !== "ok")
276
+ return;
277
+ const p = postDebugEvent(cfg.config, {
278
+ event_name: eventName,
279
+ source: "cli",
280
+ cli_version: CLI_VERSION,
281
+ session_id: opts.sessionId,
282
+ error_code: opts.errorCode,
283
+ metadata: { ...baseMetadata(), ...metadata },
284
+ }).catch(() => undefined);
285
+ void raceTimeout(p, SUCCESS_TIMEOUT_MS);
286
+ }
287
+ /**
288
+ * Last-resort crash beacon for errors that escape runWithTelemetry entirely
289
+ * (commander parse failures, module load errors). Anonymous, allowlisted,
290
+ * capped at FAILURE_TIMEOUT_MS so it never delays exit noticeably.
291
+ */
292
+ export async function postCrashBeacon(err) {
293
+ if (isTelemetryDisabled())
294
+ return;
295
+ const p = beaconPost("cli_crash", { error_code: extractErrorCode(err) });
296
+ await raceTimeout(p, FAILURE_TIMEOUT_MS);
297
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "0.1.4";
1
+ export declare const CLI_VERSION = "0.2.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.4";
1
+ export const CLI_VERSION = "0.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peekable",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Share HTML mockups with collaborators — CLI for peekable-server",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc",
11
- "dev": "tsx src/index.ts"
11
+ "dev": "tsx src/index.ts",
12
+ "test": "vitest run"
12
13
  },
13
14
  "dependencies": {
14
15
  "commander": "^12"
@@ -16,7 +17,8 @@
16
17
  "devDependencies": {
17
18
  "typescript": "^5",
18
19
  "tsx": "^4",
19
- "@types/node": "^22"
20
+ "@types/node": "^22",
21
+ "vitest": "^3"
20
22
  },
21
23
  "files": [
22
24
  "dist",