/** * Static-config subscriber store for the build bus * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 2-lite). * * The first cut of cross-machine event distribution skips the * registry-server switchboard described in ยง6 of the spec โ€” instead * the operator maintains a JSON file under their celilo data dir * that lists each subscriber URL + secret + match rule. This file * is the single source of truth for publishes that originate on * this machine. * * File layout (default `~/Library/Application Support/celilo/build-bus-subscribers.json` * on macOS, similar XDG path on Linux โ€” see config/paths.ts): * * { * "subscribers": [ * { * "name": "lunacycle build box", * "url": "https://lunacycle.lab/build-bus", * "secret": "", * "match": { "registry": "npm", "packagePattern": "@celilo/*" } * } * ] * } * * Override the file path with the CELILO_BUILD_BUS_SUBSCRIBERS_PATH * env var (used by integration tests). */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { Subscriber } from '@celilo/event-bus/build-bus'; import { getDataDir } from '../../config/paths'; const FILE_NAME = 'build-bus-subscribers.json'; interface StoreFile { subscribers: Subscriber[]; } /** * Resolve the subscriber-store path. Respects * CELILO_BUILD_BUS_SUBSCRIBERS_PATH for testability; otherwise * lives alongside the rest of celilo's per-machine state under * getDataDir(). */ export function subscriberStorePath(): string { return process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH ?? join(getDataDir(), FILE_NAME); } /** * Load the subscriber list. Missing file โ†’ empty list (treat as * "no subscribers configured"; this is the silent-no-op path that * keeps the build-bus opt-in). Malformed JSON throws so the operator * notices. */ export function loadSubscribers(): Subscriber[] { const path = subscriberStorePath(); if (!existsSync(path)) return []; const content = readFileSync(path, 'utf-8'); let parsed: StoreFile; try { parsed = JSON.parse(content) as StoreFile; } catch (err) { throw new Error(`Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`); } if (!Array.isArray(parsed.subscribers)) { throw new Error(`${path} has no "subscribers" array at the top level.`); } return parsed.subscribers; } /** * Replace the subscriber list. Creates the parent dir if needed, * writes atomically (temp file + rename) so a failed write doesn't * leave a corrupt store. */ export function saveSubscribers(subscribers: Subscriber[]): void { const path = subscriberStorePath(); mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp`; writeFileSync(tmp, `${JSON.stringify({ subscribers }, null, 2)}\n`); // Bun supports rename via the standard Node fs API; this fails fast // on EXDEV but the temp + path live in the same dir so cross-FS // isn't a concern. const { renameSync } = require('node:fs') as typeof import('node:fs'); renameSync(tmp, path); } /** * Add a subscriber. If one with the same URL exists, replace it * (idempotent re-adds with updated secret/match are common when an * operator rotates credentials). Returns the prior subscriber, if any. */ export function addSubscriber(subscriber: Subscriber): { replaced?: Subscriber } { const existing = loadSubscribers(); const prior = existing.find((s) => s.url === subscriber.url); const next = existing.filter((s) => s.url !== subscriber.url); next.push(subscriber); saveSubscribers(next); return prior ? { replaced: prior } : {}; } /** * Remove a subscriber by URL. Returns the removed entry, if any. */ export function removeSubscriberByUrl(url: string): Subscriber | undefined { const existing = loadSubscribers(); const prior = existing.find((s) => s.url === url); if (!prior) return undefined; saveSubscribers(existing.filter((s) => s.url !== url)); return prior; }