import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor'
import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob } from './lib/schedules'
import type { Child } from 'hono/jsx'
import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises'
import { existsSync } 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 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') },
},
})
// Layout
function Layout({ title, children }: { title: string; children: Child }) {
return (
{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('/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()
if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter)
}
jobs.sort((a, b) => a.id.localeCompare(b.id))
return c.html(
New Job
{jobs.length === 0 ? (
No cron jobs found.
Create a cron/*.ts file in any app to get started.
) : (
{jobs.map(job => (
{job.app}/{job.name}
{job.schedule}
))}
)}
)
})
app.get('/new', async c => {
const appName = c.req.query('app') || ''
return c.html(
)
})
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 = await discoverCronJobs()
setJobs(jobs)
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')
}
await executeJob(job, broadcast)
const appFilter = c.req.query('app')
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
})
// Initialize
async function init() {
const jobs = await discoverCronJobs()
setJobs(jobs)
console.log(`[cron] Discovered ${jobs.length} jobs`)
for (const job of jobs) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
}
// Re-discover every 60s
setInterval(async () => {
const jobs = 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.nextRun = old.nextRun
}
}
setJobs(jobs)
}, 60000)
init()
export default app.defaults