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 +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.d.ts +1 -0
- package/dist/commands/init.js +67 -43
- 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 -48
- 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/init.js
CHANGED
|
@@ -5,6 +5,8 @@ import { join, dirname } from "path";
|
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { requireConfig } from "../config.js";
|
|
7
7
|
import { api } from "../api.js";
|
|
8
|
+
import { CommandFailure } from "../command-error.js";
|
|
9
|
+
import { runWithTelemetry } from "../telemetry.js";
|
|
8
10
|
import { getClaudeDir, getClaudePeekableSkillDir, getCodexDir, getCodexPeekableSkillDir, } from "../paths.js";
|
|
9
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
function installPeekableSkill(appName, appDir, skillDir, skillSource, jsonOutput) {
|
|
@@ -35,54 +37,76 @@ export function installLocalAgentSkills(skillSource, jsonOutput, home = homedir(
|
|
|
35
37
|
codex_skill_installed: codexSkill.installed,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
40
|
+
export function formatInitNextSteps(result) {
|
|
41
|
+
const lines = ["", "Ready to go."];
|
|
42
|
+
const installedSkills = [];
|
|
43
|
+
if (result.skill_installed) {
|
|
44
|
+
installedSkills.push("Claude Code: use the /peekable skill");
|
|
45
|
+
}
|
|
46
|
+
if (result.codex_skill_installed) {
|
|
47
|
+
installedSkills.push("Codex: use the $peekable skill");
|
|
48
|
+
}
|
|
49
|
+
if (installedSkills.length > 0) {
|
|
50
|
+
lines.push("", "Installed agent skills:", ...installedSkills.map((skill) => `- ${skill}`));
|
|
51
|
+
lines.push("", "Example prompts:", '- "Use Peekable to share ./mockups/home.html"', '- "Use Peekable to proxy localhost:3000 for review"');
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
lines.push("", "No Claude Code or Codex skill was installed, but the CLI is ready.", 'Try: peekable create "My First Session"');
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
38
58
|
export const initCommand = new Command("init")
|
|
39
|
-
.description("Set up peekable — install Claude Code
|
|
59
|
+
.description("Set up peekable — install Claude Code/Codex skills and verify connection")
|
|
40
60
|
.option("--json", "Output JSON")
|
|
41
61
|
.action(async (opts) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
await runWithTelemetry("init", async () => {
|
|
63
|
+
const config = requireConfig();
|
|
64
|
+
const result = { status: "ok" };
|
|
65
|
+
// 1. Test connection
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${config.url}/health`);
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
throw new Error("Health check failed");
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const msg = `Cannot reach server at ${config.url}`;
|
|
73
|
+
if (opts.json) {
|
|
74
|
+
console.log(JSON.stringify({ status: "error", error: msg }));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.error(msg);
|
|
78
|
+
}
|
|
79
|
+
// Preserve the fetch error so telemetry can classify the connection
|
|
80
|
+
// failure (ECONNREFUSED vs DNS vs timeout).
|
|
81
|
+
throw new CommandFailure(msg, { cause: err });
|
|
82
|
+
}
|
|
83
|
+
if (!opts.json)
|
|
84
|
+
console.log("Connection verified.");
|
|
85
|
+
// 2. Detect local agent tools and install skill
|
|
86
|
+
const skillSource = join(__dirname, "..", "..", "skill", "SKILL.md");
|
|
87
|
+
Object.assign(result, installLocalAgentSkills(skillSource, opts.json));
|
|
88
|
+
// 3. Test push
|
|
89
|
+
try {
|
|
90
|
+
const session = await api(config, "POST", "/sessions", { name: "__share_init_test__" });
|
|
91
|
+
await api(config, "POST", `/sessions/${session.id}/push`, {
|
|
92
|
+
html: "<html><body><p>Share init test</p></body></html>",
|
|
93
|
+
});
|
|
94
|
+
await api(config, "DELETE", `/sessions/${session.id}`);
|
|
95
|
+
result.test_push = "passed";
|
|
96
|
+
if (!opts.json)
|
|
97
|
+
console.log("Test push verified.");
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
result.test_push = "failed";
|
|
101
|
+
result.test_error = err.message;
|
|
102
|
+
if (!opts.json)
|
|
103
|
+
console.error(`Test push failed: ${err.message}`);
|
|
104
|
+
}
|
|
52
105
|
if (opts.json) {
|
|
53
|
-
console.log(JSON.stringify(
|
|
106
|
+
console.log(JSON.stringify(result));
|
|
54
107
|
}
|
|
55
108
|
else {
|
|
56
|
-
console.
|
|
109
|
+
console.log(formatInitNextSteps(result));
|
|
57
110
|
}
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
if (!opts.json)
|
|
61
|
-
console.log("Connection verified.");
|
|
62
|
-
// 2. Detect local agent tools and install skill
|
|
63
|
-
const skillSource = join(__dirname, "..", "..", "skill", "SKILL.md");
|
|
64
|
-
Object.assign(result, installLocalAgentSkills(skillSource, opts.json));
|
|
65
|
-
// 3. Test push
|
|
66
|
-
try {
|
|
67
|
-
const session = await api(config, "POST", "/sessions", { name: "__share_init_test__" });
|
|
68
|
-
await api(config, "POST", `/sessions/${session.id}/push`, {
|
|
69
|
-
html: "<html><body><p>Share init test</p></body></html>",
|
|
70
|
-
});
|
|
71
|
-
await api(config, "DELETE", `/sessions/${session.id}`);
|
|
72
|
-
result.test_push = "passed";
|
|
73
|
-
if (!opts.json)
|
|
74
|
-
console.log("Test push verified.");
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
result.test_push = "failed";
|
|
78
|
-
result.test_error = err.message;
|
|
79
|
-
if (!opts.json)
|
|
80
|
-
console.error(`Test push failed: ${err.message}`);
|
|
81
|
-
}
|
|
82
|
-
if (opts.json) {
|
|
83
|
-
console.log(JSON.stringify(result));
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
console.log("\nReady to go! Try: peekable create \"My First Session\"");
|
|
87
|
-
}
|
|
111
|
+
});
|
|
88
112
|
});
|
package/dist/commands/list.js
CHANGED
|
@@ -2,32 +2,42 @@ import { Command } from "commander";
|
|
|
2
2
|
import { requireConfig } from "../config.js";
|
|
3
3
|
import { api } from "../api.js";
|
|
4
4
|
import { formatQuota } from "../quota.js";
|
|
5
|
+
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
6
|
+
import { runWithTelemetry } from "../telemetry.js";
|
|
5
7
|
export const listCommand = new Command("list")
|
|
6
8
|
.description("List active sessions")
|
|
7
9
|
.option("--json", "Output JSON")
|
|
8
10
|
.action(async (opts) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
await runWithTelemetry("list", async () => {
|
|
12
|
+
try {
|
|
13
|
+
const config = requireConfig();
|
|
14
|
+
const data = await api(config, "GET", "/sessions");
|
|
15
|
+
if (opts.json) {
|
|
16
|
+
console.log(JSON.stringify(data));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (data.sessions.length === 0) {
|
|
20
|
+
if (data.limits) {
|
|
21
|
+
console.log(`No active sessions. (${formatQuota(data.limits.activeSessions, data.limits.maxActiveSessions, data.limits.unlimited)})`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.log("No active sessions.");
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (data.limits) {
|
|
29
|
+
console.log(`Active sessions (${formatQuota(data.limits.activeSessions, data.limits.maxActiveSessions, data.limits.unlimited)}):`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log("Active sessions:");
|
|
33
|
+
}
|
|
34
|
+
for (const s of data.sessions) {
|
|
35
|
+
console.log(` ${s.id} ${s.name} (${new Date(s.created_at).toLocaleDateString()})`);
|
|
36
|
+
}
|
|
18
37
|
}
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
catch (err) {
|
|
39
|
+
printCommandError("List failed", err, opts.json);
|
|
40
|
+
throw new CommandFailure("List failed", { cause: err });
|
|
21
41
|
}
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
if (data.limits) {
|
|
25
|
-
console.log(`Active sessions (${formatQuota(data.limits.activeSessions, data.limits.maxActiveSessions, data.limits.unlimited)}):`);
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
console.log("Active sessions:");
|
|
29
|
-
}
|
|
30
|
-
for (const s of data.sessions) {
|
|
31
|
-
console.log(` ${s.id} ${s.name} (${new Date(s.created_at).toLocaleDateString()})`);
|
|
32
|
-
}
|
|
42
|
+
});
|
|
33
43
|
});
|
package/dist/commands/proxy.js
CHANGED
|
@@ -2,7 +2,8 @@ import { Command } from "commander";
|
|
|
2
2
|
import { watch } from "fs";
|
|
3
3
|
import { requireConfig } from "../config.js";
|
|
4
4
|
import { api } from "../api.js";
|
|
5
|
-
import { printCommandError } from "../command-error.js";
|
|
5
|
+
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
6
|
+
import { postStartEvent, postFireAndForgetDebugEvent } from "../telemetry.js";
|
|
6
7
|
const MAX_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
7
8
|
const TEXT_CONTENT_TYPES = [
|
|
8
9
|
"text/",
|
|
@@ -28,7 +29,7 @@ export const proxyCommand = new Command("proxy")
|
|
|
28
29
|
const port = parseInt(portArg, 10);
|
|
29
30
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
30
31
|
printCommandError("Proxy failed", new Error("Invalid port: must be 1-65535"), opts.json);
|
|
31
|
-
|
|
32
|
+
throw new CommandFailure("Invalid port");
|
|
32
33
|
}
|
|
33
34
|
// Create or reuse session
|
|
34
35
|
let sessionId;
|
|
@@ -49,7 +50,7 @@ export const proxyCommand = new Command("proxy")
|
|
|
49
50
|
}
|
|
50
51
|
catch (err) {
|
|
51
52
|
printCommandError("Proxy session failed", err, opts.json);
|
|
52
|
-
|
|
53
|
+
throw new CommandFailure("Proxy session failed", { cause: err });
|
|
53
54
|
}
|
|
54
55
|
sessionId = data.id;
|
|
55
56
|
shareUrl = data.url;
|
|
@@ -60,9 +61,20 @@ export const proxyCommand = new Command("proxy")
|
|
|
60
61
|
else {
|
|
61
62
|
console.log(`Share URL: ${shareUrl}`);
|
|
62
63
|
}
|
|
64
|
+
postStartEvent("cli_proxy_started", { session_id: sessionId, port });
|
|
63
65
|
// Connect relay WebSocket
|
|
64
66
|
const wsUrl = config.url.replace(/^http/, "ws") + "/ws/relay/" + sessionId;
|
|
65
|
-
|
|
67
|
+
let ws;
|
|
68
|
+
try {
|
|
69
|
+
// The constructor throws synchronously on a malformed URL; fail the
|
|
70
|
+
// command cleanly instead of escaping the action handler.
|
|
71
|
+
ws = new WebSocket(wsUrl);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
printCommandError("Relay connection failed", err, opts.json);
|
|
75
|
+
throw new CommandFailure("Relay connection failed", { cause: err });
|
|
76
|
+
}
|
|
77
|
+
const wsStartedAt = Date.now();
|
|
66
78
|
const inflight = new Map();
|
|
67
79
|
const fallbackPorts = new Set();
|
|
68
80
|
ws.addEventListener("open", () => {
|
|
@@ -88,7 +100,9 @@ export const proxyCommand = new Command("proxy")
|
|
|
88
100
|
}
|
|
89
101
|
if (msg.type === "error" && !msg.id) {
|
|
90
102
|
console.error(`Relay error: ${msg.message ?? msg.error}`);
|
|
91
|
-
process.
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
ws.close();
|
|
105
|
+
return;
|
|
92
106
|
}
|
|
93
107
|
if (msg.type === "discovered_port") {
|
|
94
108
|
const p = msg.port;
|
|
@@ -108,18 +122,31 @@ export const proxyCommand = new Command("proxy")
|
|
|
108
122
|
return;
|
|
109
123
|
}
|
|
110
124
|
if (msg.type === "request") {
|
|
111
|
-
await handleRequest(ws, msg, port, inflight, fallbackPorts);
|
|
125
|
+
await handleRequest(ws, msg, port, inflight, fallbackPorts, opts.json);
|
|
112
126
|
}
|
|
113
127
|
});
|
|
114
|
-
ws.addEventListener("close", () => {
|
|
128
|
+
ws.addEventListener("close", (event) => {
|
|
129
|
+
const closeEvent = event;
|
|
130
|
+
postFireAndForgetDebugEvent("cli_ws_disconnected", {
|
|
131
|
+
code: closeEvent?.code,
|
|
132
|
+
reason: typeof closeEvent?.reason === "string" ? closeEvent.reason.slice(0, 120) : undefined,
|
|
133
|
+
uptime_ms: Date.now() - wsStartedAt,
|
|
134
|
+
surface: "proxy",
|
|
135
|
+
}, { sessionId });
|
|
115
136
|
if (!opts.json) {
|
|
116
137
|
console.log("Relay disconnected.");
|
|
117
138
|
}
|
|
118
|
-
|
|
139
|
+
// Exit cleanly after telemetry attempts to flush
|
|
140
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), 200);
|
|
119
141
|
});
|
|
120
142
|
ws.addEventListener("error", (e) => {
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
const message = e?.message ?? String(e);
|
|
144
|
+
console.error("WebSocket error:", message);
|
|
145
|
+
postFireAndForgetDebugEvent("cli_ws_error", {
|
|
146
|
+
surface: "proxy",
|
|
147
|
+
error_message: String(message).slice(0, 280),
|
|
148
|
+
}, { sessionId });
|
|
149
|
+
process.exitCode = 1;
|
|
123
150
|
});
|
|
124
151
|
// File watching
|
|
125
152
|
if (opts.watch) {
|
|
@@ -143,10 +170,11 @@ export const proxyCommand = new Command("proxy")
|
|
|
143
170
|
// Shutdown
|
|
144
171
|
process.on("SIGINT", () => {
|
|
145
172
|
ws.close();
|
|
146
|
-
|
|
173
|
+
// give telemetry a beat, then exit
|
|
174
|
+
setTimeout(() => process.exit(0), 200);
|
|
147
175
|
});
|
|
148
176
|
});
|
|
149
|
-
async function handleRequest(ws, msg, port, inflight, fallbackPorts) {
|
|
177
|
+
async function handleRequest(ws, msg, port, inflight, fallbackPorts, jsonOutput) {
|
|
150
178
|
const ac = new AbortController();
|
|
151
179
|
inflight.set(msg.id, ac);
|
|
152
180
|
try {
|
|
@@ -263,9 +291,13 @@ async function handleRequest(ws, msg, port, inflight, fallbackPorts) {
|
|
|
263
291
|
catch (err) {
|
|
264
292
|
if (ac.signal.aborted)
|
|
265
293
|
return;
|
|
266
|
-
const
|
|
294
|
+
const isEconnRefused = err?.cause?.code === "ECONNREFUSED";
|
|
295
|
+
const message = isEconnRefused
|
|
267
296
|
? "ECONNREFUSED"
|
|
268
297
|
: err.message ?? "Upstream fetch failed";
|
|
298
|
+
if (isEconnRefused && !jsonOutput) {
|
|
299
|
+
console.error(`Upstream ECONNREFUSED: no local server responding on port ${port} for ${msg.method ?? "GET"} ${msg.path}`);
|
|
300
|
+
}
|
|
269
301
|
ws.send(JSON.stringify({
|
|
270
302
|
type: "response",
|
|
271
303
|
id: msg.id,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { requireConfig } from "../config.js";
|
|
3
3
|
import { api, fetchWithTimeout } 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
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
6
7
|
const MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
7
8
|
export const pushUrlCommand = new Command("push-url")
|
|
@@ -21,26 +22,28 @@ Note: this does not execute JavaScript, use browser cookies, or inline external
|
|
|
21
22
|
Remote URLs require --allow-remote --yes because the fetched HTML is uploaded to the session.
|
|
22
23
|
`)
|
|
23
24
|
.action(async (sessionId, urlArg, opts) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
await runWithTelemetry("push_url", async () => {
|
|
26
|
+
try {
|
|
27
|
+
const config = requireConfig();
|
|
28
|
+
const url = parseHttpUrl(urlArg);
|
|
29
|
+
validateSnapshotTarget(url, opts);
|
|
30
|
+
const html = await fetchHtml(url);
|
|
31
|
+
const data = await api(config, "POST", `/sessions/${sessionId}/push`, {
|
|
32
|
+
html,
|
|
33
|
+
source_path: url.toString(),
|
|
34
|
+
});
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
console.log(JSON.stringify(data));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log(`Pushed v${data.version} to ${sessionId} from ${url.toString()}`);
|
|
40
|
+
}
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
catch (err) {
|
|
43
|
+
printCommandError("Push URL failed", err, opts.json);
|
|
44
|
+
throw new CommandFailure("Push URL failed", { cause: err });
|
|
38
45
|
}
|
|
39
|
-
}
|
|
40
|
-
catch (err) {
|
|
41
|
-
printCommandError("Push URL failed", err, opts.json);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
46
|
+
});
|
|
44
47
|
});
|
|
45
48
|
function parseHttpUrl(urlArg) {
|
|
46
49
|
let url;
|
package/dist/commands/push.js
CHANGED
|
@@ -3,7 +3,8 @@ import { readFileSync } from "fs";
|
|
|
3
3
|
import { resolve } from "path";
|
|
4
4
|
import { requireConfig } from "../config.js";
|
|
5
5
|
import { api } from "../api.js";
|
|
6
|
-
import { printCommandError } from "../command-error.js";
|
|
6
|
+
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
7
|
+
import { runWithTelemetry } from "../telemetry.js";
|
|
7
8
|
export const pushCommand = new Command("push")
|
|
8
9
|
.argument("<session-id>", "Session ID")
|
|
9
10
|
.argument("<file>", "Standalone HTML file path")
|
|
@@ -15,25 +16,30 @@ For an HTML response snapshot, use: peekable push-url <session-id> <url>
|
|
|
15
16
|
For a live dev server, use: peekable proxy <port>
|
|
16
17
|
`)
|
|
17
18
|
.action(async (sessionId, file, opts) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const telemetryContext = { session_id: sessionId };
|
|
20
|
+
await runWithTelemetry("push", async () => {
|
|
21
|
+
try {
|
|
22
|
+
const config = requireConfig();
|
|
23
|
+
const html = readFileSync(file, "utf-8");
|
|
24
|
+
telemetryContext.file_bytes = Buffer.byteLength(html, "utf-8");
|
|
25
|
+
telemetryContext.looks_like_fragment = looksLikeHtmlFragment(html);
|
|
26
|
+
if (!opts.json && looksLikeHtmlFragment(html)) {
|
|
27
|
+
console.error("\x1b[33mNote:\x1b[0m this file looks like an HTML fragment. It may render unstyled when shared standalone. If it is part of a parent app, prefer `peekable push-url <session> <url>`. For live dev servers (React/Vite/Next), use `peekable proxy <port>`.");
|
|
28
|
+
}
|
|
29
|
+
const source_path = resolve(file);
|
|
30
|
+
const data = await api(config, "POST", `/sessions/${sessionId}/push`, { html, source_path });
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
console.log(JSON.stringify(data));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(`Pushed v${data.version} to ${sessionId}`);
|
|
36
|
+
}
|
|
23
37
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.log(JSON.stringify(data));
|
|
38
|
+
catch (err) {
|
|
39
|
+
printCommandError("Push failed", err, opts.json);
|
|
40
|
+
throw new CommandFailure("Push failed", { cause: err });
|
|
28
41
|
}
|
|
29
|
-
|
|
30
|
-
console.log(`Pushed v${data.version} to ${sessionId}`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
printCommandError("Push failed", err, opts.json);
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
42
|
+
}, telemetryContext);
|
|
37
43
|
});
|
|
38
44
|
function looksLikeHtmlFragment(html) {
|
|
39
45
|
return !/<\s*(html|body)(?:\s|>)/i.test(html);
|
|
@@ -1,61 +1,62 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { writeConfig, readConfig } from "../config.js";
|
|
2
|
+
import { writeConfig, readConfig, DEFAULT_SERVER_URL } from "../config.js";
|
|
3
3
|
import { apiNoAuth } from "../api.js";
|
|
4
|
-
|
|
4
|
+
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
5
|
+
import { runWithTelemetry } from "../telemetry.js";
|
|
5
6
|
export const registerCommand = new Command("register")
|
|
6
7
|
.description("Register for an API key")
|
|
7
8
|
.requiredOption("--name <name>", "Your display name")
|
|
8
9
|
.requiredOption("--email <email>", "Your email address")
|
|
9
10
|
.option("--invite-code <code>", "Beta invite code")
|
|
10
|
-
.option("--url <url>", "Server URL",
|
|
11
|
+
.option("--url <url>", "Server URL", DEFAULT_SERVER_URL)
|
|
11
12
|
.option("--json", "Output JSON")
|
|
12
13
|
.action(async (opts) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
14
|
+
await runWithTelemetry("register", async () => {
|
|
15
|
+
const existing = readConfig();
|
|
16
|
+
if (existing) {
|
|
17
|
+
if (opts.json) {
|
|
18
|
+
console.log(JSON.stringify({ error: "Already registered. Config exists at ~/.peekable/config.json" }));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.error("Already registered. Config exists at ~/.peekable/config.json");
|
|
22
|
+
console.error("Delete ~/.peekable/config.json to re-register.");
|
|
23
|
+
}
|
|
24
|
+
throw new CommandFailure("Already registered.");
|
|
17
25
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
try {
|
|
27
|
+
const inviteCode = typeof opts.inviteCode === "string"
|
|
28
|
+
? opts.inviteCode
|
|
29
|
+
: process.env.PEEKABLE_INVITE_CODE;
|
|
30
|
+
const body = {
|
|
31
|
+
name: opts.name,
|
|
32
|
+
email: opts.email,
|
|
33
|
+
};
|
|
34
|
+
if (inviteCode) {
|
|
35
|
+
body.invite_code = inviteCode;
|
|
36
|
+
}
|
|
37
|
+
const data = await apiNoAuth(opts.url, "POST", "/register", {
|
|
38
|
+
...body,
|
|
39
|
+
});
|
|
40
|
+
writeConfig({
|
|
41
|
+
url: opts.url,
|
|
42
|
+
api_key: data.api_key,
|
|
43
|
+
name: opts.name,
|
|
44
|
+
});
|
|
45
|
+
if (opts.json) {
|
|
46
|
+
console.log(JSON.stringify({ api_key: data.api_key, url: opts.url, name: opts.name }));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.log(`Registered as ${opts.name}.`);
|
|
50
|
+
console.log(`API key saved to ~/.peekable/config.json`);
|
|
51
|
+
console.log("Peekable CLI sends anonymous usage/failure telemetry to improve reliability. Opt out: PEEKABLE_NO_TELEMETRY=1");
|
|
52
|
+
console.log(`\nNext step: run \`peekable init\` to complete setup.`);
|
|
53
|
+
}
|
|
21
54
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
: process.env.PEEKABLE_INVITE_CODE;
|
|
28
|
-
const body = {
|
|
29
|
-
name: opts.name,
|
|
30
|
-
email: opts.email,
|
|
31
|
-
};
|
|
32
|
-
if (inviteCode) {
|
|
33
|
-
body.invite_code = inviteCode;
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof CommandFailure)
|
|
57
|
+
throw err;
|
|
58
|
+
printCommandError("Registration failed", err, opts.json);
|
|
59
|
+
throw new CommandFailure("Registration failed", { cause: err });
|
|
34
60
|
}
|
|
35
|
-
|
|
36
|
-
...body,
|
|
37
|
-
});
|
|
38
|
-
writeConfig({
|
|
39
|
-
url: opts.url,
|
|
40
|
-
api_key: data.api_key,
|
|
41
|
-
name: opts.name,
|
|
42
|
-
});
|
|
43
|
-
if (opts.json) {
|
|
44
|
-
console.log(JSON.stringify({ api_key: data.api_key, url: opts.url, name: opts.name }));
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
console.log(`Registered as ${opts.name}.`);
|
|
48
|
-
console.log(`API key saved to ~/.peekable/config.json`);
|
|
49
|
-
console.log(`\nNext step: run \`peekable init\` to complete setup.`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
catch (err) {
|
|
53
|
-
if (opts.json) {
|
|
54
|
-
console.log(JSON.stringify({ error: err.message }));
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
console.error(`Registration failed: ${err.message}`);
|
|
58
|
-
}
|
|
59
|
-
process.exit(1);
|
|
60
|
-
}
|
|
61
|
+
});
|
|
61
62
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { type DoctorScalarSnapshot } from "./doctor.js";
|
|
3
|
+
export interface ReportPayload {
|
|
4
|
+
message: string;
|
|
5
|
+
diagnostics?: DoctorScalarSnapshot;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildReportPayload(opts: {
|
|
8
|
+
message: string;
|
|
9
|
+
diagnostics: boolean;
|
|
10
|
+
}): Promise<ReportPayload>;
|
|
11
|
+
export declare const reportCommand: Command;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as readline from "readline";
|
|
3
|
+
import { api } from "../api.js";
|
|
4
|
+
import { requireConfig, ConfigError } from "../config.js";
|
|
5
|
+
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
6
|
+
import { runWithTelemetry } from "../telemetry.js";
|
|
7
|
+
import { runDoctor, toScalarSnapshot } from "./doctor.js";
|
|
8
|
+
const MAX_MESSAGE_LEN = 2000;
|
|
9
|
+
async function promptForMessage() {
|
|
10
|
+
// Piped/closed stdin never answers the question; without a TTY, don't prompt.
|
|
11
|
+
if (!process.stdin.isTTY)
|
|
12
|
+
return "";
|
|
13
|
+
const rl = readline.createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return await new Promise((resolve) => {
|
|
19
|
+
rl.question("What went wrong? ", (answer) => resolve(answer));
|
|
20
|
+
// stdin EOF closes the interface without ever invoking the question
|
|
21
|
+
// callback — resolve empty instead of hanging the process.
|
|
22
|
+
rl.once("close", () => resolve(""));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function buildReportPayload(opts) {
|
|
30
|
+
const message = opts.message.slice(0, MAX_MESSAGE_LEN);
|
|
31
|
+
if (!opts.diagnostics)
|
|
32
|
+
return { message };
|
|
33
|
+
const doctor = await runDoctor();
|
|
34
|
+
return { message, diagnostics: toScalarSnapshot(doctor) };
|
|
35
|
+
}
|
|
36
|
+
export const reportCommand = new Command("report")
|
|
37
|
+
.argument("[message...]", "Message to send")
|
|
38
|
+
.description("Report a problem or send feedback to the Peekable team")
|
|
39
|
+
.option("--no-diagnostics", "Opt out of sending diagnostics")
|
|
40
|
+
.option("--json", "Output JSON")
|
|
41
|
+
.action(async (messageParts, opts) => {
|
|
42
|
+
await runWithTelemetry("report", async () => {
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = requireConfig();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof ConfigError) {
|
|
49
|
+
const friendly = err.reason === "corrupt"
|
|
50
|
+
? "Config file is corrupted. Run `peekable register` to reconfigure."
|
|
51
|
+
: "Peekable isn't configured yet. Run `peekable register` first, or file an issue at https://github.com/peekable/peekable/issues.";
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
console.log(JSON.stringify({ error: friendly }));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.error(friendly);
|
|
57
|
+
}
|
|
58
|
+
throw new CommandFailure("Report requires registration", { cause: err });
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
let message = messageParts.join(" ").trim();
|
|
63
|
+
if (!message) {
|
|
64
|
+
try {
|
|
65
|
+
message = (await promptForMessage()).trim();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
message = "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!message) {
|
|
72
|
+
const msg = "No message provided.";
|
|
73
|
+
if (opts.json) {
|
|
74
|
+
console.log(JSON.stringify({ error: msg }));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.error(msg);
|
|
78
|
+
}
|
|
79
|
+
throw new CommandFailure(msg);
|
|
80
|
+
}
|
|
81
|
+
// Diagnostics ON by default; commander sets opts.diagnostics = false when --no-diagnostics passed.
|
|
82
|
+
const includeDiagnostics = opts.diagnostics !== false;
|
|
83
|
+
const payload = await buildReportPayload({ message, diagnostics: includeDiagnostics });
|
|
84
|
+
if (!opts.json) {
|
|
85
|
+
console.log("Sending report:");
|
|
86
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const res = await api(config, "POST", "/cli/report", payload);
|
|
90
|
+
const requestId = res && typeof res === "object" && typeof res.request_id === "string"
|
|
91
|
+
? res.request_id
|
|
92
|
+
: undefined;
|
|
93
|
+
if (opts.json) {
|
|
94
|
+
console.log(JSON.stringify({ ok: true, request_id: requestId }));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log("Report submitted. Thanks — we read every one.");
|
|
98
|
+
if (requestId)
|
|
99
|
+
console.log(`Request ID: ${requestId}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
printCommandError("Report failed", err, opts.json);
|
|
104
|
+
throw new CommandFailure("Report failed", { cause: err });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|