#!/usr/bin/env node // sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive. // // `epub-optimize` remains the standalone converter; this entry point adds the // OAuth session, BookHive client, ledger and watcher. import { existsSync, readdirSync, statSync } from 'node:fs'; import { createInterface } from 'node:readline/promises'; import { resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { DEVICE_PROFILES } from './constants.js'; import { formatBytes } from './util.js'; import { AuthError, login, logout, readSessionPointer, restoreSession, } from './sinker/auth.js'; import { createBookHiveClient } from './sinker/bookhive.js'; import { ConfigError, describeConfig, loadConfig, } from './sinker/config.js'; import { createLogger } from './sinker/log.js'; import { createContext, syncOnce, summarize } from './sinker/pipeline.js'; import { Ledger, stateFileFor } from './sinker/state.js'; import { watch } from './sinker/watcher.js'; const USAGE = ` sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive Usage: sinker [options] Commands: login Authenticate with your atproto account (opens a browser) logout Revoke the saved session and forget it status Show config, session and sync ledger sync [file…] Compress and upload new books once, then exit watch Watch the source folder and keep BookHive in sync Options: --env-file FILE load configuration from FILE (default: ./.env) --verbose log per-image detail and retry decisions --quiet only warnings and errors -h, --help show this message Run \`sinker --help\` for command options. `.trim(); const COMMAND_USAGE = { login: ` Usage: sinker login [options] Options: --handle HANDLE atproto handle (default: ATPROTO_HANDLE) --port PORT loopback callback port (default: 8765) --login-host HOST interface to bind (default: 127.0.0.1; use 0.0.0.0 in Docker) --no-open print the URL instead of opening a browser `.trim(), logout: ` Usage: sinker logout [options] `.trim(), status: ` Usage: sinker status [options] Options: --json machine-readable output --check verify the saved session against BookHive `.trim(), sync: ` Usage: sinker sync [file…] [options] Compresses and uploads new books once. With no files, scans the source folder. Options: --source DIR source folder (default: SINKER_SOURCE_DIR) --out DIR output folder (default: SINKER_OUTPUT_DIR) --device X4|X3 target screen (default: X4) --quality N JPEG quality 1-100 (default: 85) --auto-crop trim uniform margins before resizing --no-rename keep source filenames instead of "Title - Author.epub" --dry-run do everything except write files, upload, or update state --force upload even if the book is already in BookHive --retry-failed retry files that previously failed permanently `.trim(), watch: ` Usage: sinker watch [options] Watches the source folder until Ctrl+C. Options: (same as sync, plus) --no-backfill only process files added after startup --poll poll the filesystem (network volumes) --rescan-ms N periodic rescan interval in ms (default: 300000) --retry-max N upload retry attempts (default: 5) --timeout-ms N per-request timeout in ms (default: 120000) --verbose log per-image detail and retry decisions `.trim(), }; class UsageError extends Error {} function buildOverrides(values) { return { handle: values.handle, sourceDir: values.source, outputDir: values.out, device: values.device, quality: values.quality, autoCrop: values['auto-crop'] ? true : undefined, rename: values['no-rename'] ? false : undefined, loginPort: values.port, loginHost: values['login-host'], openBrowser: values['no-open'] ? false : undefined, poll: values.poll ? true : undefined, backfill: values['no-backfill'] ? false : undefined, rescanMs: values['rescan-ms'], retryMax: values['retry-max'], timeoutMs: values['timeout-ms'], dryRun: values['dry-run'] ? true : undefined, force: values.force ? true : undefined, retryFailed: values['retry-failed'] ? true : undefined, verbose: values.verbose ? true : undefined, quiet: values.quiet ? true : undefined, }; } function parseCli(argv) { let parsed; try { parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: { 'env-file': { type: 'string' }, help: { type: 'boolean', short: 'h' }, verbose: { type: 'boolean' }, quiet: { type: 'boolean' }, handle: { type: 'string' }, port: { type: 'string' }, 'login-host': { type: 'string' }, 'no-open': { type: 'boolean' }, source: { type: 'string' }, out: { type: 'string' }, device: { type: 'string' }, quality: { type: 'string' }, 'auto-crop': { type: 'boolean' }, 'no-rename': { type: 'boolean' }, 'dry-run': { type: 'boolean' }, force: { type: 'boolean' }, 'retry-failed': { type: 'boolean' }, poll: { type: 'boolean' }, 'no-backfill': { type: 'boolean' }, 'rescan-ms': { type: 'string' }, 'retry-max': { type: 'string' }, 'timeout-ms': { type: 'string' }, json: { type: 'boolean' }, check: { type: 'boolean' }, }, }); } catch (error) { throw new UsageError(error.message); } return parsed; } function helpFor(command) { if (command && COMMAND_USAGE[command]) return `${COMMAND_USAGE[command]}\n\n${USAGE}`; return USAGE; } async function promptHandle() { if (!process.stdin.isTTY) return null; const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question('Bluesky handle (e.g. you.bsky.social): '); return answer.trim() || null; } finally { rl.close(); } } function ledgerFor(config, did) { return new Ledger(stateFileFor(config.stateDir, did ?? 'anonymous')).load(); } async function runLogin(config, log) { let handle = config.handle; if (!handle) handle = await promptHandle(); if (!handle) throw new UsageError('a handle is required: pass --handle or set ATPROTO_HANDLE'); const { did } = await login(config, { handle, log }); log.ok(`Signed in as ${did}.`); return 0; } async function runLogout(config, log) { const did = await logout(config); if (did) log.ok(`Signed out ${did}.`); else log.info('No saved session.'); return 0; } function sourceSummary(config) { if (!existsSync(config.sourceDir)) { return { exists: false, epubs: 0, other: 0 }; } let epubs = 0; let other = 0; for (const entry of readdirSync(config.sourceDir, { withFileTypes: true })) { if (entry.name.startsWith('.') || entry.isDirectory()) continue; let isFile = entry.isFile(); if (!isFile) { try { isFile = statSync(resolve(config.sourceDir, entry.name)).isFile(); } catch { continue; } } if (!isFile) continue; if (/\.epub$/i.test(entry.name)) epubs++; else other++; } return { exists: true, epubs, other }; } async function runStatus(config, log, values) { const pointer = readSessionPointer(config); const did = pointer?.did ?? null; const ledger = ledgerFor(config, did); const source = sourceSummary(config); const data = { signedIn: Boolean(did), did, handle: pointer?.handle ?? config.handle ?? null, signedInAt: pointer?.signedInAt ?? null, bookhive: { url: config.bookhiveUrl, did: config.bookhiveDid }, source: { ...source, dir: config.sourceDir }, output: { dir: config.outputDir, exists: existsSync(config.outputDir) }, device: config.device, quality: config.quality, autoCrop: config.autoCrop, rename: config.rename, ledger: { file: stateFileFor(config.stateDir, did ?? 'anonymous'), counts: ledger.counts() }, }; if (values.check) { if (!did) throw new AuthError('not signed in — run `sinker login` first'); const { session } = await restoreSession(config); const client = createBookHiveClient({ config, session, logger: log }); const library = await client.refreshLibrary(); data.library = { books: library.size, storage: client.storage, }; } if (values.json) { console.log(JSON.stringify(data, null, 2)); return 0; } console.log(describeConfig(config)); console.log(''); if (did) { console.log(`session ${pointer.handle ? `@${pointer.handle} ` : ''}${did}`); console.log(`signed in ${pointer.signedInAt ?? 'unknown'}`); } else { console.log('session not signed in (run `sinker login`)'); } const counts = data.ledger.counts; const parts = Object.entries(counts).map(([status, count]) => `${count} ${status}`); console.log(`ledger ${parts.length ? parts.join(', ') : 'empty'}`); console.log(`files ${source.epubs} epub(s)${source.other ? `, ${source.other} other file(s)` : ''}`); if (data.library) { const storage = data.library.storage ? ` (${formatBytes(data.library.storage.usedBytes)} / ${formatBytes(data.library.storage.quotaBytes)})` : ''; console.log(`library ${data.library.books} book(s)${storage}`); } return 0; } async function makeClient(config, log) { const { did, session } = await restoreSession(config); const client = createBookHiveClient({ config, session, logger: log }); return { did, client }; } async function runSync(config, log, paths) { const { did, client } = await makeClient(config, log); const ctx = createContext({ config, bookhive: client, logger: log, did }); const explicit = paths.length ? paths.map(path => { const resolved = resolve(config.cwd, path); if (!existsSync(resolved) || !statSync(resolved).isFile()) { throw new UsageError(`not a file: ${resolved}`); } return resolved; }) : undefined; log.info(`Signed in as ${did}`); log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`); const stats = await syncOnce(ctx, explicit ? { paths: explicit } : {}); log.info(`Done: ${summarize(stats)}`); return stats.failed > 0 ? 1 : 0; } async function runWatch(config, log) { const { did, client } = await makeClient(config, log); const ctx = createContext({ config, bookhive: client, logger: log, did }); log.info(`Signed in as ${did}`); log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`); return watch(ctx); } async function main(argv) { const { values, positionals } = parseCli(argv); const command = positionals[0]; if (values.help || command === 'help') { console.log(helpFor(command === 'help' ? null : command)); return 0; } if (!command) { console.log(USAGE); return 2; } if (!['login', 'logout', 'status', 'sync', 'watch'].includes(command)) { throw new UsageError(`unknown command "${command}"`); } const log = createLogger({ verbose: values.verbose, quiet: values.quiet }); const config = loadConfig({ envFile: values['env-file'] ? resolve(values['env-file']) : undefined, cwd: process.cwd(), overrides: buildOverrides(values), }); const extra = positionals.slice(1); if (extra.length && command !== 'sync') { throw new UsageError(`unexpected argument "${extra[0]}"`); } switch (command) { case 'login': return runLogin(config, log); case 'logout': return runLogout(config, log); case 'status': return runStatus(config, log, values); case 'sync': return runSync(config, log, extra); case 'watch': return runWatch(config, log); default: throw new UsageError(`unknown command "${command}"`); } } main(process.argv.slice(2)) .then(code => { process.exitCode = code; }) .catch(error => { if (error instanceof UsageError) { console.error(`error: ${error.message}`); console.error(''); console.error(USAGE); } else if (error instanceof ConfigError || error instanceof AuthError) { console.error(`error: ${error.message}`); } else { console.error(`error: ${error?.message ?? error}`); if (process.env.DEBUG) console.error(error.stack); } process.exitCode = 1; });