@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
}
|