viveworker 0.1.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 +178 -0
- package/launchd/io.viveworker.app.plist.example +39 -0
- package/ntfy/docker-compose.yml.example +10 -0
- package/ntfy/server.yml.example +28 -0
- package/package.json +24 -0
- package/scripts/lib/markdown-render.mjs +274 -0
- package/scripts/lib/pairing.mjs +83 -0
- package/scripts/viveworker-bridge.mjs +8892 -0
- package/scripts/viveworker.mjs +1353 -0
- package/viveworker.env.example +99 -0
- package/web/app.css +2303 -0
- package/web/app.js +3867 -0
- package/web/i18n.js +937 -0
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-beacon-v.svg +19 -0
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-check.svg +19 -0
- package/web/icons/viveworker-v-pulse.svg +24 -0
- package/web/index.html +17 -0
- package/web/manifest.webmanifest +22 -0
- package/web/sw.js +153 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# viveworker
|
|
2
|
+
|
|
3
|
+
`viveworker` brings Codex Desktop to your iPhone.
|
|
4
|
+
|
|
5
|
+
When Codex needs an approval, asks whether to implement a plan, wants you to choose from options, or finishes a task while you are away from your desk, `viveworker` keeps all of that within reach on your phone. Instead of breaking your rhythm, it helps you keep vivecoding going from anywhere in your home or office.
|
|
6
|
+
|
|
7
|
+
Think of it as a local companion for Codex on your Mac:
|
|
8
|
+
your Mac keeps building, and your iPhone keeps you in the loop.
|
|
9
|
+
|
|
10
|
+
## Why It Feels Good
|
|
11
|
+
|
|
12
|
+
With `viveworker`, you can:
|
|
13
|
+
|
|
14
|
+
- approve or reject actions the moment Codex asks
|
|
15
|
+
- respond to `Implement this plan?` without walking back to your desk
|
|
16
|
+
- answer multiple-choice questions quickly from your phone
|
|
17
|
+
- review completions and jump back into the latest thread
|
|
18
|
+
- get a Home Screen notification when Codex needs you
|
|
19
|
+
|
|
20
|
+
The point is simple:
|
|
21
|
+
keep Codex moving, keep context close, and keep your momentum.
|
|
22
|
+
|
|
23
|
+
## Best Fit
|
|
24
|
+
|
|
25
|
+
`viveworker` works best with:
|
|
26
|
+
|
|
27
|
+
- Mac + iPhone
|
|
28
|
+
- the same Wi-Fi or LAN
|
|
29
|
+
- a trusted local network
|
|
30
|
+
- the Home Screen web app with Web Push enabled
|
|
31
|
+
|
|
32
|
+
It gets even more fun with a Mac mini.
|
|
33
|
+
Leave Codex running on a small always-on machine, and `viveworker` starts to feel like a local coding appliance: your Mac mini keeps building in the background while your iPhone handles approvals, plan checks, questions, and follow-up replies from anywhere in your home or office.
|
|
34
|
+
|
|
35
|
+
`viveworker` is designed for local use only.
|
|
36
|
+
It is not intended for Internet exposure.
|
|
37
|
+
|
|
38
|
+
## Mac mini Ideas
|
|
39
|
+
|
|
40
|
+
`viveworker` pairs especially well with a Mac mini.
|
|
41
|
+
|
|
42
|
+
You can use it as:
|
|
43
|
+
|
|
44
|
+
- an always-on Codex station that stays running in the background
|
|
45
|
+
- a way to keep approvals and plan checks moving even when you are away from your desk
|
|
46
|
+
- a lightweight monitor for long-running coding or research tasks, where your iPhone only surfaces what needs your attention
|
|
47
|
+
- a small local AI appliance for your home or office
|
|
48
|
+
- a quick way to review a completion and send “do this next” back into the latest thread from your phone
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
For the full experience, start here:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx viveworker setup --install-mkcert
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If `mkcert` is already installed and trusted on your Mac, plain setup is enough:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx viveworker setup
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
By default, `viveworker` uses port `8810`.
|
|
65
|
+
If that port is already in use, choose another one:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx viveworker setup --port 8820
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Recommended Setup Path
|
|
72
|
+
|
|
73
|
+
`viveworker` enables Web Push by default. The recommended first-time flow is:
|
|
74
|
+
|
|
75
|
+
1. Run `npx viveworker setup --install-mkcert` on your Mac
|
|
76
|
+
2. If macOS asks, allow the local CA install
|
|
77
|
+
3. On your iPhone, open the printed `rootCA.pem` URL
|
|
78
|
+
4. Install the certificate profile and trust it in iPhone certificate trust settings
|
|
79
|
+
5. Open the printed pairing URL in Safari
|
|
80
|
+
6. Pair your iPhone with the code if needed
|
|
81
|
+
7. Add `viveworker` to your Home Screen
|
|
82
|
+
8. Open the Home Screen app
|
|
83
|
+
9. In `Settings`, tap `Enable Notifications`
|
|
84
|
+
10. Tap `Send Test Notification` to verify delivery
|
|
85
|
+
|
|
86
|
+
During setup, `viveworker` prints:
|
|
87
|
+
|
|
88
|
+
- a `.local` URL
|
|
89
|
+
- a fallback IP-based URL
|
|
90
|
+
- a `rootCA.pem` download URL
|
|
91
|
+
- a short-lived pairing code
|
|
92
|
+
- a pairing URL
|
|
93
|
+
- a pairing QR code
|
|
94
|
+
|
|
95
|
+
After setup:
|
|
96
|
+
|
|
97
|
+
- use the Home Screen app for daily use
|
|
98
|
+
- use the pairing URL only for first-time setup or when you intentionally add another device
|
|
99
|
+
- keep using the Home Screen app if you want notifications to work reliably
|
|
100
|
+
|
|
101
|
+
## Common Commands
|
|
102
|
+
|
|
103
|
+
Use these commands most often:
|
|
104
|
+
|
|
105
|
+
- `npx viveworker setup`
|
|
106
|
+
create or refresh the local setup, generate pairing info, and start the app
|
|
107
|
+
- `npx viveworker start`
|
|
108
|
+
start `viveworker` again using the saved config
|
|
109
|
+
- `npx viveworker stop`
|
|
110
|
+
stop the local background service
|
|
111
|
+
- `npx viveworker status`
|
|
112
|
+
show the current app URL, launchd/background status, and health
|
|
113
|
+
- `npx viveworker doctor`
|
|
114
|
+
diagnose local setup problems when something is not working
|
|
115
|
+
- `npx viveworker setup --pair`
|
|
116
|
+
generate a fresh one-time pairing code and pairing URL for adding another device
|
|
117
|
+
|
|
118
|
+
Useful options:
|
|
119
|
+
|
|
120
|
+
- `--port <n>` if `8810` is already in use
|
|
121
|
+
- `--install-mkcert` to automate the local certificate setup
|
|
122
|
+
- `--disable-web-push` only if you intentionally do not want notifications
|
|
123
|
+
|
|
124
|
+
`--pair` reissues only the short-lived pairing code and pairing URL.
|
|
125
|
+
It does not change the main app URL, port, session secret, TLS, or Web Push settings.
|
|
126
|
+
Use it only when you want to add another trusted iPhone or browser.
|
|
127
|
+
|
|
128
|
+
## Questions and Limits
|
|
129
|
+
|
|
130
|
+
- Multiple-choice questions are handled as a single item
|
|
131
|
+
- Up to 5 questions are shown per page
|
|
132
|
+
- 6 or more questions are split across multiple pages
|
|
133
|
+
- Answers are submitted together on the final page
|
|
134
|
+
- Questions that include `Other` or free text must be answered on your Mac
|
|
135
|
+
|
|
136
|
+
## Security Model
|
|
137
|
+
|
|
138
|
+
- use `viveworker` only on a trusted LAN
|
|
139
|
+
- do not expose it directly to the Internet
|
|
140
|
+
- if you lose a paired device, revoke it from `Settings > Devices`
|
|
141
|
+
- use `setup --pair` only when you want to add another trusted device
|
|
142
|
+
|
|
143
|
+
## Optional `ntfy`
|
|
144
|
+
|
|
145
|
+
`ntfy` is optional.
|
|
146
|
+
|
|
147
|
+
Start with `viveworker` and Web Push first.
|
|
148
|
+
If you later want a second wake-up notification path, you can add `ntfy` alongside it.
|
|
149
|
+
|
|
150
|
+
## Troubleshooting
|
|
151
|
+
|
|
152
|
+
- If the `.local` URL does not open, use the printed IP-based URL
|
|
153
|
+
- If pairing has expired, run `npx viveworker setup --pair`
|
|
154
|
+
- If notifications do not appear, make sure you opened the Home Screen app, not just a Safari tab
|
|
155
|
+
- If Web Push is enabled, make sure you are opening the HTTPS URL
|
|
156
|
+
- If you are stuck, run:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npx viveworker status
|
|
160
|
+
npx viveworker doctor
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Notes
|
|
164
|
+
|
|
165
|
+
- `viveworker` stays local and runs on your Mac on the same LAN
|
|
166
|
+
- Web Push still depends on the browser/platform push service
|
|
167
|
+
- `--install-mkcert` can automate the Mac-side `mkcert` install and `mkcert -install`
|
|
168
|
+
- macOS may still show an administrator prompt while installing the local CA
|
|
169
|
+
- iPhone trust is still manual: you need to trust the local CA profile on the device
|
|
170
|
+
- Web Push supports approvals, plans, multiple-choice questions, and completions
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
Planned next steps include:
|
|
175
|
+
|
|
176
|
+
- Android support
|
|
177
|
+
- Windows support
|
|
178
|
+
- image attachment support from mobile
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<!--
|
|
6
|
+
This file is only for manual launchd setups.
|
|
7
|
+
Most users should run `npx viveworker setup`, which generates the real LaunchAgent automatically.
|
|
8
|
+
-->
|
|
9
|
+
<key>Label</key>
|
|
10
|
+
<string>io.viveworker.app</string>
|
|
11
|
+
|
|
12
|
+
<key>ProgramArguments</key>
|
|
13
|
+
<array>
|
|
14
|
+
<string>/ABSOLUTE/PATH/TO/node</string>
|
|
15
|
+
<string>/ABSOLUTE/PATH/TO/viveworker/scripts/viveworker-bridge.mjs</string>
|
|
16
|
+
</array>
|
|
17
|
+
|
|
18
|
+
<key>WorkingDirectory</key>
|
|
19
|
+
<string>/ABSOLUTE/PATH/TO/viveworker</string>
|
|
20
|
+
|
|
21
|
+
<key>EnvironmentVariables</key>
|
|
22
|
+
<dict>
|
|
23
|
+
<key>VIVEWORKER_ENV_FILE</key>
|
|
24
|
+
<string>/ABSOLUTE/PATH/TO/.viveworker/config.env</string>
|
|
25
|
+
</dict>
|
|
26
|
+
|
|
27
|
+
<key>KeepAlive</key>
|
|
28
|
+
<true/>
|
|
29
|
+
|
|
30
|
+
<key>RunAtLoad</key>
|
|
31
|
+
<true/>
|
|
32
|
+
|
|
33
|
+
<key>StandardOutPath</key>
|
|
34
|
+
<string>/ABSOLUTE/PATH/TO/.viveworker/logs/viveworker.log</string>
|
|
35
|
+
|
|
36
|
+
<key>StandardErrorPath</key>
|
|
37
|
+
<string>/ABSOLUTE/PATH/TO/.viveworker/logs/viveworker.log</string>
|
|
38
|
+
</dict>
|
|
39
|
+
</plist>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Trusted-LAN ntfy setup for viveworker notifications.
|
|
2
|
+
# Use HTTP like this only on a Wi-Fi/LAN you trust.
|
|
3
|
+
# If you want remote access or stronger transport security, put ntfy behind HTTPS.
|
|
4
|
+
|
|
5
|
+
# This URL must exactly match the Default Server you configure in the mobile app.
|
|
6
|
+
base-url: "http://YOUR-MAC-IP-OR-HOSTNAME:8080"
|
|
7
|
+
|
|
8
|
+
# The container listens on port 80 internally; Docker maps 8080 -> 80.
|
|
9
|
+
listen-http: ":80"
|
|
10
|
+
|
|
11
|
+
# Persistent cache is required so iPhone can fetch message contents after a poll request.
|
|
12
|
+
cache-file: "/var/lib/ntfy/cache.db"
|
|
13
|
+
|
|
14
|
+
# User, ACL, and token database. Created automatically if it does not exist.
|
|
15
|
+
auth-file: "/var/lib/ntfy/auth.db"
|
|
16
|
+
auth-default-access: "deny-all"
|
|
17
|
+
|
|
18
|
+
# Login is handy for debugging, but self-signup should stay off on a private instance.
|
|
19
|
+
enable-login: true
|
|
20
|
+
enable-signup: false
|
|
21
|
+
|
|
22
|
+
# Needed for near-instant iPhone delivery on a self-hosted server.
|
|
23
|
+
# Only a poll request is forwarded upstream, not your real message body.
|
|
24
|
+
upstream-base-url: "https://ntfy.sh"
|
|
25
|
+
|
|
26
|
+
# Production-safe defaults: do not log message bodies.
|
|
27
|
+
log-level: "info"
|
|
28
|
+
log-format: "json"
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "viveworker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A local iPhone companion for Codex Desktop approvals, plans, questions, and completions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"viveworker": "./scripts/viveworker.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"scripts/viveworker.mjs",
|
|
11
|
+
"scripts/viveworker-bridge.mjs",
|
|
12
|
+
"scripts/lib",
|
|
13
|
+
"web",
|
|
14
|
+
"README.md",
|
|
15
|
+
"viveworker.env.example",
|
|
16
|
+
"launchd/io.viveworker.app.plist.example",
|
|
17
|
+
"ntfy/server.yml.example",
|
|
18
|
+
"ntfy/docker-compose.yml.example"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"qrcode-terminal": "^0.12.0",
|
|
22
|
+
"web-push": "^3.6.7"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
function escapeHtml(value) {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replace(/&/gu, "&")
|
|
4
|
+
.replace(/</gu, "<")
|
|
5
|
+
.replace(/>/gu, ">")
|
|
6
|
+
.replace(/"/gu, """)
|
|
7
|
+
.replace(/'/gu, "'");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stripPresentationEnvelopeTags(markdown) {
|
|
11
|
+
return String(markdown ?? "")
|
|
12
|
+
.replace(/^\s*<\/?proposed_plan>\s*$/gimu, "")
|
|
13
|
+
.replace(/<\/?proposed_plan>/giu, "")
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeHref(value) {
|
|
18
|
+
const text = String(value ?? "").trim();
|
|
19
|
+
if (!text) {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
if (/^https?:\/\//iu.test(text) || /^mailto:/iu.test(text) || /^codex:/iu.test(text)) {
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
if (/^\/(?:approvals|native-approvals|completion-details)\b/u.test(text)) {
|
|
26
|
+
return text;
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseAutoLink(text, start) {
|
|
32
|
+
const segment = text.slice(start);
|
|
33
|
+
const match = segment.match(/^(https?:\/\/[^\s<>"']+|mailto:[^\s<>"']+|codex:[^\s<>"']+)/iu);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let href = match[0];
|
|
39
|
+
while (/[),.;!?]$/u.test(href)) {
|
|
40
|
+
href = href.slice(0, -1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sanitized = sanitizeHref(href);
|
|
44
|
+
if (!sanitized) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
href: sanitized,
|
|
50
|
+
end: start + href.length,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderInline(text) {
|
|
55
|
+
let html = "";
|
|
56
|
+
let index = 0;
|
|
57
|
+
|
|
58
|
+
while (index < text.length) {
|
|
59
|
+
if (text.startsWith("**", index)) {
|
|
60
|
+
const end = text.indexOf("**", index + 2);
|
|
61
|
+
if (end > index + 2) {
|
|
62
|
+
html += `<strong>${renderInline(text.slice(index + 2, end))}</strong>`;
|
|
63
|
+
index = end + 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (text[index] === "`") {
|
|
69
|
+
const end = text.indexOf("`", index + 1);
|
|
70
|
+
if (end > index + 1) {
|
|
71
|
+
html += `<code>${escapeHtml(text.slice(index + 1, end))}</code>`;
|
|
72
|
+
index = end + 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (text[index] === "[") {
|
|
78
|
+
const link = parseMarkdownLink(text, index);
|
|
79
|
+
if (link) {
|
|
80
|
+
const labelHtml = renderInline(link.label);
|
|
81
|
+
const href = sanitizeHref(link.href);
|
|
82
|
+
html += href
|
|
83
|
+
? `<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">${labelHtml}</a>`
|
|
84
|
+
: `${labelHtml} <code>${escapeHtml(link.href)}</code>`;
|
|
85
|
+
index = link.end;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const autoLink = parseAutoLink(text, index);
|
|
91
|
+
if (autoLink) {
|
|
92
|
+
html += `<a href="${escapeHtml(autoLink.href)}" target="_blank" rel="noreferrer">${escapeHtml(autoLink.href)}</a>`;
|
|
93
|
+
index = autoLink.end;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (text[index] === "*") {
|
|
98
|
+
const end = text.indexOf("*", index + 1);
|
|
99
|
+
if (end > index + 1) {
|
|
100
|
+
const content = text.slice(index + 1, end).trim();
|
|
101
|
+
if (content) {
|
|
102
|
+
html += `<em>${renderInline(content)}</em>`;
|
|
103
|
+
index = end + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
html += escapeHtml(text[index]);
|
|
110
|
+
index += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return html;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseMarkdownLink(text, start) {
|
|
117
|
+
const closeLabel = text.indexOf("]", start + 1);
|
|
118
|
+
if (closeLabel === -1 || text[closeLabel + 1] !== "(") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let depth = 1;
|
|
123
|
+
let index = closeLabel + 2;
|
|
124
|
+
while (index < text.length) {
|
|
125
|
+
if (text[index] === "(") {
|
|
126
|
+
depth += 1;
|
|
127
|
+
} else if (text[index] === ")") {
|
|
128
|
+
depth -= 1;
|
|
129
|
+
if (depth === 0) {
|
|
130
|
+
return {
|
|
131
|
+
label: text.slice(start + 1, closeLabel),
|
|
132
|
+
href: text.slice(closeLabel + 2, index),
|
|
133
|
+
end: index + 1,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isUnorderedList(line) {
|
|
144
|
+
return /^[-*+]\s+/u.test(line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isOrderedList(line) {
|
|
148
|
+
return /^\d+\.\s+/u.test(line);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isFence(line) {
|
|
152
|
+
return /^```/u.test(line);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderList(lines, ordered) {
|
|
156
|
+
const tag = ordered ? "ol" : "ul";
|
|
157
|
+
const items = lines
|
|
158
|
+
.map((line) => line.replace(ordered ? /^\d+\.\s+/u : /^[-*+]\s+/u, ""))
|
|
159
|
+
.map((line) => `<li>${renderInline(line.trim())}</li>`)
|
|
160
|
+
.join("");
|
|
161
|
+
return `<${tag}>${items}</${tag}>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderBlockquote(lines) {
|
|
165
|
+
const inner = renderMarkdownHtml(lines.map((line) => line.replace(/^>\s?/u, "")).join("\n"), {
|
|
166
|
+
fallbackHtml: "<p></p>",
|
|
167
|
+
});
|
|
168
|
+
return `<blockquote>${inner}</blockquote>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderCodeBlock(lines) {
|
|
172
|
+
const [fence, ...rest] = lines;
|
|
173
|
+
const language = fence.replace(/^```/u, "").trim();
|
|
174
|
+
const className = language ? ` class="language-${escapeHtml(language)}"` : "";
|
|
175
|
+
return `<pre><code${className}>${escapeHtml(rest.join("\n"))}</code></pre>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderParagraph(lines) {
|
|
179
|
+
return `<p>${lines.map((line) => renderInline(line.trim())).join("<br>")}</p>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseBlocks(markdown) {
|
|
183
|
+
const lines = markdown.replace(/\r\n/gu, "\n").trim().split("\n");
|
|
184
|
+
const blocks = [];
|
|
185
|
+
let index = 0;
|
|
186
|
+
|
|
187
|
+
while (index < lines.length) {
|
|
188
|
+
const line = lines[index];
|
|
189
|
+
if (!line.trim()) {
|
|
190
|
+
index += 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (isFence(line)) {
|
|
195
|
+
const block = [line];
|
|
196
|
+
index += 1;
|
|
197
|
+
while (index < lines.length) {
|
|
198
|
+
block.push(lines[index]);
|
|
199
|
+
if (isFence(lines[index])) {
|
|
200
|
+
index += 1;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
index += 1;
|
|
204
|
+
}
|
|
205
|
+
blocks.push(renderCodeBlock(block));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/u);
|
|
210
|
+
if (heading) {
|
|
211
|
+
const level = heading[1].length;
|
|
212
|
+
blocks.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`);
|
|
213
|
+
index += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (/^(?:---|\*\*\*|___)\s*$/u.test(line)) {
|
|
218
|
+
blocks.push("<hr>");
|
|
219
|
+
index += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (/^>\s?/u.test(line)) {
|
|
224
|
+
const block = [];
|
|
225
|
+
while (index < lines.length && /^>\s?/u.test(lines[index])) {
|
|
226
|
+
block.push(lines[index]);
|
|
227
|
+
index += 1;
|
|
228
|
+
}
|
|
229
|
+
blocks.push(renderBlockquote(block));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isUnorderedList(line) || isOrderedList(line)) {
|
|
234
|
+
const ordered = isOrderedList(line);
|
|
235
|
+
const block = [];
|
|
236
|
+
while (index < lines.length && (ordered ? isOrderedList(lines[index]) : isUnorderedList(lines[index]))) {
|
|
237
|
+
block.push(lines[index]);
|
|
238
|
+
index += 1;
|
|
239
|
+
}
|
|
240
|
+
blocks.push(renderList(block, ordered));
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const block = [];
|
|
245
|
+
while (index < lines.length) {
|
|
246
|
+
const current = lines[index];
|
|
247
|
+
if (!current.trim() || isFence(current) || /^(#{1,6})\s+/u.test(current) || /^>\s?/u.test(current)) {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (isUnorderedList(current) || isOrderedList(current) || /^(?:---|\*\*\*|___)\s*$/u.test(current)) {
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
block.push(current);
|
|
254
|
+
index += 1;
|
|
255
|
+
}
|
|
256
|
+
blocks.push(renderParagraph(block));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return blocks;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function renderMarkdownHtml(markdown, { fallbackHtml = "<p></p>" } = {}) {
|
|
263
|
+
const normalized = stripPresentationEnvelopeTags(markdown);
|
|
264
|
+
if (!normalized) {
|
|
265
|
+
return fallbackHtml;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const blocks = parseBlocks(normalized);
|
|
269
|
+
if (blocks.length === 0) {
|
|
270
|
+
return fallbackHtml;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return blocks.join("\n");
|
|
274
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const PAIRING_TTL_MS = 15 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
const PAIRING_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
6
|
+
|
|
7
|
+
export function isPairingExpired(expiresAtMs, now = Date.now()) {
|
|
8
|
+
const normalized = Number(expiresAtMs) || 0;
|
|
9
|
+
return normalized > 0 && now >= normalized;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function shouldRotatePairing({ force = false, pairingCode = "", pairingToken = "", pairingExpiresAtMs = 0 } = {}, now = Date.now()) {
|
|
13
|
+
return Boolean(force || !pairingCode || !pairingToken || isPairingExpired(pairingExpiresAtMs, now));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generatePairingCode(length = 8) {
|
|
17
|
+
const bytes = crypto.randomBytes(length);
|
|
18
|
+
let output = "";
|
|
19
|
+
for (let index = 0; index < length; index += 1) {
|
|
20
|
+
output += PAIRING_ALPHABET[bytes[index] % PAIRING_ALPHABET.length];
|
|
21
|
+
}
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function generatePairingCredentials(now = Date.now()) {
|
|
26
|
+
return {
|
|
27
|
+
pairingCode: generatePairingCode(8),
|
|
28
|
+
pairingToken: crypto.randomBytes(18).toString("hex"),
|
|
29
|
+
pairingExpiresAtMs: now + PAIRING_TTL_MS,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function upsertEnvText(rawText, updates) {
|
|
34
|
+
const text = String(rawText || "");
|
|
35
|
+
const entries = Object.entries(updates || {}).filter(([key]) => key);
|
|
36
|
+
if (entries.length === 0) {
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const updateMap = new Map(entries.map(([key, value]) => [String(key), String(value ?? "")]));
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const output = [];
|
|
43
|
+
|
|
44
|
+
for (const rawLine of text.split(/\r?\n/u)) {
|
|
45
|
+
const line = String(rawLine);
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
48
|
+
output.push(line);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const separator = line.indexOf("=");
|
|
53
|
+
if (separator === -1) {
|
|
54
|
+
output.push(line);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const key = line.slice(0, separator).trim();
|
|
59
|
+
if (!updateMap.has(key)) {
|
|
60
|
+
output.push(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (seen.has(key)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
output.push(`${key}=${updateMap.get(key)}`);
|
|
69
|
+
seen.add(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [key, value] of updateMap.entries()) {
|
|
73
|
+
if (!seen.has(key)) {
|
|
74
|
+
output.push(`${key}=${value}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
while (output.length > 0 && output[output.length - 1] === "") {
|
|
79
|
+
output.pop();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${output.join("\n")}\n`;
|
|
83
|
+
}
|