@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.
@@ -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
+ }
@@ -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`
@@ -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
+ }
@@ -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
+ }