import { readdir, readFile } from 'fs/promises' import { existsSync } from 'fs' import { join } from 'path' import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules' const APPS_DIR = process.env.APPS_DIR! const SCHEDULE_RE = /export\s+const\s+schedule\s*=\s*['"]([^'"]+)['"]/ export async function getApps(): Promise { const entries = await readdir(APPS_DIR, { withFileTypes: true }) const apps: string[] = [] for (const entry of entries) { if (!entry.isDirectory()) continue // Check if it has a current symlink (valid app) if (existsSync(join(APPS_DIR, entry.name, 'current'))) { apps.push(entry.name) } } return apps.sort() } export type DiscoveryResult = { jobs: CronJob[] invalid: InvalidJob[] } export async function discoverCronJobs(): Promise { const jobs: CronJob[] = [] const invalid: InvalidJob[] = [] const apps = await readdir(APPS_DIR, { withFileTypes: true }) for (const app of apps) { if (!app.isDirectory()) continue const cronDir = join(APPS_DIR, app.name, 'current', 'cron') if (!existsSync(cronDir)) continue const files = await readdir(cronDir) for (const file of files) { if (!file.endsWith('.ts')) continue const filePath = join(cronDir, file) const name = file.replace(/\.ts$/, '') const id = `${app.name}:${name}` try { const source = await readFile(filePath, 'utf-8') const match = source.match(SCHEDULE_RE) if (!match) { invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' }) continue } const schedule = match[1] as Schedule if (!isValidSchedule(schedule)) { invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` }) continue } jobs.push({ id, app: app.name, name, file: filePath, schedule, cronExpr: toCronExpr(schedule), state: 'idle', }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) invalid.push({ id, app: app.name, name, file: filePath, error: msg }) } } } return { jobs, invalid } }