joerglohrerde/publish/src/cli.ts

234 lines
7.4 KiB
TypeScript
Raw Normal View History

import { parseArgs } from '@std/cli/parse-args'
import { join } from '@std/path'
import { loadConfig } from './core/config.ts'
import { createBunkerSigner } from './core/signer.ts'
import { loadOutbox } from './core/outbox.ts'
import { loadBlossomServers } from './core/blossom-list.ts'
import { parseFrontmatter } from './core/frontmatter.ts'
import { checkExisting, publishToRelays } from './core/relays.ts'
import { uploadBlob } from './core/blossom.ts'
import { collectImages } from './core/image-collector.ts'
import { allPostDirs, changedPostDirs } from './core/change-detection.ts'
import { createLogger, type RunMode } from './core/log.ts'
import { type PostDeps, processPost } from './subcommands/publish.ts'
import { printCheckResult, runCheck } from './subcommands/check.ts'
import { validatePostFile } from './subcommands/validate-post.ts'
import { runDelete } from './subcommands/delete.ts'
function uuid(): string {
return crypto.randomUUID()
}
async function cmdCheck(): Promise<number> {
const config = loadConfig()
const result = await runCheck(config)
printCheckResult(result)
return result.ok ? 0 : 1
}
async function cmdValidatePost(path: string | undefined): Promise<number> {
if (!path) {
console.error('usage: validate-post <path-to-index.md>')
return 2
}
const result = await validatePostFile(path)
if (result.ok) {
console.log(`${path} ok (slug: ${result.slug})`)
return 0
}
console.error(`${path}: ${result.error}`)
return 1
}
async function findBySlug(dirs: string[], slug: string): Promise<string | undefined> {
for (const d of dirs) {
try {
const text = await Deno.readTextFile(join(d, 'index.md'))
const { fm } = parseFrontmatter(text)
if (fm.slug === slug) return d
} catch {
// skip
}
}
return undefined
}
async function resolvePostDirs(
mode: RunMode,
contentRoot: string,
single?: string,
): Promise<string[]> {
if (mode === 'post-single' && single) {
if (single.startsWith(contentRoot + '/')) return [single]
const all = await allPostDirs(contentRoot)
const match = all.find((d) => d.endsWith(`/${single}`)) ?? (await findBySlug(all, single))
if (!match) throw new Error(`post mit slug "${single}" nicht gefunden`)
return [match]
}
if (mode === 'force-all') return await allPostDirs(contentRoot)
const before = Deno.env.get('GITHUB_EVENT_BEFORE') ?? 'HEAD~1'
return await changedPostDirs({ from: before, to: 'HEAD', contentRoot })
}
async function cmdPublish(flags: {
forceAll: boolean
post?: string
dryRun: boolean
}): Promise<number> {
const config = loadConfig()
const mode: RunMode = flags.post ? 'post-single' : flags.forceAll ? 'force-all' : 'diff'
const runId = uuid()
const logger = createLogger({ mode, runId })
console.log('[1/3] signer…')
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
console.log('[2/3] outbox…')
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
console.log('[3/3] blossom-server-liste…')
const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
console.log('setup done')
if (outbox.write.length === 0) {
console.error('no write relays in kind:10002')
return 1
}
if (blossomServers.length === 0) {
console.error('no blossom servers in kind:10063')
return 1
}
const postDirs = await resolvePostDirs(mode, config.contentRoot, flags.post)
console.log(
`mode=${mode} posts=${postDirs.length} runId=${runId} contentRoot=${config.contentRoot}`,
)
if (flags.dryRun) {
for (const d of postDirs) console.log(` dry-run: ${d}`)
return 0
}
const deps: PostDeps = {
readPostFile: async (p) => parseFrontmatter(await Deno.readTextFile(p)),
collectImages: (dir) => collectImages(dir),
uploadBlossom: (a) =>
uploadBlob({
data: a.data,
fileName: a.fileName,
mimeType: a.mimeType,
servers: blossomServers,
signer,
}),
sign: (ev) => signer.signEvent(ev),
publish: (ev, relays) => publishToRelays(relays, ev),
checkExisting: (slug, relays) => checkExisting(slug, config.authorPubkeyHex, relays),
}
let anyFailed = false
for (const dir of postDirs) {
const result = await processPost({
postDir: dir,
writeRelays: outbox.write,
blossomServers,
pubkeyHex: config.authorPubkeyHex,
clientTag: config.clientTag,
minRelayAcks: config.minRelayAcks,
deps,
})
if (result.status === 'success') {
logger.postSuccess({
slug: result.slug,
action: result.action!,
eventId: result.eventId!,
relaysOk: result.relaysOk,
relaysFailed: result.relaysFailed,
blossomServersOk: result.blossomServersOk,
imagesUploaded: result.imagesUploaded,
durationMs: result.durationMs,
})
} else if (result.status === 'skipped-draft') {
logger.postSkippedDraft(result.slug)
} else {
anyFailed = true
logger.postFailed({
slug: result.slug,
error: result.error ?? 'unknown',
durationMs: result.durationMs,
})
}
}
const exitCode = anyFailed ? 1 : 0
const summary = logger.finalize(exitCode)
await Deno.mkdir('./logs', { recursive: true })
const logPath = `./logs/publish-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
await logger.writeJson(logPath, summary)
console.log(`log: ${logPath}`)
return exitCode
}
async function cmdDelete(flags: {
eventIds: string[]
reason?: string
}): Promise<number> {
if (flags.eventIds.length === 0) {
console.error('usage: delete --event-id <hex> [--event-id <hex> ...] [--reason "text"]')
return 2
}
const config = loadConfig()
console.log('[1/2] signer…')
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
console.log('[2/2] outbox…')
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
if (outbox.write.length === 0) {
console.error('no write relays in kind:10002')
return 1
}
console.log(`deleting ${flags.eventIds.length} event(s) on ${outbox.write.length} relay(s)…`)
const result = await runDelete({
eventIds: flags.eventIds,
reason: flags.reason,
signer,
writeRelays: outbox.write,
pubkeyHex: config.authorPubkeyHex,
clientTag: config.clientTag,
minRelayAcks: config.minRelayAcks,
})
console.log(`delete-event id: ${result.deleteEventId}`)
console.log(`relays ok: ${result.relaysOk.join(', ')}`)
console.log(`relays failed: ${result.relaysFailed.join(', ') || '(none)'}`)
return result.ok ? 0 : 1
}
async function main(): Promise<number> {
const args = parseArgs(Deno.args, {
boolean: ['force-all', 'dry-run'],
string: ['post', 'event-id', 'reason'],
collect: ['event-id'],
})
const sub = args._[0]
if (sub === 'check') return cmdCheck()
if (sub === 'validate-post') return cmdValidatePost(args._[1] as string | undefined)
if (sub === 'publish') {
return cmdPublish({
forceAll: args['force-all'] === true,
post: args.post,
dryRun: args['dry-run'] === true,
})
}
if (sub === 'delete') {
return cmdDelete({
eventIds: (args['event-id'] as string[]) ?? [],
reason: args.reason,
})
}
console.error('usage: cli.ts <publish | check | validate-post | delete> [flags]')
return 2
}
if (import.meta.main) {
Deno.exit(await main())
}