peekable 0.1.0 ā 0.1.2
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/README.md +100 -0
- package/dist/api.d.ts +24 -2
- package/dist/api.js +48 -10
- package/dist/command-error.d.ts +3 -0
- package/dist/command-error.js +23 -0
- package/dist/commands/create.js +13 -6
- package/dist/commands/doctor.d.ts +21 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/feedback.js +38 -8
- package/dist/commands/init.js +3 -3
- package/dist/commands/list.js +13 -2
- package/dist/commands/proxy.d.ts +2 -0
- package/dist/commands/proxy.js +280 -0
- package/dist/commands/push-url.d.ts +2 -0
- package/dist/commands/push-url.js +136 -0
- package/dist/commands/push.js +29 -9
- package/dist/commands/register.js +11 -1
- package/dist/commands/resolve.d.ts +2 -0
- package/dist/commands/resolve.js +20 -0
- package/dist/commands/uninstall.d.ts +21 -0
- package/dist/commands/uninstall.js +81 -0
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +40 -0
- package/dist/config.js +3 -4
- package/dist/index.js +14 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +14 -0
- package/dist/quota.d.ts +1 -0
- package/dist/quota.js +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +1 -1
- package/skill/SKILL.md +106 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { watch } from "fs";
|
|
3
|
+
import { requireConfig } from "../config.js";
|
|
4
|
+
import { api } from "../api.js";
|
|
5
|
+
import { printCommandError } from "../command-error.js";
|
|
6
|
+
const MAX_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
7
|
+
const TEXT_CONTENT_TYPES = [
|
|
8
|
+
"text/",
|
|
9
|
+
"application/json",
|
|
10
|
+
"application/javascript",
|
|
11
|
+
"application/xml",
|
|
12
|
+
"image/svg+xml",
|
|
13
|
+
];
|
|
14
|
+
function isTextContentType(ct) {
|
|
15
|
+
return TEXT_CONTENT_TYPES.some((t) => ct.includes(t));
|
|
16
|
+
}
|
|
17
|
+
export const proxyCommand = new Command("proxy")
|
|
18
|
+
.argument("<port>", "Local port to proxy")
|
|
19
|
+
.description("Proxy a local dev server through a peekable share URL")
|
|
20
|
+
.option("--name <name>", "Session name", "Proxy")
|
|
21
|
+
.option("--session <id>", "Reuse an existing session ID")
|
|
22
|
+
.option("--watch <dir>", "Watch directory for changes and trigger reload")
|
|
23
|
+
.option("--takeover", "Take over an existing relay connection")
|
|
24
|
+
.option("--json", "Output JSON")
|
|
25
|
+
.action(async (portArg, opts) => {
|
|
26
|
+
const config = requireConfig();
|
|
27
|
+
// Validate port
|
|
28
|
+
const port = parseInt(portArg, 10);
|
|
29
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
30
|
+
printCommandError("Proxy failed", new Error("Invalid port: must be 1-65535"), opts.json);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
// Create or reuse session
|
|
34
|
+
let sessionId;
|
|
35
|
+
let shareUrl;
|
|
36
|
+
if (opts.session) {
|
|
37
|
+
sessionId = opts.session;
|
|
38
|
+
const baseDomain = new URL(config.url).hostname;
|
|
39
|
+
shareUrl = `https://${sessionId}.${baseDomain}`;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
let data;
|
|
43
|
+
try {
|
|
44
|
+
data = await api(config, "POST", "/sessions", {
|
|
45
|
+
name: opts.name,
|
|
46
|
+
mode: "proxy",
|
|
47
|
+
proxy_target: `localhost:${port}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
printCommandError("Proxy session failed", err, opts.json);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
sessionId = data.id;
|
|
55
|
+
shareUrl = data.url;
|
|
56
|
+
}
|
|
57
|
+
if (opts.json) {
|
|
58
|
+
console.log(JSON.stringify({ id: sessionId, url: shareUrl }));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log(`Share URL: ${shareUrl}`);
|
|
62
|
+
}
|
|
63
|
+
// Connect relay WebSocket
|
|
64
|
+
const wsUrl = config.url.replace(/^http/, "ws") + "/ws/relay/" + sessionId;
|
|
65
|
+
const ws = new WebSocket(wsUrl);
|
|
66
|
+
const inflight = new Map();
|
|
67
|
+
const fallbackPorts = new Set();
|
|
68
|
+
ws.addEventListener("open", () => {
|
|
69
|
+
ws.send(JSON.stringify({
|
|
70
|
+
type: "auth",
|
|
71
|
+
token: config.api_key,
|
|
72
|
+
takeover: !!opts.takeover,
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
ws.addEventListener("message", async (event) => {
|
|
76
|
+
let msg;
|
|
77
|
+
try {
|
|
78
|
+
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (msg.type === "auth_ok") {
|
|
84
|
+
if (!opts.json) {
|
|
85
|
+
console.log("Relay connected. Waiting for viewers...");
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (msg.type === "error" && !msg.id) {
|
|
90
|
+
console.error(`Relay error: ${msg.message ?? msg.error}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
if (msg.type === "discovered_port") {
|
|
94
|
+
const p = msg.port;
|
|
95
|
+
if (typeof p === "number" && p !== port && !fallbackPorts.has(p)) {
|
|
96
|
+
fallbackPorts.add(p);
|
|
97
|
+
if (!opts.json)
|
|
98
|
+
console.log(`Discovered backend on port ${p}`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (msg.type === "cancel") {
|
|
103
|
+
const ac = inflight.get(msg.id);
|
|
104
|
+
if (ac) {
|
|
105
|
+
ac.abort();
|
|
106
|
+
inflight.delete(msg.id);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (msg.type === "request") {
|
|
111
|
+
await handleRequest(ws, msg, port, inflight, fallbackPorts);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
ws.addEventListener("close", () => {
|
|
115
|
+
if (!opts.json) {
|
|
116
|
+
console.log("Relay disconnected.");
|
|
117
|
+
}
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
ws.addEventListener("error", (e) => {
|
|
121
|
+
console.error("WebSocket error:", e.message ?? e);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
124
|
+
// File watching
|
|
125
|
+
if (opts.watch) {
|
|
126
|
+
let debounce = null;
|
|
127
|
+
watch(opts.watch, { recursive: true }, () => {
|
|
128
|
+
if (debounce)
|
|
129
|
+
clearTimeout(debounce);
|
|
130
|
+
debounce = setTimeout(async () => {
|
|
131
|
+
try {
|
|
132
|
+
await api(config, "POST", `/sessions/${sessionId}/reload`, {});
|
|
133
|
+
if (!opts.json) {
|
|
134
|
+
console.log("File change detected, reload triggered.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error("Reload failed:", err.message);
|
|
139
|
+
}
|
|
140
|
+
}, 500);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Shutdown
|
|
144
|
+
process.on("SIGINT", () => {
|
|
145
|
+
ws.close();
|
|
146
|
+
process.exit(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
async function handleRequest(ws, msg, port, inflight, fallbackPorts) {
|
|
150
|
+
const ac = new AbortController();
|
|
151
|
+
inflight.set(msg.id, ac);
|
|
152
|
+
try {
|
|
153
|
+
const url = `http://localhost:${port}${msg.path}`;
|
|
154
|
+
// Build headers, skip host and x-forwarded-*
|
|
155
|
+
const headers = new Headers();
|
|
156
|
+
if (Array.isArray(msg.headers)) {
|
|
157
|
+
for (const [k, v] of msg.headers) {
|
|
158
|
+
const lower = k.toLowerCase();
|
|
159
|
+
if (lower === "host" || lower.startsWith("x-forwarded-") || lower === "origin" || lower === "referer")
|
|
160
|
+
continue;
|
|
161
|
+
headers.set(k, v);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
headers.set("host", `localhost:${port}`);
|
|
165
|
+
headers.set("origin", `http://localhost:${port}`);
|
|
166
|
+
headers.set("accept-encoding", "identity");
|
|
167
|
+
// Decode body
|
|
168
|
+
let body;
|
|
169
|
+
if (msg.body) {
|
|
170
|
+
const binary = atob(msg.body);
|
|
171
|
+
const bytes = new Uint8Array(binary.length);
|
|
172
|
+
for (let i = 0; i < binary.length; i++)
|
|
173
|
+
bytes[i] = binary.charCodeAt(i);
|
|
174
|
+
body = bytes.buffer;
|
|
175
|
+
}
|
|
176
|
+
let upstream = await fetch(url, {
|
|
177
|
+
method: msg.method ?? "GET",
|
|
178
|
+
headers,
|
|
179
|
+
body: body,
|
|
180
|
+
redirect: "manual",
|
|
181
|
+
signal: ac.signal,
|
|
182
|
+
});
|
|
183
|
+
// Detect when primary port can't handle the request ā try fallback ports.
|
|
184
|
+
// Key signal: response is HTML but request didn't ask for HTML (fetch vs navigation)
|
|
185
|
+
const upstreamCt = upstream.headers.get("content-type") ?? "";
|
|
186
|
+
const method = (msg.method ?? "GET").toUpperCase();
|
|
187
|
+
const requestAccept = (Array.isArray(msg.headers) ? msg.headers.find(([k]) => k.toLowerCase() === "accept")?.[1] : "") ?? "";
|
|
188
|
+
const requestExpectsHtml = requestAccept.includes("text/html");
|
|
189
|
+
const isModifyingMethod = method !== "GET" && method !== "HEAD";
|
|
190
|
+
const gotUnexpectedHtml = upstreamCt.includes("text/html") && !requestExpectsHtml;
|
|
191
|
+
const shouldTryFallback = (gotUnexpectedHtml || isModifyingMethod) && fallbackPorts.size > 0;
|
|
192
|
+
if (shouldTryFallback) {
|
|
193
|
+
for (const fbPort of fallbackPorts) {
|
|
194
|
+
if (fbPort === port)
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const fbHeaders = new Headers(headers);
|
|
198
|
+
fbHeaders.set("host", `localhost:${fbPort}`);
|
|
199
|
+
fbHeaders.set("origin", `http://localhost:${fbPort}`);
|
|
200
|
+
const fbUrl = `http://localhost:${fbPort}${msg.path}`;
|
|
201
|
+
const fbResp = await fetch(fbUrl, {
|
|
202
|
+
method: msg.method ?? "GET",
|
|
203
|
+
headers: fbHeaders,
|
|
204
|
+
body: body,
|
|
205
|
+
redirect: "manual",
|
|
206
|
+
signal: ac.signal,
|
|
207
|
+
});
|
|
208
|
+
if (fbResp.status !== 404) {
|
|
209
|
+
// Fallback port handled the request (any status other than 404) ā use it
|
|
210
|
+
upstream = fbResp;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Fallback port not reachable, continue
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Build response header tuples
|
|
220
|
+
const respHeaders = [];
|
|
221
|
+
upstream.headers.forEach((v, k) => {
|
|
222
|
+
respHeaders.push([k, v]);
|
|
223
|
+
});
|
|
224
|
+
// Read body
|
|
225
|
+
const respBuffer = await upstream.arrayBuffer();
|
|
226
|
+
if (respBuffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
227
|
+
ws.send(JSON.stringify({
|
|
228
|
+
type: "response",
|
|
229
|
+
id: msg.id,
|
|
230
|
+
status: 502,
|
|
231
|
+
headers: [["content-type", "text/plain"]],
|
|
232
|
+
body: "Response too large (>25MB)",
|
|
233
|
+
}));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const ct = upstream.headers.get("content-type") ?? "";
|
|
237
|
+
let respBody;
|
|
238
|
+
let encoding;
|
|
239
|
+
if (isTextContentType(ct)) {
|
|
240
|
+
respBody = new TextDecoder().decode(respBuffer);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// Base64 encode binary
|
|
244
|
+
const bytes = new Uint8Array(respBuffer);
|
|
245
|
+
let binary = "";
|
|
246
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
247
|
+
binary += String.fromCharCode(bytes[i]);
|
|
248
|
+
}
|
|
249
|
+
respBody = btoa(binary);
|
|
250
|
+
encoding = "base64";
|
|
251
|
+
}
|
|
252
|
+
const response = {
|
|
253
|
+
type: "response",
|
|
254
|
+
id: msg.id,
|
|
255
|
+
status: upstream.status,
|
|
256
|
+
headers: respHeaders,
|
|
257
|
+
body: respBody,
|
|
258
|
+
};
|
|
259
|
+
if (encoding)
|
|
260
|
+
response.encoding = encoding;
|
|
261
|
+
ws.send(JSON.stringify(response));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (ac.signal.aborted)
|
|
265
|
+
return;
|
|
266
|
+
const message = err.cause?.code === "ECONNREFUSED"
|
|
267
|
+
? "ECONNREFUSED"
|
|
268
|
+
: err.message ?? "Upstream fetch failed";
|
|
269
|
+
ws.send(JSON.stringify({
|
|
270
|
+
type: "response",
|
|
271
|
+
id: msg.id,
|
|
272
|
+
status: 502,
|
|
273
|
+
headers: [["content-type", "text/plain"]],
|
|
274
|
+
body: message,
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
inflight.delete(msg.id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { requireConfig } from "../config.js";
|
|
3
|
+
import { api, fetchWithTimeout } from "../api.js";
|
|
4
|
+
import { printCommandError } from "../command-error.js";
|
|
5
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
6
|
+
const MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export const pushUrlCommand = new Command("push-url")
|
|
8
|
+
.argument("<session-id>", "Session ID")
|
|
9
|
+
.argument("<url>", "URL that returns an HTML response")
|
|
10
|
+
.description("Fetch an HTML response from a URL and push it as a new version")
|
|
11
|
+
.option("--json", "Output JSON")
|
|
12
|
+
.option("--allow-remote", "Allow snapshotting a non-localhost URL")
|
|
13
|
+
.option("--allow-private", "Allow snapshotting a private-network URL")
|
|
14
|
+
.option("-y, --yes", "Confirm the fetched HTML can be uploaded to the session")
|
|
15
|
+
.addHelpText("after", `
|
|
16
|
+
Use this when a running page returns a mostly self-contained HTML response you want to share.
|
|
17
|
+
For standalone HTML files, use: peekable push <session-id> <file>
|
|
18
|
+
For live dev servers with routes/assets/interactivity, use: peekable proxy <port>
|
|
19
|
+
|
|
20
|
+
Note: this does not execute JavaScript, use browser cookies, or inline external assets.
|
|
21
|
+
Remote URLs require --allow-remote --yes because the fetched HTML is uploaded to the session.
|
|
22
|
+
`)
|
|
23
|
+
.action(async (sessionId, urlArg, opts) => {
|
|
24
|
+
try {
|
|
25
|
+
const config = requireConfig();
|
|
26
|
+
const url = parseHttpUrl(urlArg);
|
|
27
|
+
validateSnapshotTarget(url, opts);
|
|
28
|
+
const html = await fetchHtml(url);
|
|
29
|
+
const data = await api(config, "POST", `/sessions/${sessionId}/push`, {
|
|
30
|
+
html,
|
|
31
|
+
source_path: url.toString(),
|
|
32
|
+
});
|
|
33
|
+
if (opts.json) {
|
|
34
|
+
console.log(JSON.stringify(data));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(`Pushed v${data.version} to ${sessionId} from ${url.toString()}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
printCommandError("Push URL failed", err, opts.json);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
function parseHttpUrl(urlArg) {
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(urlArg);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`Invalid URL: ${urlArg}`);
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
54
|
+
throw new Error("URL must use http or https");
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
function validateSnapshotTarget(url, opts) {
|
|
59
|
+
const local = isLocalhost(url.hostname);
|
|
60
|
+
const privateNetwork = !local && isPrivateNetworkHost(url.hostname);
|
|
61
|
+
if (!local && !opts.allowRemote) {
|
|
62
|
+
throw new Error("push-url snapshots localhost by default. Pass --allow-remote --yes to upload HTML from a remote URL.");
|
|
63
|
+
}
|
|
64
|
+
if (privateNetwork && !opts.allowPrivate) {
|
|
65
|
+
throw new Error("Private-network URLs require --allow-private --yes so internal pages are not uploaded accidentally.");
|
|
66
|
+
}
|
|
67
|
+
if ((!local || privateNetwork) && !opts.yes) {
|
|
68
|
+
throw new Error("Pass --yes to confirm the fetched HTML can be uploaded to the session.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function fetchHtml(url) {
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetchWithTimeout(url, {
|
|
75
|
+
headers: { Accept: "text/html,*/*;q=0.8" },
|
|
76
|
+
redirect: "follow",
|
|
77
|
+
}, FETCH_TIMEOUT_MS);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`Failed to fetch ${url.toString()}: HTTP ${res.status}`);
|
|
84
|
+
}
|
|
85
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
86
|
+
if (!contentType.toLowerCase().includes("text/html")) {
|
|
87
|
+
throw new Error(`Expected text/html from ${url.toString()}, got ${contentType || "no content-type"}`);
|
|
88
|
+
}
|
|
89
|
+
const declaredLength = Number(res.headers.get("content-length") ?? 0);
|
|
90
|
+
if (declaredLength > MAX_HTML_BYTES) {
|
|
91
|
+
throw new Error(`HTML response is too large (${declaredLength} bytes, max ${MAX_HTML_BYTES})`);
|
|
92
|
+
}
|
|
93
|
+
return readTextWithLimit(res, MAX_HTML_BYTES);
|
|
94
|
+
}
|
|
95
|
+
async function readTextWithLimit(res, maxBytes) {
|
|
96
|
+
if (!res.body) {
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
|
99
|
+
throw new Error(`HTML response is too large (max ${maxBytes} bytes)`);
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
const reader = res.body.getReader();
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
while (true) {
|
|
107
|
+
const { done, value } = await reader.read();
|
|
108
|
+
if (done)
|
|
109
|
+
break;
|
|
110
|
+
total += value.byteLength;
|
|
111
|
+
if (total > maxBytes) {
|
|
112
|
+
await reader.cancel();
|
|
113
|
+
throw new Error(`HTML response is too large (max ${maxBytes} bytes)`);
|
|
114
|
+
}
|
|
115
|
+
chunks.push(value);
|
|
116
|
+
}
|
|
117
|
+
const bytes = new Uint8Array(total);
|
|
118
|
+
let offset = 0;
|
|
119
|
+
for (const chunk of chunks) {
|
|
120
|
+
bytes.set(chunk, offset);
|
|
121
|
+
offset += chunk.byteLength;
|
|
122
|
+
}
|
|
123
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
124
|
+
}
|
|
125
|
+
function isLocalhost(hostname) {
|
|
126
|
+
const host = hostname.toLowerCase();
|
|
127
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
128
|
+
}
|
|
129
|
+
function isPrivateNetworkHost(hostname) {
|
|
130
|
+
const host = hostname.toLowerCase();
|
|
131
|
+
if (!host.includes("."))
|
|
132
|
+
return true;
|
|
133
|
+
if (host.endsWith(".local"))
|
|
134
|
+
return true;
|
|
135
|
+
return /^(10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(host);
|
|
136
|
+
}
|
package/dist/commands/push.js
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { readFileSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
3
4
|
import { requireConfig } from "../config.js";
|
|
4
5
|
import { api } from "../api.js";
|
|
6
|
+
import { printCommandError } from "../command-error.js";
|
|
5
7
|
export const pushCommand = new Command("push")
|
|
6
8
|
.argument("<session-id>", "Session ID")
|
|
7
|
-
.argument("<file>", "HTML file path")
|
|
8
|
-
.description("Push
|
|
9
|
+
.argument("<file>", "Standalone HTML file path")
|
|
10
|
+
.description("Push a standalone HTML file as a new version")
|
|
9
11
|
.option("--json", "Output JSON")
|
|
12
|
+
.addHelpText("after", `
|
|
13
|
+
Use this for complete HTML documents with their own <html>/<body> shell.
|
|
14
|
+
For an HTML response snapshot, use: peekable push-url <session-id> <url>
|
|
15
|
+
For a live dev server, use: peekable proxy <port>
|
|
16
|
+
`)
|
|
10
17
|
.action(async (sessionId, file, opts) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
try {
|
|
19
|
+
const config = requireConfig();
|
|
20
|
+
const html = readFileSync(file, "utf-8");
|
|
21
|
+
if (!opts.json && looksLikeHtmlFragment(html)) {
|
|
22
|
+
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>`.");
|
|
23
|
+
}
|
|
24
|
+
const source_path = resolve(file);
|
|
25
|
+
const data = await api(config, "POST", `/sessions/${sessionId}/push`, { html, source_path });
|
|
26
|
+
if (opts.json) {
|
|
27
|
+
console.log(JSON.stringify(data));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.log(`Pushed v${data.version} to ${sessionId}`);
|
|
31
|
+
}
|
|
16
32
|
}
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
catch (err) {
|
|
34
|
+
printCommandError("Push failed", err, opts.json);
|
|
35
|
+
process.exit(1);
|
|
19
36
|
}
|
|
20
37
|
});
|
|
38
|
+
function looksLikeHtmlFragment(html) {
|
|
39
|
+
return !/<\s*(html|body)(?:\s|>)/i.test(html);
|
|
40
|
+
}
|
|
@@ -6,6 +6,7 @@ export const registerCommand = new Command("register")
|
|
|
6
6
|
.description("Register for an API key")
|
|
7
7
|
.requiredOption("--name <name>", "Your display name")
|
|
8
8
|
.requiredOption("--email <email>", "Your email address")
|
|
9
|
+
.option("--invite-code <code>", "Beta invite code")
|
|
9
10
|
.option("--url <url>", "Server URL", DEFAULT_URL)
|
|
10
11
|
.option("--json", "Output JSON")
|
|
11
12
|
.action(async (opts) => {
|
|
@@ -21,9 +22,18 @@ export const registerCommand = new Command("register")
|
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
try {
|
|
24
|
-
const
|
|
25
|
+
const inviteCode = typeof opts.inviteCode === "string"
|
|
26
|
+
? opts.inviteCode
|
|
27
|
+
: process.env.PEEKABLE_INVITE_CODE;
|
|
28
|
+
const body = {
|
|
25
29
|
name: opts.name,
|
|
26
30
|
email: opts.email,
|
|
31
|
+
};
|
|
32
|
+
if (inviteCode) {
|
|
33
|
+
body.invite_code = inviteCode;
|
|
34
|
+
}
|
|
35
|
+
const data = await apiNoAuth(opts.url, "POST", "/register", {
|
|
36
|
+
...body,
|
|
27
37
|
});
|
|
28
38
|
writeConfig({
|
|
29
39
|
url: opts.url,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { requireConfig } from "../config.js";
|
|
3
|
+
import { api } from "../api.js";
|
|
4
|
+
export const resolveCommand = new Command("resolve")
|
|
5
|
+
.argument("<session-id>", "Session ID")
|
|
6
|
+
.argument("<annotation-ids...>", "Annotation IDs to resolve")
|
|
7
|
+
.description("Mark annotations as resolved")
|
|
8
|
+
.option("--json", "Output JSON")
|
|
9
|
+
.action(async (sessionId, annotationIds, opts) => {
|
|
10
|
+
const config = requireConfig();
|
|
11
|
+
const data = await api(config, "PATCH", `/sessions/${sessionId}/annotations/resolve`, {
|
|
12
|
+
annotationIds,
|
|
13
|
+
});
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(JSON.stringify(data));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log(`Resolved ${data.resolved} annotation(s).`);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { rmSync } from "fs";
|
|
3
|
+
type RemoveTarget = typeof rmSync;
|
|
4
|
+
type UninstallTarget = {
|
|
5
|
+
label: string;
|
|
6
|
+
path: string;
|
|
7
|
+
existed: boolean;
|
|
8
|
+
removed: boolean;
|
|
9
|
+
error?: string;
|
|
10
|
+
};
|
|
11
|
+
type UninstallResult = {
|
|
12
|
+
status: "ok" | "partial";
|
|
13
|
+
targets: UninstallTarget[];
|
|
14
|
+
npmUninstallCommand: string;
|
|
15
|
+
note: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function removeLocalPeekableFiles(home?: string, removeTarget?: RemoveTarget): UninstallResult;
|
|
18
|
+
export declare function formatUninstallResult(result: UninstallResult): string;
|
|
19
|
+
export declare function createUninstallCommand(): Command;
|
|
20
|
+
export declare const uninstallCommand: Command;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { existsSync, rmSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { getClaudePeekableSkillDir, getPeekableConfigDir } from "../paths.js";
|
|
5
|
+
function getUninstallTargets(home = homedir()) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
label: "Peekable config",
|
|
9
|
+
path: getPeekableConfigDir(home),
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
label: "Claude Code skill",
|
|
13
|
+
path: getClaudePeekableSkillDir(home),
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
export function removeLocalPeekableFiles(home = homedir(), removeTarget = rmSync) {
|
|
18
|
+
const targets = getUninstallTargets(home).map((target) => {
|
|
19
|
+
const existed = existsSync(target.path);
|
|
20
|
+
if (!existed) {
|
|
21
|
+
return { ...target, existed, removed: false };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
removeTarget(target.path, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return {
|
|
28
|
+
...target,
|
|
29
|
+
existed,
|
|
30
|
+
removed: false,
|
|
31
|
+
error: error instanceof Error ? error.message : String(error),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
...target,
|
|
36
|
+
existed,
|
|
37
|
+
removed: !existsSync(target.path),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const hasFailure = targets.some((target) => target.error || (target.existed && !target.removed));
|
|
41
|
+
return {
|
|
42
|
+
status: hasFailure ? "partial" : "ok",
|
|
43
|
+
targets,
|
|
44
|
+
npmUninstallCommand: "npm uninstall -g peekable",
|
|
45
|
+
note: "This only removes local Peekable files. It does not delete hosted sessions or account data.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function formatUninstallResult(result) {
|
|
49
|
+
const lines = result.targets.map((target) => {
|
|
50
|
+
if (target.error) {
|
|
51
|
+
return `${target.label}: failed to remove (${target.path}) - ${target.error}`;
|
|
52
|
+
}
|
|
53
|
+
if (!target.existed) {
|
|
54
|
+
return `${target.label}: not found (${target.path})`;
|
|
55
|
+
}
|
|
56
|
+
return `${target.label}: ${target.removed ? "removed" : "not removed"} (${target.path})`;
|
|
57
|
+
});
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(`To remove the CLI package, run: ${result.npmUninstallCommand}`);
|
|
60
|
+
lines.push(result.note);
|
|
61
|
+
lines.push("For hosted account or data deletion, contact support.");
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
export function createUninstallCommand() {
|
|
65
|
+
return new Command("uninstall")
|
|
66
|
+
.description("Remove local Peekable config and installed Claude Code skill")
|
|
67
|
+
.option("--json", "Output JSON")
|
|
68
|
+
.action((opts) => {
|
|
69
|
+
const result = removeLocalPeekableFiles();
|
|
70
|
+
if (opts.json) {
|
|
71
|
+
console.log(JSON.stringify(result));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log(formatUninstallResult(result));
|
|
75
|
+
}
|
|
76
|
+
if (result.status !== "ok") {
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export const uninstallCommand = createUninstallCommand();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { requireConfig } from "../config.js";
|
|
3
|
+
export const watchCommand = new Command("watch")
|
|
4
|
+
.argument("<session-id>", "Session ID to watch")
|
|
5
|
+
.description("Watch for annotation notifications on a session")
|
|
6
|
+
.action(async (sessionId) => {
|
|
7
|
+
const config = requireConfig();
|
|
8
|
+
const wsUrl = config.url.replace(/^http/, "ws") + `/ws/${sessionId}`;
|
|
9
|
+
let reconnectDelay = 1000;
|
|
10
|
+
const maxDelay = 30000;
|
|
11
|
+
function connect() {
|
|
12
|
+
const ws = new WebSocket(wsUrl);
|
|
13
|
+
ws.addEventListener("open", () => {
|
|
14
|
+
reconnectDelay = 1000;
|
|
15
|
+
console.log(`Watching session ${sessionId} for annotations...`);
|
|
16
|
+
});
|
|
17
|
+
ws.addEventListener("message", (event) => {
|
|
18
|
+
try {
|
|
19
|
+
const msg = JSON.parse(String(event.data));
|
|
20
|
+
if (msg.type === "annotations_submitted") {
|
|
21
|
+
const viewer = msg.viewer || "Someone";
|
|
22
|
+
console.log(`\nš ${viewer} submitted annotations on v${msg.version}`);
|
|
23
|
+
console.log(` Run: /peekable review ${sessionId}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Non-JSON messages (e.g. "reload") ā ignore
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
ws.addEventListener("close", () => {
|
|
31
|
+
console.log(`Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`);
|
|
32
|
+
setTimeout(connect, reconnectDelay);
|
|
33
|
+
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
|
|
34
|
+
});
|
|
35
|
+
ws.addEventListener("error", () => {
|
|
36
|
+
// Error triggers close, which handles reconnection
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
connect();
|
|
40
|
+
});
|