From 18d9dad56ecf5bd3b7830e5ccde26e434d9c2d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Sat, 18 Apr 2026 05:45:54 +0200 Subject: [PATCH] publish(task 18): cli-entrypoint mit subcommand-dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/cli.ts dispatcht via @std/cli/parse-args: - publish [--force-all | --post | --dry-run] - check - validate-post cmdPublish orchestriert: 1. config laden, signer initialisieren, outbox + blossom-server laden 2. post-dirs resolven (diff/force-all/single per slug) 3. dry-run → liste printen, exit 0 4. für jeden post: processPost aufrufen, logger aktualisieren 5. am ende: logs/publish-.json, exit-code je nach fehlern resolvePostDirs schaltet zwischen den drei modi um und findet bei --post den passenden ordner über allPostDirs + findBySlug. smoke-tests aus dem plan (usage → exit 2, validate-post → ✓) gehen durch. alle 57 tests grün. Co-Authored-By: Claude Opus 4.6 (1M context) --- publish/src/cli.ts | 184 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 publish/src/cli.ts diff --git a/publish/src/cli.ts b/publish/src/cli.ts new file mode 100644 index 0000000..8aec696 --- /dev/null +++ b/publish/src/cli.ts @@ -0,0 +1,184 @@ +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' + +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 }) + + const signer = await createBunkerSigner(config.bunkerUrl) + const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex) + const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex) + 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 main(): Promise { + const args = parseArgs(Deno.args, { + boolean: ['force-all', 'dry-run'], + string: ['post'], + }) + 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, + }) + } + console.error('usage: cli.ts [flags]') + return 2 +} + +if (import.meta.main) { + Deno.exit(await main()) +}