@onmars/lunar-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,202 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+ import { log } from './logger'
5
+
6
+ /**
7
+ * Lunar configuration.
8
+ * Loaded from .env file + environment variables.
9
+ *
10
+ * Secrets and credentials live here (.env).
11
+ * Non-sensitive config (channels, moons, security) lives in config.yaml.
12
+ */
13
+ export interface LunarConfig {
14
+ /** Project root directory (or CWD for npm-installed users) */
15
+ rootDir: string
16
+
17
+ /** Moon workspace directory */
18
+ moonWorkspace: string
19
+
20
+ /** Runtime data directory (sessions.db, PID file, logs). Defaults to moonWorkspace. */
21
+ dataDir: string
22
+
23
+ /**
24
+ * Discord bot token.
25
+ * @deprecated Use platforms.discord.token in config.yaml instead.
26
+ * Kept for backward compat — if platforms section exists, this is ignored.
27
+ */
28
+ discordToken?: string
29
+ /**
30
+ * Allowed Discord user IDs.
31
+ * @deprecated Use platforms.discord.allowedUsers in config.yaml instead.
32
+ */
33
+ discordAllowedUsers?: string[]
34
+ /**
35
+ * Discord guild ID.
36
+ * @deprecated Use platforms.discord.guild in config.yaml instead.
37
+ */
38
+ discordGuildId?: string
39
+
40
+ /** Agent backend to use */
41
+ agentBackend: 'claude' | 'codex' | 'ollama'
42
+
43
+ /** Brain API URL (for long-term memory) */
44
+ brainUrl?: string
45
+
46
+ /** ElevenLabs API key */
47
+ elevenlabsApiKey?: string
48
+ /** ElevenLabs voice ID */
49
+ elevenlabsVoiceId?: string
50
+
51
+ /** Log level */
52
+ logLevel: string
53
+ /** Development mode */
54
+ dev: boolean
55
+ }
56
+
57
+ /**
58
+ * Find the project root by looking for package.json with workspaces.
59
+ * Returns null if not found (user installed via npm, not cloned repo).
60
+ */
61
+ function findProjectRoot(startDir: string): string | null {
62
+ let dir = startDir
63
+ for (let i = 0; i < 10; i++) {
64
+ const pkgPath = resolve(dir, 'package.json')
65
+ if (existsSync(pkgPath)) {
66
+ try {
67
+ const pkg = JSON.parse(require('node:fs').readFileSync(pkgPath, 'utf-8'))
68
+ if (pkg.workspaces || pkg.name === 'lunar') {
69
+ return dir
70
+ }
71
+ } catch {}
72
+ }
73
+ const parent = resolve(dir, '..')
74
+ if (parent === dir) break
75
+ dir = parent
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Load .env file manually into process.env.
82
+ * Doesn't override existing env vars (env > file).
83
+ * Returns true if the file was found and loaded.
84
+ */
85
+ function loadEnvFile(filePath: string): boolean {
86
+ if (!existsSync(filePath)) return false
87
+
88
+ const content = require('node:fs').readFileSync(filePath, 'utf-8') as string
89
+ let loaded = 0
90
+
91
+ for (const line of content.split('\n')) {
92
+ const trimmed = line.trim()
93
+ if (!trimmed || trimmed.startsWith('#')) continue
94
+
95
+ const eqIndex = trimmed.indexOf('=')
96
+ if (eqIndex === -1) continue
97
+
98
+ const key = trimmed.slice(0, eqIndex).trim()
99
+ let value = trimmed.slice(eqIndex + 1).trim()
100
+
101
+ // Strip surrounding quotes
102
+ if (
103
+ (value.startsWith('"') && value.endsWith('"')) ||
104
+ (value.startsWith("'") && value.endsWith("'"))
105
+ ) {
106
+ value = value.slice(1, -1)
107
+ }
108
+
109
+ // Don't override existing env vars
110
+ if (!(key in process.env)) {
111
+ process.env[key] = value
112
+ loaded++
113
+ }
114
+ }
115
+
116
+ return loaded > 0 || true // File exists = loaded
117
+ }
118
+
119
+ /**
120
+ * Load configuration from environment variables.
121
+ *
122
+ * .env search order (first found wins, vars don't override):
123
+ * 1. Environment variables already set (Docker, systemd, export)
124
+ * 2. .env in CWD
125
+ * 3. .env in workspace (~/.lunar/.env)
126
+ * 4. .env in project root (for repo developers)
127
+ */
128
+ export function loadConfig(rootDir?: string): LunarConfig {
129
+ const cwd = process.cwd()
130
+ const workspace = resolve(process.env.MOON_WORKSPACE ?? resolve(homedir(), '.lunar'))
131
+ const projectRoot = rootDir ?? findProjectRoot(cwd)
132
+
133
+ // Load .env files in priority order (later files don't override earlier ones)
134
+ const envSources: Array<{ path: string; label: string }> = [
135
+ { path: resolve(cwd, '.env'), label: 'cwd' },
136
+ { path: resolve(workspace, '.env'), label: 'workspace' },
137
+ ]
138
+
139
+ if (projectRoot) {
140
+ envSources.push({ path: resolve(projectRoot, '.env'), label: 'project-root' })
141
+ }
142
+
143
+ const loadedFrom: string[] = []
144
+ for (const source of envSources) {
145
+ if (loadEnvFile(source.path)) {
146
+ loadedFrom.push(source.label)
147
+ log.debug({ path: source.path, source: source.label }, 'Loaded .env file')
148
+ }
149
+ }
150
+
151
+ if (loadedFrom.length === 0) {
152
+ log.warn(
153
+ {
154
+ searched: envSources.map((s) => s.path),
155
+ },
156
+ 'No .env file found — relying on environment variables only',
157
+ )
158
+ } else {
159
+ log.info({ sources: loadedFrom }, 'Configuration loaded from .env')
160
+ }
161
+
162
+ // Helper functions
163
+ const env = (key: string, fallback?: string): string => {
164
+ const raw = process.env[key]
165
+ const val = raw !== undefined && raw !== '' ? raw : fallback
166
+ if (val === undefined || val === '') {
167
+ throw new Error(
168
+ `Missing required environment variable: ${key}\nSet it in your environment, or create a .env file in one of:\n${envSources.map((s) => ` - ${s.path}`).join('\n')}`,
169
+ )
170
+ }
171
+ return val
172
+ }
173
+
174
+ const envOptional = (key: string): string | undefined => {
175
+ const val = process.env[key]
176
+ return val !== undefined && val !== '' ? val : undefined
177
+ }
178
+
179
+ const envList = (key: string, fallback: string[] = []): string[] => {
180
+ const val = process.env[key]
181
+ if (!val) return fallback
182
+ return val
183
+ .split(',')
184
+ .map((s) => s.trim())
185
+ .filter(Boolean)
186
+ }
187
+
188
+ return {
189
+ rootDir: projectRoot ?? cwd,
190
+ moonWorkspace: workspace,
191
+ dataDir: resolve(envOptional('LUNAR_DATA_DIR') ?? workspace, 'store'),
192
+ discordToken: envOptional('DISCORD_TOKEN'),
193
+ discordAllowedUsers: envList('DISCORD_ALLOWED_USERS'),
194
+ discordGuildId: envOptional('DISCORD_GUILD_ID'),
195
+ agentBackend: (envOptional('AGENT_BACKEND') ?? 'claude') as LunarConfig['agentBackend'],
196
+ brainUrl: envOptional('BRAIN_URL'),
197
+ elevenlabsApiKey: envOptional('ELEVENLABS_API_KEY'),
198
+ elevenlabsVoiceId: envOptional('ELEVENLABS_VOICE_ID'),
199
+ logLevel: envOptional('LOG_LEVEL') ?? 'info',
200
+ dev: envOptional('NODE_ENV') === 'development' || process.argv.includes('--dev'),
201
+ }
202
+ }
@@ -0,0 +1,388 @@
1
+ //
2
+ // Cron Parser — Zero-dependency cron expression parser.
3
+ //
4
+ // Spec: Standard 5-field cron format
5
+ // minute (0-59) hour (0-23) day-of-month (1-31) month (1-12) day-of-week (0-7, 0=7=Sun)
6
+ //
7
+ // Supports:
8
+ // - Exact values: 45 6 * * 1
9
+ // - Ranges: 0 9-17 * * *
10
+ // - Lists: 0,15,30,45 * * * *
11
+ // - Steps: */15 * * * * (and range/steps: 1-5/2)
12
+ // - Wildcards: *
13
+ // - Day names: MON-FRI, SUN, etc.
14
+ // - Month names: JAN-DEC
15
+ //
16
+ // Not supported (v0.3 simplicity):
17
+ // - 6-field (seconds)
18
+ // - @yearly, @monthly aliases
19
+ // - L, W, # (Quartz extensions)
20
+ //
21
+ // Timezone support via Intl.DateTimeFormat.
22
+ //
23
+
24
+ const DAY_NAMES: Record<string, number> = {
25
+ SUN: 0,
26
+ MON: 1,
27
+ TUE: 2,
28
+ WED: 3,
29
+ THU: 4,
30
+ FRI: 5,
31
+ SAT: 6,
32
+ }
33
+
34
+ const MONTH_NAMES: Record<string, number> = {
35
+ JAN: 1,
36
+ FEB: 2,
37
+ MAR: 3,
38
+ APR: 4,
39
+ MAY: 5,
40
+ JUN: 6,
41
+ JUL: 7,
42
+ AUG: 8,
43
+ SEP: 9,
44
+ OCT: 10,
45
+ NOV: 11,
46
+ DEC: 12,
47
+ }
48
+
49
+ /** Parsed cron expression — each field is a Set of allowed values */
50
+ export interface CronFields {
51
+ minutes: Set<number>
52
+ hours: Set<number>
53
+ daysOfMonth: Set<number>
54
+ months: Set<number>
55
+ daysOfWeek: Set<number>
56
+ }
57
+
58
+ /**
59
+ * Parse a single cron field into a Set of allowed integer values.
60
+ *
61
+ * @param field - The field string (e.g., "1-5", "star/15", "0,30")
62
+ * @param min - Minimum allowed value (inclusive)
63
+ * @param max - Maximum allowed value (inclusive)
64
+ * @param nameMap - Optional name->number mapping (days, months)
65
+ */
66
+ export function parseField(
67
+ field: string,
68
+ min: number,
69
+ max: number,
70
+ nameMap?: Record<string, number>,
71
+ ): Set<number> {
72
+ const result = new Set<number>()
73
+
74
+ // Replace names with numbers (case-insensitive)
75
+ let resolved = field.toUpperCase()
76
+ if (nameMap) {
77
+ for (const [name, num] of Object.entries(nameMap)) {
78
+ resolved = resolved.replace(new RegExp(name, 'g'), String(num))
79
+ }
80
+ }
81
+
82
+ // Split on comma for lists: "1,3,5" or "1-3,7"
83
+ const parts = resolved.split(',')
84
+
85
+ for (const part of parts) {
86
+ // Check for step: "*/2" or "1-5/2"
87
+ const [rangePart, stepStr] = part.split('/')
88
+ const step = stepStr ? Number.parseInt(stepStr, 10) : 1
89
+
90
+ if (Number.isNaN(step) || step < 1) {
91
+ throw new Error(`Invalid step value in cron field: ${field}`)
92
+ }
93
+
94
+ if (rangePart === '*') {
95
+ // Wildcard with optional step
96
+ for (let i = min; i <= max; i += step) {
97
+ result.add(i)
98
+ }
99
+ } else if (rangePart.includes('-')) {
100
+ // Range: "1-5" or "MON-FRI"
101
+ const [startStr, endStr] = rangePart.split('-')
102
+ const start = Number.parseInt(startStr, 10)
103
+ const end = Number.parseInt(endStr, 10)
104
+
105
+ if (Number.isNaN(start) || Number.isNaN(end)) {
106
+ throw new Error(`Invalid range in cron field: ${field}`)
107
+ }
108
+ if (start < min || end > max) {
109
+ throw new Error(`Range ${start}-${end} out of bounds [${min}-${max}] in: ${field}`)
110
+ }
111
+
112
+ for (let i = start; i <= end; i += step) {
113
+ result.add(i)
114
+ }
115
+ } else {
116
+ // Single value
117
+ const val = Number.parseInt(rangePart, 10)
118
+ if (Number.isNaN(val)) {
119
+ throw new Error(`Invalid value in cron field: ${field}`)
120
+ }
121
+ if (val < min || val > max) {
122
+ throw new Error(`Value ${val} out of bounds [${min}-${max}] in: ${field}`)
123
+ }
124
+ result.add(val)
125
+ }
126
+ }
127
+
128
+ return result
129
+ }
130
+
131
+ /**
132
+ * Parse a 5-field cron expression into structured fields.
133
+ *
134
+ * @param expr - Cron expression string (e.g., "45 6 * * 1-5")
135
+ * @returns Parsed fields with Sets of allowed values
136
+ * @throws Error if the expression is invalid
137
+ */
138
+ export function parseCron(expr: string): CronFields {
139
+ const parts = expr.trim().split(/\s+/)
140
+ if (parts.length !== 5) {
141
+ throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length} in "${expr}"`)
142
+ }
143
+
144
+ const [minuteField, hourField, domField, monthField, dowField] = parts
145
+
146
+ // Parse day-of-week: normalize 7 -> 0 (both mean Sunday)
147
+ const rawDow = parseField(dowField, 0, 7, DAY_NAMES)
148
+ const daysOfWeek = new Set<number>()
149
+ for (const d of rawDow) {
150
+ daysOfWeek.add(d === 7 ? 0 : d)
151
+ }
152
+
153
+ return {
154
+ minutes: parseField(minuteField, 0, 59),
155
+ hours: parseField(hourField, 0, 23),
156
+ daysOfMonth: parseField(domField, 1, 31),
157
+ months: parseField(monthField, 1, 12, MONTH_NAMES),
158
+ daysOfWeek,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get the components of a Date in a specific timezone.
164
+ * Uses Intl.DateTimeFormat for timezone conversion.
165
+ */
166
+ function getDateInTz(
167
+ date: Date,
168
+ tz: string,
169
+ ): {
170
+ year: number
171
+ month: number
172
+ day: number
173
+ hour: number
174
+ minute: number
175
+ dow: number
176
+ } {
177
+ const fmt = new Intl.DateTimeFormat('en-US', {
178
+ timeZone: tz,
179
+ year: 'numeric',
180
+ month: 'numeric',
181
+ day: 'numeric',
182
+ hour: 'numeric',
183
+ minute: 'numeric',
184
+ weekday: 'short',
185
+ hour12: false,
186
+ })
187
+
188
+ const parts = fmt.formatToParts(date)
189
+ const get = (type: string) => {
190
+ const p = parts.find((p) => p.type === type)
191
+ return p ? Number.parseInt(p.value, 10) : 0
192
+ }
193
+
194
+ const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun'
195
+ const dowMap: Record<string, number> = {
196
+ Sun: 0,
197
+ Mon: 1,
198
+ Tue: 2,
199
+ Wed: 3,
200
+ Thu: 4,
201
+ Fri: 5,
202
+ Sat: 6,
203
+ }
204
+
205
+ // Intl may return hour=24 for midnight in some locales
206
+ let hour = get('hour')
207
+ if (hour === 24) hour = 0
208
+
209
+ return {
210
+ year: get('year'),
211
+ month: get('month'),
212
+ day: get('day'),
213
+ hour,
214
+ minute: get('minute'),
215
+ dow: dowMap[weekdayStr] ?? 0,
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Create a Date from timezone-local components.
221
+ * Finds the UTC instant that corresponds to the given local time.
222
+ */
223
+ function createDateInTz(
224
+ year: number,
225
+ month: number,
226
+ day: number,
227
+ hour: number,
228
+ minute: number,
229
+ tz: string,
230
+ ): Date {
231
+ // Start with a UTC estimate
232
+ const estimate = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0))
233
+
234
+ // Get the offset by comparing what the estimate looks like in the target TZ
235
+ const local = getDateInTz(estimate, tz)
236
+
237
+ // Compute offset: how many minutes ahead/behind is the TZ vs our estimate
238
+ const diffMinutes = local.hour * 60 + local.minute - (hour * 60 + minute)
239
+
240
+ // Handle day boundary crossings
241
+ let adjustMs = diffMinutes * 60 * 1000
242
+ if (local.day !== day) {
243
+ // Day wrapped -- large offset (e.g., crossing midnight)
244
+ if (local.day > day || local.month > month) {
245
+ adjustMs += 24 * 60 * 60 * 1000
246
+ } else {
247
+ adjustMs -= 24 * 60 * 60 * 1000
248
+ }
249
+ }
250
+
251
+ return new Date(estimate.getTime() - adjustMs)
252
+ }
253
+
254
+ /** Maximum iterations to prevent infinite loops in nextOccurrence */
255
+ const MAX_ITERATIONS = 366 * 24 // ~1 year of hours (skipping optimizations avoid minute-level iteration)
256
+
257
+ /**
258
+ * Compute the next occurrence of a cron expression after a reference time.
259
+ *
260
+ * Algorithm: Starting from refTime + 1 minute (rounded to minute boundary),
261
+ * iterate forward checking each component against the parsed fields.
262
+ * Optimized: skips forward by month/day/hour when possible instead of
263
+ * iterating minute by minute.
264
+ *
265
+ * @param fields - Parsed cron fields
266
+ * @param refTime - Reference time (Date or ms timestamp)
267
+ * @param tz - Timezone string (default: 'UTC')
268
+ * @returns Next matching Date, or null if none found within ~1 year
269
+ */
270
+ export function nextOccurrence(
271
+ fields: CronFields,
272
+ refTime: Date | number,
273
+ tz = 'UTC',
274
+ ): Date | null {
275
+ const ref = typeof refTime === 'number' ? new Date(refTime) : refTime
276
+
277
+ // Start from next minute (ceiling to minute boundary)
278
+ let cursor = new Date(ref.getTime())
279
+ cursor.setUTCSeconds(0, 0)
280
+ cursor = new Date(cursor.getTime() + 60_000) // +1 minute
281
+
282
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
283
+ const t = getDateInTz(cursor, tz)
284
+
285
+ // Check month
286
+ if (!fields.months.has(t.month)) {
287
+ // Skip to first day of next matching month
288
+ const nextMonth = getNextInSet(fields.months, t.month)
289
+ if (nextMonth !== null && nextMonth > t.month) {
290
+ cursor = createDateInTz(t.year, nextMonth, 1, 0, 0, tz)
291
+ } else {
292
+ // Wrap to next year
293
+ const firstMonth = Math.min(...fields.months)
294
+ cursor = createDateInTz(t.year + 1, firstMonth, 1, 0, 0, tz)
295
+ }
296
+ continue
297
+ }
298
+
299
+ // Check day-of-month AND day-of-week
300
+ // Standard cron behavior:
301
+ // - Both wildcard: any day matches
302
+ // - Only one restricted: that one must match
303
+ // - Both restricted: either matching is sufficient (OR)
304
+ const domMatch = fields.daysOfMonth.has(t.day)
305
+ const dowMatch = fields.daysOfWeek.has(t.dow)
306
+
307
+ const domIsWildcard = fields.daysOfMonth.size === 31
308
+ const dowIsWildcard = fields.daysOfWeek.size === 7
309
+
310
+ let dayMatch: boolean
311
+ if (domIsWildcard && dowIsWildcard) {
312
+ dayMatch = true
313
+ } else if (domIsWildcard) {
314
+ dayMatch = dowMatch
315
+ } else if (dowIsWildcard) {
316
+ dayMatch = domMatch
317
+ } else {
318
+ // Both restricted -> OR (standard cron behavior)
319
+ dayMatch = domMatch || dowMatch
320
+ }
321
+
322
+ if (!dayMatch) {
323
+ // Skip to next day
324
+ cursor = createDateInTz(t.year, t.month, t.day + 1, 0, 0, tz)
325
+ continue
326
+ }
327
+
328
+ // Check hour
329
+ if (!fields.hours.has(t.hour)) {
330
+ const nextHour = getNextInSet(fields.hours, t.hour)
331
+ if (nextHour !== null && nextHour > t.hour) {
332
+ cursor = createDateInTz(t.year, t.month, t.day, nextHour, 0, tz)
333
+ } else {
334
+ // Skip to next day
335
+ cursor = createDateInTz(t.year, t.month, t.day + 1, 0, 0, tz)
336
+ }
337
+ continue
338
+ }
339
+
340
+ // Check minute
341
+ if (!fields.minutes.has(t.minute)) {
342
+ const nextMinute = getNextInSet(fields.minutes, t.minute)
343
+ if (nextMinute !== null && nextMinute > t.minute) {
344
+ cursor = createDateInTz(t.year, t.month, t.day, t.hour, nextMinute, tz)
345
+ } else {
346
+ // Skip to next hour
347
+ cursor = createDateInTz(t.year, t.month, t.day, t.hour + 1, 0, tz)
348
+ }
349
+ continue
350
+ }
351
+
352
+ // All fields match!
353
+ return cursor
354
+ }
355
+
356
+ return null // No match within iteration limit
357
+ }
358
+
359
+ /**
360
+ * Get the next value in a Set that is strictly greater than `current`.
361
+ * Returns null if no such value exists.
362
+ */
363
+ function getNextInSet(set: Set<number>, current: number): number | null {
364
+ let best: number | null = null
365
+ for (const v of set) {
366
+ if (v > current && (best === null || v < best)) {
367
+ best = v
368
+ }
369
+ }
370
+ return best
371
+ }
372
+
373
+ /**
374
+ * Convenience: parse a cron expression and compute the next occurrence.
375
+ *
376
+ * @param expr - Cron expression string
377
+ * @param refTime - Reference time (default: now)
378
+ * @param tz - Timezone (default: 'UTC')
379
+ * @returns Next matching Date, or null if none found
380
+ */
381
+ export function nextCron(
382
+ expr: string,
383
+ refTime: Date | number = new Date(),
384
+ tz = 'UTC',
385
+ ): Date | null {
386
+ const fields = parseCron(expr)
387
+ return nextOccurrence(fields, refTime, tz)
388
+ }