letmeknow-cli 0.4.2 → 0.4.3
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 +37 -128
- package/SKILL.md +49 -115
- package/bin/letmeknow.js +9 -286
- package/bin/relay.js +440 -0
- package/bin/remote.js +299 -0
- package/package.json +6 -3
package/bin/remote.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeSync } from "node:fs";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
|
|
6
|
+
if (process.argv[2] === "--skill") {
|
|
7
|
+
if (process.argv.length !== 3) {
|
|
8
|
+
process.stderr.write("Usage: npx letmeknow-cli --skill\n");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const control = new URL(process.env.LETMEKNOW_URL || "https://letmeknow.dev");
|
|
16
|
+
const graceSeconds = 10 * 60;
|
|
17
|
+
const connectionAttemptTimeout = 10_000;
|
|
18
|
+
const maxRetryDelay = 5_000;
|
|
19
|
+
const subprotocolTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
20
|
+
let socket;
|
|
21
|
+
let input;
|
|
22
|
+
let credential;
|
|
23
|
+
let sessionUrl;
|
|
24
|
+
let retryTimer;
|
|
25
|
+
let connectionTimer;
|
|
26
|
+
let retryDelay = 100;
|
|
27
|
+
let stdinClosed = false;
|
|
28
|
+
let signalRequested = false;
|
|
29
|
+
let explicitSessionClosed = false;
|
|
30
|
+
let closeCommandAccepted = false;
|
|
31
|
+
let closeCommandSent = false;
|
|
32
|
+
let retryUntil = 0;
|
|
33
|
+
let connected = false;
|
|
34
|
+
let finished = false;
|
|
35
|
+
const queued = [];
|
|
36
|
+
|
|
37
|
+
function endpoint() {
|
|
38
|
+
const url = new URL(control);
|
|
39
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
40
|
+
url.pathname = "/v1/connect";
|
|
41
|
+
url.search = "";
|
|
42
|
+
url.hash = "";
|
|
43
|
+
if (credential && sessionUrl) {
|
|
44
|
+
const publicUrl = new URL(sessionUrl);
|
|
45
|
+
const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
|
|
46
|
+
const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
|
|
47
|
+
const code = hostCode?.[1] || pathCode?.[1];
|
|
48
|
+
if (code) {
|
|
49
|
+
url.searchParams.set("code", code);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clearConnectionTimer() {
|
|
56
|
+
if (connectionTimer) clearTimeout(connectionTimer);
|
|
57
|
+
connectionTimer = undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finish(code) {
|
|
61
|
+
if (finished) return;
|
|
62
|
+
finished = true;
|
|
63
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
64
|
+
clearConnectionTimer();
|
|
65
|
+
input?.close();
|
|
66
|
+
process.exitCode = code;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validCredential(value) {
|
|
70
|
+
return typeof value === "string" && subprotocolTokenPattern.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validSessionUrl(value) {
|
|
74
|
+
if (typeof value !== "string") return false;
|
|
75
|
+
let url;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(value);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
82
|
+
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
83
|
+
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCloseCommand(line) {
|
|
87
|
+
try {
|
|
88
|
+
const packet = JSON.parse(line);
|
|
89
|
+
return packet !== null
|
|
90
|
+
&& typeof packet === "object"
|
|
91
|
+
&& !Array.isArray(packet)
|
|
92
|
+
&& packet.type === "close"
|
|
93
|
+
&& (packet.id === undefined || typeof packet.id === "string");
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function send(line) {
|
|
100
|
+
if (!socket || !connected || socket.readyState !== WebSocket.OPEN) return false;
|
|
101
|
+
try {
|
|
102
|
+
socket.send(line);
|
|
103
|
+
if (isCloseCommand(line)) closeCommandSent = true;
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
socket.close();
|
|
108
|
+
} catch {
|
|
109
|
+
// The close event still determines whether reconnect is needed.
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sendOrQueue(line) {
|
|
116
|
+
if (finished || closeCommandAccepted) return;
|
|
117
|
+
if (isCloseCommand(line)) closeCommandAccepted = true;
|
|
118
|
+
if (!send(line)) queued.push(line);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function flush() {
|
|
122
|
+
if (finished) return;
|
|
123
|
+
while (queued.length && !closeCommandSent) {
|
|
124
|
+
if (!send(queued[0])) break;
|
|
125
|
+
queued.shift();
|
|
126
|
+
}
|
|
127
|
+
if (closeCommandSent) queued.length = 0;
|
|
128
|
+
if (!queued.length && stdinClosed && !closeCommandSent && socket && connected) {
|
|
129
|
+
socket.close(1000, "stdin closed");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function protocolFailure(message) {
|
|
134
|
+
if (finished) return;
|
|
135
|
+
process.stderr.write(`letmeknow: server protocol error: ${message}\n`);
|
|
136
|
+
const current = socket;
|
|
137
|
+
if (current) {
|
|
138
|
+
try {
|
|
139
|
+
current.close(1000, "protocol error");
|
|
140
|
+
} catch {
|
|
141
|
+
// The process still exits below.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finish(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleMessage(event) {
|
|
148
|
+
if (finished) return;
|
|
149
|
+
if (typeof event.data !== "string") {
|
|
150
|
+
protocolFailure("binary WebSocket frame");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const text = event.data;
|
|
154
|
+
let packet;
|
|
155
|
+
try {
|
|
156
|
+
packet = JSON.parse(text);
|
|
157
|
+
} catch {
|
|
158
|
+
protocolFailure("invalid JSON");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!packet || typeof packet !== "object" || Array.isArray(packet)) {
|
|
162
|
+
protocolFailure("packet must be a JSON object");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (typeof packet.type !== "string") {
|
|
166
|
+
protocolFailure("packet type is required");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (packet.type === "credential") {
|
|
170
|
+
if (!validCredential(packet.credential)) {
|
|
171
|
+
protocolFailure("invalid credential");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
credential = packet.credential;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (packet.type === "session") {
|
|
178
|
+
if (!validSessionUrl(packet.url)) {
|
|
179
|
+
protocolFailure("invalid session URL");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (typeof packet.expires_after_disconnect !== "number"
|
|
183
|
+
|| !Number.isFinite(packet.expires_after_disconnect)
|
|
184
|
+
|| packet.expires_after_disconnect <= 0) {
|
|
185
|
+
protocolFailure("invalid session expiration");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
sessionUrl = packet.url;
|
|
189
|
+
retryDelay = 100;
|
|
190
|
+
}
|
|
191
|
+
if (packet.type === "closing") explicitSessionClosed = true;
|
|
192
|
+
process.stdout.write(`${text}\n`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function retry() {
|
|
196
|
+
if (finished || signalRequested || explicitSessionClosed || closeCommandSent || (stdinClosed && !queued.length) || Date.now() >= retryUntil) {
|
|
197
|
+
finish(explicitSessionClosed || signalRequested || closeCommandSent || (stdinClosed && !queued.length) ? 0 : 1);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
retryTimer = setTimeout(() => {
|
|
201
|
+
retryTimer = undefined;
|
|
202
|
+
start();
|
|
203
|
+
}, retryDelay);
|
|
204
|
+
retryDelay = Math.min(retryDelay * 2, maxRetryDelay);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function start() {
|
|
208
|
+
if (finished || signalRequested || explicitSessionClosed || closeCommandSent) return;
|
|
209
|
+
const reconnecting = Boolean(credential && sessionUrl);
|
|
210
|
+
const current = socket = reconnecting
|
|
211
|
+
? new WebSocket(endpoint(), credential)
|
|
212
|
+
: new WebSocket(endpoint());
|
|
213
|
+
connectionTimer = setTimeout(() => {
|
|
214
|
+
if (socket !== current || connected || finished) return;
|
|
215
|
+
clearConnectionTimer();
|
|
216
|
+
process.stderr.write("letmeknow: WebSocket connection attempt timed out\n");
|
|
217
|
+
try {
|
|
218
|
+
current.close();
|
|
219
|
+
} catch {
|
|
220
|
+
// The close event is not available when construction failed.
|
|
221
|
+
}
|
|
222
|
+
socket = undefined;
|
|
223
|
+
connected = false;
|
|
224
|
+
if (reconnecting) retry();
|
|
225
|
+
else finish(1);
|
|
226
|
+
}, connectionAttemptTimeout);
|
|
227
|
+
current.addEventListener("message", handleMessage);
|
|
228
|
+
current.addEventListener("error", () => {
|
|
229
|
+
if (!finished) process.stderr.write("letmeknow: WebSocket connection failed; retrying\n");
|
|
230
|
+
});
|
|
231
|
+
current.addEventListener("open", () => {
|
|
232
|
+
if (socket !== current || finished) return;
|
|
233
|
+
clearConnectionTimer();
|
|
234
|
+
connected = true;
|
|
235
|
+
retryDelay = 100;
|
|
236
|
+
if (reconnecting) retryUntil = 0;
|
|
237
|
+
flush();
|
|
238
|
+
});
|
|
239
|
+
current.addEventListener("close", (event) => {
|
|
240
|
+
if (socket !== current) return;
|
|
241
|
+
clearConnectionTimer();
|
|
242
|
+
connected = false;
|
|
243
|
+
if (finished) return;
|
|
244
|
+
socket = undefined;
|
|
245
|
+
if (closeCommandSent || signalRequested || explicitSessionClosed || (stdinClosed && !queued.length)) {
|
|
246
|
+
finish(0);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!credential || !sessionUrl) {
|
|
250
|
+
process.stderr.write(`letmeknow: connection closed (${event.code}${event.reason ? `: ${event.reason}` : ""})\n`);
|
|
251
|
+
finish(1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!retryUntil) retryUntil = Date.now() + graceSeconds * 1_000;
|
|
255
|
+
retry();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
260
|
+
input.on("line", (line) => {
|
|
261
|
+
if (!finished && line.trim() && !closeCommandAccepted) sendOrQueue(line);
|
|
262
|
+
});
|
|
263
|
+
input.on("close", () => {
|
|
264
|
+
if (finished) return;
|
|
265
|
+
stdinClosed = true;
|
|
266
|
+
if (!socket) {
|
|
267
|
+
if (!queued.length) finish(0);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (connected) flush();
|
|
271
|
+
else if (!queued.length) {
|
|
272
|
+
const current = socket;
|
|
273
|
+
clearConnectionTimer();
|
|
274
|
+
try {
|
|
275
|
+
current.close();
|
|
276
|
+
} catch {
|
|
277
|
+
// The process still exits below.
|
|
278
|
+
}
|
|
279
|
+
socket = undefined;
|
|
280
|
+
finish(0);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
285
|
+
process.on(signal, () => {
|
|
286
|
+
signalRequested = true;
|
|
287
|
+
clearConnectionTimer();
|
|
288
|
+
if (socket) {
|
|
289
|
+
try {
|
|
290
|
+
socket.close(1000, signal);
|
|
291
|
+
} catch {
|
|
292
|
+
// The process still exits below.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
finish(0);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
start();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "A live Vite preview with agent-readable form submissions.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
7
7
|
"SKILL.md"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"letmeknow": "bin/letmeknow.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
|
-
"node": ">=22"
|
|
14
|
+
"node": ">=22.12.0"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"dev": "wrangler dev",
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"vitest": "^4.1.11",
|
|
28
28
|
"wrangler": "^4.126.0",
|
|
29
29
|
"ws": "^8.21.3"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"vite": "^7.3.6"
|
|
30
33
|
}
|
|
31
34
|
}
|