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.
package/dist/api.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ShareConfig } from "./config.js";
2
2
  export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
3
+ export declare const USER_AGENT: string;
3
4
  export declare class ApiError extends Error {
4
5
  status: number;
5
6
  details: Record<string, unknown>;
package/dist/api.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { CLI_VERSION } from "./version.js";
1
2
  export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
3
+ export const USER_AGENT = `peekable-cli/${CLI_VERSION} (${process.platform}; ${process.arch}; node ${process.version})`;
2
4
  export class ApiError extends Error {
3
5
  status;
4
6
  details;
@@ -15,6 +17,7 @@ export async function api(config, method, path, body, opts = {}) {
15
17
  headers: {
16
18
  Authorization: `Bearer ${config.api_key}`,
17
19
  "Content-Type": "application/json",
20
+ "User-Agent": USER_AGENT,
18
21
  },
19
22
  body: body ? JSON.stringify(body) : undefined,
20
23
  }, opts.timeoutMs);
@@ -29,7 +32,10 @@ export async function postDebugEvent(config, body) {
29
32
  export async function apiNoAuth(url, method, path, body, opts = {}) {
30
33
  const res = await fetchWithTimeout(`${url}/api${path}`, {
31
34
  method,
32
- headers: { "Content-Type": "application/json" },
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ "User-Agent": USER_AGENT,
38
+ },
33
39
  body: body ? JSON.stringify(body) : undefined,
34
40
  }, opts.timeoutMs);
35
41
  if (!res.ok) {
@@ -40,9 +46,14 @@ export async function apiNoAuth(url, method, path, body, opts = {}) {
40
46
  export async function fetchWithTimeout(input, init = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
41
47
  const controller = new AbortController();
42
48
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
49
+ const headers = new Headers(init.headers);
50
+ if (!headers.has("User-Agent")) {
51
+ headers.set("User-Agent", USER_AGENT);
52
+ }
43
53
  try {
44
54
  return await fetch(input, {
45
55
  ...init,
56
+ headers,
46
57
  signal: init.signal ?? controller.signal,
47
58
  });
48
59
  }
@@ -61,5 +72,12 @@ async function buildApiError(res) {
61
72
  const body = details && typeof details === "object" && !Array.isArray(details)
62
73
  ? details
63
74
  : { error: res.statusText };
75
+ // Every response carries X-Request-Id, but only 500 bodies embed it —
76
+ // lift it from the header so 4xx failures stay correlatable too.
77
+ if (typeof body.request_id !== "string") {
78
+ const headerRequestId = res.headers.get("x-request-id");
79
+ if (headerRequestId)
80
+ body.request_id = headerRequestId;
81
+ }
64
82
  return new ApiError(res.status, body, res.statusText);
65
83
  }
@@ -1,3 +1,11 @@
1
+ export declare class CommandFailure extends Error {
2
+ exitCode: number;
3
+ cause?: unknown;
4
+ constructor(message: string, opts?: {
5
+ exitCode?: number;
6
+ cause?: unknown;
7
+ });
8
+ }
1
9
  export declare function errorPayload(err: unknown): Record<string, unknown>;
2
10
  export declare function getErrorMessage(err: unknown): string;
3
11
  export declare function printCommandError(prefix: string, err: unknown, json?: boolean): void;
@@ -1,4 +1,14 @@
1
1
  import { ApiError } from "./api.js";
2
+ export class CommandFailure extends Error {
3
+ exitCode;
4
+ cause;
5
+ constructor(message, opts = {}) {
6
+ super(message);
7
+ this.name = "CommandFailure";
8
+ this.exitCode = opts.exitCode ?? 1;
9
+ this.cause = opts.cause;
10
+ }
11
+ }
2
12
  export function errorPayload(err) {
3
13
  if (err instanceof ApiError) {
4
14
  return {
@@ -10,14 +20,35 @@ export function errorPayload(err) {
10
20
  return { error: getErrorMessage(err) };
11
21
  }
12
22
  export function getErrorMessage(err) {
23
+ if (err instanceof CommandFailure) {
24
+ if (err.cause !== undefined)
25
+ return getErrorMessage(err.cause);
26
+ return err.message;
27
+ }
28
+ if (err instanceof ApiError && err.details.code === "trial_expired") {
29
+ const upgradeUrl = typeof err.details.upgrade_url === "string" ? err.details.upgrade_url : null;
30
+ return `Your Peekable trial has ended. Run \`peekable upgrade\` to keep pushing.${upgradeUrl ? ` Upgrade: ${upgradeUrl}` : ""}`;
31
+ }
13
32
  if (err instanceof Error)
14
33
  return err.message;
15
34
  return String(err);
16
35
  }
36
+ function getRequestId(err) {
37
+ const source = err instanceof CommandFailure ? err.cause : err;
38
+ if (source instanceof ApiError) {
39
+ const rid = source.details.request_id;
40
+ if (typeof rid === "string" && rid)
41
+ return rid;
42
+ }
43
+ return undefined;
44
+ }
17
45
  export function printCommandError(prefix, err, json) {
46
+ const cause = err instanceof CommandFailure && err.cause !== undefined ? err.cause : err;
18
47
  if (json) {
19
- console.log(JSON.stringify(errorPayload(err)));
48
+ console.log(JSON.stringify(errorPayload(cause)));
20
49
  return;
21
50
  }
22
- console.error(`${prefix}: ${getErrorMessage(err)}`);
51
+ const requestId = getRequestId(err);
52
+ const suffix = requestId ? ` (request_id: ${requestId})` : "";
53
+ console.error(`${prefix}: ${getErrorMessage(err)}${suffix}`);
23
54
  }
@@ -1,17 +1,27 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
3
  import { api } from "../api.js";
4
+ import { CommandFailure, printCommandError } from "../command-error.js";
5
+ import { runWithTelemetry } from "../telemetry.js";
4
6
  export const closeCommand = new Command("close")
5
7
  .argument("<session-id>", "Session ID")
6
8
  .description("Close a session")
7
9
  .option("--json", "Output JSON")
8
10
  .action(async (sessionId, opts) => {
9
- const config = requireConfig();
10
- await api(config, "DELETE", `/sessions/${sessionId}`);
11
- if (opts.json) {
12
- console.log(JSON.stringify({ ok: true }));
13
- }
14
- else {
15
- console.log(`Closed session ${sessionId}`);
16
- }
11
+ await runWithTelemetry("close", async () => {
12
+ try {
13
+ const config = requireConfig();
14
+ await api(config, "DELETE", `/sessions/${sessionId}`);
15
+ if (opts.json) {
16
+ console.log(JSON.stringify({ ok: true }));
17
+ }
18
+ else {
19
+ console.log(`Closed session ${sessionId}`);
20
+ }
21
+ }
22
+ catch (err) {
23
+ printCommandError("Close failed", err, opts.json);
24
+ throw new CommandFailure("Close failed", { cause: err });
25
+ }
26
+ });
17
27
  });
@@ -1,25 +1,28 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
3
  import { api } from "../api.js";
4
- import { printCommandError } from "../command-error.js";
4
+ import { CommandFailure, printCommandError } from "../command-error.js";
5
+ import { runWithTelemetry } from "../telemetry.js";
5
6
  export const createCommand = new Command("create")
6
7
  .argument("<name>", "Session name")
7
8
  .description("Create a new sharing session")
8
9
  .option("--json", "Output JSON")
9
10
  .action(async (name, opts) => {
10
- const config = requireConfig();
11
- try {
12
- const data = await api(config, "POST", "/sessions", { name });
13
- if (opts.json) {
14
- console.log(JSON.stringify(data));
11
+ await runWithTelemetry("create", async () => {
12
+ try {
13
+ const config = requireConfig();
14
+ const data = await api(config, "POST", "/sessions", { name });
15
+ if (opts.json) {
16
+ console.log(JSON.stringify(data));
17
+ }
18
+ else {
19
+ console.log(`Created session: ${data.id}`);
20
+ console.log(`URL: ${data.url}`);
21
+ }
15
22
  }
16
- else {
17
- console.log(`Created session: ${data.id}`);
18
- console.log(`URL: ${data.url}`);
23
+ catch (err) {
24
+ printCommandError("Create failed", err, opts.json);
25
+ throw new CommandFailure("Create failed", { cause: err });
19
26
  }
20
- }
21
- catch (err) {
22
- printCommandError("Create failed", err, opts.json);
23
- process.exit(1);
24
- }
27
+ });
25
28
  });
@@ -3,19 +3,49 @@ export type DoctorStatus = "ok" | "failed" | "skipped";
3
3
  export interface DoctorReport {
4
4
  cliVersion: string;
5
5
  nodeVersion: string;
6
+ os: string;
7
+ osRelease: string;
8
+ arch: string;
9
+ isTty: boolean;
6
10
  configPath: string;
7
11
  configured: boolean;
8
12
  serverUrl: string | null;
9
13
  health: DoctorStatus;
10
14
  auth: DoctorStatus;
15
+ identity: DoctorStatus;
16
+ identityName?: string | null;
17
+ identityEmail?: string | null;
18
+ planState?: string | null;
19
+ daysRemaining?: number | null;
11
20
  activeSessions: number | null;
12
21
  maxActiveSessions: number | null;
13
22
  activeSessionUnlimited?: boolean;
14
23
  testPush: DoctorStatus;
15
24
  errors?: string[];
16
25
  }
26
+ /**
27
+ * Scalar-only snapshot suitable for authenticated telemetry. Excludes raw
28
+ * error strings, identity fields, and any path or free-form text.
29
+ */
30
+ export interface DoctorScalarSnapshot {
31
+ cli_version: string;
32
+ node_version: string;
33
+ os: string;
34
+ os_release: string;
35
+ arch: string;
36
+ is_tty: boolean;
37
+ configured: boolean;
38
+ health: DoctorStatus;
39
+ auth: DoctorStatus;
40
+ identity: DoctorStatus;
41
+ test_push: DoctorStatus;
42
+ active_sessions: number | null;
43
+ max_active_sessions: number | null;
44
+ active_session_unlimited: boolean;
45
+ }
17
46
  export declare const doctorCommand: Command;
18
47
  export declare function runDoctor(opts?: {
19
48
  testPush?: boolean;
20
49
  }): Promise<DoctorReport>;
50
+ export declare function toScalarSnapshot(report: DoctorReport): DoctorScalarSnapshot;
21
51
  export declare function formatDoctorReport(report: DoctorReport): string;
@@ -1,35 +1,90 @@
1
1
  import { Command } from "commander";
2
- import { api, fetchWithTimeout } from "../api.js";
3
- import { getConfigPath, readConfig } from "../config.js";
4
- import { getErrorMessage } from "../command-error.js";
2
+ import { release } from "os";
3
+ import { api, fetchWithTimeout, postDebugEvent } from "../api.js";
4
+ import { ConfigError, getConfigPath, readConfig, tryReadConfig } from "../config.js";
5
+ import { CommandFailure, getErrorMessage } from "../command-error.js";
5
6
  import { formatQuota } from "../quota.js";
6
7
  import { CLI_VERSION } from "../version.js";
8
+ import { runWithTelemetry } from "../telemetry.js";
7
9
  export const doctorCommand = new Command("doctor")
8
10
  .description("Print a support snapshot for debugging Peekable setup")
9
11
  .option("--json", "Output JSON")
10
12
  .option("--test-push", "Create, push, and close a temporary test session")
13
+ .option("--report", "Send a scalar snapshot to Peekable as authenticated telemetry")
11
14
  .action(async (opts) => {
12
- const report = await runDoctor({ testPush: Boolean(opts.testPush) });
13
- if (opts.json) {
14
- console.log(JSON.stringify(report));
15
- return;
16
- }
17
- console.log(formatDoctorReport(report));
15
+ await runWithTelemetry("doctor", async () => {
16
+ const report = await runDoctor({ testPush: Boolean(opts.testPush) });
17
+ if (opts.json) {
18
+ console.log(JSON.stringify(report));
19
+ }
20
+ else {
21
+ console.log(formatDoctorReport(report));
22
+ }
23
+ if (opts.report) {
24
+ const configResult = tryReadConfig();
25
+ if (configResult.state !== "ok") {
26
+ // Distinguish corrupt from missing so telemetry classifies the
27
+ // beacon as cli_config_corrupt rather than the generic catch-all.
28
+ if (configResult.state === "corrupt") {
29
+ const msg = "Cannot send doctor report: config file is corrupted. Run `peekable register` to reconfigure.";
30
+ console.error(msg);
31
+ throw new CommandFailure(msg, {
32
+ cause: new ConfigError("corrupt", configResult.parseError),
33
+ });
34
+ }
35
+ console.error("Cannot send doctor report: not configured. Run `peekable register` first.");
36
+ throw new CommandFailure("Not configured");
37
+ }
38
+ const config = configResult.config;
39
+ const snapshot = toScalarSnapshot(report);
40
+ if (!opts.json) {
41
+ console.log("\nSending doctor report:");
42
+ console.log(JSON.stringify(snapshot, null, 2));
43
+ }
44
+ try {
45
+ await postDebugEvent(config, {
46
+ event_name: "cli_doctor_ran",
47
+ source: "cli",
48
+ command: "doctor",
49
+ cli_version: CLI_VERSION,
50
+ status: "ok",
51
+ metadata: {
52
+ ...snapshot,
53
+ diagnostics_json: JSON.stringify(snapshot),
54
+ },
55
+ });
56
+ if (!opts.json)
57
+ console.log("Report sent.");
58
+ }
59
+ catch (err) {
60
+ if (!opts.json)
61
+ console.error(`Report send failed: ${getErrorMessage(err)}`);
62
+ throw new CommandFailure("Report send failed", { cause: err });
63
+ }
64
+ }
65
+ });
18
66
  });
19
67
  export async function runDoctor(opts = {}) {
20
68
  const config = readConfig();
21
69
  const report = {
22
70
  cliVersion: CLI_VERSION,
23
71
  nodeVersion: process.version,
72
+ os: process.platform,
73
+ osRelease: release(),
74
+ arch: process.arch,
75
+ isTty: Boolean(process.stdout.isTTY),
24
76
  configPath: redactHomePath(getConfigPath()),
25
77
  configured: Boolean(config),
26
78
  serverUrl: config?.url ?? null,
27
79
  health: "skipped",
28
80
  auth: "skipped",
81
+ identity: "skipped",
29
82
  activeSessions: null,
30
83
  maxActiveSessions: null,
31
84
  activeSessionUnlimited: false,
32
85
  testPush: "skipped",
86
+ planState: null,
87
+ daysRemaining: null,
33
88
  errors: [],
34
89
  };
35
90
  if (!config) {
@@ -57,9 +112,27 @@ export async function runDoctor(opts = {}) {
57
112
  report.auth = "failed";
58
113
  report.errors.push(`Auth check failed: ${getErrorMessage(err)}`);
59
114
  }
115
+ try {
116
+ const me = await api(config, "GET", "/me");
117
+ report.identity = "ok";
118
+ if (me && typeof me === "object") {
119
+ const anyMe = me;
120
+ report.identityName = typeof anyMe.name === "string" ? anyMe.name : null;
121
+ report.identityEmail = typeof anyMe.email === "string" ? anyMe.email : null;
122
+ report.planState = typeof anyMe.plan_state === "string" ? anyMe.plan_state : null;
123
+ report.daysRemaining = typeof anyMe.days_remaining === "number" ? anyMe.days_remaining : null;
124
+ }
125
+ }
126
+ catch (err) {
127
+ report.identity = "failed";
128
+ report.errors.push(`Identity check failed: ${getErrorMessage(err)}`);
129
+ }
60
130
  if (opts.testPush && report.auth === "ok") {
61
131
  try {
62
- const session = await api(config, "POST", "/sessions", { name: "__peekable_doctor__" });
132
+ const session = await api(config, "POST", "/sessions", {
133
+ name: "__peekable_doctor__",
134
+ diagnostic: true,
135
+ });
63
136
  try {
64
137
  await api(config, "POST", `/sessions/${session.id}/push`, {
65
138
  html: "<html><body><p>Peekable doctor test</p></body></html>",
@@ -79,13 +152,35 @@ export async function runDoctor(opts = {}) {
79
152
  delete report.errors;
80
153
  return report;
81
154
  }
155
+ export function toScalarSnapshot(report) {
156
+ return {
157
+ cli_version: report.cliVersion,
158
+ node_version: report.nodeVersion,
159
+ os: report.os,
160
+ os_release: report.osRelease,
161
+ arch: report.arch,
162
+ is_tty: report.isTty,
163
+ configured: report.configured,
164
+ health: report.health,
165
+ auth: report.auth,
166
+ identity: report.identity,
167
+ test_push: report.testPush,
168
+ active_sessions: report.activeSessions,
169
+ max_active_sessions: report.maxActiveSessions,
170
+ active_session_unlimited: Boolean(report.activeSessionUnlimited),
171
+ };
172
+ }
82
173
  export function formatDoctorReport(report) {
83
174
  const lines = [
84
175
  `Peekable CLI: ${report.cliVersion}`,
85
176
  `Node: ${report.nodeVersion}`,
177
+ `OS: ${report.os} ${report.osRelease} (${report.arch})`,
178
+ `TTY: ${report.isTty ? "yes" : "no"}`,
86
179
  `Config: ${report.configured ? report.configPath : "missing"}`,
87
180
  `Server: ${formatServer(report)}`,
88
181
  `Auth: ${formatAuth(report.auth)}`,
182
+ `Identity: ${formatIdentity(report)}`,
183
+ `Plan: ${formatPlan(report)}`,
89
184
  `Active sessions: ${formatQuota(report.activeSessions, report.maxActiveSessions, report.activeSessionUnlimited)}`,
90
185
  `Test push: ${report.testPush}`,
91
186
  ];
@@ -102,6 +197,19 @@ export function formatDoctorReport(report) {
102
197
  }
103
198
  return lines.join("\n");
104
199
  }
200
+ function formatPlan(report) {
201
+ if (report.planState === "trial_registered")
202
+ return "trial — not activated";
203
+ if (report.planState === "trial_active") {
204
+ const days = report.daysRemaining ?? 0;
205
+ return `trial (${days} ${days === 1 ? "day" : "days"} left)`;
206
+ }
207
+ if (report.planState === "trial_expired")
208
+ return "trial expired — run `peekable upgrade`";
209
+ if (report.planState === "paid")
210
+ return "paid";
211
+ return "free";
212
+ }
105
213
  function formatServer(report) {
106
214
  if (!report.serverUrl)
107
215
  return "not configured";
@@ -118,6 +226,19 @@ function formatAuth(status) {
118
226
  return "failed";
119
227
  return "not checked";
120
228
  }
229
+ function formatIdentity(report) {
230
+ if (report.identity === "ok") {
231
+ const parts = [];
232
+ if (report.identityName)
233
+ parts.push(report.identityName);
234
+ if (report.identityEmail)
235
+ parts.push(`<${report.identityEmail}>`);
236
+ return parts.length ? parts.join(" ") : "ok";
237
+ }
238
+ if (report.identity === "failed")
239
+ return "failed";
240
+ return "not checked";
241
+ }
121
242
  async function getUsageSnapshot(config) {
122
243
  try {
123
244
  return await api(config, "GET", "/me/usage");
@@ -1,56 +1,66 @@
1
1
  import { Command } from "commander";
2
2
  import { requireConfig } from "../config.js";
3
3
  import { api } from "../api.js";
4
+ import { CommandFailure, printCommandError } from "../command-error.js";
5
+ import { runWithTelemetry } from "../telemetry.js";
4
6
  export const feedbackCommand = new Command("feedback")
5
7
  .argument("<session-id>", "Session ID")
6
8
  .description("Fetch feedback for a session")
7
9
  .option("--json", "Output JSON")
8
10
  .action(async (sessionId, opts) => {
9
- const config = requireConfig();
10
- const [eventsData, annotationsData] = await Promise.all([
11
- api(config, "GET", `/sessions/${sessionId}/feedback`),
12
- api(config, "GET", `/sessions/${sessionId}/annotations`).catch(() => ({ annotations: [] })),
13
- ]);
14
- if (opts.json) {
15
- console.log(JSON.stringify({ ...eventsData, ...annotationsData }));
16
- return;
17
- }
18
- const hasFeedback = eventsData.feedback.length > 0;
19
- const hasAnnotations = annotationsData.annotations && annotationsData.annotations.length > 0;
20
- if (!hasFeedback && !hasAnnotations) {
21
- console.log("No feedback yet.");
22
- return;
23
- }
24
- // Display choice feedback
25
- if (hasFeedback) {
26
- console.log("\nChoice Feedback:");
27
- for (const group of eventsData.feedback) {
28
- console.log(`\n--- Version ${group.version} ---`);
29
- for (const event of group.events) {
30
- const who = event.viewer ?? "Anonymous";
31
- console.log(` ${who} chose "${event.choice}" — ${event.label}`);
11
+ await runWithTelemetry("feedback", async () => {
12
+ try {
13
+ const config = requireConfig();
14
+ const [eventsData, annotationsData] = await Promise.all([
15
+ api(config, "GET", `/sessions/${sessionId}/feedback`),
16
+ api(config, "GET", `/sessions/${sessionId}/annotations`).catch(() => ({ annotations: [] })),
17
+ ]);
18
+ if (opts.json) {
19
+ console.log(JSON.stringify({ ...eventsData, ...annotationsData }));
20
+ return;
32
21
  }
33
- }
34
- }
35
- // Display annotations
36
- if (hasAnnotations) {
37
- console.log("\nAnnotations:");
38
- for (const group of annotationsData.annotations) {
39
- console.log(`\n--- Version ${group.version} ---`);
40
- for (const ann of group.items) {
41
- const who = ann.viewer ?? "Anonymous";
42
- const elementName = ann.element_name || "Unknown";
43
- const selector = ann.selector || "";
44
- console.log(` [${who}] ${elementName} (${selector})`);
45
- console.log(` "${ann.note}"`);
46
- if (ann.element_context?.computedStyles) {
47
- const styles = Object.entries(ann.element_context.computedStyles)
48
- .map(([key, val]) => `${key}: ${val}`)
49
- .join(", ");
50
- if (styles)
51
- console.log(` Styles: ${styles}`);
22
+ const hasFeedback = eventsData.feedback.length > 0;
23
+ const hasAnnotations = annotationsData.annotations && annotationsData.annotations.length > 0;
24
+ if (!hasFeedback && !hasAnnotations) {
25
+ console.log("No feedback yet.");
26
+ return;
27
+ }
28
+ // Display choice feedback
29
+ if (hasFeedback) {
30
+ console.log("\nChoice Feedback:");
31
+ for (const group of eventsData.feedback) {
32
+ console.log(`\n--- Version ${group.version} ---`);
33
+ for (const event of group.events) {
34
+ const who = event.viewer ?? "Anonymous";
35
+ console.log(` ${who} chose "${event.choice}" — ${event.label}`);
36
+ }
52
37
  }
53
38
  }
39
+ // Display annotations
40
+ if (hasAnnotations) {
41
+ console.log("\nAnnotations:");
42
+ for (const group of annotationsData.annotations) {
43
+ console.log(`\n--- Version ${group.version} ---`);
44
+ for (const ann of group.items) {
45
+ const who = ann.viewer ?? "Anonymous";
46
+ const elementName = ann.element_name || "Unknown";
47
+ const selector = ann.selector || "";
48
+ console.log(` [${who}] ${elementName} (${selector})`);
49
+ console.log(` "${ann.note}"`);
50
+ if (ann.element_context?.computedStyles) {
51
+ const styles = Object.entries(ann.element_context.computedStyles)
52
+ .map(([key, val]) => `${key}: ${val}`)
53
+ .join(", ");
54
+ if (styles)
55
+ console.log(` Styles: ${styles}`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ catch (err) {
62
+ printCommandError("Feedback failed", err, opts.json);
63
+ throw new CommandFailure("Feedback failed", { cause: err });
54
64
  }
55
- }
65
+ });
56
66
  });