peekable 0.1.4 → 0.1.5
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/api.d.ts +1 -0
- package/dist/api.js +19 -1
- package/dist/command-error.d.ts +8 -0
- package/dist/command-error.js +29 -2
- package/dist/commands/close.js +18 -8
- package/dist/commands/create.js +17 -14
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.js +109 -9
- package/dist/commands/feedback.js +53 -43
- package/dist/commands/init.js +48 -42
- package/dist/commands/list.js +32 -22
- package/dist/commands/proxy.js +45 -13
- package/dist/commands/push-url.js +22 -19
- package/dist/commands/push.js +24 -18
- package/dist/commands/register.js +49 -44
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +107 -0
- package/dist/commands/resolve.js +19 -9
- package/dist/commands/uninstall.js +14 -11
- package/dist/commands/watch.js +37 -3
- package/dist/config.d.ts +16 -0
- package/dist/config.js +64 -15
- package/dist/index.js +34 -1
- package/dist/telemetry.d.ts +26 -0
- package/dist/telemetry.js +297 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -3
package/dist/commands/watch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { requireConfig } from "../config.js";
|
|
3
|
+
import { postStartEvent, postFireAndForgetDebugEvent } from "../telemetry.js";
|
|
3
4
|
export const watchCommand = new Command("watch")
|
|
4
5
|
.argument("<session-id>", "Session ID to watch")
|
|
5
6
|
.description("Watch for annotation notifications on a session")
|
|
@@ -8,8 +9,27 @@ export const watchCommand = new Command("watch")
|
|
|
8
9
|
const wsUrl = config.url.replace(/^http/, "ws") + `/ws/${sessionId}`;
|
|
9
10
|
let reconnectDelay = 1000;
|
|
10
11
|
const maxDelay = 30000;
|
|
12
|
+
postStartEvent("cli_watch_started", { session_id: sessionId });
|
|
11
13
|
function connect() {
|
|
12
|
-
const
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
let ws;
|
|
16
|
+
try {
|
|
17
|
+
// The constructor throws synchronously on a malformed URL (e.g. a
|
|
18
|
+
// config.url scheme that http->ws substitution didn't convert).
|
|
19
|
+
ws = new WebSocket(wsUrl);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23
|
+
console.error(`WebSocket connect failed: ${message}`);
|
|
24
|
+
postFireAndForgetDebugEvent("cli_ws_error", {
|
|
25
|
+
surface: "watch",
|
|
26
|
+
error_message: message.slice(0, 280),
|
|
27
|
+
}, { sessionId });
|
|
28
|
+
console.log(`Reconnecting in ${reconnectDelay / 1000}s...`);
|
|
29
|
+
setTimeout(connect, reconnectDelay);
|
|
30
|
+
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
13
33
|
ws.addEventListener("open", () => {
|
|
14
34
|
reconnectDelay = 1000;
|
|
15
35
|
console.log(`Watching session ${sessionId} for annotations...`);
|
|
@@ -27,12 +47,26 @@ export const watchCommand = new Command("watch")
|
|
|
27
47
|
// Non-JSON messages (e.g. "reload") — ignore
|
|
28
48
|
}
|
|
29
49
|
});
|
|
30
|
-
ws.addEventListener("close", () => {
|
|
50
|
+
ws.addEventListener("close", (event) => {
|
|
51
|
+
const closeEvent = event;
|
|
52
|
+
postFireAndForgetDebugEvent("cli_ws_disconnected", {
|
|
53
|
+
code: closeEvent?.code,
|
|
54
|
+
reason: typeof closeEvent?.reason === "string" ? closeEvent.reason.slice(0, 120) : undefined,
|
|
55
|
+
uptime_ms: Date.now() - startedAt,
|
|
56
|
+
surface: "watch",
|
|
57
|
+
}, { sessionId });
|
|
31
58
|
console.log(`Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`);
|
|
32
59
|
setTimeout(connect, reconnectDelay);
|
|
33
60
|
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
|
|
34
61
|
});
|
|
35
|
-
ws.addEventListener("error", () => {
|
|
62
|
+
ws.addEventListener("error", (event) => {
|
|
63
|
+
const anyEvt = event;
|
|
64
|
+
const message = typeof anyEvt?.message === "string" ? anyEvt.message : "WebSocket error";
|
|
65
|
+
console.error(`WebSocket error: ${message}`);
|
|
66
|
+
postFireAndForgetDebugEvent("cli_ws_error", {
|
|
67
|
+
surface: "watch",
|
|
68
|
+
error_message: String(message).slice(0, 280),
|
|
69
|
+
}, { sessionId });
|
|
36
70
|
// Error triggers close, which handles reconnection
|
|
37
71
|
});
|
|
38
72
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,22 @@ export interface ShareConfig {
|
|
|
3
3
|
api_key: string;
|
|
4
4
|
name: string;
|
|
5
5
|
}
|
|
6
|
+
export declare const DEFAULT_SERVER_URL = "https://peekable.fyi";
|
|
7
|
+
export type TryReadConfigResult = {
|
|
8
|
+
state: "ok";
|
|
9
|
+
config: ShareConfig;
|
|
10
|
+
} | {
|
|
11
|
+
state: "missing";
|
|
12
|
+
} | {
|
|
13
|
+
state: "corrupt";
|
|
14
|
+
parseError: string;
|
|
15
|
+
};
|
|
16
|
+
export declare class ConfigError extends Error {
|
|
17
|
+
reason: "missing" | "corrupt";
|
|
18
|
+
parseError?: string;
|
|
19
|
+
constructor(reason: "missing" | "corrupt", parseError?: string);
|
|
20
|
+
}
|
|
21
|
+
export declare function tryReadConfig(configFile?: string): TryReadConfigResult;
|
|
6
22
|
export declare function readConfig(): ShareConfig | null;
|
|
7
23
|
export declare function writeConfig(config: ShareConfig): void;
|
|
8
24
|
export declare function requireConfig(): ShareConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,36 +1,85 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
2
|
import { getPeekableConfigDir, getPeekableConfigFile } from "./paths.js";
|
|
3
|
+
export const DEFAULT_SERVER_URL = "https://peekable.fyi";
|
|
3
4
|
const CONFIG_DIR = getPeekableConfigDir();
|
|
4
5
|
const CONFIG_FILE = getPeekableConfigFile();
|
|
5
|
-
export
|
|
6
|
+
export class ConfigError extends Error {
|
|
7
|
+
reason;
|
|
8
|
+
parseError;
|
|
9
|
+
constructor(reason, parseError) {
|
|
10
|
+
super(reason === "corrupt"
|
|
11
|
+
? "Config file is corrupted. Run `peekable register` to reconfigure."
|
|
12
|
+
: "Not configured. Run `peekable register` first.");
|
|
13
|
+
this.name = "ConfigError";
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
this.parseError = parseError;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function tryReadConfig(configFile = CONFIG_FILE) {
|
|
6
19
|
if (process.env.SHARE_URL && process.env.SHARE_API_KEY) {
|
|
7
20
|
return {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
21
|
+
state: "ok",
|
|
22
|
+
config: {
|
|
23
|
+
url: process.env.SHARE_URL,
|
|
24
|
+
api_key: process.env.SHARE_API_KEY,
|
|
25
|
+
name: process.env.SHARE_NAME ?? "User",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (!existsSync(configFile))
|
|
30
|
+
return { state: "missing" };
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(configFile, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return {
|
|
37
|
+
state: "corrupt",
|
|
38
|
+
parseError: err instanceof Error ? err.message : String(err),
|
|
11
39
|
};
|
|
12
40
|
}
|
|
13
|
-
if (!existsSync(CONFIG_FILE))
|
|
14
|
-
return null;
|
|
15
41
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (!parsed ||
|
|
44
|
+
typeof parsed !== "object" ||
|
|
45
|
+
typeof parsed.url !== "string" ||
|
|
46
|
+
typeof parsed.api_key !== "string") {
|
|
47
|
+
return { state: "corrupt", parseError: "missing required fields" };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
state: "ok",
|
|
51
|
+
config: {
|
|
52
|
+
url: parsed.url,
|
|
53
|
+
api_key: parsed.api_key,
|
|
54
|
+
name: typeof parsed.name === "string" ? parsed.name : "User",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
18
57
|
}
|
|
19
|
-
catch {
|
|
20
|
-
return
|
|
58
|
+
catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
state: "corrupt",
|
|
61
|
+
parseError: err instanceof Error ? err.message : String(err),
|
|
62
|
+
};
|
|
21
63
|
}
|
|
22
64
|
}
|
|
65
|
+
export function readConfig() {
|
|
66
|
+
const result = tryReadConfig();
|
|
67
|
+
if (result.state === "ok")
|
|
68
|
+
return result.config;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
23
71
|
export function writeConfig(config) {
|
|
24
72
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
73
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
26
74
|
}
|
|
27
75
|
export function requireConfig() {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
const result = tryReadConfig();
|
|
77
|
+
if (result.state === "ok")
|
|
78
|
+
return result.config;
|
|
79
|
+
if (result.state === "corrupt") {
|
|
80
|
+
throw new ConfigError("corrupt", result.parseError);
|
|
32
81
|
}
|
|
33
|
-
|
|
82
|
+
throw new ConfigError("missing");
|
|
34
83
|
}
|
|
35
84
|
export function getConfigPath() {
|
|
36
85
|
return CONFIG_FILE;
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,11 @@ import { proxyCommand } from "./commands/proxy.js";
|
|
|
13
13
|
import { pushUrlCommand } from "./commands/push-url.js";
|
|
14
14
|
import { doctorCommand } from "./commands/doctor.js";
|
|
15
15
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
16
|
+
import { reportCommand } from "./commands/report.js";
|
|
16
17
|
import { CLI_VERSION } from "./version.js";
|
|
18
|
+
import { CommandFailure } from "./command-error.js";
|
|
19
|
+
import { ConfigError } from "./config.js";
|
|
20
|
+
import { postCrashBeacon } from "./telemetry.js";
|
|
17
21
|
program
|
|
18
22
|
.name("peekable")
|
|
19
23
|
.description("Share HTML mockups with collaborators via peekable")
|
|
@@ -31,4 +35,33 @@ program.addCommand(resolveCommand);
|
|
|
31
35
|
program.addCommand(proxyCommand);
|
|
32
36
|
program.addCommand(doctorCommand);
|
|
33
37
|
program.addCommand(uninstallCommand);
|
|
34
|
-
program.
|
|
38
|
+
program.addCommand(reportCommand);
|
|
39
|
+
program.addHelpText("afterAll", `
|
|
40
|
+
Telemetry:
|
|
41
|
+
Peekable CLI sends anonymous usage/failure telemetry to improve reliability.
|
|
42
|
+
Opt out with PEEKABLE_NO_TELEMETRY=1.
|
|
43
|
+
`);
|
|
44
|
+
async function main() {
|
|
45
|
+
try {
|
|
46
|
+
await program.parseAsync(process.argv);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof CommandFailure) {
|
|
50
|
+
// Command already printed its user-facing error via printCommandError.
|
|
51
|
+
process.exitCode = err.exitCode;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (err instanceof ConfigError) {
|
|
55
|
+
console.error(err.message);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
console.error(message);
|
|
61
|
+
// Unexpected crash (escaped every command handler) — fire the anonymous
|
|
62
|
+
// crash beacon so it isn't invisible server-side.
|
|
63
|
+
await postCrashBeacon(err);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
main();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare function isTelemetryDisabled(): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a short-lived command action so success/failure telemetry is emitted.
|
|
4
|
+
* The command's original error (if any) is rethrown after telemetry settles.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runWithTelemetry(commandName: string, fn: () => Promise<void>, context?: Record<string, unknown>): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Fire-and-forget start event for long-running commands (watch, proxy).
|
|
9
|
+
* The returned promise resolves once telemetry settles (or times out); callers
|
|
10
|
+
* may ignore it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function postStartEvent(eventName: "cli_watch_started" | "cli_proxy_started" | string, metadata?: Record<string, unknown>): void;
|
|
13
|
+
/**
|
|
14
|
+
* Fire-and-forget best-effort debug event with a 1s cap. Used for WS
|
|
15
|
+
* close/error handlers in long-running commands.
|
|
16
|
+
*/
|
|
17
|
+
export declare function postFireAndForgetDebugEvent(eventName: string, metadata?: Record<string, unknown>, opts?: {
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
errorCode?: string;
|
|
20
|
+
}): void;
|
|
21
|
+
/**
|
|
22
|
+
* Last-resort crash beacon for errors that escape runWithTelemetry entirely
|
|
23
|
+
* (commander parse failures, module load errors). Anonymous, allowlisted,
|
|
24
|
+
* capped at FAILURE_TIMEOUT_MS so it never delays exit noticeably.
|
|
25
|
+
*/
|
|
26
|
+
export declare function postCrashBeacon(err: unknown): Promise<void>;
|
|
@@ -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.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.5";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peekable",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|