@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,319 @@
1
+ import { stringFlag, UsageError } from '../core/args.ts'
2
+ import { ApiError } from '../core/api.ts'
3
+ import {
4
+ findServerAddress,
5
+ listServerAddresses,
6
+ mintJwt,
7
+ type ServerAddress,
8
+ } from '../core/admin.ts'
9
+ import { normalizeDomain } from '../core/config.ts'
10
+ import { addressJwt, readCredentials, writeCredentials } from '../core/credentials.ts'
11
+ import { serverDate } from '../core/time.ts'
12
+ import { generateName, parseCustomName, parseName, parseSite, parseTier } from '../core/names.ts'
13
+ import { type Context, type Input, resolveAddress } from './context.ts'
14
+ import { readRegistry, writeRegistry, type Registry, type RegistryEntry } from '../core/registry.ts'
15
+
16
+ export type AddressView = RegistryEntry & { address: string }
17
+
18
+ /** Tries at a free name before giving up: three random characters rarely collide twice. */
19
+ const CREATE_ATTEMPTS = 5
20
+
21
+ const KEPT = new Set(['lte', 'custom'])
22
+
23
+ type Created = { jwt: string; address: string; address_id: number }
24
+
25
+ function view(address: string, entry: RegistryEntry): AddressView {
26
+ return { address, ...entry }
27
+ }
28
+
29
+ function registered(address: string): { registry: Registry; entry: RegistryEntry } {
30
+ const registry = readRegistry()
31
+ const entry = registry.addresses[address]
32
+ if (!entry) throw new Error(`${address} is not in the registry`)
33
+ return { registry, entry }
34
+ }
35
+
36
+ const NEW_USAGE = 'usage: buzon new <ste|mte|lte> | buzon new custom <name>'
37
+
38
+ /** A tiered name is generated and retried on collision; a custom name is taken as given. */
39
+ async function createOnInstance(
40
+ api: Context['api'],
41
+ tier: RegistryEntry['tier'],
42
+ site: string | undefined,
43
+ custom: string | undefined,
44
+ domain: string,
45
+ ): Promise<Created> {
46
+ if (tier === 'custom') {
47
+ const address = `${custom}@${domain}`
48
+ try {
49
+ return await api.request<Created>('/admin/new_address', {
50
+ body: { name: custom, domain, enablePrefix: false },
51
+ })
52
+ } catch (error) {
53
+ if (error instanceof ApiError && /already exists/i.test(error.message)) {
54
+ throw new Error(`${address} already exists: \`buzon adopt ${address}\` adds it`, {
55
+ cause: error,
56
+ })
57
+ }
58
+ throw error
59
+ }
60
+ }
61
+ for (let attempt = 1; ; attempt++) {
62
+ const name = generateName(tier, site)
63
+ try {
64
+ return await api.request<Created>('/api/new_address', { body: { name, domain } })
65
+ } catch (error) {
66
+ const taken = error instanceof ApiError && /already exists/i.test(error.message)
67
+ if (!taken || attempt >= CREATE_ATTEMPTS) throw error
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The credential is saved before the registry entry, so a failure leaves at worst a server
74
+ * address without a registry entry, and the error names it with the way to recover it.
75
+ */
76
+ export async function create(
77
+ { positionals, flags }: Input,
78
+ { config, api }: Context,
79
+ ): Promise<AddressView & { token?: string }> {
80
+ const custom = positionals[0] === 'custom'
81
+ if (positionals.length !== (custom ? 2 : 1)) throw new UsageError(NEW_USAGE)
82
+ const tier = custom ? 'custom' : parseTier(positionals[0])
83
+ const name = custom ? parseCustomName(positionals[1]!) : undefined
84
+ const site = parseSite(tier, stringFlag(flags, 'site'))
85
+ const note = stringFlag(flags, 'note')
86
+ const domainFlag = stringFlag(flags, 'domain')
87
+ const domain = domainFlag ? normalizeDomain(domainFlag) : config.domain
88
+
89
+ const registry = readRegistry()
90
+ const credentials = readCredentials()
91
+ const created = await createOnInstance(api, tier, site, name, domain)
92
+
93
+ const { address, jwt } = created
94
+ const recover = `run \`buzon adopt ${address}\` to recover it`
95
+ try {
96
+ writeCredentials({ ...credentials, [address]: jwt })
97
+ } catch (error) {
98
+ throw new Error(`created ${address}, but its credential was not saved: ${recover}`, {
99
+ cause: error,
100
+ })
101
+ }
102
+ const entry: RegistryEntry = {
103
+ tier,
104
+ ...(site && { site }),
105
+ ...(note && { note }),
106
+ createdAt: new Date().toISOString(),
107
+ addressId: created.address_id,
108
+ }
109
+ try {
110
+ writeRegistry({ addresses: { ...registry.addresses, [address]: entry } })
111
+ } catch (error) {
112
+ throw new Error(
113
+ `created ${address} and saved its credential, but not its registry entry: ${recover}`,
114
+ {
115
+ cause: error,
116
+ },
117
+ )
118
+ }
119
+ return { ...view(address, entry), ...(flags['show-token'] === true && { token: jwt }) }
120
+ }
121
+
122
+ export function createText(result: AddressView & { token?: string }): string {
123
+ return result.token ? `${result.address}\n${result.token}` : result.address
124
+ }
125
+
126
+ export function list({ flags }: Input): AddressView[] {
127
+ const tierFlag = stringFlag(flags, 'tier')
128
+ if (tierFlag && tierFlag !== 'custom') parseTier(tierFlag)
129
+ const site = stringFlag(flags, 'site')?.toLowerCase()
130
+ return Object.entries(readRegistry().addresses)
131
+ .filter(([, entry]) => (!tierFlag || entry.tier === tierFlag) && (!site || entry.site === site))
132
+ .map(([address, entry]) => view(address, entry))
133
+ .toSorted((a, b) => a.createdAt.localeCompare(b.createdAt))
134
+ }
135
+
136
+ export function listText(rows: AddressView[]): string {
137
+ if (rows.length === 0) return 'no addresses'
138
+ const width = Math.max(...rows.map((row) => row.address.length))
139
+ return rows
140
+ .map((row) => {
141
+ const columns = [row.address.padEnd(width), row.tier.padEnd(6), row.createdAt.slice(0, 10)]
142
+ if (row.note) columns.push(row.note)
143
+ return columns.join(' ')
144
+ })
145
+ .join('\n')
146
+ }
147
+
148
+ const SHOW_USAGE = 'usage: buzon show <address>'
149
+
150
+ export async function show(
151
+ { positionals }: Input,
152
+ { config, api }: Context,
153
+ ): Promise<AddressView & { mails: number }> {
154
+ const address = resolveAddress(positionals[0], config, SHOW_USAGE)
155
+ const { entry } = registered(address)
156
+ const jwt = addressJwt(address)
157
+ const { count } = await api.request<{ count: number }>('/api/mails?limit=1&offset=0', { jwt })
158
+ return { ...view(address, entry), mails: count }
159
+ }
160
+
161
+ export function showText(result: AddressView & { mails: number }): string {
162
+ const lines = [result.address, `tier ${result.tier}`]
163
+ if (result.site) lines.push(`site ${result.site}`)
164
+ if (result.note) lines.push(`note ${result.note}`)
165
+ lines.push(`created ${result.createdAt}`, `mails ${result.mails}`)
166
+ return lines.join('\n')
167
+ }
168
+
169
+ const NOTE_USAGE = 'usage: buzon note <address> "<note>" (an empty note clears it)'
170
+
171
+ export function setNote({ positionals }: Input, { config }: Pick<Context, 'config'>): AddressView {
172
+ const address = resolveAddress(positionals[0], config, NOTE_USAGE)
173
+ const text = positionals[1]
174
+ if (text === undefined || positionals.length > 2) throw new UsageError(NOTE_USAGE)
175
+ const { registry, entry } = registered(address)
176
+ const { note: _, ...rest } = entry
177
+ const updated: RegistryEntry = text.trim() ? { ...rest, note: text.trim() } : rest
178
+ writeRegistry({ addresses: { ...registry.addresses, [address]: updated } })
179
+ return view(address, updated)
180
+ }
181
+
182
+ export const setNoteText = (result: AddressView) =>
183
+ result.note ? `${result.address}: ${result.note}` : `${result.address}: note cleared`
184
+
185
+ const DELETE_USAGE = 'usage: buzon delete <address> [--force]'
186
+
187
+ export type DeleteResult = { address: string; alreadyGone: boolean }
188
+
189
+ /**
190
+ * A deleted address answers 401 to its own JWT, and so does a JWT signed with a rotated
191
+ * secret. The admin list tells the two apart before anything local is removed.
192
+ */
193
+ export async function remove(
194
+ { positionals, flags }: Input,
195
+ { config, api }: Context,
196
+ ): Promise<DeleteResult> {
197
+ const address = resolveAddress(positionals[0], config, DELETE_USAGE)
198
+ const { registry, entry } = registered(address)
199
+ if (KEPT.has(entry.tier) && flags.force !== true) {
200
+ throw new UsageError(`${address} is ${entry.tier}, which is kept: pass --force to delete it`)
201
+ }
202
+
203
+ const credentials = readCredentials()
204
+ const jwt = credentials[address]
205
+ let rejected: ApiError | undefined
206
+ if (jwt) {
207
+ try {
208
+ const { success } = await api.request<{ success: boolean }>('/api/delete_address', {
209
+ method: 'DELETE',
210
+ jwt,
211
+ })
212
+ if (!success) throw new Error(`the instance did not delete ${address}`)
213
+ } catch (error) {
214
+ if (!(error instanceof ApiError && error.status === 401)) throw error
215
+ rejected = error
216
+ }
217
+ }
218
+ const alreadyGone = !jwt || rejected !== undefined
219
+ if (alreadyGone && (await findServerAddress(api, address))) {
220
+ const why = jwt ? 'the instance rejects its credential' : 'no credential'
221
+ throw new Error(`cannot delete ${address}, ${why}: run \`buzon reissue ${address}\``, {
222
+ cause: rejected,
223
+ })
224
+ }
225
+
226
+ const { [address]: _entry, ...addresses } = registry.addresses
227
+ writeRegistry({ addresses })
228
+ const { [address]: _jwt, ...rest } = credentials
229
+ writeCredentials(rest)
230
+ return { address, alreadyGone }
231
+ }
232
+
233
+ export const removeText = (result: DeleteResult) =>
234
+ result.alreadyGone
235
+ ? `${result.address} was already gone from the instance, removed it locally`
236
+ : `deleted ${result.address}`
237
+
238
+ const REISSUE_USAGE = 'usage: buzon reissue <address> | --all'
239
+
240
+ export type ReissueResult = { reissued: string[]; missing: string[] }
241
+
242
+ /** Fresh JWTs from the admin API, for a lost credentials file or a rotated `JWT_SECRET`. */
243
+ export async function reissue(
244
+ { positionals, flags }: Input,
245
+ { config, api }: Context,
246
+ ): Promise<ReissueResult> {
247
+ const all = flags.all === true
248
+ if (all === positionals.length > 0) throw new UsageError(REISSUE_USAGE)
249
+ const registry = readRegistry()
250
+ const targets = all
251
+ ? Object.keys(registry.addresses)
252
+ : [resolveAddress(positionals[0], config, REISSUE_USAGE)]
253
+ for (const address of targets) {
254
+ if (!registry.addresses[address]) {
255
+ throw new Error(`${address} is not in the registry: \`buzon adopt ${address}\` adds it`)
256
+ }
257
+ }
258
+
259
+ const server = new Map<string, ServerAddress>()
260
+ if (all) {
261
+ for (const row of await listServerAddresses(api)) server.set(row.name, row)
262
+ } else {
263
+ const row = await findServerAddress(api, targets[0]!)
264
+ if (row) server.set(row.name, row)
265
+ }
266
+
267
+ const credentials = readCredentials()
268
+ const result: ReissueResult = { reissued: [], missing: [] }
269
+ for (const address of targets) {
270
+ const row = server.get(address)
271
+ if (!row) {
272
+ result.missing.push(address)
273
+ continue
274
+ }
275
+ credentials[address] = await mintJwt(api, row.id)
276
+ registry.addresses[address] = { ...registry.addresses[address]!, addressId: row.id }
277
+ result.reissued.push(address)
278
+ }
279
+ writeCredentials(credentials)
280
+ writeRegistry(registry)
281
+ return result
282
+ }
283
+
284
+ export function reissueText(result: ReissueResult): string {
285
+ const lines = result.reissued.map((address) => `reissued ${address}`)
286
+ for (const address of result.missing) {
287
+ lines.push(`${address} is not on the instance: \`buzon delete ${address} --force\` drops it`)
288
+ }
289
+ return lines.join('\n') || 'nothing to reissue'
290
+ }
291
+
292
+ const ADOPT_USAGE = 'usage: buzon adopt <address> [--note "…"]'
293
+
294
+ /** An address made elsewhere, such as the admin UI. A name outside the scheme is `custom`. */
295
+ export async function adopt(
296
+ { positionals, flags }: Input,
297
+ { config, api }: Context,
298
+ ): Promise<AddressView> {
299
+ const address = resolveAddress(positionals[0], config, ADOPT_USAGE)
300
+ const note = stringFlag(flags, 'note')
301
+ const registry = readRegistry()
302
+ if (registry.addresses[address]) throw new Error(`${address} is already in the registry`)
303
+ const row = await findServerAddress(api, address)
304
+ if (!row) throw new Error(`${address} is not on the instance`)
305
+ const jwt = await mintJwt(api, row.id)
306
+
307
+ const scheme = parseName(address.split('@')[0]!)
308
+ const entry: RegistryEntry = {
309
+ ...(scheme ?? { tier: 'custom' }),
310
+ ...(note && { note }),
311
+ createdAt: serverDate(row.created_at) ?? new Date().toISOString(),
312
+ addressId: row.id,
313
+ }
314
+ writeCredentials({ ...readCredentials(), [address]: jwt })
315
+ writeRegistry({ addresses: { ...registry.addresses, [address]: entry } })
316
+ return view(address, entry)
317
+ }
318
+
319
+ export const adoptText = (result: AddressView) => `adopted ${result.address} as ${result.tier}`
@@ -0,0 +1,13 @@
1
+ import { UsageError, type Parsed } from '../core/args.ts'
2
+ import type { Api } from '../core/api.ts'
3
+ import type { Config } from '../core/config.ts'
4
+
5
+ export type Context = { config: Config; api: Api }
6
+ export type Input = Pick<Parsed, 'positionals' | 'flags'>
7
+
8
+ /** A bare name gets the configured domain. */
9
+ export function resolveAddress(raw: string | undefined, config: Config, usage: string): string {
10
+ if (!raw) throw new UsageError(usage)
11
+ const address = raw.trim().toLowerCase()
12
+ return address.includes('@') ? address : `${address}@${config.domain}`
13
+ }
@@ -0,0 +1,188 @@
1
+ import { mkdirSync, readdirSync, rmSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { stringFlag, UsageError, type Parsed } from '../core/args.ts'
4
+ import { normalizeTag, writeConfig, type Config } from '../core/config.ts'
5
+ import { paths } from '../core/paths.ts'
6
+ import { withPrompt, type Ask } from '../core/prompt.ts'
7
+ import {
8
+ addedDbFiles,
9
+ changelogMentions,
10
+ checkout,
11
+ databaseName,
12
+ prepare,
13
+ requireTools,
14
+ run,
15
+ templateDiff,
16
+ wrangler,
17
+ } from '../core/upstream.ts'
18
+ import type { Context } from './context.ts'
19
+ import { verify } from './init.ts'
20
+
21
+ export type DeployResult = { tag: string; url: string; dryRun: boolean; version?: string }
22
+ export type ExportResult = { file: string; pruned: string[] }
23
+ export type UpgradeResult = {
24
+ from: string
25
+ to: string
26
+ backup?: string
27
+ applied: string[]
28
+ skipped: string[]
29
+ version: string
30
+ }
31
+
32
+ /** Backups kept by `export-db`, oldest pruned first. */
33
+ const KEEP = 12
34
+
35
+ const say = (line: string) => console.error(line)
36
+
37
+ function deployedTag(config: Config): string {
38
+ if (!config.tag) throw new UsageError('no tag in the config: run `buzon deploy --tag vX.Y.Z`')
39
+ return config.tag
40
+ }
41
+
42
+ /** The new Worker can take a few seconds to answer everywhere. */
43
+ async function awaitVersion(context: Context, tag: string): Promise<string> {
44
+ let version = ''
45
+ for (let attempt = 0; attempt < 10; attempt++) {
46
+ if (attempt > 0) await Bun.sleep(3000)
47
+ version = await verify(context.config, context.api)
48
+ if (version === tag) return version
49
+ }
50
+ throw new Error(`${context.config.url} still reports ${version} after deploying ${tag}`)
51
+ }
52
+
53
+ async function ship(context: Context, tag: string, dryRun: boolean): Promise<DeployResult> {
54
+ const dir = await prepare(tag)
55
+ await run(['pnpm', 'run', 'build:pages'], join(dir, 'frontend'))
56
+ const { url } = context.config
57
+ if (dryRun) {
58
+ await wrangler(dir, ['deploy', '--minify', '--dry-run'])
59
+ return { tag, url, dryRun }
60
+ }
61
+ await wrangler(dir, ['deploy', '--minify'])
62
+ const version = await awaitVersion(context, tag)
63
+ writeConfig({ ...context.config, tag })
64
+ return { tag, url, dryRun, version }
65
+ }
66
+
67
+ /** Secrets already live on the Worker: a deploy never sends them. */
68
+ export function deploy({ flags }: Pick<Parsed, 'flags'>, context: Context): Promise<DeployResult> {
69
+ const raw = stringFlag(flags, 'tag')
70
+ const tag = raw ? normalizeTag(raw) : deployedTag(context.config)
71
+ return ship(context, tag, flags['dry-run'] === true)
72
+ }
73
+
74
+ export function deployText(result: DeployResult): string {
75
+ if (result.dryRun) return `${result.tag} built and checked, nothing deployed`
76
+ return `${result.url} runs ${result.version}`
77
+ }
78
+
79
+ const stamp = () =>
80
+ new Date()
81
+ .toISOString()
82
+ .replace(/\.\d+Z$/, 'Z')
83
+ .replaceAll(':', '-')
84
+
85
+ /** The dump goes through `wrangler` in the checkout, so `dir` is made absolute first. */
86
+ async function exportTo(out: string, tag: string): Promise<ExportResult> {
87
+ const dir = resolve(out)
88
+ mkdirSync(dir, { recursive: true })
89
+ const checkoutDir = await prepare(tag)
90
+ const file = join(dir, `${stamp()}.sql`)
91
+ await wrangler(checkoutDir, ['d1', 'export', databaseName(), '--remote', '--output', file])
92
+ const dumps = readdirSync(dir)
93
+ .filter((name) => /^\d{4}-\d{2}-\d{2}T[\d-]+Z\.sql$/.test(name))
94
+ .toSorted()
95
+ const pruned = dumps.slice(0, Math.max(0, dumps.length - KEEP)).map((name) => join(dir, name))
96
+ for (const old of pruned) rmSync(old)
97
+ return { file, pruned }
98
+ }
99
+
100
+ export function exportDb(
101
+ { flags }: Pick<Parsed, 'flags'>,
102
+ context: Context,
103
+ ): Promise<ExportResult> {
104
+ return exportTo(stringFlag(flags, 'out') ?? paths.backups, deployedTag(context.config))
105
+ }
106
+
107
+ export function exportDbText(result: ExportResult): string {
108
+ const pruned = result.pruned.length > 0 ? `\npruned ${result.pruned.length} older dumps` : ''
109
+ return `wrote ${result.file}${pruned}`
110
+ }
111
+
112
+ type Answer = 'yes' | 'skip' | 'stop'
113
+
114
+ async function confirm(ask: Ask, question: string, skippable: boolean): Promise<Answer> {
115
+ const choices = skippable ? 'y/s/N' : 'y/N'
116
+ const answer = (await ask(`${question} (${choices})`)).toLowerCase()
117
+ if (answer === 'y' || answer === 'yes') return 'yes'
118
+ if (skippable && (answer === 's' || answer === 'skip')) return 'skip'
119
+ return 'stop'
120
+ }
121
+
122
+ function review(older: string, newer: string, patches: string[]): void {
123
+ const diff = templateDiff(older, newer)
124
+ say(diff ? `wrangler.toml.template changes:\n${diff}` : 'wrangler.toml.template is unchanged')
125
+ if (patches.length === 0) {
126
+ say('no new db/ files')
127
+ return
128
+ }
129
+ say('new db/ files:')
130
+ for (const patch of patches) {
131
+ const mentions = changelogMentions(newer, patch)
132
+ say(` ${patch}${mentions.length === 0 ? ' (not in the changelog)' : ''}`)
133
+ for (const line of mentions) say(` ${line}`)
134
+ }
135
+ }
136
+
137
+ /** Every step is asked for: nothing runs against the live database unreviewed. */
138
+ export async function upgrade(
139
+ { flags }: Pick<Parsed, 'flags'>,
140
+ context: Context,
141
+ interactive: boolean,
142
+ ): Promise<UpgradeResult> {
143
+ const raw = stringFlag(flags, 'to')
144
+ if (!raw) throw new UsageError('usage: buzon upgrade --to vX.Y.Z')
145
+ const to = normalizeTag(raw)
146
+ const from = deployedTag(context.config)
147
+ if (from === to) throw new UsageError(`the instance already runs ${to}`)
148
+ if (!interactive)
149
+ throw new UsageError('buzon upgrade asks before each step: run it on a terminal')
150
+
151
+ requireTools()
152
+ const older = await checkout(from)
153
+ const newer = await checkout(to)
154
+ const patches = addedDbFiles(older, newer)
155
+ review(older, newer, patches)
156
+
157
+ return withPrompt(async (ask) => {
158
+ const result: UpgradeResult = { from, to, applied: [], skipped: [], version: from }
159
+ if ((await confirm(ask, 'Export the database first?', false)) === 'yes') {
160
+ result.backup = (await exportTo(paths.backups, from)).file
161
+ say(`wrote ${result.backup}`)
162
+ }
163
+ const dir = await prepare(to)
164
+ for (const patch of patches) {
165
+ const answer = await confirm(ask, `Run db/${patch} against the live database?`, true)
166
+ if (answer === 'stop') throw new Error(`upgrade stopped before db/${patch}`)
167
+ if (answer === 'skip') {
168
+ result.skipped.push(patch)
169
+ continue
170
+ }
171
+ await wrangler(dir, ['d1', 'execute', databaseName(), '--remote', `--file=../db/${patch}`])
172
+ result.applied.push(patch)
173
+ }
174
+ if ((await confirm(ask, `Deploy ${to}?`, false)) !== 'yes') {
175
+ throw new Error(`upgrade stopped before deploying ${to}`)
176
+ }
177
+ result.version = (await ship(context, to, false)).version ?? to
178
+ return result
179
+ })
180
+ }
181
+
182
+ export function upgradeText(result: UpgradeResult): string {
183
+ const lines = [`${result.from} -> ${result.version}`]
184
+ if (result.backup) lines.push(`backup: ${result.backup}`)
185
+ if (result.applied.length > 0) lines.push(`applied: ${result.applied.join(', ')}`)
186
+ if (result.skipped.length > 0) lines.push(`skipped: ${result.skipped.join(', ')}`)
187
+ return lines.join('\n')
188
+ }
@@ -0,0 +1,81 @@
1
+ import { stringFlag, UsageError, type Parsed } from '../core/args.ts'
2
+ import { ApiError, createApi, type Api } from '../core/api.ts'
3
+ import {
4
+ normalizeDomain,
5
+ normalizeUrl,
6
+ readConfig,
7
+ writeConfig,
8
+ type Config,
9
+ } from '../core/config.ts'
10
+ import { paths } from '../core/paths.ts'
11
+ import { withPrompt } from '../core/prompt.ts'
12
+
13
+ export const INIT_USAGE =
14
+ 'usage: buzon init --url <https://mail.example.com> --domain <example.com>'
15
+
16
+ export type InitResult = Config & { file: string; version: string }
17
+
18
+ type OpenSettings = { version?: string; domains?: string[] }
19
+
20
+ function existing(): Partial<Config> {
21
+ try {
22
+ return readConfig()
23
+ } catch {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ /** Flags win. A missing value is asked on a terminal, and is a usage error elsewhere. */
29
+ export async function resolveInput(flags: Parsed['flags'], interactive: boolean): Promise<Config> {
30
+ let url = stringFlag(flags, 'url')
31
+ let domain = stringFlag(flags, 'domain')
32
+ if ((!url || !domain) && !interactive) throw new UsageError(INIT_USAGE)
33
+ if (url && domain) return { url: normalizeUrl(url), domain: normalizeDomain(domain) }
34
+ const current = existing()
35
+ return withPrompt(async (ask) => {
36
+ const normalized = normalizeUrl(url ?? (await ask('Instance URL', current.url)))
37
+ const fallback = current.domain ?? new URL(normalized).hostname
38
+ return {
39
+ url: normalized,
40
+ domain: normalizeDomain(domain ?? (await ask('Mail domain', fallback))),
41
+ }
42
+ })
43
+ }
44
+
45
+ /**
46
+ * `/health_check` is shadowed by the frontend assets on a deploy that serves both, so the
47
+ * public settings route is the probe. A site-only route then checks the site password:
48
+ * `/api/settings` also wants an address JWT, so it cannot tell a wrong password apart.
49
+ */
50
+ export async function verify(config: Config, api: Api = createApi(config)): Promise<string> {
51
+ const settings = await api.request<OpenSettings>('/open_api/settings', { anonymous: true })
52
+ if (typeof settings.version !== 'string') {
53
+ throw new Error(`${config.url} does not look like a cloudflare_temp_email instance`)
54
+ }
55
+ if (!settings.domains?.includes(config.domain)) {
56
+ const served = settings.domains?.join(', ') || 'none'
57
+ throw new Error(`${config.url} does not serve ${config.domain} (it serves: ${served})`)
58
+ }
59
+ try {
60
+ await api.request('/user_api/open_settings')
61
+ } catch (error) {
62
+ if (error instanceof ApiError && error.code === 'AUTH_SITE_PASSWORD_INVALID') {
63
+ throw new Error('the instance rejects the site password', { cause: error })
64
+ }
65
+ throw error
66
+ }
67
+ return settings.version
68
+ }
69
+
70
+ /** Nothing is written until the instance answers and accepts the site password. */
71
+ export async function init(flags: Parsed['flags'], interactive: boolean): Promise<InitResult> {
72
+ const config = await resolveInput(flags, interactive)
73
+ const version = await verify(config)
74
+ const { tag } = existing()
75
+ writeConfig(tag ? { ...config, tag } : config)
76
+ return { ...config, file: paths.config, version }
77
+ }
78
+
79
+ export function initText(result: InitResult): string {
80
+ return `${result.url} (${result.version}), addresses on ${result.domain}\nwrote ${result.file}`
81
+ }