letmeknow-cli 0.4.11 → 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 +112 -143
- package/package.json +3 -3
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,20 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
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";
|
|
6
8
|
import { parseArgs } from "node:util";
|
|
7
9
|
import chokidar from "chokidar";
|
|
8
|
-
import ignore from "ignore";
|
|
9
10
|
import { lookup } from "mrmime";
|
|
10
11
|
|
|
11
12
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
12
13
|
const GRACE_SECONDS = 10 * 60;
|
|
13
14
|
const CONNECTION_TIMEOUT = 10_000;
|
|
14
15
|
const MAX_RETRY_DELAY = 5_000;
|
|
16
|
+
const REVISION_QUIET_MS = 200;
|
|
17
|
+
const CONTROL_URL = "https://letmeknow.dev";
|
|
15
18
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
16
|
-
const
|
|
17
|
-
const privateFilePattern =
|
|
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;
|
|
18
21
|
|
|
19
22
|
function getMimeType(filename) {
|
|
20
23
|
const type = lookup(filename);
|
|
@@ -23,92 +26,6 @@ function getMimeType(filename) {
|
|
|
23
26
|
? `${type}; charset=utf-8`
|
|
24
27
|
: type;
|
|
25
28
|
}
|
|
26
|
-
const client = String.raw`
|
|
27
|
-
const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
|
|
28
|
-
const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
|
|
29
|
-
const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
|
|
30
|
-
const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
|
|
31
|
-
let credential;
|
|
32
|
-
try{credential=sessionStorage.getItem(credentialKey)}catch{}
|
|
33
|
-
let socket;
|
|
34
|
-
let retryTimer;
|
|
35
|
-
let updateTimer;
|
|
36
|
-
let reloadTimer;
|
|
37
|
-
let terminal=false;
|
|
38
|
-
const controls=()=>[...document.querySelectorAll("button,input,select,textarea")];
|
|
39
|
-
const details=()=>[...document.querySelectorAll("details")];
|
|
40
|
-
const uniqueId=(element,all)=>element.id&&all.filter(candidate=>candidate.id===element.id).length===1?element.id:null;
|
|
41
|
-
const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribute("action")||"document";
|
|
42
|
-
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};
|
|
43
|
-
const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
|
|
44
|
-
const pageIdentity=()=>location.pathname+location.search+location.hash;
|
|
45
|
-
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,options)=>option.selected?[option.value,options.slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length]:null).filter(Boolean):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}};
|
|
46
|
-
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
47
|
-
const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
|
|
48
|
-
const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
|
|
49
|
-
const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
|
|
50
|
-
const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
|
|
51
|
-
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)){const selectedIndexes=new Set(state.selected.filter(Number.isInteger));const selectedValues=new Set(state.selected.filter(Array.isArray).map(entry=>entry.join("\u0000")));for(const [optionIndex,option] of [...control.options].entries()){const occurrence=[...control.options].slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length;option.selected=selectedIndexes.has(optionIndex)||selectedValues.has([option.value,occurrence].join("\u0000"))}}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)};
|
|
52
|
-
const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
|
|
53
|
-
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};
|
|
54
|
-
const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
|
|
55
|
-
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)};
|
|
56
|
-
const pendingUpdates=new Set();
|
|
57
|
-
const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
|
|
58
|
-
let saveTimer;
|
|
59
|
-
const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
|
|
60
|
-
addEventListener("input",scheduleSave,true);
|
|
61
|
-
addEventListener("change",scheduleSave,true);
|
|
62
|
-
addEventListener("toggle",scheduleSave,true);
|
|
63
|
-
addEventListener("focusin",scheduleSave,true);
|
|
64
|
-
addEventListener("selectionchange",scheduleSave,true);
|
|
65
|
-
addEventListener("scroll",scheduleSave,{passive:true});
|
|
66
|
-
addEventListener("pagehide",save);
|
|
67
|
-
addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
|
|
68
|
-
document.addEventListener("reset",()=>setTimeout(save));
|
|
69
|
-
const connect=()=>{
|
|
70
|
-
clearTimeout(retryTimer);retryTimer=undefined;
|
|
71
|
-
const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
72
|
-
const current=credential?new WebSocket(url,credential):new WebSocket(url);
|
|
73
|
-
socket=current;
|
|
74
|
-
current.onmessage=event=>{
|
|
75
|
-
if(socket!==current||typeof event.data!=="string")return;
|
|
76
|
-
let message;try{message=JSON.parse(event.data)}catch{return}
|
|
77
|
-
if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
|
|
78
|
-
if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
|
|
79
|
-
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
80
|
-
if(message.type==="file_update"){update(message.path);return}
|
|
81
|
-
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
|
|
82
|
-
};
|
|
83
|
-
current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
84
|
-
current.onerror=()=>{};
|
|
85
|
-
};
|
|
86
|
-
document.addEventListener("submit",async event=>{
|
|
87
|
-
const form=event.target;
|
|
88
|
-
if(!(form instanceof HTMLFormElement))return;
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
const submitter=event.submitter;
|
|
91
|
-
const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
|
|
92
|
-
if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
|
|
93
|
-
if(!form.checkValidity()){form.reportValidity();return}
|
|
94
|
-
let target;
|
|
95
|
-
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}
|
|
96
|
-
if(target.origin!==location.origin){status("Form actions must stay on this site");return}
|
|
97
|
-
if(sessionBase!=="/"){
|
|
98
|
-
const sessionPath=sessionBase.slice(0,-1);
|
|
99
|
-
if(target.pathname===sessionPath)target.pathname=sessionBase;
|
|
100
|
-
else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
|
|
101
|
-
}
|
|
102
|
-
const values=new URLSearchParams();
|
|
103
|
-
for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
|
|
104
|
-
const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
|
|
105
|
-
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")}};
|
|
106
|
-
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??"")};
|
|
107
|
-
if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
|
|
108
|
-
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")}
|
|
109
|
-
});
|
|
110
|
-
connect();
|
|
111
|
-
`;
|
|
112
29
|
|
|
113
30
|
function header(packet, name) {
|
|
114
31
|
const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
|
|
@@ -139,8 +56,7 @@ function errorResponse(packet, status, message) {
|
|
|
139
56
|
}
|
|
140
57
|
|
|
141
58
|
function deniedPath(pathname) {
|
|
142
|
-
|
|
143
|
-
return normalized !== "" && (ig.ignores(normalized) || pathname.split("/").filter(Boolean).some(part => ig.ignores(part) || privateFilePattern.test(part)));
|
|
59
|
+
return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
|
|
144
60
|
}
|
|
145
61
|
|
|
146
62
|
function inside(root, target) {
|
|
@@ -175,13 +91,6 @@ function requestUrl(packet) {
|
|
|
175
91
|
return { pathname, encodedPathname: url.pathname, search: url.search };
|
|
176
92
|
}
|
|
177
93
|
|
|
178
|
-
function htmlWithClient(body) {
|
|
179
|
-
const text = body.toString("utf8");
|
|
180
|
-
const script = `<script type="module" data-letmeknow-client>${client}</script>`;
|
|
181
|
-
const closingBody = text.search(/<\/body\s*>/i);
|
|
182
|
-
return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
94
|
async function staticResponse(root, packet) {
|
|
186
95
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
187
96
|
if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
|
|
@@ -202,8 +111,8 @@ async function staticResponse(root, packet) {
|
|
|
202
111
|
return errorResponse(packet, 500, "preview request failed");
|
|
203
112
|
}
|
|
204
113
|
if (info.isDirectory()) {
|
|
205
|
-
if (!request.
|
|
206
|
-
const location = request.encodedPathname + "/" + request.search;
|
|
114
|
+
if (!request.encodedPathname.endsWith("/")) {
|
|
115
|
+
const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
|
|
207
116
|
return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
|
|
208
117
|
}
|
|
209
118
|
const index = resolve(target, "index.html");
|
|
@@ -213,31 +122,73 @@ async function staticResponse(root, packet) {
|
|
|
213
122
|
}
|
|
214
123
|
if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
|
|
215
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
|
+
}
|
|
216
130
|
} else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
|
|
217
|
-
let
|
|
218
|
-
try {
|
|
131
|
+
let file;
|
|
132
|
+
try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
|
|
219
133
|
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
|
|
220
|
-
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");
|
|
135
|
+
return errorResponse(packet, 500, "preview request failed");
|
|
136
|
+
}
|
|
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 {
|
|
221
145
|
return errorResponse(packet, 500, "preview request failed");
|
|
146
|
+
} finally {
|
|
147
|
+
await file.close();
|
|
222
148
|
}
|
|
223
|
-
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
224
|
-
if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
|
|
225
|
-
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
226
|
-
return response(packet, 200, body, { "Content-Type": getMimeType(target) });
|
|
227
149
|
}
|
|
228
150
|
|
|
229
|
-
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) {
|
|
230
174
|
const url = requestUrl(packet);
|
|
231
175
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
232
176
|
const values = Object.create(null);
|
|
177
|
+
let attachments;
|
|
233
178
|
if (method === "GET") {
|
|
234
179
|
for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
|
|
235
180
|
} else if (method === "POST") {
|
|
236
181
|
const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
|
|
237
182
|
if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
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");
|
|
241
192
|
} else throw new Error("unsupported submission method");
|
|
242
193
|
const event = {
|
|
243
194
|
type: "submit",
|
|
@@ -248,35 +199,33 @@ async function submission(packet) {
|
|
|
248
199
|
trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
|
|
249
200
|
values
|
|
250
201
|
};
|
|
202
|
+
if (attachments?.length) event.attachments = attachments;
|
|
251
203
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
252
|
-
return response(packet,
|
|
204
|
+
return response(packet, 202);
|
|
253
205
|
}
|
|
254
206
|
|
|
255
|
-
async function handleRequest(root, packet) {
|
|
207
|
+
async function handleRequest(root, packet, getAttachmentInbox) {
|
|
256
208
|
if (header(packet, "x-letmeknow-submission") === "1") {
|
|
257
|
-
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"); }
|
|
258
210
|
}
|
|
259
211
|
return staticResponse(root, packet);
|
|
260
212
|
}
|
|
261
213
|
|
|
262
214
|
function options(directory) {
|
|
263
|
-
const root = resolve(directory
|
|
215
|
+
const root = resolve(directory);
|
|
264
216
|
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
265
217
|
return realpath(root).then(root => ({ root }));
|
|
266
218
|
}
|
|
267
219
|
|
|
268
|
-
function endpoint(
|
|
269
|
-
const url = new URL(
|
|
270
|
-
url.protocol =
|
|
271
|
-
url.pathname = "/
|
|
272
|
-
url.search = "";
|
|
273
|
-
url.hash = "";
|
|
220
|
+
function endpoint(credential, sessionUrl) {
|
|
221
|
+
const url = new URL(CONTROL_URL);
|
|
222
|
+
url.protocol = "wss:";
|
|
223
|
+
url.pathname = "/v2/connect";
|
|
274
224
|
if (credential && sessionUrl) {
|
|
275
225
|
const publicUrl = new URL(sessionUrl);
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
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);
|
|
280
229
|
}
|
|
281
230
|
return url;
|
|
282
231
|
}
|
|
@@ -285,22 +234,37 @@ function validSessionUrl(value) {
|
|
|
285
234
|
if (typeof value !== "string") return false;
|
|
286
235
|
let url;
|
|
287
236
|
try { url = new URL(value); } catch { return false; }
|
|
288
|
-
|
|
289
|
-
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
290
|
-
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;
|
|
291
238
|
}
|
|
292
239
|
|
|
293
240
|
async function start(directory) {
|
|
294
241
|
const { root } = await options(directory);
|
|
295
|
-
|
|
242
|
+
let attachmentInboxPromise;
|
|
243
|
+
const getAttachmentInbox = () => {
|
|
244
|
+
attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
|
|
245
|
+
return attachmentInboxPromise;
|
|
246
|
+
};
|
|
296
247
|
let send = () => false;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (!filename) { send({ type: "file_update", path: "/" }); return; }
|
|
248
|
+
let revisionTimer;
|
|
249
|
+
const watchedPath = filename => {
|
|
300
250
|
const file = resolve(root, String(filename));
|
|
301
251
|
const path = relative(root, file).split(sep).join("/");
|
|
302
|
-
|
|
303
|
-
|
|
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();
|
|
304
268
|
});
|
|
305
269
|
let socket;
|
|
306
270
|
let credential;
|
|
@@ -316,8 +280,13 @@ async function start(directory) {
|
|
|
316
280
|
stopped = true;
|
|
317
281
|
clearTimeout(retryTimer);
|
|
318
282
|
clearTimeout(connectionTimer);
|
|
283
|
+
clearTimeout(revisionTimer);
|
|
284
|
+
send({ type: "close" });
|
|
319
285
|
try { socket?.close(); } catch {}
|
|
320
286
|
await watcher.close();
|
|
287
|
+
if (attachmentInboxPromise) {
|
|
288
|
+
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
289
|
+
}
|
|
321
290
|
process.exit(code);
|
|
322
291
|
};
|
|
323
292
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -332,11 +301,11 @@ async function start(directory) {
|
|
|
332
301
|
const connect = () => {
|
|
333
302
|
if (stopped) return;
|
|
334
303
|
const reconnecting = Boolean(credential && sessionUrl);
|
|
335
|
-
const current = socket = reconnecting ? new WebSocket(endpoint(
|
|
304
|
+
const current = socket = reconnecting ? new WebSocket(endpoint(credential, sessionUrl), credential) : new WebSocket(endpoint());
|
|
336
305
|
connectionTimer = setTimeout(() => {
|
|
337
306
|
if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
|
|
338
307
|
try { current.close(); } catch {}
|
|
339
|
-
if (reconnecting)
|
|
308
|
+
if (!reconnecting) void stop(1);
|
|
340
309
|
}, CONNECTION_TIMEOUT);
|
|
341
310
|
current.addEventListener("open", () => {
|
|
342
311
|
if (socket !== current || stopped) return;
|
|
@@ -361,7 +330,7 @@ async function start(directory) {
|
|
|
361
330
|
sessionUrl = packet.url;
|
|
362
331
|
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
|
|
363
332
|
} else if (packet.type === "http_request") {
|
|
364
|
-
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")));
|
|
365
334
|
} else if (packet.type === "closed") {
|
|
366
335
|
void stop(0);
|
|
367
336
|
} else if (packet.type === "error") {
|
|
@@ -403,9 +372,9 @@ if (parsed.values.skill) {
|
|
|
403
372
|
if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
|
|
404
373
|
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
405
374
|
} else if (parsed.values.help) {
|
|
406
|
-
process.stdout.write("Usage: npx letmeknow-cli
|
|
407
|
-
} else if (parsed.positionals.length
|
|
408
|
-
process.stderr.write("letmeknow:
|
|
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");
|
|
409
378
|
process.exit(1);
|
|
410
379
|
} else {
|
|
411
380
|
try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
|
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",
|
|
@@ -30,7 +31,6 @@
|
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"chokidar": "^5.0.0",
|
|
33
|
-
"ignore": "^7.0.6",
|
|
34
34
|
"mrmime": "^2.0.1"
|
|
35
35
|
}
|
|
36
36
|
}
|