peekable 0.1.3 → 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 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,31 @@ 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 instanceof Error)
25
+ return err.cause.message;
26
+ return err.message;
27
+ }
13
28
  if (err instanceof Error)
14
29
  return err.message;
15
30
  return String(err);
16
31
  }
32
+ function getRequestId(err) {
33
+ const source = err instanceof CommandFailure ? err.cause : err;
34
+ if (source instanceof ApiError) {
35
+ const rid = source.details.request_id;
36
+ if (typeof rid === "string" && rid)
37
+ return rid;
38
+ }
39
+ return undefined;
40
+ }
17
41
  export function printCommandError(prefix, err, json) {
42
+ const cause = err instanceof CommandFailure && err.cause !== undefined ? err.cause : err;
18
43
  if (json) {
19
- console.log(JSON.stringify(errorPayload(err)));
44
+ console.log(JSON.stringify(errorPayload(cause)));
20
45
  return;
21
46
  }
22
- console.error(`${prefix}: ${getErrorMessage(err)}`);
47
+ const requestId = getRequestId(err);
48
+ const suffix = requestId ? ` (request_id: ${requestId})` : "";
49
+ console.error(`${prefix}: ${getErrorMessage(err)}${suffix}`);
23
50
  }
@@ -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,47 @@ 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;
11
18
  activeSessions: number | null;
12
19
  maxActiveSessions: number | null;
13
20
  activeSessionUnlimited?: boolean;
14
21
  testPush: DoctorStatus;
15
22
  errors?: string[];
16
23
  }
24
+ /**
25
+ * Scalar-only snapshot suitable for authenticated telemetry. Excludes raw
26
+ * error strings, identity fields, and any path or free-form text.
27
+ */
28
+ export interface DoctorScalarSnapshot {
29
+ cli_version: string;
30
+ node_version: string;
31
+ os: string;
32
+ os_release: string;
33
+ arch: string;
34
+ is_tty: boolean;
35
+ configured: boolean;
36
+ health: DoctorStatus;
37
+ auth: DoctorStatus;
38
+ identity: DoctorStatus;
39
+ test_push: DoctorStatus;
40
+ active_sessions: number | null;
41
+ max_active_sessions: number | null;
42
+ active_session_unlimited: boolean;
43
+ }
17
44
  export declare const doctorCommand: Command;
18
45
  export declare function runDoctor(opts?: {
19
46
  testPush?: boolean;
20
47
  }): Promise<DoctorReport>;
48
+ export declare function toScalarSnapshot(report: DoctorReport): DoctorScalarSnapshot;
21
49
  export declare function formatDoctorReport(report: DoctorReport): string;
@@ -1,31 +1,84 @@
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,
@@ -57,6 +110,19 @@ export async function runDoctor(opts = {}) {
57
110
  report.auth = "failed";
58
111
  report.errors.push(`Auth check failed: ${getErrorMessage(err)}`);
59
112
  }
113
+ try {
114
+ const me = await api(config, "GET", "/me");
115
+ report.identity = "ok";
116
+ if (me && typeof me === "object") {
117
+ const anyMe = me;
118
+ report.identityName = typeof anyMe.name === "string" ? anyMe.name : null;
119
+ report.identityEmail = typeof anyMe.email === "string" ? anyMe.email : null;
120
+ }
121
+ }
122
+ catch (err) {
123
+ report.identity = "failed";
124
+ report.errors.push(`Identity check failed: ${getErrorMessage(err)}`);
125
+ }
60
126
  if (opts.testPush && report.auth === "ok") {
61
127
  try {
62
128
  const session = await api(config, "POST", "/sessions", { name: "__peekable_doctor__" });
@@ -79,13 +145,34 @@ export async function runDoctor(opts = {}) {
79
145
  delete report.errors;
80
146
  return report;
81
147
  }
148
+ export function toScalarSnapshot(report) {
149
+ return {
150
+ cli_version: report.cliVersion,
151
+ node_version: report.nodeVersion,
152
+ os: report.os,
153
+ os_release: report.osRelease,
154
+ arch: report.arch,
155
+ is_tty: report.isTty,
156
+ configured: report.configured,
157
+ health: report.health,
158
+ auth: report.auth,
159
+ identity: report.identity,
160
+ test_push: report.testPush,
161
+ active_sessions: report.activeSessions,
162
+ max_active_sessions: report.maxActiveSessions,
163
+ active_session_unlimited: Boolean(report.activeSessionUnlimited),
164
+ };
165
+ }
82
166
  export function formatDoctorReport(report) {
83
167
  const lines = [
84
168
  `Peekable CLI: ${report.cliVersion}`,
85
169
  `Node: ${report.nodeVersion}`,
170
+ `OS: ${report.os} ${report.osRelease} (${report.arch})`,
171
+ `TTY: ${report.isTty ? "yes" : "no"}`,
86
172
  `Config: ${report.configured ? report.configPath : "missing"}`,
87
173
  `Server: ${formatServer(report)}`,
88
174
  `Auth: ${formatAuth(report.auth)}`,
175
+ `Identity: ${formatIdentity(report)}`,
89
176
  `Active sessions: ${formatQuota(report.activeSessions, report.maxActiveSessions, report.activeSessionUnlimited)}`,
90
177
  `Test push: ${report.testPush}`,
91
178
  ];
@@ -118,6 +205,19 @@ function formatAuth(status) {
118
205
  return "failed";
119
206
  return "not checked";
120
207
  }
208
+ function formatIdentity(report) {
209
+ if (report.identity === "ok") {
210
+ const parts = [];
211
+ if (report.identityName)
212
+ parts.push(report.identityName);
213
+ if (report.identityEmail)
214
+ parts.push(`<${report.identityEmail}>`);
215
+ return parts.length ? parts.join(" ") : "ok";
216
+ }
217
+ if (report.identity === "failed")
218
+ return "failed";
219
+ return "not checked";
220
+ }
121
221
  async function getUsageSnapshot(config) {
122
222
  try {
123
223
  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
  });
@@ -6,5 +6,6 @@ type LocalAgentSkillResult = {
6
6
  codex_skill_installed: boolean;
7
7
  };
8
8
  export declare function installLocalAgentSkills(skillSource: string, jsonOutput: boolean, home?: string): LocalAgentSkillResult;
9
+ export declare function formatInitNextSteps(result: LocalAgentSkillResult): string;
9
10
  export declare const initCommand: Command;
10
11
  export {};