@shbernal/buzon 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +151 -2
- package/bin/buzon +2 -0
- package/package.json +47 -4
- package/skills/README.md +15 -0
- package/skills/buzon/SKILL.md +79 -0
- package/src/bin.ts +3 -0
- package/src/cli.ts +149 -0
- package/src/commands/addresses.ts +319 -0
- package/src/commands/context.ts +13 -0
- package/src/commands/deploy.ts +188 -0
- package/src/commands/init.ts +81 -0
- package/src/commands/mail.ts +203 -0
- package/src/core/admin.ts +38 -0
- package/src/core/api.ts +101 -0
- package/src/core/args.ts +57 -0
- package/src/core/config.ts +84 -0
- package/src/core/credentials.ts +19 -0
- package/src/core/errors.ts +19 -0
- package/src/core/extract.ts +173 -0
- package/src/core/files.ts +40 -0
- package/src/core/help.ts +35 -0
- package/src/core/mail.ts +80 -0
- package/src/core/names.ts +77 -0
- package/src/core/paths.ts +43 -0
- package/src/core/prompt.ts +16 -0
- package/src/core/registry.ts +28 -0
- package/src/core/secrets.ts +62 -0
- package/src/core/time.ts +16 -0
- package/src/core/upstream.ts +96 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const ENTITIES: Record<string, string> = {
|
|
2
|
+
amp: '&',
|
|
3
|
+
lt: '<',
|
|
4
|
+
gt: '>',
|
|
5
|
+
quot: '"',
|
|
6
|
+
apos: "'",
|
|
7
|
+
nbsp: ' ',
|
|
8
|
+
zwnj: '',
|
|
9
|
+
shy: '',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function decodeEntities(text: string): string {
|
|
13
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity: string) => {
|
|
14
|
+
const name = entity.toLowerCase()
|
|
15
|
+
if (!name.startsWith('#')) return ENTITIES[name] ?? match
|
|
16
|
+
const code = name.startsWith('#x') ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1))
|
|
17
|
+
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
|
|
18
|
+
? String.fromCodePoint(code)
|
|
19
|
+
: match
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ANCHOR = /<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi
|
|
24
|
+
const HREF = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
|
|
25
|
+
|
|
26
|
+
/** Readable text from an HTML part, with each link's target after its text. */
|
|
27
|
+
export function htmlToText(html: string): string {
|
|
28
|
+
return decodeEntities(
|
|
29
|
+
html
|
|
30
|
+
.replace(/<\s*(script|style|head|svg|title)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
|
|
31
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
32
|
+
.replace(ANCHOR, (_, attributes: string, inner: string) => {
|
|
33
|
+
const href = anchorHref(attributes)
|
|
34
|
+
const text = inner.replace(/<[^>]+>/g, ' ').trim()
|
|
35
|
+
return href && href !== decodeEntities(text) ? ` ${text} (${href}) ` : ` ${text} `
|
|
36
|
+
})
|
|
37
|
+
.replace(/<\s*br\s*\/?>/gi, '\n')
|
|
38
|
+
.replace(/<\/\s*(p|div|tr|li|table|section|article|header|footer|h[1-6])\s*>/gi, '\n')
|
|
39
|
+
.replace(/<[^>]+>/g, ' '),
|
|
40
|
+
)
|
|
41
|
+
.replace(/[ \t\r\f\v ]+/g, ' ')
|
|
42
|
+
.replace(/ ?\n ?/g, '\n')
|
|
43
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
44
|
+
.trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function anchorHref(attributes: string): string | undefined {
|
|
48
|
+
const match = HREF.exec(attributes)
|
|
49
|
+
const raw = match?.[1] ?? match?.[2] ?? match?.[3]
|
|
50
|
+
return raw === undefined ? undefined : decodeEntities(raw.trim())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Link = { url: string; text: string }
|
|
54
|
+
|
|
55
|
+
export function htmlLinks(html: string): Link[] {
|
|
56
|
+
const links: Link[] = []
|
|
57
|
+
for (const [, attributes, inner] of html.matchAll(ANCHOR)) {
|
|
58
|
+
const url = anchorHref(attributes!)
|
|
59
|
+
if (url) links.push({ url, text: htmlToText(inner!) })
|
|
60
|
+
}
|
|
61
|
+
return links
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bare URLs, without the punctuation that usually closes the sentence around them. The rest
|
|
66
|
+
* of the URL's line is its text, or the line above when the URL stands alone.
|
|
67
|
+
*/
|
|
68
|
+
export function textLinks(text: string): Link[] {
|
|
69
|
+
const links: Link[] = []
|
|
70
|
+
const lines = text.split(/\r?\n/)
|
|
71
|
+
lines.forEach((line, index) => {
|
|
72
|
+
for (const [url] of line.matchAll(/https?:\/\/[^\s<>"'\]]+/gi)) {
|
|
73
|
+
const label = line.replace(url, ' ').trim() || (lines[index - 1] ?? '').trim()
|
|
74
|
+
links.push({ url: url.replace(/[.,;:!?)]+$/, ''), text: label })
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
return links
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const UNWANTED =
|
|
81
|
+
/unsubscribe|opt[-_ ]?out|preferences|manage[-_ ]?(subscription|e?mail)|list-manage/i
|
|
82
|
+
const ASSET = /\.(png|jpe?g|gif|svg|webp|ico|css|js|woff2?|ttf)([?#]|$)/i
|
|
83
|
+
const TRACKING = /\/(open|pixel|beacon)(\.gif)?([/?#]|$)/i
|
|
84
|
+
const LINK_KEYWORDS =
|
|
85
|
+
/verif|confirm|activat|token|magic|log-?in|sign-?in|sign-?up|auth|validat|reset/gi
|
|
86
|
+
|
|
87
|
+
const score = (link: Link) =>
|
|
88
|
+
(link.text.match(LINK_KEYWORDS)?.length ?? 0) * 2 + (link.url.match(LINK_KEYWORDS)?.length ?? 0)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The link that most looks like a verification link: HTML anchors first, else URLs in the text
|
|
92
|
+
* part. Unsubscribe, tracking and asset links never count, and neither does a link with no
|
|
93
|
+
* keyword in its text or URL, so a mail without one is no match.
|
|
94
|
+
*/
|
|
95
|
+
export function bestLink(mail: { html: string; text: string }): string | undefined {
|
|
96
|
+
const fromHtml = mail.html ? htmlLinks(mail.html) : []
|
|
97
|
+
const candidates = (fromHtml.length > 0 ? fromHtml : textLinks(mail.text)).filter(
|
|
98
|
+
(link) =>
|
|
99
|
+
/^https?:\/\//i.test(link.url) &&
|
|
100
|
+
!UNWANTED.test(link.url) &&
|
|
101
|
+
!UNWANTED.test(link.text) &&
|
|
102
|
+
!ASSET.test(link.url) &&
|
|
103
|
+
!TRACKING.test(link.url),
|
|
104
|
+
)
|
|
105
|
+
let best: { url: string; score: number } | undefined
|
|
106
|
+
for (const link of candidates) {
|
|
107
|
+
const value = score(link)
|
|
108
|
+
if (value > 0 && (!best || value > best.score)) best = { url: link.url, score: value }
|
|
109
|
+
}
|
|
110
|
+
return best?.url
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The instance's own extraction, saved in the row's `metadata` shortly after the mail lands. */
|
|
114
|
+
export function serverCode(metadata: unknown): string | undefined {
|
|
115
|
+
if (typeof metadata !== 'string' || !metadata) return undefined
|
|
116
|
+
try {
|
|
117
|
+
const { ai_extract } = JSON.parse(metadata) as {
|
|
118
|
+
ai_extract?: { type?: string; result?: string }
|
|
119
|
+
}
|
|
120
|
+
return ai_extract?.type === 'auth_code' && ai_extract.result ? ai_extract.result : undefined
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const CODE_KEYWORD =
|
|
127
|
+
/(?<!(promo|coupon|discount|gift|referral|invite|zip|postal|country|area|error|status|tracking|order|reference|voucher)[\s-]?)\bcodes?\b|verification|\bverify\b|\botp\b|passcode|\bpin\b|one[\s-]?time|验证码|c[óo]digo|codice|код/giu
|
|
128
|
+
const DIGIT_CODE = /(?<![\w+\-/.,:$€£#])(\d{3}[ -]\d{3}|\d{4,8})(?![\w/%]|[.,:]\d)/g
|
|
129
|
+
const ALNUM_CODE =
|
|
130
|
+
/(?<![\w\-+/#])((?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{6,8})(?![\w\-/])/g
|
|
131
|
+
/** How far from a keyword a code may sit, in characters. */
|
|
132
|
+
const NEAR = 120
|
|
133
|
+
|
|
134
|
+
function isDate(digits: string): boolean {
|
|
135
|
+
const year = Number(digits.slice(0, 4))
|
|
136
|
+
if (year < 1900 || year > 2099) return false
|
|
137
|
+
if (digits.length === 4) return true
|
|
138
|
+
const month = Number(digits.slice(4, 6))
|
|
139
|
+
const day = Number(digits.slice(6, 8))
|
|
140
|
+
return digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A code near a verification keyword: 4 to 8 digits, or 6 to 8 letters and digits with at
|
|
145
|
+
* least one of each. URLs and addresses are dropped first, since their digits are never codes.
|
|
146
|
+
* The candidate closest to a keyword wins, digits on a tie.
|
|
147
|
+
*/
|
|
148
|
+
export function localCode(subject: string, body: string): string | undefined {
|
|
149
|
+
const text = `${subject}\n\n${body}`
|
|
150
|
+
.replace(/[-]/g, '')
|
|
151
|
+
.replace(/\bhttps?:\/\/[^\s<>"']+/gi, ' ')
|
|
152
|
+
.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, ' ')
|
|
153
|
+
const keywords = [...text.matchAll(CODE_KEYWORD)].map((match) => match.index)
|
|
154
|
+
if (keywords.length === 0) return undefined
|
|
155
|
+
|
|
156
|
+
let best: { code: string; distance: number } | undefined
|
|
157
|
+
for (const [pattern, digits] of [
|
|
158
|
+
[DIGIT_CODE, true],
|
|
159
|
+
[ALNUM_CODE, false],
|
|
160
|
+
] as const) {
|
|
161
|
+
for (const match of text.matchAll(pattern)) {
|
|
162
|
+
const code = match[1]!.replace(/[ -]/g, '')
|
|
163
|
+
if (digits && isDate(code)) continue
|
|
164
|
+
const start = match.index
|
|
165
|
+
const end = start + match[0].length
|
|
166
|
+
const distance = Math.min(
|
|
167
|
+
...keywords.map((at) => (at < start ? start - at : Math.max(0, at - end))),
|
|
168
|
+
)
|
|
169
|
+
if (distance <= NEAR && (!best || distance < best.distance)) best = { code, distance }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return best?.code
|
|
173
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** The parsed file, or `undefined` when it does not exist. A damaged file throws. */
|
|
5
|
+
export function readJson(file: string): unknown {
|
|
6
|
+
let text: string
|
|
7
|
+
try {
|
|
8
|
+
text = readFileSync(file, 'utf8')
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
11
|
+
throw error
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text) as unknown
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error(`${file} is not valid JSON`, { cause: error })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Written beside the target and renamed over it, so no reader ever sees half a file. The
|
|
22
|
+
* temp file gets its mode before any content, so a secret is never readable by others.
|
|
23
|
+
*/
|
|
24
|
+
export function writeAtomic(file: string, content: string, mode = 0o644): void {
|
|
25
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
26
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(temp, '', { mode })
|
|
29
|
+
chmodSync(temp, mode)
|
|
30
|
+
writeFileSync(temp, content)
|
|
31
|
+
renameSync(temp, file)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
rmSync(temp, { force: true })
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeJson(file: string, value: unknown, mode?: number): void {
|
|
39
|
+
writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, mode)
|
|
40
|
+
}
|
package/src/core/help.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const HELP = `buzon: tiered addresses on a self-hosted cloudflare_temp_email instance
|
|
2
|
+
|
|
3
|
+
usage: buzon <command> [options]
|
|
4
|
+
|
|
5
|
+
commands:
|
|
6
|
+
init write the instance URL and mail domain, then check both
|
|
7
|
+
--url <https://mail.example.com> --domain <example.com>
|
|
8
|
+
new create an address: new <ste|mte|lte> | new custom <name>
|
|
9
|
+
[--site <site>] [--note "…"] [--domain <example.com>] [--show-token]
|
|
10
|
+
list registry addresses: [--tier <ste|mte|lte|custom>] [--site <site>]
|
|
11
|
+
show registry entry and inbox count: show <address>
|
|
12
|
+
note set or clear the registry note: note <address> "<note>"
|
|
13
|
+
delete delete on the instance and locally: delete <address> [--force]
|
|
14
|
+
(lte and custom addresses need --force)
|
|
15
|
+
reissue fresh credentials from the admin API: reissue <address> | --all
|
|
16
|
+
adopt add an address made elsewhere: adopt <address> [--note "…"]
|
|
17
|
+
read print mail: read <address> [--latest | --id <n> | --all] [--raw]
|
|
18
|
+
wait wait for new mail, print its code, link or whole text:
|
|
19
|
+
wait <address> [--code | --link] [--from <regex>] [--subject <regex>]
|
|
20
|
+
[--timeout 300] [--since now|<duration>|<date>]
|
|
21
|
+
(exits 1 on timeout; --since 10m also takes mail that came before)
|
|
22
|
+
delete-mail delete one mail: delete-mail <address> <id>
|
|
23
|
+
deploy build and deploy the pinned upstream release with the local wrangler.toml:
|
|
24
|
+
[--tag vX.Y.Z] [--dry-run] (secrets stay as they are on the Worker)
|
|
25
|
+
upgrade move to a newer release, asking before the backup, each db patch and the
|
|
26
|
+
deploy: upgrade --to vX.Y.Z
|
|
27
|
+
export-db dump the D1 database: [--out <dir>] (keeps the last 12)
|
|
28
|
+
|
|
29
|
+
tiers: ste is one-shot, mte lives for a process, lte is tied to a real account.
|
|
30
|
+
An <address> without @ gets the configured domain.
|
|
31
|
+
|
|
32
|
+
options:
|
|
33
|
+
--json machine-readable output
|
|
34
|
+
--help show this help
|
|
35
|
+
--version show the version`
|
package/src/core/mail.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Api } from './api.ts'
|
|
2
|
+
import { htmlToText } from './extract.ts'
|
|
3
|
+
import { serverDate } from './time.ts'
|
|
4
|
+
|
|
5
|
+
/** A row of `/api/parsed_mails`: the stored mail with its MIME parsed by the instance. */
|
|
6
|
+
export type ParsedMail = {
|
|
7
|
+
id: number
|
|
8
|
+
/** Envelope sender. `sender` is the `From` header. */
|
|
9
|
+
source: string
|
|
10
|
+
address: string
|
|
11
|
+
created_at: string
|
|
12
|
+
metadata: string | null
|
|
13
|
+
sender: string
|
|
14
|
+
subject: string
|
|
15
|
+
text: string
|
|
16
|
+
html: string
|
|
17
|
+
attachments: { filename: string; mimeType: string; size: number }[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Mail = {
|
|
21
|
+
id: number
|
|
22
|
+
from: string
|
|
23
|
+
to: string
|
|
24
|
+
subject: string
|
|
25
|
+
date: string
|
|
26
|
+
/** The text part, or the HTML part as text when there is none. */
|
|
27
|
+
text: string
|
|
28
|
+
html: string
|
|
29
|
+
attachments: ParsedMail['attachments']
|
|
30
|
+
metadata: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toMail(row: ParsedMail): Mail {
|
|
34
|
+
return {
|
|
35
|
+
id: row.id,
|
|
36
|
+
from: row.sender || row.source,
|
|
37
|
+
to: row.address,
|
|
38
|
+
subject: row.subject,
|
|
39
|
+
date: serverDate(row.created_at) ?? row.created_at,
|
|
40
|
+
text: row.text.trim() || htmlToText(row.html),
|
|
41
|
+
html: row.html,
|
|
42
|
+
attachments: row.attachments,
|
|
43
|
+
metadata: row.metadata,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The largest page the instance serves. */
|
|
48
|
+
const LIMIT = 100
|
|
49
|
+
|
|
50
|
+
/** Newest first. */
|
|
51
|
+
export async function listMails(api: Api, jwt: string, limit = LIMIT): Promise<Mail[]> {
|
|
52
|
+
const out: Mail[] = []
|
|
53
|
+
for (let offset = 0; ; offset += LIMIT) {
|
|
54
|
+
const page = Math.min(LIMIT, limit - out.length)
|
|
55
|
+
const { results } = await api.request<{ results: ParsedMail[] }>(
|
|
56
|
+
`/api/parsed_mails?limit=${page}&offset=${offset}`,
|
|
57
|
+
{ jwt },
|
|
58
|
+
)
|
|
59
|
+
out.push(...results.map(toMail))
|
|
60
|
+
if (results.length < page || out.length >= limit) return out
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getMail(api: Api, jwt: string, id: number): Promise<Mail | undefined> {
|
|
65
|
+
const row = await api.request<ParsedMail | null>(`/api/parsed_mail/${id}`, { jwt })
|
|
66
|
+
return row ? toMail(row) : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getRawMail(api: Api, jwt: string, id: number): Promise<string | undefined> {
|
|
70
|
+
const row = await api.request<{ raw?: string } | null>(`/api/mail/${id}`, { jwt })
|
|
71
|
+
return row?.raw
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function deleteMail(api: Api, jwt: string, id: number): Promise<boolean> {
|
|
75
|
+
const { success } = await api.request<{ success: boolean }>(`/api/mails/${id}`, {
|
|
76
|
+
method: 'DELETE',
|
|
77
|
+
jwt,
|
|
78
|
+
})
|
|
79
|
+
return success
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { UsageError } from './args.ts'
|
|
2
|
+
|
|
3
|
+
/** `ste` is one-shot, `mte` lives for a process, `lte` is tied to a real account. */
|
|
4
|
+
export const TIERS = ['ste', 'mte', 'lte'] as const
|
|
5
|
+
export type Tier = (typeof TIERS)[number]
|
|
6
|
+
|
|
7
|
+
/** The instance's `ADDRESS_CHECK_REGEX`, and `MAX_ADDRESS_LEN`. */
|
|
8
|
+
export const NAME_REGEX = /^(ste|mte|lte)-[0-9]{4}(-[a-z0-9]{1,12})?-[a-z0-9]{3}$/
|
|
9
|
+
export const MAX_NAME_LENGTH = 30
|
|
10
|
+
|
|
11
|
+
const SITE = /^[a-z0-9]{1,12}$/
|
|
12
|
+
const CUSTOM = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/
|
|
13
|
+
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
14
|
+
|
|
15
|
+
export function parseTier(raw: string | undefined): Tier {
|
|
16
|
+
if (!TIERS.includes(raw as Tier)) throw new UsageError(`tier must be one of: ${TIERS.join(', ')}`)
|
|
17
|
+
return raw as Tier
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseSite(tier: Tier | 'custom', raw: string | undefined): string | undefined {
|
|
21
|
+
if (raw === undefined) return undefined
|
|
22
|
+
if (tier === 'ste') throw new UsageError('ste addresses are one-shot and take no --site')
|
|
23
|
+
const site = raw.toLowerCase()
|
|
24
|
+
if (!SITE.test(site)) throw new UsageError(`--site must be 1 to 12 of [a-z0-9], not "${raw}"`)
|
|
25
|
+
return site
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Rejection sampling, so every character is equally likely. */
|
|
29
|
+
export function randomChars(count: number): string {
|
|
30
|
+
let out = ''
|
|
31
|
+
while (out.length < count) {
|
|
32
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(count))) {
|
|
33
|
+
if (byte < 252 && out.length < count) out += ALPHABET[byte % ALPHABET.length]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `{tier}-{YYMM}[-{site}]-{rand3}`, with the UTC month. */
|
|
40
|
+
export function generateName(
|
|
41
|
+
tier: Tier,
|
|
42
|
+
site: string | undefined,
|
|
43
|
+
now = new Date(),
|
|
44
|
+
random = randomChars,
|
|
45
|
+
): string {
|
|
46
|
+
const yymm = now.toISOString().slice(2, 7).replace('-', '')
|
|
47
|
+
const name = [tier, yymm, site, random(3)].filter(Boolean).join('-')
|
|
48
|
+
if (!NAME_REGEX.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
49
|
+
throw new Error(`generated "${name}", which the instance would reject`)
|
|
50
|
+
}
|
|
51
|
+
return name
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Tier and site of a name that follows the scheme, or `undefined` for a custom one. */
|
|
55
|
+
export function parseName(name: string): { tier: Tier; site?: string } | undefined {
|
|
56
|
+
const match = NAME_REGEX.exec(name)
|
|
57
|
+
if (!match) return undefined
|
|
58
|
+
const site = match[2]?.slice(1)
|
|
59
|
+
return site ? { tier: match[1] as Tier, site } : { tier: match[1] as Tier }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A name outside the scheme, checked here because the instance silently strips characters
|
|
64
|
+
* outside `ADDRESS_REGEX`. A tier prefix is refused: the cleanup matches on it.
|
|
65
|
+
*/
|
|
66
|
+
export function parseCustomName(raw: string): string {
|
|
67
|
+
const name = raw.trim().toLowerCase()
|
|
68
|
+
if (!CUSTOM.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
69
|
+
throw new UsageError(
|
|
70
|
+
`a custom name is 1 to ${MAX_NAME_LENGTH} of [a-z0-9.-], starting and ending with a letter or digit, not "${raw}"`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (/^(ste|mte|lte)-/.test(name)) {
|
|
74
|
+
throw new UsageError(`"${raw}" starts with a tier prefix: use \`buzon new <tier>\` instead`)
|
|
75
|
+
}
|
|
76
|
+
return name
|
|
77
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
type Kind = 'config' | 'data' | 'state' | 'cache'
|
|
5
|
+
|
|
6
|
+
const XDG: Record<Kind, [variable: string, fallback: string]> = {
|
|
7
|
+
config: ['XDG_CONFIG_HOME', '.config'],
|
|
8
|
+
data: ['XDG_DATA_HOME', join('.local', 'share')],
|
|
9
|
+
state: ['XDG_STATE_HOME', join('.local', 'state')],
|
|
10
|
+
cache: ['XDG_CACHE_HOME', '.cache'],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The XDG spec ignores a relative value, so a relative variable falls back like an unset one. */
|
|
14
|
+
function dir(kind: Kind): string {
|
|
15
|
+
const [variable, fallback] = XDG[kind]
|
|
16
|
+
const value = process.env[variable]
|
|
17
|
+
const base = value?.startsWith('/') ? value : join(homedir(), fallback)
|
|
18
|
+
return join(base, 'buzon')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const paths = {
|
|
22
|
+
get configDir() {
|
|
23
|
+
return dir('config')
|
|
24
|
+
},
|
|
25
|
+
get config() {
|
|
26
|
+
return join(dir('config'), 'config.toml')
|
|
27
|
+
},
|
|
28
|
+
get wrangler() {
|
|
29
|
+
return join(dir('config'), 'wrangler.toml')
|
|
30
|
+
},
|
|
31
|
+
get registry() {
|
|
32
|
+
return join(dir('data'), 'registry.json')
|
|
33
|
+
},
|
|
34
|
+
get backups() {
|
|
35
|
+
return join(dir('data'), 'backups')
|
|
36
|
+
},
|
|
37
|
+
get credentials() {
|
|
38
|
+
return join(dir('state'), 'credentials.json')
|
|
39
|
+
},
|
|
40
|
+
upstream(tag: string) {
|
|
41
|
+
return join(dir('cache'), 'upstream', tag)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
|
+
|
|
3
|
+
export type Ask = (question: string, fallback?: string) => Promise<string>
|
|
4
|
+
|
|
5
|
+
/** One interface for every question: a second one would lose input the first had buffered. */
|
|
6
|
+
export async function withPrompt<T>(use: (ask: Ask) => Promise<T>): Promise<T> {
|
|
7
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
8
|
+
try {
|
|
9
|
+
return await use(async (question, fallback) => {
|
|
10
|
+
const hint = fallback ? ` [${fallback}]` : ''
|
|
11
|
+
return (await rl.question(`${question}${hint}: `)).trim() || fallback || ''
|
|
12
|
+
})
|
|
13
|
+
} finally {
|
|
14
|
+
rl.close()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { paths } from './paths.ts'
|
|
2
|
+
import { readJson, writeJson } from './files.ts'
|
|
3
|
+
import type { Tier } from './names.ts'
|
|
4
|
+
|
|
5
|
+
/** `custom` is an address named outside the scheme and adopted later. It is kept like `lte`. */
|
|
6
|
+
export type RegistryTier = Tier | 'custom'
|
|
7
|
+
|
|
8
|
+
/** What an address is for. Kept in the user's dotfiles, so it never holds a secret. */
|
|
9
|
+
export type RegistryEntry = {
|
|
10
|
+
tier: RegistryTier
|
|
11
|
+
site?: string
|
|
12
|
+
note?: string
|
|
13
|
+
createdAt: string
|
|
14
|
+
addressId: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Keyed by full address. */
|
|
18
|
+
export type Registry = {
|
|
19
|
+
addresses: Record<string, RegistryEntry>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readRegistry(file = paths.registry): Registry {
|
|
23
|
+
return (readJson(file) as Registry | undefined) ?? { addresses: {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeRegistry(registry: Registry, file = paths.registry): void {
|
|
27
|
+
writeJson(file, registry)
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type SecretKey = 'site-password' | 'admin-password'
|
|
2
|
+
|
|
3
|
+
const ENV: Record<SecretKey, string> = {
|
|
4
|
+
'site-password': 'BUZON_SITE_PASSWORD',
|
|
5
|
+
'admin-password': 'BUZON_ADMIN_PASSWORD',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Keyring {
|
|
9
|
+
name: string
|
|
10
|
+
lookup: (key: SecretKey) => string[]
|
|
11
|
+
store: (key: SecretKey) => string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const KEYRINGS: Partial<Record<NodeJS.Platform, Keyring>> = {
|
|
15
|
+
linux: {
|
|
16
|
+
name: 'secret-tool',
|
|
17
|
+
lookup: (key) => ['secret-tool', 'lookup', 'service', 'buzon', 'key', key],
|
|
18
|
+
store: (key) => `secret-tool store --label="buzon ${key}" service buzon key ${key}`,
|
|
19
|
+
},
|
|
20
|
+
darwin: {
|
|
21
|
+
name: 'the macOS Keychain',
|
|
22
|
+
lookup: (key) => ['security', 'find-generic-password', '-s', 'buzon', '-a', key, '-w'],
|
|
23
|
+
store: (key) => `security add-generic-password -s buzon -a ${key} -w`,
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Run = (argv: string[]) => { success: boolean; stdout: string }
|
|
28
|
+
|
|
29
|
+
const run: Run = (argv) => {
|
|
30
|
+
const result = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe' })
|
|
31
|
+
return { success: result.success, stdout: result.stdout?.toString() ?? '' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment variable, else the platform keyring: the Secret Service through `secret-tool`
|
|
36
|
+
* on Linux, the Keychain through `security` on macOS. Other platforms use the variable only.
|
|
37
|
+
*/
|
|
38
|
+
export function secret(
|
|
39
|
+
key: SecretKey,
|
|
40
|
+
{ env = process.env, platform = process.platform, spawn = run } = {},
|
|
41
|
+
): string {
|
|
42
|
+
const fromEnv = env[ENV[key]]
|
|
43
|
+
if (fromEnv) return fromEnv
|
|
44
|
+
|
|
45
|
+
const keyring = KEYRINGS[platform]
|
|
46
|
+
if (!keyring) throw new Error(`no ${key}: set ${ENV[key]}`)
|
|
47
|
+
|
|
48
|
+
const store = keyring.store(key)
|
|
49
|
+
let result: ReturnType<Run>
|
|
50
|
+
try {
|
|
51
|
+
result = spawn(keyring.lookup(key))
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`no ${key}: set ${ENV[key]}, or store it in ${keyring.name}: \`${store}\``, {
|
|
54
|
+
cause: error,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const value = result.stdout.replace(/\n$/, '')
|
|
58
|
+
if (!result.success || value === '') {
|
|
59
|
+
throw new Error(`no ${key} in ${keyring.name}: run \`${store}\`, or set ${ENV[key]}`)
|
|
60
|
+
}
|
|
61
|
+
return value
|
|
62
|
+
}
|
package/src/core/time.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { UsageError } from './args.ts'
|
|
2
|
+
|
|
3
|
+
/** D1 stores `YYYY-MM-DD HH:MM:SS` in UTC. */
|
|
4
|
+
export function serverDate(raw: string): string | undefined {
|
|
5
|
+
const date = new Date(`${raw.replace(' ', 'T')}Z`)
|
|
6
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
|
10
|
+
|
|
11
|
+
/** `90`, `90s`, `5m`, `2h` or `1d`, in milliseconds. A bare number is seconds. */
|
|
12
|
+
export function parseDuration(raw: string, flag: string): number {
|
|
13
|
+
const match = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(raw.trim())
|
|
14
|
+
if (!match) throw new UsageError(`--${flag} must be a duration such as 90s, 5m or 2h`)
|
|
15
|
+
return Number(match[1]) * UNITS[match[2] || 's']!
|
|
16
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { paths } from './paths.ts'
|
|
4
|
+
|
|
5
|
+
const REPO = 'https://github.com/dreamhunter2333/cloudflare_temp_email'
|
|
6
|
+
|
|
7
|
+
/** Upstream builds with pnpm, and the checkout is a git clone. */
|
|
8
|
+
export function requireTools(): void {
|
|
9
|
+
for (const tool of ['git', 'pnpm']) {
|
|
10
|
+
if (!Bun.which(tool)) throw new Error(`${tool} is not on PATH: buzon needs it to deploy`)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The child's stdout goes to stderr, so `--json` output stays alone on stdout. */
|
|
15
|
+
export async function run(cmd: string[], cwd: string): Promise<void> {
|
|
16
|
+
const child = Bun.spawn(cmd, { cwd, stdin: 'inherit', stdout: 2 as never, stderr: 'inherit' })
|
|
17
|
+
const code = await child.exited
|
|
18
|
+
if (code !== 0) throw new Error(`\`${cmd.join(' ')}\` failed (exit ${code}) in ${cwd}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function capture(cmd: string[], cwd?: string): { code: number; out: string } {
|
|
22
|
+
const result = Bun.spawnSync(cmd, { cwd, stdout: 'pipe', stderr: 'inherit' })
|
|
23
|
+
return { code: result.exitCode, out: result.stdout.toString() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A shallow clone of `tag`, made on first use. Local edits stop a deploy from shipping them. */
|
|
27
|
+
export async function checkout(tag: string): Promise<string> {
|
|
28
|
+
const dir = paths.upstream(tag)
|
|
29
|
+
if (!existsSync(dir)) {
|
|
30
|
+
await run(['git', 'clone', '--quiet', '--depth', '1', '--branch', tag, REPO, dir], '/')
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
const status = capture(['git', 'status', '--porcelain'], dir)
|
|
34
|
+
if (status.code !== 0) throw new Error(`${dir} is not a git checkout: remove it and retry`)
|
|
35
|
+
if (status.out.trim()) throw new Error(`${dir} has local changes:\n${status.out.trimEnd()}`)
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Dependencies installed and the user's `wrangler.toml` copied into the worker. */
|
|
40
|
+
export async function prepare(tag: string): Promise<string> {
|
|
41
|
+
requireTools()
|
|
42
|
+
if (!existsSync(paths.wrangler)) throw new Error(`no wrangler config at ${paths.wrangler}`)
|
|
43
|
+
const dir = await checkout(tag)
|
|
44
|
+
for (const part of ['worker', 'frontend']) {
|
|
45
|
+
await run(['pnpm', 'install', '--frozen-lockfile'], join(dir, part))
|
|
46
|
+
}
|
|
47
|
+
copyFileSync(paths.wrangler, join(dir, 'worker', 'wrangler.toml'))
|
|
48
|
+
return dir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type WranglerToml = { d1_databases?: { database_name?: string }[] }
|
|
52
|
+
|
|
53
|
+
export function databaseName(file = paths.wrangler): string {
|
|
54
|
+
const parsed = Bun.TOML.parse(readFileSync(file, 'utf8')) as WranglerToml
|
|
55
|
+
const name = parsed.d1_databases?.[0]?.database_name
|
|
56
|
+
if (!name) throw new Error(`${file} names no D1 database`)
|
|
57
|
+
return name
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `wrangler`, from the checkout's own dependencies. */
|
|
61
|
+
export function wrangler(dir: string, args: string[]): Promise<void> {
|
|
62
|
+
return run(['pnpm', 'wrangler', ...args], join(dir, 'worker'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `db/` files the newer checkout has and the older one lacks, oldest first. */
|
|
66
|
+
export function addedDbFiles(older: string, newer: string): string[] {
|
|
67
|
+
const before = new Set(readdirSync(join(older, 'db')))
|
|
68
|
+
return readdirSync(join(newer, 'db'))
|
|
69
|
+
.filter((file) => file.endsWith('.sql') && !before.has(file))
|
|
70
|
+
.toSorted()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Changelog lines that name `file`. Some patches are never named there. */
|
|
74
|
+
export function changelogMentions(dir: string, file: string): string[] {
|
|
75
|
+
const changelog = readFileSync(join(dir, 'CHANGELOG_EN.md'), 'utf8')
|
|
76
|
+
return changelog
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter((line) => line.includes(file))
|
|
79
|
+
.map((line) => line.trim())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A unified diff of the two `wrangler.toml.template` files, empty when they match. */
|
|
83
|
+
export function templateDiff(older: string, newer: string): string {
|
|
84
|
+
const file = join('worker', 'wrangler.toml.template')
|
|
85
|
+
const { code, out } = capture([
|
|
86
|
+
'git',
|
|
87
|
+
'diff',
|
|
88
|
+
'--no-index',
|
|
89
|
+
'--no-color',
|
|
90
|
+
'--',
|
|
91
|
+
join(older, file),
|
|
92
|
+
join(newer, file),
|
|
93
|
+
])
|
|
94
|
+
if (code > 1) throw new Error('could not diff the wrangler templates')
|
|
95
|
+
return out
|
|
96
|
+
}
|