@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,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
|
+
}
|