#!/usr/bin/env node // Entry point. Computes the avatar state, renders it, and (unless --dry-run) // pushes it to Bluesky — only when it differs from the last applied state. // // Flags: // --dry-run render to ./out.png, don't upload // --force upload even if state is unchanged // --state '' skip computation; render this literal state (preview) import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { dirname, join } from 'node:path'; import { loadConfig, ASSETS_DIR, STATE_FILE, ROOT } from './config.js'; import { todayParts } from './dates.js'; import { resolveLocation } from './location.js'; import { resolveWeather } from './weather.js'; import { resolveState } from './state.js'; import { renderAvatar } from './compositor.js'; import { updateAvatar } from './bluesky.js'; import { updateSignalAvatar } from './signal.js'; import { reportLocationHealth } from './health.js'; loadDotEnv(); const args = new Set(process.argv.slice(2)); const dryRun = args.has('--dry-run'); const force = args.has('--force'); const stateArgIdx = process.argv.indexOf('--state'); const literalState = stateArgIdx > -1 ? JSON.parse(process.argv[stateArgIdx + 1]) : null; run().catch((e) => { console.error(`[fatal] ${e.stack || e.message}`); // No harm if a run fails: exit clean in production so cron stays quiet. process.exit(dryRun ? 1 : 0); }); async function run() { const cfg = loadConfig(); const { year, iso, monthDay, month, nowKey } = todayParts(cfg.settings.timezone); // Per-year holidays (e.g. Diwali) have no formula; warn when the table runs out. const datedYears = Object.keys(cfg.holidays.dated || {}).map((d) => Number(d.slice(0, 4))); if (datedYears.length && Math.max(...datedYears) < year) { console.error(`[warn] 'dated:' holidays in holidays.yaml stop at ${Math.max(...datedYears)} — add ${year}+ dates (e.g. Diwali)`); } let state; if (literalState) { state = { _why: 'literal --state', ...literalState }; } else { const location = await resolveLocation(cfg); await reportLocationHealth(cfg, location); const weatherLayer = await resolveWeather(location); state = resolveState(cfg, { year, iso, monthDay, month, nowKey, country: location.country, region: location.region, weatherLayer, }); const place = location.region ? `${location.country}/${location.region}` : location.country; console.error(`[ctx] ${iso} | ${location.source} ${place} (${location.lat},${location.lon}) | weather=${weatherLayer || 'n/a'}`); } console.error(`[state] ${JSON.stringify(stripMeta(state))} <- ${state._why}`); // Per-target idempotency. Bluesky tracks the full state; Signal (in 'events' // mode) tracks only bg+holiday so daily weather/ring churn doesn't push there. const fullHash = hashState(state); const signalKey = cfg.signal.mirror === 'all' ? fullHash : sigHash({ bg: state.bg, holiday: state.holiday }); const forceAll = force || dryRun || Boolean(literalState); const last = readState(); const bskyNeeds = forceAll || fullHash !== last.bluesky; const signalNeeds = cfg.signal.enabled && (forceAll || signalKey !== last.signal); if (!dryRun && !bskyNeeds && !signalNeeds) { console.error('[skip] state unchanged since last run'); return; } const { buffer, mime } = await renderAvatar(state, { assetsDir: ASSETS_DIR }); const kb = (buffer.length / 1024).toFixed(0); if (dryRun) { const out = join(ROOT, 'out.png'); writeFileSync(out, buffer); console.error(`[dry-run] wrote ${out} (${kb} KB, ${mime}) — not uploaded`); return; } // Push each target independently and fail-soft, so one being down never blocks the other. const next = { ...last, iso, state: stripMeta(state), ts: new Date().toISOString() }; if (bskyNeeds) { try { const r = await updateAvatar({ buffer, mime }, cfg.env); next.bluesky = fullHash; console.error(`[ok] bluesky updated (${kb} KB ${mime}) for ${r.did}`); } catch (e) { console.error(`[err] bluesky update failed: ${e.message}`); } } if (signalNeeds) { try { const r = await updateSignalAvatar({ buffer, mime }, cfg.signal); next.signal = signalKey; console.error(`[ok] signal updated (${kb} KB) for ${r.number}`); } catch (e) { console.error(`[err] signal update failed: ${e.message}`); } } writeState(next); } // --- helpers --- function stripMeta(s) { const { _why, ...rest } = s; return rest; } function hashState(s) { return createHash('sha256').update(JSON.stringify(stripMeta(s))).digest('hex'); } function sigHash(o) { return createHash('sha256').update(JSON.stringify(o)).digest('hex'); } function readState() { try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } } function writeState(obj) { mkdirSync(dirname(STATE_FILE), { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(obj, null, 2)); } // Minimal .env loader (no dependency); does not override already-set env vars. function loadDotEnv() { const path = join(ROOT, '.env'); if (!existsSync(path)) return; for (const line of readFileSync(path, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); if (!m) continue; const key = m[1]; let val = m[2].trim().replace(/^["']|["']$/g, ''); if (!(key in process.env)) process.env[key] = val; } }