-
-
Save jlongster/99c15e40c7978404bb97b5171df0e645 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| chmod, | |
| mkdir, | |
| readdir, | |
| readFile, | |
| rm, | |
| stat, | |
| writeFile, | |
| } from "node:fs/promises"; | |
| import { existsSync } from "node:fs"; | |
| import { basename, join, resolve } from "node:path"; | |
| export const QBOT_PREFIX = "qbot"; | |
| export const DATA_DIR = "/Users/james/kraken"; | |
| export const USER_JOBS_DIR = join(DATA_DIR, "jobs"); | |
| export const LAUNCH_AGENTS_DIR = "/Users/james/Library/LaunchAgents"; | |
| export const PROJECT_JOBS_DIR = resolve(import.meta.dir, "./system-jobs"); | |
| type Scope = "system" | "user"; | |
| type ParsedSchedule = | |
| | { kind: "periodic"; seconds: number; runAtLoad?: boolean } | |
| | { kind: "scheduled"; calendar: unknown; runAtLoad?: boolean } | |
| | { kind: "on-change"; paths: string[]; runAtLoad?: boolean }; | |
| interface ParsedScheduleFile { | |
| schedule?: ParsedSchedule; | |
| program?: string; | |
| disabled?: boolean; | |
| } | |
| export interface JobDefinition { | |
| scope: Scope; | |
| name: string; | |
| label: string; | |
| directory: string; | |
| runScriptPath: string; | |
| schedule: ParsedSchedule; | |
| disabled: false; | |
| } | |
| export interface DisabledJobDefinition { | |
| scope: Scope; | |
| name: string; | |
| label: string; | |
| directory: string; | |
| disabled: true; | |
| schedule?: ParsedSchedule; | |
| } | |
| export type DiscoveredJobDefinition = JobDefinition | DisabledJobDefinition; | |
| interface LaunchctlResult { | |
| ok: boolean; | |
| stdout: string; | |
| stderr: string; | |
| } | |
| function sanitizeName(value: string): string { | |
| return value | |
| .toLowerCase() | |
| .replace(/[^a-z0-9._-]+/g, "-") | |
| .replace(/^-+|-+$/g, ""); | |
| } | |
| function labelFor(scope: Scope, name: string): string { | |
| return `${QBOT_PREFIX}.${scope}.${sanitizeName(name)}`; | |
| } | |
| function decodeBytes(bytes: Uint8Array<ArrayBufferLike>): string { | |
| return new TextDecoder().decode(bytes); | |
| } | |
| function runLaunchctl(args: string[]): LaunchctlResult { | |
| const proc = Bun.spawnSync(["launchctl", ...args]); | |
| return { | |
| ok: proc.exitCode === 0, | |
| stdout: decodeBytes(proc.stdout), | |
| stderr: decodeBytes(proc.stderr), | |
| }; | |
| } | |
| function currentUid(): string { | |
| const uid = typeof process.getuid === "function" ? process.getuid() : 0; | |
| return String(uid); | |
| } | |
| function parseIntegerSchedule(text: string): ParsedSchedule | null { | |
| if (!/^\d+$/.test(text)) return null; | |
| const seconds = Number.parseInt(text, 10); | |
| if (!Number.isFinite(seconds) || seconds <= 0) { | |
| throw new Error(`Invalid periodic interval in schedule: ${text}`); | |
| } | |
| return { kind: "periodic", seconds }; | |
| } | |
| function parseScheduleText(raw: string): ParsedScheduleFile { | |
| const text = raw.trim(); | |
| const integerSchedule = parseIntegerSchedule(text); | |
| if (integerSchedule) return { schedule: integerSchedule }; | |
| let parsed: unknown; | |
| try { | |
| parsed = JSON.parse(text); | |
| } catch { | |
| throw new Error("Schedule file must be either an integer or JSON"); | |
| } | |
| if (typeof parsed === "number") { | |
| const seconds = Math.trunc(parsed); | |
| if (seconds <= 0) throw new Error("Periodic seconds must be > 0"); | |
| return { schedule: { kind: "periodic", seconds } }; | |
| } | |
| if (!parsed || typeof parsed !== "object") { | |
| throw new Error("Schedule JSON must be an object"); | |
| } | |
| if (Array.isArray(parsed)) { | |
| return { schedule: { kind: "scheduled", calendar: parsed } }; | |
| } | |
| const obj = parsed as Record<string, unknown>; | |
| const runAtLoad = typeof obj.runAtLoad === "boolean" ? obj.runAtLoad : undefined; | |
| const program = typeof obj.program === "string" ? obj.program : undefined; | |
| const disabled = typeof obj.disabled === "boolean" ? obj.disabled : undefined; | |
| const type = typeof obj.type === "string" ? obj.type : null; | |
| if (type === "periodic") { | |
| const seconds = Number(obj.seconds); | |
| if (!Number.isFinite(seconds) || seconds <= 0) { | |
| throw new Error("periodic schedule requires positive numeric 'seconds'"); | |
| } | |
| return { schedule: { kind: "periodic", seconds: Math.trunc(seconds), runAtLoad }, program, disabled }; | |
| } | |
| if (type === "scheduled") { | |
| const calendar = obj.calendar; | |
| if (!calendar) { | |
| throw new Error("scheduled schedule requires 'calendar'"); | |
| } | |
| return { schedule: { kind: "scheduled", calendar, runAtLoad }, program, disabled }; | |
| } | |
| if (type === "on-change") { | |
| const rawPaths = obj.paths; | |
| const paths = Array.isArray(rawPaths) | |
| ? rawPaths.filter((p): p is string => typeof p === "string" && p.length > 0) | |
| : typeof rawPaths === "string" | |
| ? [rawPaths] | |
| : []; | |
| if (paths.length === 0) { | |
| throw new Error("on-change schedule requires 'paths' (string or string[])"); | |
| } | |
| return { schedule: { kind: "on-change", paths, runAtLoad }, program, disabled }; | |
| } | |
| if (disabled) { | |
| return { program, disabled }; | |
| } | |
| throw new Error("Unknown schedule format"); | |
| } | |
| function normalizeCalendarKey(key: string): string { | |
| const normalized = key.trim().toLowerCase(); | |
| const keyMap: Record<string, string> = { | |
| minute: "Minute", | |
| hour: "Hour", | |
| day: "Day", | |
| weekday: "Weekday", | |
| month: "Month", | |
| year: "Year", | |
| second: "Second", | |
| }; | |
| return keyMap[normalized] ?? key; | |
| } | |
| function normalizeCalendarForLaunchd(value: unknown): unknown { | |
| if (Array.isArray(value)) { | |
| return value.map(entry => normalizeCalendarForLaunchd(entry)); | |
| } | |
| if (value && typeof value === "object") { | |
| const out: Record<string, unknown> = {}; | |
| for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { | |
| out[normalizeCalendarKey(key)] = normalizeCalendarForLaunchd(entry); | |
| } | |
| return out; | |
| } | |
| return value; | |
| } | |
| function escapeXml(value: string): string { | |
| return value | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function plistValue(value: unknown, indent = " "): string { | |
| if (typeof value === "string") return `${indent}<string>${escapeXml(value)}</string>`; | |
| if (typeof value === "number") return `${indent}<integer>${Math.trunc(value)}</integer>`; | |
| if (typeof value === "boolean") return `${indent}<${value ? "true" : "false"}/>`; | |
| if (Array.isArray(value)) { | |
| const entries = value.map(entry => plistValue(entry, `${indent} `)).join("\n"); | |
| return `${indent}<array>\n${entries}\n${indent}</array>`; | |
| } | |
| if (value && typeof value === "object") { | |
| const entries = Object.entries(value as Record<string, unknown>) | |
| .map(([key, entry]) => `${indent} <key>${escapeXml(key)}</key>\n${plistValue(entry, `${indent} `)}`) | |
| .join("\n"); | |
| return `${indent}<dict>\n${entries}\n${indent}</dict>`; | |
| } | |
| return `${indent}<string></string>`; | |
| } | |
| function plistForJob(job: JobDefinition): string { | |
| const logsDir = join(DATA_DIR, "logs", "launchd"); | |
| const base: Record<string, unknown> = { | |
| Label: job.label, | |
| ProgramArguments: [job.runScriptPath], | |
| WorkingDirectory: job.directory, | |
| StandardOutPath: join(logsDir, `${job.label}.out.log`), | |
| StandardErrorPath: join(logsDir, `${job.label}.err.log`), | |
| ProcessType: "Background", | |
| KeepAlive: false, | |
| RunAtLoad: job.schedule.runAtLoad ?? false, | |
| EnvironmentVariables: { | |
| QBOT_JOB_LABEL: job.label, | |
| }, | |
| }; | |
| if (job.schedule.kind === "periodic") { | |
| base.StartInterval = job.schedule.seconds; | |
| } | |
| if (job.schedule.kind === "scheduled") { | |
| base.StartCalendarInterval = normalizeCalendarForLaunchd(job.schedule.calendar); | |
| } | |
| if (job.schedule.kind === "on-change") { | |
| base.WatchPaths = job.schedule.paths; | |
| } | |
| const dict = plistValue(base, " "); | |
| return [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', | |
| '<plist version="1.0">', | |
| dict, | |
| "</plist>", | |
| "", | |
| ].join("\n"); | |
| } | |
| function isEnabledJobDefinition(job: DiscoveredJobDefinition): job is JobDefinition { | |
| return !job.disabled; | |
| } | |
| async function ensureJobDefinition(scope: Scope, jobDir: string): Promise<DiscoveredJobDefinition> { | |
| const name = basename(jobDir); | |
| const schedulePath = join(jobDir, "schedule"); | |
| const label = labelFor(scope, name); | |
| const scheduleRaw = await readFile(schedulePath, "utf-8"); | |
| const parsedSchedule = parseScheduleText(scheduleRaw); | |
| if (parsedSchedule.disabled) { | |
| return { | |
| scope, | |
| name, | |
| label, | |
| directory: jobDir, | |
| disabled: true, | |
| schedule: parsedSchedule.schedule, | |
| }; | |
| } | |
| if (!parsedSchedule.schedule) { | |
| throw new Error(`Job '${name}' schedule is missing a valid schedule definition`); | |
| } | |
| const programName = parsedSchedule.program ?? "run"; | |
| const runScriptPath = join(jobDir, programName); | |
| await stat(runScriptPath); | |
| await chmod(runScriptPath, 0o755); | |
| return { | |
| scope, | |
| name, | |
| label, | |
| directory: jobDir, | |
| runScriptPath, | |
| schedule: parsedSchedule.schedule, | |
| disabled: false, | |
| }; | |
| } | |
| export async function installJob(job: JobDefinition): Promise<boolean> { | |
| await mkdir(LAUNCH_AGENTS_DIR, { recursive: true }); | |
| await mkdir(join(DATA_DIR, "logs", "launchd"), { recursive: true }); | |
| const plistPath = join(LAUNCH_AGENTS_DIR, `${job.label}.plist`); | |
| const plist = plistForJob(job); | |
| try { | |
| const existingPlist = await readFile(plistPath, "utf-8"); | |
| if (existingPlist === plist) { | |
| return false; | |
| } | |
| } catch { | |
| // No existing plist yet, continue with install. | |
| } | |
| await writeFile(plistPath, plist, "utf-8"); | |
| await chmod(plistPath, 0o644); | |
| const uid = currentUid(); | |
| runLaunchctl(["bootout", `gui/${uid}`, plistPath]); | |
| const bootstrap = runLaunchctl(["bootstrap", `gui/${uid}`, plistPath]); | |
| if (!bootstrap.ok) { | |
| throw new Error(`Failed to bootstrap ${job.label}: ${bootstrap.stderr || bootstrap.stdout}`); | |
| } | |
| runLaunchctl(["enable", `gui/${uid}/${job.label}`]); | |
| return true; | |
| } | |
| export async function uninstallLabel(label: string): Promise<void> { | |
| const uid = currentUid(); | |
| const plistPath = join(LAUNCH_AGENTS_DIR, `${label}.plist`); | |
| runLaunchctl(["bootout", `gui/${uid}`, plistPath]); | |
| runLaunchctl(["bootout", `gui/${uid}/${label}`]); | |
| await rm(plistPath, { force: true }); | |
| } | |
| async function discoverFromRoot(root: string, scope: Scope): Promise<DiscoveredJobDefinition[]> { | |
| if (!existsSync(root)) return []; | |
| const entries = await readdir(root, { withFileTypes: true }); | |
| const jobs: DiscoveredJobDefinition[] = []; | |
| for (const entry of entries) { | |
| if (!entry.isDirectory() || entry.name.startsWith(".")) continue; | |
| const dir = join(root, entry.name); | |
| const schedulePath = join(dir, "schedule"); | |
| if (!existsSync(schedulePath)) continue; | |
| jobs.push(await ensureJobDefinition(scope, dir)); | |
| } | |
| return jobs; | |
| } | |
| export async function discoverAllJobDefinitions(): Promise<DiscoveredJobDefinition[]> { | |
| const [systemJobs, userJobs] = await Promise.all([ | |
| discoverFromRoot(PROJECT_JOBS_DIR, "system"), | |
| discoverFromRoot(USER_JOBS_DIR, "user"), | |
| ]); | |
| return [...systemJobs, ...userJobs]; | |
| } | |
| async function labelsFromLaunchAgentsDir(): Promise<string[]> { | |
| if (!existsSync(LAUNCH_AGENTS_DIR)) return []; | |
| const entries = await readdir(LAUNCH_AGENTS_DIR, { withFileTypes: true }); | |
| return entries | |
| .filter(entry => entry.isFile() && entry.name.startsWith(`${QBOT_PREFIX}.`) && entry.name.endsWith(".plist")) | |
| .map(entry => entry.name.slice(0, -6)); | |
| } | |
| export async function listNativeQbotLabels(): Promise<string[]> { | |
| return (await labelsFromLaunchAgentsDir()).sort(); | |
| } | |
| export interface SyncReport { | |
| synced: string[]; | |
| removed: string[]; | |
| unchanged: string[]; | |
| desired: string[]; | |
| existing: string[]; | |
| } | |
| export async function syncAllJobs(): Promise<SyncReport> { | |
| const definitions = await discoverAllJobDefinitions(); | |
| const enabledDefinitions = definitions.filter(isEnabledJobDefinition); | |
| const desiredLabels = enabledDefinitions.map(job => job.label); | |
| const existingLabels = await listNativeQbotLabels(); | |
| const synced: string[] = []; | |
| const unchanged: string[] = []; | |
| for (const job of enabledDefinitions) { | |
| const changed = await installJob(job); | |
| if (changed) { | |
| synced.push(job.label); | |
| } else { | |
| unchanged.push(job.label); | |
| } | |
| } | |
| const staleLabels = existingLabels.filter(label => !desiredLabels.includes(label)); | |
| for (const label of staleLabels) { | |
| await uninstallLabel(label); | |
| } | |
| return { | |
| synced, | |
| removed: staleLabels, | |
| unchanged, | |
| desired: desiredLabels, | |
| existing: existingLabels, | |
| }; | |
| } | |
| export async function loadDefinitionFromDirectory(dir: string, scope: Scope): Promise<DiscoveredJobDefinition> { | |
| return ensureJobDefinition(scope, dir); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { syncAllJobs } from "./manager"; | |
| async function main(): Promise<void> { | |
| const report = await syncAllJobs(); | |
| console.log(`[jobs] Synced ${report.synced.length} jobs`); | |
| if (report.synced.length > 0) { | |
| console.log(`[jobs] Updated jobs: ${report.synced.join(", ")}`); | |
| } | |
| if (report.removed.length > 0) { | |
| console.log(`[jobs] Removed stale jobs: ${report.removed.join(", ")}`); | |
| } | |
| } | |
| main().catch((error) => { | |
| console.error("[jobs] Sync failed:", error); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment