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.
@@ -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,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.parse();
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.4";
1
+ export declare const CLI_VERSION = "0.1.5";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.4";
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.4",
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",