import { Hype } from '@because/hype' import { define, stylesToCSS } from '@because/forge' import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { discoverCronJobs } from './lib/discovery' import { scheduleJob, stopJob } from './lib/scheduler' import { executeJob } from './lib/executor' import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state' import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules' import type { Child } from 'hono/jsx' import { join } from 'path' import { mkdir, writeFile } from 'fs/promises' import { existsSync, watch } from 'fs' const APPS_DIR = process.env.APPS_DIR! const app = new Hype({ prettyHTML: false }) // Styles (follow versions tool pattern) const Container = define('Container', { fontFamily: theme('fonts-sans'), padding: '20px', paddingTop: 0, maxWidth: '900px', margin: '0 auto', color: theme('colors-text'), }) const JobList = define('JobList', { listStyle: 'none', padding: 0, margin: '20px 0', border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), overflow: 'hidden', }) const JobItem = define('JobItem', { padding: '12px 15px', borderBottom: `1px solid ${theme('colors-border')}`, display: 'flex', alignItems: 'center', gap: '15px', states: { ':last-child': { borderBottom: 'none' }, ':hover': { backgroundColor: theme('colors-bgHover') }, }, }) const StatusDot = define('StatusDot', { width: '10px', height: '10px', borderRadius: '50%', flexShrink: 0, }) const JobName = define('JobName', { fontFamily: theme('fonts-mono'), fontSize: '14px', flex: 1, }) const Schedule = define('Schedule', { fontSize: '13px', color: theme('colors-textMuted'), minWidth: '80px', }) const Time = define('Time', { fontSize: '12px', color: theme('colors-textMuted'), minWidth: '100px', }) const RunButton = define('RunButton', { base: 'button', padding: '4px 10px', fontSize: '12px', backgroundColor: theme('colors-primary'), color: 'white', border: 'none', borderRadius: theme('radius-md'), cursor: 'pointer', states: { ':hover': { opacity: 0.9 }, ':disabled': { opacity: 0.5, cursor: 'not-allowed' }, }, }) const EmptyState = define('EmptyState', { padding: '40px 20px', textAlign: 'center', color: theme('colors-textMuted'), }) const InvalidItem = define('InvalidItem', { padding: '12px 15px', borderBottom: `1px solid ${theme('colors-border')}`, display: 'flex', alignItems: 'center', gap: '15px', opacity: 0.7, states: { ':last-child': { borderBottom: 'none' }, }, }) const ErrorText = define('ErrorText', { fontSize: '12px', color: theme('colors-error'), flex: 1, }) const ActionRow = define('ActionRow', { display: 'flex', justifyContent: 'flex-end', marginBottom: '15px', }) const NewButton = define('NewButton', { base: 'a', padding: '8px 16px', fontSize: '14px', backgroundColor: theme('colors-primary'), color: 'white', border: 'none', borderRadius: theme('radius-md'), cursor: 'pointer', textDecoration: 'none', display: 'inline-block', states: { ':hover': { opacity: 0.9 }, }, }) const buttonStyles = { padding: '8px 16px', fontSize: '14px', backgroundColor: theme('colors-primary'), color: 'white', border: 'none', borderRadius: theme('radius-md'), cursor: 'pointer', } const Form = define('Form', { display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '400px', }) const FormGroup = define('FormGroup', { display: 'flex', flexDirection: 'column', gap: '6px', }) const Label = define('Label', { fontSize: '14px', fontWeight: 500, }) const inputStyles = { padding: '8px 12px', fontSize: '14px', border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), backgroundColor: theme('colors-bg'), color: theme('colors-text'), } const ButtonRow = define('ButtonRow', { display: 'flex', gap: '10px', marginTop: '10px', }) const CancelButton = define('CancelButton', { base: 'a', padding: '8px 16px', fontSize: '14px', backgroundColor: 'transparent', color: theme('colors-text'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), cursor: 'pointer', textDecoration: 'none', states: { ':hover': { backgroundColor: theme('colors-bgHover') }, }, }) const BackLink = define('BackLink', { base: 'a', fontSize: '13px', color: theme('colors-textMuted'), textDecoration: 'none', states: { ':hover': { color: theme('colors-text') }, }, }) const DetailHeader = define('DetailHeader', { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px', }) const DetailTitle = define('DetailTitle', { base: 'h1', fontFamily: theme('fonts-mono'), fontSize: '18px', fontWeight: 600, margin: 0, flex: 1, }) const DetailMeta = define('DetailMeta', { display: 'flex', gap: '20px', marginBottom: '20px', fontSize: '13px', color: theme('colors-textMuted'), }) const MetaItem = define('MetaItem', { display: 'flex', gap: '6px', }) const MetaLabel = define('MetaLabel', { fontWeight: 500, color: theme('colors-text'), }) const OutputSection = define('OutputSection', { marginTop: '20px', }) const OutputLabel = define('OutputLabel', { fontSize: '13px', fontWeight: 500, marginBottom: '8px', }) const OutputBlock = define('OutputBlock', { base: 'pre', fontFamily: theme('fonts-mono'), fontSize: '12px', lineHeight: 1.5, padding: '12px', backgroundColor: theme('colors-bgElement'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), overflowX: 'auto', overflowY: 'auto', maxHeight: '60vh', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, }) const ErrorBlock = define('ErrorBlock', { base: 'pre', fontFamily: theme('fonts-mono'), fontSize: '12px', lineHeight: 1.5, padding: '12px', backgroundColor: theme('colors-bgElement'), border: `1px solid ${theme('colors-error')}`, borderRadius: theme('radius-md'), color: theme('colors-error'), overflowX: 'auto', overflowY: 'auto', maxHeight: '60vh', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, }) const StatusBadge = define('StatusBadge', { base: 'span', fontSize: '12px', padding: '2px 8px', borderRadius: '9999px', fontWeight: 500, }) // Layout function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) { return ( {refresh && } {title} {children} ) } function formatRelative(ts?: number): string { if (!ts) return '-' const diff = Date.now() - ts if (diff < 0) { const mins = Math.round(-diff / 60000) if (mins < 60) return `in ${mins}m` const hours = Math.round(mins / 60) if (hours < 24) return `in ${hours}h` return `in ${Math.round(hours / 24)}d` } const mins = Math.round(diff / 60000) if (mins < 60) return `${mins}m ago` const hours = Math.round(mins / 60) if (hours < 24) return `${hours}h ago` return `${Math.round(hours / 24)}d ago` } function statusColor(job: CronJob): string { if (job.state === 'running') return theme('colors-statusRunning') if (job.state === 'disabled') return theme('colors-textMuted') if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error') return theme('colors-statusRunning') } // Routes app.get('/ok', c => c.text('ok')) app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8', })) app.get('/', async c => { const appFilter = c.req.query('app') let jobs = getAllJobs() let invalid = getInvalidJobs() if (appFilter) { jobs = jobs.filter(j => j.app === appFilter) invalid = invalid.filter(j => j.app === appFilter) } jobs.sort((a, b) => a.id.localeCompare(b.id)) invalid.sort((a, b) => a.id.localeCompare(b.id)) const hasAny = jobs.length > 0 || invalid.length > 0 const anyRunning = jobs.some(j => j.state === 'running') return c.html( New Job {!hasAny ? ( No cron jobs found.
Create a cron/*.ts file in any app to get started.
) : ( {jobs.map(job => ( {job.app}/{job.name} {job.schedule}
{job.state === 'running' ? 'Running...' : 'Run Now'}
))} {invalid.map(job => ( {job.app}/{job.name} {job.error} ))}
)}
) }) function statusBadgeStyle(job: CronJob): Record { if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' } if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' } return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') } } function statusLabel(job: CronJob): string { if (job.state === 'running') return 'running' if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}` if (job.lastRun) return 'ok' return 'idle' } function formatDuration(ms?: number): string { if (!ms) return '-' if (ms < 1000) return `${ms}ms` if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` return `${Math.round(ms / 60000)}m` } app.get('/job/:app/:name', async c => { const id = `${c.req.param('app')}:${c.req.param('name')}` const job = getJob(id) const appFilter = c.req.query('app') const backUrl = appFilter ? `/?app=${appFilter}` : '/' if (!job) { return c.html( ← Back Job not found: {id} ) } return c.html( ← Back
{job.app}/{job.name} {statusLabel(job)}
{job.state === 'running' ? 'Running...' : 'Run Now'}
Schedule {job.schedule} Last run {job.state === 'running' ? 'now' : formatRelative(job.lastRun)} Duration {job.state === 'running' ? formatDuration(Date.now() - job.lastRun!) : formatDuration(job.lastDuration)} Next run {formatRelative(job.nextRun)} {job.lastError && ( Error {job.lastError} )} {job.lastOutput ? ( Output {job.lastOutput} ) : job.state === 'running' ? ( Output Waiting for output... ) : job.lastRun && !job.lastError ? ( No output ) : null}
) }) app.get('/new', async c => { const appName = c.req.query('app') || '' return c.html(
Cancel
) }) app.post('/new', async c => { const body = await c.req.parseBody() const appName = body.app as string const name = body.name as string const schedule = body.schedule as string if (!appName || !name || !schedule) { return c.redirect('/new?error=missing-fields') } // Validate name (lowercase, numbers, hyphens) if (!/^[a-z0-9-]+$/.test(name)) { return c.redirect('/new?error=invalid-name') } const cronDir = join(APPS_DIR, appName, 'current', 'cron') const filePath = join(cronDir, `${name}.ts`) // Check if file already exists if (existsSync(filePath)) { return c.redirect('/new?error=already-exists') } // Create cron directory if needed if (!existsSync(cronDir)) { await mkdir(cronDir, { recursive: true }) } // Write the cron file const content = `export const schedule = "${schedule}" export default async function() { console.log("${appName}/${name} executed at", new Date().toISOString()) } ` await writeFile(filePath, content) console.log(`[cron] Created ${appName}:${name}`) // Trigger rediscovery const { jobs, invalid } = await discoverCronJobs() setJobs(jobs) setInvalidJobs(invalid) for (const job of jobs) { if (job.id === `${appName}:${name}`) { scheduleJob(job, broadcast) console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`) } } return c.redirect('/') }) app.post('/run/:app/:name', async c => { const id = `${c.req.param('app')}:${c.req.param('name')}` const job = getJob(id) if (!job) { return c.redirect('/?error=not-found') } // Fire-and-forget so the redirect happens immediately executeJob(job, broadcast) const returnTo = c.req.query('return') const appFilter = c.req.query('app') if (returnTo === 'detail') { return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`) } return c.redirect(appFilter ? `/?app=${appFilter}` : '/') }) // Initialize async function init() { const { jobs, invalid } = await discoverCronJobs() setJobs(jobs) setInvalidJobs(invalid) console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`) for (const job of jobs) { scheduleJob(job, broadcast) console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`) } for (const job of invalid) { console.log(`[cron] Invalid ${job.id}: ${job.error}`) } } // Watch for cron file changes let debounceTimer: Timer | null = null async function rediscover() { const { jobs, invalid } = await discoverCronJobs() const existing = getAllJobs() // Stop removed jobs for (const old of existing) { if (!jobs.find(j => j.id === old.id)) { stopJob(old.id) console.log(`[cron] Removed ${old.id}`) } } // Add/update jobs for (const job of jobs) { const old = existing.find(j => j.id === job.id) if (!old || old.cronExpr !== job.cronExpr) { scheduleJob(job, broadcast) console.log(`[cron] Updated ${job.id}: ${job.schedule}`) } else { // Preserve runtime state job.state = old.state job.lastRun = old.lastRun job.lastDuration = old.lastDuration job.lastExitCode = old.lastExitCode job.lastError = old.lastError job.lastOutput = old.lastOutput job.nextRun = old.nextRun } } setJobs(jobs) setInvalidJobs(invalid) } watch(APPS_DIR, { recursive: true }, (_event, filename) => { if (!filename?.includes('/cron/') && !filename?.includes('\\cron\\')) return if (!filename.endsWith('.ts')) return if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(rediscover, 100) }) init() export default app.defaults