Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
9.5 kB · 308 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309// Minimal cron expression parsing and next-run calculation.//// Supports the standard 5-field cron subset:// minute hour day-of-month month day-of-week//// Field syntax: wildcard, N, step (star-slash-N), range (N-M), list (N,M,...).// No L, W, ?, or name aliases. All times are interpreted in the process's// local timezone — "0 9 * * *" means 9am wherever the CLI is running.
export type CronFields = { minute: number[] hour: number[] dayOfMonth: number[] month: number[] dayOfWeek: number[]}
type FieldRange = { min: number; max: number }
const FIELD_RANGES: FieldRange[] = [ { min: 0, max: 59 }, // minute { min: 0, max: 23 }, // hour { min: 1, max: 31 }, // dayOfMonth { min: 1, max: 12 }, // month { min: 0, max: 6 }, // dayOfWeek (0=Sunday; 7 accepted as Sunday alias)]
// Parse a single cron field into a sorted array of matching values.// Supports: wildcard, N, star-slash-N (step), N-M (range), and comma-lists.// Returns null if invalid.function expandField(field: string, range: FieldRange): number[] | null { const { min, max } = range const out = new Set<number>()
for (const part of field.split(',')) { // wildcard or star-slash-N const stepMatch = part.match(/^\*(?:\/(\d+))?$/) if (stepMatch) { const step = stepMatch[1] ? parseInt(stepMatch[1], 10) : 1 if (step < 1) return null for (let i = min; i <= max; i += step) out.add(i) continue }
// N-M or N-M/S const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/) if (rangeMatch) { const lo = parseInt(rangeMatch[1]!, 10) const hi = parseInt(rangeMatch[2]!, 10) const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1 // dayOfWeek: accept 7 as Sunday alias in ranges (e.g. 5-7 = Fri,Sat,Sun → [5,6,0]) const isDow = min === 0 && max === 6 const effMax = isDow ? 7 : max if (lo > hi || step < 1 || lo < min || hi > effMax) return null for (let i = lo; i <= hi; i += step) { out.add(isDow && i === 7 ? 0 : i) } continue }
// plain N const singleMatch = part.match(/^\d+$/) if (singleMatch) { let n = parseInt(part, 10) // dayOfWeek: accept 7 as Sunday alias → 0 if (min === 0 && max === 6 && n === 7) n = 0 if (n < min || n > max) return null out.add(n) continue }
return null }
if (out.size === 0) return null return Array.from(out).sort((a, b) => a - b)}
/** * Parse a 5-field cron expression into expanded number arrays. * Returns null if invalid or unsupported syntax. */export function parseCronExpression(expr: string): CronFields | null { const parts = expr.trim().split(/\s+/) if (parts.length !== 5) return null
const expanded: number[][] = [] for (let i = 0; i < 5; i++) { const result = expandField(parts[i]!, FIELD_RANGES[i]!) if (!result) return null expanded.push(result) }
return { minute: expanded[0]!, hour: expanded[1]!, dayOfMonth: expanded[2]!, month: expanded[3]!, dayOfWeek: expanded[4]!, }}
/** * Compute the next Date strictly after `from` that matches the cron fields, * using the process's local timezone. Walks forward minute-by-minute. Bounded * at 366 days; returns null if no match (impossible for valid cron, but * satisfies the type). * * Standard cron semantics: when both dayOfMonth and dayOfWeek are constrained * (neither is the full range), a date matches if EITHER matches. * * DST: fixed-hour crons targeting a spring-forward gap (e.g. `30 2 * * *` * in a US timezone) skip the transition day — the gap hour never appears * in local time, so the hour-set check fails and the loop moves on. * Wildcard-hour crons (`30 * * * *`) fire at the first valid minute after * the gap. Fall-back repeats fire once (the step-forward logic jumps past * the second occurrence). This matches vixie-cron behavior. */export function computeNextCronRun( fields: CronFields, from: Date,): Date | null { const minuteSet = new Set(fields.minute) const hourSet = new Set(fields.hour) const domSet = new Set(fields.dayOfMonth) const monthSet = new Set(fields.month) const dowSet = new Set(fields.dayOfWeek)
// Is the field wildcarded (full range)? const domWild = fields.dayOfMonth.length === 31 const dowWild = fields.dayOfWeek.length === 7
// Round up to the next whole minute (strictly after `from`) const t = new Date(from.getTime()) t.setSeconds(0, 0) t.setMinutes(t.getMinutes() + 1)
const maxIter = 366 * 24 * 60 for (let i = 0; i < maxIter; i++) { const month = t.getMonth() + 1 if (!monthSet.has(month)) { // Jump to start of next month t.setMonth(t.getMonth() + 1, 1) t.setHours(0, 0, 0, 0) continue }
const dom = t.getDate() const dow = t.getDay() // When both dom/dow are constrained, either match is sufficient (OR semantics) const dayMatches = domWild && dowWild ? true : domWild ? dowSet.has(dow) : dowWild ? domSet.has(dom) : domSet.has(dom) || dowSet.has(dow)
if (!dayMatches) { // Jump to start of next day t.setDate(t.getDate() + 1) t.setHours(0, 0, 0, 0) continue }
if (!hourSet.has(t.getHours())) { t.setHours(t.getHours() + 1, 0, 0, 0) continue }
if (!minuteSet.has(t.getMinutes())) { t.setMinutes(t.getMinutes() + 1) continue }
return t }
return null}
// --- cronToHuman ------------------------------------------------------------// Intentionally narrow: covers common patterns; falls through to the raw cron// string for anything else. The `utc` option exists for CCR remote triggers// (agents-platform.tsx), which run on servers and always use UTC cron strings// — that path translates UTC→local for display and needs midnight-crossing// logic for the weekday case. Local scheduled tasks (the default) need neither.
const DAY_NAMES = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',]
function formatLocalTime(minute: number, hour: number): string { // January 1 — no DST gap anywhere. Using `new Date()` (today) would roll // 2am→3am on the one spring-forward day per year. const d = new Date(2000, 0, 1, hour, minute) return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
function formatUtcTimeAsLocal(minute: number, hour: number): string { // Create a date in UTC and format in user's local timezone const d = new Date() d.setUTCHours(hour, minute, 0, 0) return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short', })}
export function cronToHuman(cron: string, opts?: { utc?: boolean }): string { const utc = opts?.utc ?? false const parts = cron.trim().split(/\s+/) if (parts.length !== 5) return cron
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [ string, string, string, string, string, ]
// Every N minutes: step/N * * * * const everyMinMatch = minute.match(/^\*\/(\d+)$/) if ( everyMinMatch && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { const n = parseInt(everyMinMatch[1]!, 10) return n === 1 ? 'Every minute' : `Every ${n} minutes` }
// Every hour: 0 * * * * if ( minute.match(/^\d+$/) && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { const m = parseInt(minute, 10) if (m === 0) return 'Every hour' return `Every hour at :${m.toString().padStart(2, '0')}` }
// Every N hours: 0 step/N * * * const everyHourMatch = hour.match(/^\*\/(\d+)$/) if ( minute.match(/^\d+$/) && everyHourMatch && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' ) { const n = parseInt(everyHourMatch[1]!, 10) const m = parseInt(minute, 10) const suffix = m === 0 ? '' : ` at :${m.toString().padStart(2, '0')}` return n === 1 ? `Every hour${suffix}` : `Every ${n} hours${suffix}` }
// --- Remaining cases reference hour+minute: branch on utc ----------------
if (!minute.match(/^\d+$/) || !hour.match(/^\d+$/)) return cron const m = parseInt(minute, 10) const h = parseInt(hour, 10) const fmtTime = utc ? formatUtcTimeAsLocal : formatLocalTime
// Daily at specific time: M H * * * if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { return `Every day at ${fmtTime(m, h)}` }
// Specific day of week: M H * * D if (dayOfMonth === '*' && month === '*' && dayOfWeek.match(/^\d$/)) { const dayIndex = parseInt(dayOfWeek, 10) % 7 // normalize 7 (Sunday alias) -> 0 let dayName: string | undefined if (utc) { // UTC day+time may land on a different local day (midnight crossing). // Compute the actual local weekday by constructing the UTC instant. const ref = new Date() const daysToAdd = (dayIndex - ref.getUTCDay() + 7) % 7 ref.setUTCDate(ref.getUTCDate() + daysToAdd) ref.setUTCHours(h, m, 0, 0) dayName = DAY_NAMES[ref.getDay()] } else { dayName = DAY_NAMES[dayIndex] } if (dayName) return `Every ${dayName} at ${fmtTime(m, h)}` }
// Weekdays: M H * * 1-5 if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') { return `Weekdays at ${fmtTime(m, h)}` }
return cron}