letmeknow-cli 0.4.10 → 0.5.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.
- package/README.md +23 -28
- package/SKILL.md +24 -49
- package/bin/letmeknow.js +147 -174
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -1,46 +1,38 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent
|
|
3
|
+
LetMeKnow gives an agent a temporary public preview of a dedicated folder and receives structured input from a human. The CLI serves the folder over an outbound connection to the LetMeKnow relay; it does not listen on a network port.
|
|
4
4
|
|
|
5
|
-
## Start
|
|
5
|
+
## Start a session
|
|
6
6
|
|
|
7
|
-
Node.js 22.12 or newer is required.
|
|
7
|
+
Node.js 22.12 or newer is required. Pass the preview directory explicitly:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx letmeknow-cli ./
|
|
10
|
+
npx letmeknow-cli ./preview
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The CLI prints JSON lines to stdout. The first line contains the public
|
|
13
|
+
The CLI prints JSON lines to stdout. The first line contains the public URL:
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Open
|
|
19
|
+
Open the URL in one or more browsers. The URL is a bearer capability: anyone who has it can view the preview and submit its forms. Sessions are hosted at `letmeknow.dev` and are temporary. A graceful CLI stop closes the session; an unexpected disconnect can reconnect for up to ten minutes. `--skill` prints instructions for an agent without starting a session. Diagnostics go to stderr.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
LETMEKNOW_URL=https://letmeknow.dev npx letmeknow-cli ./workspace
|
|
23
|
-
```
|
|
21
|
+
The preview directory is public. Keep secrets and unrelated project files elsewhere. Use a dedicated directory containing only the files intended for the human.
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
## Live workspace
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
Create an ordinary static site in the directory, usually with `index.html`, plus its CSS, JavaScript, images, and other assets. The relay injects the small browser runtime into HTML pages. The CLI serves the workspace files unchanged.
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
Every file in the preview directory is part of the live artifact. A burst of changes is coalesced into one workspace revision. Each connected browser then performs a full-page reload.
|
|
30
28
|
|
|
31
|
-
The
|
|
29
|
+
The runtime preserves scroll position and the values of controls with stable, unique `id` attributes, including checked and selected state. Other browser state is not part of the preview contract. Use relative asset URLs and normal links between pages.
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
If a requested page does not exist, the live 404 page remains connected and recovers when the page is created. Connection status pages likewise reconnect and recover when the producer becomes available again.
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
## Forms
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
<p data-letmeknow-status aria-live="polite"></p>
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## Form submissions
|
|
42
|
-
|
|
43
|
-
Forms are submitted without navigation. GET and POST forms are sent through the relay to the CLI, which prints each submission as one JSON line on stdout. The agent can read that line and edit the folder in response.
|
|
35
|
+
Use native HTML GET and POST forms with same-origin or relative actions:
|
|
44
36
|
|
|
45
37
|
```html
|
|
46
38
|
<form id="decision" action="/decide" method="post">
|
|
@@ -50,29 +42,32 @@ Forms are submitted without navigation. GET and POST forms are sent through the
|
|
|
50
42
|
</form>
|
|
51
43
|
```
|
|
52
44
|
|
|
53
|
-
|
|
45
|
+
The runtime sends the submission to the CLI, which prints one `submit` event to stdout:
|
|
54
46
|
|
|
55
47
|
```json
|
|
56
48
|
{"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
|
|
57
49
|
```
|
|
58
50
|
|
|
59
|
-
Repeated field names become arrays
|
|
51
|
+
Repeated field names become arrays, and native browser validation runs before delivery. POST forms may include file inputs; the total submission is limited to 1 MiB. Uploaded files are stored in a private temporary inbox and the event includes an `attachments` array with each field name, original filename, media type, size, and local path. Attachment paths remain available until the CLI stops and are never published unless the agent deliberately copies them into the preview directory.
|
|
52
|
+
|
|
53
|
+
The event ID identifies the submission; it is not a response handle. Read the event, treat values and attachments as untrusted input, update the workspace, and let the next revision show the result.
|
|
60
54
|
|
|
61
|
-
|
|
55
|
+
Submission feedback is automatic: **Sending…** or **Uploading…**, then **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` where a form or page needs a particular status location. Forms are accepted for asynchronous processing, so the transport response is `202 Accepted`.
|
|
62
56
|
|
|
63
57
|
## Security
|
|
64
58
|
|
|
65
|
-
The preview URL
|
|
59
|
+
The preview URL grants access to the session. The relay receives served files and submitted values. The preview folder is trusted code from the browser's perspective, so do not include secrets or credentials unless that is intentional. The CLI makes outbound relay connections only and accepts no inbound browser connections.
|
|
66
60
|
|
|
67
|
-
The
|
|
61
|
+
The CLI resolves requested paths and denies symlinks whose targets leave the selected directory. Sensitive names such as `.env`, `.git`, private keys, and database files are excluded. Processes with write access to the preview directory are trusted publishers and can make data public through it.
|
|
68
62
|
|
|
69
63
|
## Development
|
|
70
64
|
|
|
71
65
|
```bash
|
|
72
66
|
npm install
|
|
73
67
|
npm test
|
|
68
|
+
npm run test:browser
|
|
74
69
|
npm run dev
|
|
75
70
|
npm run deploy
|
|
76
71
|
```
|
|
77
72
|
|
|
78
|
-
`npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
|
73
|
+
`npm run test:browser` requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. The stdout contract is JSONL (`ready` and `submit` events); diagnostics belong on stderr.
|
package/SKILL.md
CHANGED
|
@@ -1,56 +1,39 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description: Give a human a live
|
|
3
|
+
description: Give a human a temporary live preview of an agent-managed folder and receive browser form submissions as JSON lines.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
7
7
|
|
|
8
|
-
Use LetMeKnow when
|
|
9
|
-
|
|
10
|
-
The CLI does **not** listen on a local network port.
|
|
8
|
+
Use LetMeKnow when a human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. You own the files in a dedicated preview folder. The CLI serves that folder through the hosted LetMeKnow relay and reports browser submissions on stdout. It does not listen on a local network port.
|
|
11
9
|
|
|
12
10
|
## Start
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
Pass only the files intended for public viewing in an explicit directory:
|
|
15
13
|
|
|
16
14
|
```bash
|
|
17
|
-
npx letmeknow-cli ./
|
|
15
|
+
npx letmeknow-cli ./preview
|
|
18
16
|
```
|
|
19
17
|
|
|
20
|
-
Node.js 22.12 or newer is required. Read stdout and stderr separately. Stdout is JSONL; the first event is:
|
|
18
|
+
Node.js 22.12 or newer is required. Keep the process running while the human uses the page. Read stdout and stderr separately. Stdout is JSONL; the first event is:
|
|
21
19
|
|
|
22
20
|
```json
|
|
23
21
|
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
24
22
|
```
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Do not send file commands to stdin. Read and write the folder directly. Keep the process running while the human uses the page.
|
|
29
|
-
|
|
30
|
-
## Build the page
|
|
24
|
+
Give the human the URL. It is a bearer capability, so anyone with it can view the preview and submit forms. Multiple browsers may view the session. `--skill` prints these instructions without starting a session.
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
## Build the preview
|
|
33
27
|
|
|
34
|
-
|
|
28
|
+
Create an ordinary static site in the folder, usually `index.html`, with its CSS, JavaScript, images, and other assets. Use semantic HTML, accessible labels, and relative asset URLs. Use normal links for navigation.
|
|
35
29
|
|
|
36
|
-
The
|
|
30
|
+
The relay injects the browser runtime into HTML. All files in the folder form one live workspace. Changes made in a short burst become one revision, and connected browsers perform a full-page reload. The runtime preserves scroll position and values, checked state, and selected state for controls with stable, unique `id` attributes.
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- A change to the current HTML route reloads the page with its saved state.
|
|
41
|
-
- CSS changes cache-bust matching linked stylesheets without navigating.
|
|
42
|
-
- Changes to a different HTML route do not disturb the current page.
|
|
43
|
-
- Changes to other assets reload the page. Arbitrary JavaScript heap state cannot be preserved.
|
|
44
|
-
|
|
45
|
-
An optional status element gives the human feedback after a form submission:
|
|
46
|
-
|
|
47
|
-
```html
|
|
48
|
-
<p data-letmeknow-status aria-live="polite"></p>
|
|
49
|
-
```
|
|
32
|
+
A missing page displays a live 404 page that can recover when you create the page. Connection status pages reconnect and recover when the producer is available again.
|
|
50
33
|
|
|
51
34
|
## Receive form submissions
|
|
52
35
|
|
|
53
|
-
GET and POST forms
|
|
36
|
+
Use native GET and POST forms with relative or same-origin actions:
|
|
54
37
|
|
|
55
38
|
```html
|
|
56
39
|
<form id="search" action="/search" method="post">
|
|
@@ -59,40 +42,32 @@ GET and POST forms are intercepted before navigation and sent through the relay
|
|
|
59
42
|
</form>
|
|
60
43
|
```
|
|
61
44
|
|
|
62
|
-
The
|
|
45
|
+
The runtime provides automatic transport feedback: **Sending…** or **Uploading…**, **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` for a custom status location. The transport accepts submissions asynchronously with `202 Accepted`.
|
|
46
|
+
|
|
47
|
+
Read stdout for a `submit` event:
|
|
63
48
|
|
|
64
49
|
```json
|
|
65
50
|
{"type":"submit","id":"…","method":"POST","action":"/search","form_id":"search","trigger":{"id":null,"name":"scope","value":"all"},"values":{"query":"quarterly report","scope":"all"}}
|
|
66
51
|
```
|
|
67
52
|
|
|
68
|
-
|
|
53
|
+
Give forms stable IDs and controls meaningful `name` values. Native browser validation runs before delivery, and repeated names become arrays. POST forms may include file inputs within the 1 MiB total submission limit. Attachment metadata includes a local temporary path that remains readable until the CLI stops; treat the filename, media type, and contents as untrusted. Attachments are private unless you deliberately copy them into the preview folder.
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
- Give controls meaningful `name` values.
|
|
72
|
-
- Use normal relative or same-origin actions.
|
|
73
|
-
- Use `formaction` and `formmethod` on submitters when different buttons have different intents.
|
|
74
|
-
- Native `required`, input types, ranges, and patterns validate in the browser before the event is sent.
|
|
75
|
-
- Repeated names become string arrays.
|
|
76
|
-
- File inputs and cross-origin actions are not supported.
|
|
55
|
+
The event ID identifies the submission, not a response channel: validate the values and attachments, update the files, and let the next workspace revision show the result. Do not write commands to stdin.
|
|
77
56
|
|
|
78
|
-
|
|
57
|
+
## Example workflow
|
|
79
58
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
1. Render the initial state in `index.html`.
|
|
59
|
+
1. Build the initial page in `index.html`.
|
|
83
60
|
2. Wait for a `submit` event on stdout.
|
|
84
|
-
3. Validate its
|
|
85
|
-
4. Rewrite the relevant HTML or data file in the
|
|
86
|
-
5.
|
|
61
|
+
3. Validate its values and action.
|
|
62
|
+
4. Rewrite the relevant HTML or data file in the preview folder.
|
|
63
|
+
5. Let the workspace revision reload the human's page.
|
|
87
64
|
|
|
88
|
-
Escape untrusted values before placing them in HTML.
|
|
65
|
+
Escape untrusted values before placing them in HTML.
|
|
89
66
|
|
|
90
67
|
## Security
|
|
91
68
|
|
|
92
|
-
The public
|
|
93
|
-
|
|
94
|
-
The selected folder is resolved with real paths, and requests cannot escape it through symlinks. `.env` files, `.git`, private-key files, and database files are denied. The CLI makes outbound relay connections only; it does not accept inbound browser connections.
|
|
69
|
+
The preview folder is public and is trusted code from the browser's perspective. Keep secrets and unrelated project files elsewhere. The CLI restricts requests to the selected directory and excludes `.env`, `.git`, private-key, and database files. Treat every process with write access to the preview folder as a trusted publisher. The CLI makes outbound relay connections only and accepts no inbound browser connections.
|
|
95
70
|
|
|
96
71
|
## Stop
|
|
97
72
|
|
|
98
|
-
Send `SIGINT` or `SIGTERM` to
|
|
73
|
+
Send `SIGINT` or `SIGTERM` to close the session. After an unexpected disconnect, the CLI can reconnect to the session for up to ten minutes. Diagnostics go to stderr; stdout remains JSONL.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,124 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, statSync,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import chokidar from "chokidar";
|
|
10
|
+
import { lookup } from "mrmime";
|
|
6
11
|
|
|
7
12
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
13
|
const GRACE_SECONDS = 10 * 60;
|
|
9
14
|
const CONNECTION_TIMEOUT = 10_000;
|
|
10
15
|
const MAX_RETRY_DELAY = 5_000;
|
|
16
|
+
const REVISION_QUIET_MS = 200;
|
|
17
|
+
const CONTROL_URL = "https://letmeknow.dev";
|
|
11
18
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
".json": "application/json; charset=utf-8",
|
|
23
|
-
".map": "application/json; charset=utf-8",
|
|
24
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
25
|
-
".pdf": "application/pdf",
|
|
26
|
-
".png": "image/png",
|
|
27
|
-
".svg": "image/svg+xml",
|
|
28
|
-
".txt": "text/plain; charset=utf-8",
|
|
29
|
-
".wasm": "application/wasm",
|
|
30
|
-
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
31
|
-
".webp": "image/webp",
|
|
32
|
-
".woff": "font/woff",
|
|
33
|
-
".woff2": "font/woff2",
|
|
34
|
-
".xml": "application/xml; charset=utf-8"
|
|
35
|
-
};
|
|
36
|
-
const client = String.raw`
|
|
37
|
-
const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
|
|
38
|
-
const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
|
|
39
|
-
const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
|
|
40
|
-
const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
|
|
41
|
-
let credential;
|
|
42
|
-
try{credential=sessionStorage.getItem(credentialKey)}catch{}
|
|
43
|
-
let socket;
|
|
44
|
-
let retryTimer;
|
|
45
|
-
let updateTimer;
|
|
46
|
-
let reloadTimer;
|
|
47
|
-
let terminal=false;
|
|
48
|
-
const controls=()=>[...document.querySelectorAll("button,input,select,textarea")];
|
|
49
|
-
const details=()=>[...document.querySelectorAll("details")];
|
|
50
|
-
const uniqueId=(element,all)=>element.id&&all.filter(candidate=>candidate.id===element.id).length===1?element.id:null;
|
|
51
|
-
const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribute("action")||"document";
|
|
52
|
-
const controlKey=(control,index,all=controls())=>{const id=uniqueId(control,all);if(id)return"id:"+id;const form=control.form;const identity=formIdentity(form)+":"+(control.type||control.localName)+":"+(control.name||"");const occurrence=all.slice(0,index).filter(candidate=>!uniqueId(candidate,all)&&formIdentity(candidate.form)+":"+(candidate.type||candidate.localName)+":"+(candidate.name||"")===identity).length;return"control:"+identity+":"+occurrence};
|
|
53
|
-
const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
|
|
54
|
-
const pageIdentity=()=>location.pathname+location.search+location.hash;
|
|
55
|
-
const snapshot=()=>{const all=controls();return{version:1,page:pageIdentity(),controls:all.map((control,index)=>({key:controlKey(control,index,all),value:control.value,checked:control.checked,indeterminate:control.indeterminate,selected:control instanceof HTMLSelectElement?[...control.options].map((option,optionIndex)=>option.selected?optionIndex:null).filter(optionIndex=>optionIndex!==null):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined,direction:control.selectionDirection||undefined})),active:document.activeElement instanceof Element?controlKey(document.activeElement,all.indexOf(document.activeElement),all):undefined,details:details().map((element,index)=>({key:detailKey(element,index),open:element.open})),x:scrollX,y:scrollY}};
|
|
56
|
-
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
57
|
-
const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
|
|
58
|
-
const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
|
|
59
|
-
const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
|
|
60
|
-
const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
|
|
61
|
-
const restore=()=>{let raw;try{raw=sessionStorage.getItem(snapshotKey())}catch{return}if(!raw)return;let saved;try{saved=JSON.parse(raw)}catch{return}if(saved.version!==1||saved.page!==pageIdentity())return;const all=controls();const savedControls=new Map((Array.isArray(saved.controls)?saved.controls:[]).map(state=>[state.key,state]));let active;for(const [index,control] of all.entries()){const state=savedControls.get(controlKey(control,index,all));if(!state)continue;if(control instanceof HTMLSelectElement&&Array.isArray(state.selected))for(const [optionIndex,option] of [...control.options].entries())option.selected=state.selected.includes(optionIndex);else if(control.type==="checkbox"||control.type==="radio"){control.checked=state.checked;control.indeterminate=state.indeterminate}else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end,state.direction||"none")}if(controlKey(control,index,all)===saved.active)active=control}const savedDetails=new Map((Array.isArray(saved.details)?saved.details:[]).map(state=>[state.key,state]));for(const [index,element] of details().entries()){const state=savedDetails.get(detailKey(element,index));if(state)element.open=state.open}active?.focus({preventScroll:true});scrollTo(saved.x||0,saved.y||0)};
|
|
62
|
-
const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
|
|
63
|
-
const linkedStylesheet=path=>{for(const link of document.querySelectorAll('link[rel~="stylesheet"]')){let url;try{url=new URL(link.href,location.href)}catch{continue}if(url.origin!==location.origin)continue;if(sessionBase!=="/"&&!url.pathname.startsWith(sessionBase))continue;if(pagePath(url.pathname)===path)return link}return null};
|
|
64
|
-
const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
|
|
65
|
-
const flushUpdates=()=>{updateTimer=undefined;const paths=[...pendingUpdates];pendingUpdates.clear();let shouldReload=false;const styles=[];for(const path of paths){if(/\.html?$/i.test(path)){if(path===routePath())shouldReload=true}else if(/\.css$/i.test(path)){const link=linkedStylesheet(path);if(link)styles.push([path,link]);else shouldReload=true}else shouldReload=true}if(shouldReload){reload();return}for(const [,link] of styles)refreshStylesheet(link)};
|
|
66
|
-
const pendingUpdates=new Set();
|
|
67
|
-
const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
|
|
68
|
-
let saveTimer;
|
|
69
|
-
const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
|
|
70
|
-
addEventListener("input",scheduleSave,true);
|
|
71
|
-
addEventListener("change",scheduleSave,true);
|
|
72
|
-
addEventListener("toggle",scheduleSave,true);
|
|
73
|
-
addEventListener("focusin",scheduleSave,true);
|
|
74
|
-
addEventListener("selectionchange",scheduleSave,true);
|
|
75
|
-
addEventListener("scroll",scheduleSave,{passive:true});
|
|
76
|
-
addEventListener("pagehide",save);
|
|
77
|
-
addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
|
|
78
|
-
document.addEventListener("reset",()=>setTimeout(save));
|
|
79
|
-
const connect=()=>{
|
|
80
|
-
clearTimeout(retryTimer);retryTimer=undefined;
|
|
81
|
-
const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
82
|
-
const current=credential?new WebSocket(url,credential):new WebSocket(url);
|
|
83
|
-
socket=current;
|
|
84
|
-
current.onmessage=event=>{
|
|
85
|
-
if(socket!==current||typeof event.data!=="string")return;
|
|
86
|
-
let message;try{message=JSON.parse(event.data)}catch{return}
|
|
87
|
-
if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
|
|
88
|
-
if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
|
|
89
|
-
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
90
|
-
if(message.type==="file_update"){update(message.path);return}
|
|
91
|
-
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
|
|
92
|
-
};
|
|
93
|
-
current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
94
|
-
current.onerror=()=>{};
|
|
95
|
-
};
|
|
96
|
-
document.addEventListener("submit",async event=>{
|
|
97
|
-
const form=event.target;
|
|
98
|
-
if(!(form instanceof HTMLFormElement))return;
|
|
99
|
-
event.preventDefault();
|
|
100
|
-
const submitter=event.submitter;
|
|
101
|
-
const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
|
|
102
|
-
if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
|
|
103
|
-
if(!form.checkValidity()){form.reportValidity();return}
|
|
104
|
-
let target;
|
|
105
|
-
try{const action=submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href;const base=sessionBase!=="/"&&location.pathname===sessionBase.slice(0,-1)?new URL(sessionBase,location.href):location.href;target=new URL(action,base)}catch{status("Invalid form action");return}
|
|
106
|
-
if(target.origin!==location.origin){status("Form actions must stay on this site");return}
|
|
107
|
-
if(sessionBase!=="/"){
|
|
108
|
-
const sessionPath=sessionBase.slice(0,-1);
|
|
109
|
-
if(target.pathname===sessionPath)target.pathname=sessionBase;
|
|
110
|
-
else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
|
|
111
|
-
}
|
|
112
|
-
const values=new URLSearchParams();
|
|
113
|
-
for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
|
|
114
|
-
const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
|
|
115
|
-
const metadata={id:crypto.randomUUID(),form_id:form.id||null,action:actionPath+target.search,trigger:{id:submitter?.id||null,name:submitter?.getAttribute("name"),value:submitter?.getAttribute("value")}};
|
|
116
|
-
const headers={"X-LetMeKnow-Submission":"1","X-LetMeKnow-ID":encodeURIComponent(metadata.id),"X-LetMeKnow-Form-ID":encodeURIComponent(metadata.form_id??""),"X-LetMeKnow-Action":encodeURIComponent(metadata.action),"X-LetMeKnow-Trigger-ID":encodeURIComponent(metadata.trigger.id??""),"X-LetMeKnow-Trigger-Name":encodeURIComponent(metadata.trigger.name??""),"X-LetMeKnow-Trigger-Value":encodeURIComponent(metadata.trigger.value??"")};
|
|
117
|
-
if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
|
|
118
|
-
try{const response=await fetch(target,{method:method.toUpperCase(),headers,...(method==="post"?{body:values}:{})});if(!response.ok)throw new Error();status("Submitted")}catch{status("The submission failed")}
|
|
119
|
-
});
|
|
120
|
-
connect();
|
|
121
|
-
`;
|
|
19
|
+
const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
20
|
+
const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
|
|
21
|
+
|
|
22
|
+
function getMimeType(filename) {
|
|
23
|
+
const type = lookup(filename);
|
|
24
|
+
if (!type) return "application/octet-stream";
|
|
25
|
+
return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/manifest+json"
|
|
26
|
+
? `${type}; charset=utf-8`
|
|
27
|
+
: type;
|
|
28
|
+
}
|
|
122
29
|
|
|
123
30
|
function header(packet, name) {
|
|
124
31
|
const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
|
|
@@ -149,7 +56,7 @@ function errorResponse(packet, status, message) {
|
|
|
149
56
|
}
|
|
150
57
|
|
|
151
58
|
function deniedPath(pathname) {
|
|
152
|
-
return pathname.split("/").some(part =>
|
|
59
|
+
return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
|
|
153
60
|
}
|
|
154
61
|
|
|
155
62
|
function inside(root, target) {
|
|
@@ -184,13 +91,6 @@ function requestUrl(packet) {
|
|
|
184
91
|
return { pathname, encodedPathname: url.pathname, search: url.search };
|
|
185
92
|
}
|
|
186
93
|
|
|
187
|
-
function htmlWithClient(body) {
|
|
188
|
-
const text = body.toString("utf8");
|
|
189
|
-
const script = `<script type="module" data-letmeknow-client>${client}</script>`;
|
|
190
|
-
const closingBody = text.search(/<\/body\s*>/i);
|
|
191
|
-
return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
|
|
192
|
-
}
|
|
193
|
-
|
|
194
94
|
async function staticResponse(root, packet) {
|
|
195
95
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
196
96
|
if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
|
|
@@ -211,8 +111,8 @@ async function staticResponse(root, packet) {
|
|
|
211
111
|
return errorResponse(packet, 500, "preview request failed");
|
|
212
112
|
}
|
|
213
113
|
if (info.isDirectory()) {
|
|
214
|
-
if (!request.
|
|
215
|
-
const location = request.encodedPathname + "/" + request.search;
|
|
114
|
+
if (!request.encodedPathname.endsWith("/")) {
|
|
115
|
+
const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
|
|
216
116
|
return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
|
|
217
117
|
}
|
|
218
118
|
const index = resolve(target, "index.html");
|
|
@@ -222,31 +122,73 @@ async function staticResponse(root, packet) {
|
|
|
222
122
|
}
|
|
223
123
|
if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
|
|
224
124
|
if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
|
|
125
|
+
try { info = await stat(target); } catch (cause) {
|
|
126
|
+
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
|
|
127
|
+
if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
|
|
128
|
+
return errorResponse(packet, 500, "preview request failed");
|
|
129
|
+
}
|
|
225
130
|
} else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
|
|
226
|
-
let
|
|
227
|
-
try {
|
|
131
|
+
let file;
|
|
132
|
+
try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
|
|
228
133
|
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
|
|
229
|
-
if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
|
|
134
|
+
if (cause?.code === "EACCES" || cause?.code === "EPERM" || cause?.code === "ELOOP") return errorResponse(packet, 403, "forbidden");
|
|
230
135
|
return errorResponse(packet, 500, "preview request failed");
|
|
231
136
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
137
|
+
try {
|
|
138
|
+
info = await file.stat();
|
|
139
|
+
if (!info.isFile()) return errorResponse(packet, 404, "not found");
|
|
140
|
+
if (info.size > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
141
|
+
const body = await file.readFile();
|
|
142
|
+
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
143
|
+
return response(packet, 200, body, { "Content-Type": getMimeType(target) });
|
|
144
|
+
} catch {
|
|
145
|
+
return errorResponse(packet, 500, "preview request failed");
|
|
146
|
+
} finally {
|
|
147
|
+
await file.close();
|
|
148
|
+
}
|
|
236
149
|
}
|
|
237
150
|
|
|
238
|
-
async function
|
|
151
|
+
async function multipartSubmission(body, contentType, getAttachmentInbox) {
|
|
152
|
+
const formData = await new Request("http://letmeknow.local", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "Content-Type": contentType },
|
|
155
|
+
body
|
|
156
|
+
}).formData();
|
|
157
|
+
const values = Object.create(null);
|
|
158
|
+
const attachments = [];
|
|
159
|
+
for (const [name, value] of formData) {
|
|
160
|
+
if (typeof value === "string") {
|
|
161
|
+
addValue(values, name, value);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (value.name === "") continue;
|
|
165
|
+
const bytes = Buffer.from(await value.arrayBuffer());
|
|
166
|
+
const path = join(await getAttachmentInbox(), randomUUID());
|
|
167
|
+
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
168
|
+
attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
|
|
169
|
+
}
|
|
170
|
+
return { values, attachments };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function submission(packet, getAttachmentInbox) {
|
|
239
174
|
const url = requestUrl(packet);
|
|
240
175
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
241
176
|
const values = Object.create(null);
|
|
177
|
+
let attachments;
|
|
242
178
|
if (method === "GET") {
|
|
243
179
|
for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
|
|
244
180
|
} else if (method === "POST") {
|
|
245
181
|
const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
|
|
246
182
|
if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
183
|
+
const contentTypeHeader = header(packet, "content-type");
|
|
184
|
+
const contentType = contentTypeHeader?.split(";", 1)[0].trim().toLowerCase();
|
|
185
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
186
|
+
for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
|
|
187
|
+
} else if (contentType === "multipart/form-data" && contentTypeHeader) {
|
|
188
|
+
const parsed = await multipartSubmission(body, contentTypeHeader, getAttachmentInbox);
|
|
189
|
+
Object.assign(values, parsed.values);
|
|
190
|
+
attachments = parsed.attachments;
|
|
191
|
+
} else throw new Error("unsupported submission encoding");
|
|
250
192
|
} else throw new Error("unsupported submission method");
|
|
251
193
|
const event = {
|
|
252
194
|
type: "submit",
|
|
@@ -257,41 +199,33 @@ async function submission(packet) {
|
|
|
257
199
|
trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
|
|
258
200
|
values
|
|
259
201
|
};
|
|
202
|
+
if (attachments?.length) event.attachments = attachments;
|
|
260
203
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
261
|
-
return response(packet,
|
|
204
|
+
return response(packet, 202);
|
|
262
205
|
}
|
|
263
206
|
|
|
264
|
-
async function handleRequest(root, packet) {
|
|
207
|
+
async function handleRequest(root, packet, getAttachmentInbox) {
|
|
265
208
|
if (header(packet, "x-letmeknow-submission") === "1") {
|
|
266
|
-
try { return await submission(packet); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
209
|
+
try { return await submission(packet, getAttachmentInbox); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
267
210
|
}
|
|
268
211
|
return staticResponse(root, packet);
|
|
269
212
|
}
|
|
270
213
|
|
|
271
|
-
function options(
|
|
272
|
-
|
|
273
|
-
for (const argument of args) {
|
|
274
|
-
if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
|
|
275
|
-
if (root !== undefined) throw new Error("only one directory may be provided");
|
|
276
|
-
root = resolve(argument);
|
|
277
|
-
}
|
|
278
|
-
root = root || process.cwd();
|
|
214
|
+
function options(directory) {
|
|
215
|
+
const root = resolve(directory);
|
|
279
216
|
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
280
217
|
return realpath(root).then(root => ({ root }));
|
|
281
218
|
}
|
|
282
219
|
|
|
283
|
-
function endpoint(
|
|
284
|
-
const url = new URL(
|
|
285
|
-
url.protocol =
|
|
286
|
-
url.pathname = "/
|
|
287
|
-
url.search = "";
|
|
288
|
-
url.hash = "";
|
|
220
|
+
function endpoint(credential, sessionUrl) {
|
|
221
|
+
const url = new URL(CONTROL_URL);
|
|
222
|
+
url.protocol = "wss:";
|
|
223
|
+
url.pathname = "/v2/connect";
|
|
289
224
|
if (credential && sessionUrl) {
|
|
290
225
|
const publicUrl = new URL(sessionUrl);
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (code) url.searchParams.set("code", code);
|
|
226
|
+
const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
|
|
227
|
+
if (!code) throw new Error("invalid session URL");
|
|
228
|
+
url.searchParams.set("code", code);
|
|
295
229
|
}
|
|
296
230
|
return url;
|
|
297
231
|
}
|
|
@@ -300,21 +234,37 @@ function validSessionUrl(value) {
|
|
|
300
234
|
if (typeof value !== "string") return false;
|
|
301
235
|
let url;
|
|
302
236
|
try { url = new URL(value); } catch { return false; }
|
|
303
|
-
|
|
304
|
-
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
305
|
-
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
237
|
+
return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
|
|
306
238
|
}
|
|
307
239
|
|
|
308
|
-
async function start(
|
|
309
|
-
const { root } = await options(
|
|
310
|
-
|
|
240
|
+
async function start(directory) {
|
|
241
|
+
const { root } = await options(directory);
|
|
242
|
+
let attachmentInboxPromise;
|
|
243
|
+
const getAttachmentInbox = () => {
|
|
244
|
+
attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
|
|
245
|
+
return attachmentInboxPromise;
|
|
246
|
+
};
|
|
311
247
|
let send = () => false;
|
|
312
|
-
|
|
313
|
-
|
|
248
|
+
let revisionTimer;
|
|
249
|
+
const watchedPath = filename => {
|
|
314
250
|
const file = resolve(root, String(filename));
|
|
315
251
|
const path = relative(root, file).split(sep).join("/");
|
|
316
|
-
|
|
317
|
-
|
|
252
|
+
return path && path !== ".." && !path.startsWith("../") && !deniedPath("/" + path);
|
|
253
|
+
};
|
|
254
|
+
const watcher = chokidar.watch(root, {
|
|
255
|
+
ignoreInitial: true,
|
|
256
|
+
ignored: filename => {
|
|
257
|
+
const path = relative(root, resolve(root, String(filename))).split(sep).join("/");
|
|
258
|
+
return path !== "" && (path === ".." || path.startsWith("../") || deniedPath("/" + path));
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
const scheduleRevision = () => {
|
|
262
|
+
clearTimeout(revisionTimer);
|
|
263
|
+
revisionTimer = setTimeout(() => { revisionTimer = undefined; send({ type: "revision" }); }, REVISION_QUIET_MS);
|
|
264
|
+
};
|
|
265
|
+
watcher.on("all", (_event, filename) => {
|
|
266
|
+
if (filename && !watchedPath(filename)) return;
|
|
267
|
+
scheduleRevision();
|
|
318
268
|
});
|
|
319
269
|
let socket;
|
|
320
270
|
let credential;
|
|
@@ -330,8 +280,13 @@ async function start(args) {
|
|
|
330
280
|
stopped = true;
|
|
331
281
|
clearTimeout(retryTimer);
|
|
332
282
|
clearTimeout(connectionTimer);
|
|
283
|
+
clearTimeout(revisionTimer);
|
|
284
|
+
send({ type: "close" });
|
|
333
285
|
try { socket?.close(); } catch {}
|
|
334
|
-
watcher.close();
|
|
286
|
+
await watcher.close();
|
|
287
|
+
if (attachmentInboxPromise) {
|
|
288
|
+
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
289
|
+
}
|
|
335
290
|
process.exit(code);
|
|
336
291
|
};
|
|
337
292
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -346,11 +301,11 @@ async function start(args) {
|
|
|
346
301
|
const connect = () => {
|
|
347
302
|
if (stopped) return;
|
|
348
303
|
const reconnecting = Boolean(credential && sessionUrl);
|
|
349
|
-
const current = socket = reconnecting ? new WebSocket(endpoint(
|
|
304
|
+
const current = socket = reconnecting ? new WebSocket(endpoint(credential, sessionUrl), credential) : new WebSocket(endpoint());
|
|
350
305
|
connectionTimer = setTimeout(() => {
|
|
351
306
|
if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
|
|
352
307
|
try { current.close(); } catch {}
|
|
353
|
-
if (reconnecting)
|
|
308
|
+
if (!reconnecting) void stop(1);
|
|
354
309
|
}, CONNECTION_TIMEOUT);
|
|
355
310
|
current.addEventListener("open", () => {
|
|
356
311
|
if (socket !== current || stopped) return;
|
|
@@ -375,7 +330,7 @@ async function start(args) {
|
|
|
375
330
|
sessionUrl = packet.url;
|
|
376
331
|
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
|
|
377
332
|
} else if (packet.type === "http_request") {
|
|
378
|
-
void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
|
|
333
|
+
void handleRequest(root, packet, getAttachmentInbox).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
|
|
379
334
|
} else if (packet.type === "closed") {
|
|
380
335
|
void stop(0);
|
|
381
336
|
} else if (packet.type === "error") {
|
|
@@ -398,11 +353,29 @@ async function start(args) {
|
|
|
398
353
|
await new Promise(() => {});
|
|
399
354
|
}
|
|
400
355
|
|
|
401
|
-
|
|
402
|
-
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = parseArgs({
|
|
359
|
+
args: process.argv.slice(2),
|
|
360
|
+
options: {
|
|
361
|
+
skill: { type: "boolean" },
|
|
362
|
+
help: { type: "boolean", short: "h" }
|
|
363
|
+
},
|
|
364
|
+
allowPositionals: true
|
|
365
|
+
});
|
|
366
|
+
} catch (cause) {
|
|
367
|
+
process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "invalid arguments"}\n`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (parsed.values.skill) {
|
|
372
|
+
if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
|
|
403
373
|
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
404
|
-
} else if (
|
|
405
|
-
process.stdout.write("Usage: npx letmeknow-cli
|
|
374
|
+
} else if (parsed.values.help) {
|
|
375
|
+
process.stdout.write("Usage: npx letmeknow-cli <directory>\n\nServe a folder through the hosted LetMeKnow relay. The CLI does not listen on a network port. Form submissions are JSON lines on stdout.\n");
|
|
376
|
+
} else if (parsed.positionals.length !== 1) {
|
|
377
|
+
process.stderr.write("letmeknow: exactly one directory must be provided\n");
|
|
378
|
+
process.exit(1);
|
|
406
379
|
} else {
|
|
407
|
-
try { await start(
|
|
380
|
+
try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
|
|
408
381
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A live static preview with agent-readable form submissions.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"dev": "wrangler dev",
|
|
18
18
|
"deploy": "wrangler deploy",
|
|
19
19
|
"check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
20
|
-
"test": "npm run check && vitest run && node --test tests/cli.test.js
|
|
20
|
+
"test": "npm run check && vitest run && node --test tests/cli.test.js",
|
|
21
|
+
"test:browser": "node --test tests/browser.test.js"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@cloudflare/vitest-plugin": "^1.1.0",
|
|
@@ -27,5 +28,9 @@
|
|
|
27
28
|
"vitest": "^4.1.11",
|
|
28
29
|
"wrangler": "^4.126.0",
|
|
29
30
|
"ws": "^8.21.3"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"chokidar": "^5.0.0",
|
|
34
|
+
"mrmime": "^2.0.1"
|
|
30
35
|
}
|
|
31
36
|
}
|