jevway 0.2.0 → 0.4.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 +37 -2
- package/{INSTALL-FOR-AGENTS.md → SETUP.md} +94 -14
- package/dist/browser/code-boxes.js +121 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +23 -2
- package/dist/browser/snapshot.js +82 -1
- package/dist/browser/tab.js +100 -12
- package/dist/cli/dispatch.js +2 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/look-command.js +10 -3
- package/dist/cli/run-command.js +51 -3
- package/dist/daemon/server.js +22 -3
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +11 -2
- package/dist/mcp/server.js +15 -8
- package/dist/mcp/task-tools.js +24 -8
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +37 -1
- package/dist/output/manifest.js +6 -0
- package/dist/output/run-folder.js +44 -2
- package/dist/recording/describe-step.js +3 -0
- package/dist/session/session-service.js +31 -11
- package/dist/setup/skill-command.js +44 -21
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +32 -11
- package/dist/task/run-task.js +327 -36
- package/dist/task/start-task.js +49 -6
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +2 -2
- package/skill/jevway/SKILL.md +5 -2
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* inbox.mjs: a throwaway email inbox on the mail.tm REST API (no key, no dependencies, Node >= 24).
|
|
4
|
+
*
|
|
5
|
+
* Gives a coding agent a real address for a test or demo sign-up the user asked for, then pulls the verification
|
|
6
|
+
* code or confirmation link out of the email that arrives. The password lives only in the chmod-600 env file and is
|
|
7
|
+
* never printed.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node inbox.mjs create --out "$SCRATCH/inbox.env" # prints only the address
|
|
11
|
+
* node inbox.mjs wait --env "$SCRATCH/inbox.env" --from example --timeout 120
|
|
12
|
+
* # → {"id":"…","from":"…","subject":"…","code":"123456","links":[…],"text":"…"}
|
|
13
|
+
* node inbox.mjs list --env "$SCRATCH/inbox.env" # date, from, subject, id
|
|
14
|
+
* node inbox.mjs read --env "$SCRATCH/inbox.env" <id> # full message text
|
|
15
|
+
*
|
|
16
|
+
* Env file (written by `create`, the same lines jevway's own inbox files hold): ADDR=… PASS=… API=…
|
|
17
|
+
* `create` tries mail.tm, then mail.gw (same API) once if mail.tm fails. `--api https://api.mail.gw` (or
|
|
18
|
+
* MAILTM_API) uses only that provider: use it once when a site rejects the first address's domain.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from 'node:crypto'
|
|
22
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { tmpdir } from 'node:os'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { parseArgs } from 'node:util'
|
|
26
|
+
|
|
27
|
+
const MAIL_TM = 'https://api.mail.tm'
|
|
28
|
+
const MAIL_GW = 'https://api.mail.gw'
|
|
29
|
+
const POLL_INTERVAL_MS = 3000
|
|
30
|
+
|
|
31
|
+
// --- HTTP ---
|
|
32
|
+
|
|
33
|
+
/** Replaces every secret in `text` with `[secret]`, so a provider that echoes one never leaks it. */
|
|
34
|
+
const scrub = (text, secrets) => secrets.filter(Boolean).reduce((clean, secret) => clean.split(secret).join('[secret]'), text)
|
|
35
|
+
|
|
36
|
+
async function callApi(api, path, { method = 'GET', body, token, secrets = [] } = {}) {
|
|
37
|
+
const response = await fetch(`${api}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
accept: 'application/ld+json',
|
|
41
|
+
...(body && { 'content-type': 'application/json' }),
|
|
42
|
+
...(token && { authorization: `Bearer ${token}` }),
|
|
43
|
+
},
|
|
44
|
+
body: body && JSON.stringify(body),
|
|
45
|
+
})
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const detail = scrub((await response.text()).slice(0, 300), [...secrets, token])
|
|
48
|
+
throw new Error(`${method} ${api}${path} → ${response.status} ${detail}`)
|
|
49
|
+
}
|
|
50
|
+
return response.json()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collections come back as `{ 'hydra:member': [...] }` (ld+json) or a bare array (plain json). */
|
|
54
|
+
const membersOf = (collection) => (Array.isArray(collection) ? collection : collection['hydra:member'] ?? [])
|
|
55
|
+
|
|
56
|
+
const fetchToken = async ({ ADDR, PASS, API }) =>
|
|
57
|
+
(await callApi(API, '/token', { method: 'POST', body: { address: ADDR, password: PASS }, secrets: [PASS] })).token
|
|
58
|
+
|
|
59
|
+
const listMessages = async (inbox, token) => membersOf(await callApi(inbox.API, '/messages', { token, secrets: [inbox.PASS] }))
|
|
60
|
+
|
|
61
|
+
const readMessage = (inbox, token, id) => callApi(inbox.API, `/messages/${encodeURIComponent(id)}`, { token, secrets: [inbox.PASS] })
|
|
62
|
+
|
|
63
|
+
// --- Env file ---
|
|
64
|
+
|
|
65
|
+
function readEnvFile(file) {
|
|
66
|
+
if (!file) throw new Error('missing --env <file> (written by `create`)')
|
|
67
|
+
const entries = readFileSync(file, 'utf8')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.map((line) => line.match(/^(\w+)=(.*)$/))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map(([, key, value]) => [key, value.trim()])
|
|
72
|
+
const inbox = Object.fromEntries(entries)
|
|
73
|
+
if (!inbox.ADDR || !inbox.PASS) throw new Error(`${file} has no ADDR/PASS`)
|
|
74
|
+
return { API: process.env.MAILTM_API || MAIL_TM, ...inbox }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeEnvFile(file, inbox) {
|
|
78
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
79
|
+
writeFileSync(file, `ADDR=${inbox.ADDR}\nPASS=${inbox.PASS}\nAPI=${inbox.API}\n`, { mode: 0o600 })
|
|
80
|
+
chmodSync(file, 0o600) // mode is ignored when the file already exists
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Message parsing ---
|
|
84
|
+
|
|
85
|
+
const htmlOf = (message) => [message.html ?? []].flat().join('\n')
|
|
86
|
+
|
|
87
|
+
const stripHtml = (html) =>
|
|
88
|
+
html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
89
|
+
|
|
90
|
+
const bodyText = (message) => message.text || stripHtml(htmlOf(message))
|
|
91
|
+
|
|
92
|
+
/** First standalone 4–8 digit number in subject + body ("Your code is 482913" → "482913"). */
|
|
93
|
+
const extractCode = (message) =>
|
|
94
|
+
`${message.subject}\n${bodyText(message)}`.match(/(?<![\w.,/-])\d{4,8}(?!\d|[.,/-]\d)/)?.[0] ?? null
|
|
95
|
+
|
|
96
|
+
// Doctype/namespace URLs and images (logos, tracking pixels) are never the link to follow.
|
|
97
|
+
const NOT_A_LINK = /^https?:\/\/www\.w3\.org\/|\.(png|jpe?g|gif|svg|webp|ico)(\?|$)/i
|
|
98
|
+
|
|
99
|
+
/** Unique http(s) URLs a person could follow, from the text and html parts, html entities for `&` decoded. */
|
|
100
|
+
function extractLinks(message) {
|
|
101
|
+
const urls = `${message.text ?? ''}\n${htmlOf(message)}`.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []
|
|
102
|
+
return [...new Set(urls.map((url) => url.replace(/&/g, '&')))].filter((url) => !NOT_A_LINK.test(url))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const matchesFilters = (message, { from, subject }) => {
|
|
106
|
+
const sender = `${message.from?.name ?? ''} ${message.from?.address ?? ''}`.toLowerCase()
|
|
107
|
+
return (
|
|
108
|
+
(!from || sender.includes(from.toLowerCase())) &&
|
|
109
|
+
(!subject || (message.subject ?? '').toLowerCase().includes(subject.toLowerCase()))
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Commands ---
|
|
114
|
+
|
|
115
|
+
async function createOn(api) {
|
|
116
|
+
const domains = membersOf(await callApi(api, '/domains'))
|
|
117
|
+
const domain = domains.find((entry) => entry.isActive !== false)?.domain
|
|
118
|
+
if (!domain) throw new Error(`${api} offered no active domain`)
|
|
119
|
+
const inbox = {
|
|
120
|
+
ADDR: `test-${randomBytes(5).toString('hex')}@${domain}`,
|
|
121
|
+
PASS: randomBytes(18).toString('base64url'),
|
|
122
|
+
API: api,
|
|
123
|
+
}
|
|
124
|
+
await callApi(api, '/accounts', { method: 'POST', body: { address: inbox.ADDR, password: inbox.PASS }, secrets: [inbox.PASS] })
|
|
125
|
+
return inbox
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function create({ out, api }) {
|
|
129
|
+
let inbox
|
|
130
|
+
if (api) {
|
|
131
|
+
inbox = await createOn(api)
|
|
132
|
+
} else {
|
|
133
|
+
try {
|
|
134
|
+
inbox = await createOn(MAIL_TM)
|
|
135
|
+
} catch (first) {
|
|
136
|
+
console.error(`mail.tm failed (${first.message}); trying mail.gw once`)
|
|
137
|
+
inbox = await createOn(MAIL_GW)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const envFile = out || join(tmpdir(), 'jevway-inbox', `${inbox.ADDR.split('@')[0]}.env`)
|
|
141
|
+
writeEnvFile(envFile, inbox)
|
|
142
|
+
console.error(`credentials → ${envFile} (chmod 600)`)
|
|
143
|
+
console.log(inbox.ADDR)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function wait({ env, from, subject, timeout }) {
|
|
147
|
+
const inbox = readEnvFile(env)
|
|
148
|
+
const token = await fetchToken(inbox)
|
|
149
|
+
const deadline = Date.now() + Number(timeout) * 1000
|
|
150
|
+
|
|
151
|
+
for (;;) {
|
|
152
|
+
const match = (await listMessages(inbox, token)).find((message) => matchesFilters(message, { from, subject }))
|
|
153
|
+
if (match) {
|
|
154
|
+
const message = await readMessage(inbox, token, match.id)
|
|
155
|
+
const text = bodyText(message)
|
|
156
|
+
const result = { id: message.id, from: message.from?.address, subject: message.subject, code: extractCode(message), links: extractLinks(message), text }
|
|
157
|
+
console.log(JSON.stringify(result, null, 2))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const remaining = deadline - Date.now()
|
|
161
|
+
if (remaining <= 0) break
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)))
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`no matching message for ${inbox.ADDR} within ${timeout}s`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function list({ env }) {
|
|
168
|
+
const inbox = readEnvFile(env)
|
|
169
|
+
const messages = await listMessages(inbox, await fetchToken(inbox))
|
|
170
|
+
if (messages.length === 0) return console.log(`(empty) ${inbox.ADDR}`)
|
|
171
|
+
for (const message of messages) {
|
|
172
|
+
console.log([message.createdAt, message.from?.address, message.subject, message.id].join(' '))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function read({ env }, id) {
|
|
177
|
+
if (!id) throw new Error('usage: read --env <file> <id>')
|
|
178
|
+
const inbox = readEnvFile(env)
|
|
179
|
+
const message = await readMessage(inbox, await fetchToken(inbox), id)
|
|
180
|
+
console.log(`From: ${message.from?.address}\nSubject: ${message.subject}\nDate: ${message.createdAt}\n\n${bodyText(message)}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- CLI ---
|
|
184
|
+
|
|
185
|
+
const commands = { create, wait, list, read }
|
|
186
|
+
|
|
187
|
+
const { values: options, positionals } = parseArgs({
|
|
188
|
+
allowPositionals: true,
|
|
189
|
+
options: {
|
|
190
|
+
out: { type: 'string' },
|
|
191
|
+
env: { type: 'string' },
|
|
192
|
+
api: { type: 'string', default: process.env.MAILTM_API },
|
|
193
|
+
from: { type: 'string' },
|
|
194
|
+
subject: { type: 'string' },
|
|
195
|
+
timeout: { type: 'string', default: '120' },
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const [commandName, ...commandArgs] = positionals
|
|
200
|
+
const command = commands[commandName]
|
|
201
|
+
if (!command) {
|
|
202
|
+
console.error('usage: inbox.mjs <create [--out f] [--api url] | wait --env f [--from s] [--subject s] [--timeout 120] | list --env f | read --env f <id>>')
|
|
203
|
+
process.exit(2)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
command({ ...options, api: options.api?.replace(/\/$/, '') }, ...commandArgs).catch((error) => {
|
|
207
|
+
console.error(`inbox.mjs ${commandName}: ${error.message}`)
|
|
208
|
+
process.exit(1)
|
|
209
|
+
})
|