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.
@@ -1,20 +1,30 @@
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 resolveCommand = new Command("resolve")
5
7
  .argument("<session-id>", "Session ID")
6
8
  .argument("<annotation-ids...>", "Annotation IDs to resolve")
7
9
  .description("Mark annotations as resolved")
8
10
  .option("--json", "Output JSON")
9
11
  .action(async (sessionId, annotationIds, opts) => {
10
- const config = requireConfig();
11
- const data = await api(config, "PATCH", `/sessions/${sessionId}/annotations/resolve`, {
12
- annotationIds,
12
+ await runWithTelemetry("resolve", async () => {
13
+ try {
14
+ const config = requireConfig();
15
+ const data = await api(config, "PATCH", `/sessions/${sessionId}/annotations/resolve`, {
16
+ annotationIds,
17
+ });
18
+ if (opts.json) {
19
+ console.log(JSON.stringify(data));
20
+ }
21
+ else {
22
+ console.log(`Resolved ${data.resolved} annotation(s).`);
23
+ }
24
+ }
25
+ catch (err) {
26
+ printCommandError("Resolve failed", err, opts.json);
27
+ throw new CommandFailure("Resolve failed", { cause: err });
28
+ }
13
29
  });
14
- if (opts.json) {
15
- console.log(JSON.stringify(data));
16
- }
17
- else {
18
- console.log(`Resolved ${data.resolved} annotation(s).`);
19
- }
20
30
  });
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { existsSync, rmSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { getClaudePeekableSkillDir, getCodexPeekableSkillDir, getPeekableConfigDir } from "../paths.js";
5
+ import { runWithTelemetry } from "../telemetry.js";
5
6
  function getUninstallTargets(home = homedir()) {
6
7
  return [
7
8
  {
@@ -69,17 +70,19 @@ export function createUninstallCommand() {
69
70
  return new Command("uninstall")
70
71
  .description("Remove local Peekable config and installed Claude Code skill")
71
72
  .option("--json", "Output JSON")
72
- .action((opts) => {
73
- const result = removeLocalPeekableFiles();
74
- if (opts.json) {
75
- console.log(JSON.stringify(result));
76
- }
77
- else {
78
- console.log(formatUninstallResult(result));
79
- }
80
- if (result.status !== "ok") {
81
- process.exitCode = 1;
82
- }
73
+ .action(async (opts) => {
74
+ await runWithTelemetry("uninstall", async () => {
75
+ const result = removeLocalPeekableFiles();
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(result));
78
+ }
79
+ else {
80
+ console.log(formatUninstallResult(result));
81
+ }
82
+ if (result.status !== "ok") {
83
+ process.exitCode = 1;
84
+ }
85
+ });
83
86
  });
84
87
  }
85
88
  export const uninstallCommand = createUninstallCommand();
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const upgradeCommand: Command;
@@ -0,0 +1,63 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Command } from "commander";
3
+ import { api, ApiError } from "../api.js";
4
+ import { CommandFailure, printCommandError } from "../command-error.js";
5
+ import { requireConfig } from "../config.js";
6
+ import { runWithTelemetry } from "../telemetry.js";
7
+ export const upgradeCommand = new Command("upgrade")
8
+ .description("Upgrade a Peekable trial through Stripe Checkout")
9
+ .option("--json", "Output JSON")
10
+ .action(async (opts) => {
11
+ await runWithTelemetry("upgrade", async () => {
12
+ const config = requireConfig();
13
+ try {
14
+ const data = await api(config, "POST", "/billing/checkout", {});
15
+ const url = checkoutUrl(data?.url);
16
+ if (!url)
17
+ throw new Error("Checkout did not return a valid URL.");
18
+ if (opts.json) {
19
+ console.log(JSON.stringify({ url, sessionId: data.sessionId ?? null }));
20
+ }
21
+ else {
22
+ console.log("Complete your Peekable upgrade in Stripe Checkout:");
23
+ console.log(url);
24
+ openBrowserBestEffort(url);
25
+ }
26
+ }
27
+ catch (err) {
28
+ if (!opts.json
29
+ && err instanceof ApiError
30
+ && (err.details.code === "billing_account_exists"
31
+ || err.details.code === "billing_account_requires_support")) {
32
+ console.error("Email support@peekable.fyi from your registered account email for help.");
33
+ }
34
+ printCommandError("Upgrade failed", err, opts.json);
35
+ throw new CommandFailure("Upgrade failed", { cause: err });
36
+ }
37
+ });
38
+ });
39
+ function checkoutUrl(value) {
40
+ if (typeof value !== "string")
41
+ return null;
42
+ try {
43
+ const url = new URL(value);
44
+ return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ function openBrowserBestEffort(url) {
51
+ try {
52
+ const child = process.platform === "darwin"
53
+ ? spawn("open", [url], { detached: true, stdio: "ignore" })
54
+ : process.platform === "win32"
55
+ ? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })
56
+ : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
57
+ child.on("error", () => undefined);
58
+ child.unref();
59
+ }
60
+ catch {
61
+ // The URL remains printed for humans and coding agents to relay.
62
+ }
63
+ }
@@ -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 ws = new WebSocket(wsUrl);
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 function readConfig() {
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
- url: process.env.SHARE_URL,
9
- api_key: process.env.SHARE_API_KEY,
10
- name: process.env.SHARE_NAME ?? "User",
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 raw = readFileSync(CONFIG_FILE, "utf-8");
17
- return JSON.parse(raw);
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 null;
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 config = readConfig();
29
- if (!config) {
30
- console.error("Not configured. Run `peekable register` first.");
31
- process.exit(1);
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
- return config;
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,12 @@ 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";
17
+ import { upgradeCommand } from "./commands/upgrade.js";
16
18
  import { CLI_VERSION } from "./version.js";
19
+ import { CommandFailure } from "./command-error.js";
20
+ import { ConfigError } from "./config.js";
21
+ import { postCrashBeacon } from "./telemetry.js";
17
22
  program
18
23
  .name("peekable")
19
24
  .description("Share HTML mockups with collaborators via peekable")
@@ -31,4 +36,34 @@ program.addCommand(resolveCommand);
31
36
  program.addCommand(proxyCommand);
32
37
  program.addCommand(doctorCommand);
33
38
  program.addCommand(uninstallCommand);
34
- program.parse();
39
+ program.addCommand(reportCommand);
40
+ program.addCommand(upgradeCommand);
41
+ program.addHelpText("afterAll", `
42
+ Telemetry:
43
+ Peekable CLI sends anonymous usage/failure telemetry to improve reliability.
44
+ Opt out with PEEKABLE_NO_TELEMETRY=1.
45
+ `);
46
+ async function main() {
47
+ try {
48
+ await program.parseAsync(process.argv);
49
+ }
50
+ catch (err) {
51
+ if (err instanceof CommandFailure) {
52
+ // Command already printed its user-facing error via printCommandError.
53
+ process.exitCode = err.exitCode;
54
+ return;
55
+ }
56
+ if (err instanceof ConfigError) {
57
+ console.error(err.message);
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ console.error(message);
63
+ // Unexpected crash (escaped every command handler) — fire the anonymous
64
+ // crash beacon so it isn't invisible server-side.
65
+ await postCrashBeacon(err);
66
+ process.exitCode = 1;
67
+ }
68
+ }
69
+ 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>;