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 { const config = loadConfig() const result = await runCheck(config) printCheckResult(result) return result.ok ? 0 : 1 } async function cmdValidatePost(path: string | undefined): Promise { if (!path) { console.error('usage: validate-post ') 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 { 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 { 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 { 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 { if (flags.eventIds.length === 0) { console.error('usage: delete --event-id [--event-id ...] [--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 { 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 [flags]') return 2 } if (import.meta.main) { Deno.exit(await main()) }