cli4ai 0.8.2 → 0.8.3

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.
@@ -2,7 +2,7 @@
2
2
  * Global cli4ai configuration (~/.cli4ai/)
3
3
  */
4
4
 
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync, realpathSync } from 'fs';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync, realpathSync, openSync, closeSync, unlinkSync, renameSync } from 'fs';
6
6
  import { resolve, join, normalize } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { outputError, log } from '../lib/cli.js';
@@ -69,12 +69,17 @@ export function isPathSafe(path: string): boolean {
69
69
  // PATHS
70
70
  // ═══════════════════════════════════════════════════════════════════════════
71
71
 
72
- export const CLI4AI_HOME = resolve(homedir(), '.cli4ai');
72
+ export const CLI4AI_HOME = process.env.CLI4AI_HOME
73
+ ? resolve(process.env.CLI4AI_HOME)
74
+ : resolve(homedir(), '.cli4ai');
73
75
  export const CONFIG_FILE = resolve(CLI4AI_HOME, 'config.json');
74
76
  export const PACKAGES_DIR = resolve(CLI4AI_HOME, 'packages');
75
77
  export const CACHE_DIR = resolve(CLI4AI_HOME, 'cache');
76
78
  export const ROUTINES_DIR = resolve(CLI4AI_HOME, 'routines');
79
+ export const SCHEDULER_DIR = resolve(CLI4AI_HOME, 'scheduler');
77
80
  export const CREDENTIALS_FILE = resolve(CLI4AI_HOME, 'credentials.json');
81
+ const CONFIG_LOCK_FILE = CONFIG_FILE + '.lock';
82
+ const CONFIG_TMP_FILE = CONFIG_FILE + '.tmp';
78
83
 
79
84
  // Local project paths
80
85
  export const LOCAL_DIR = '.cli4ai';
@@ -116,7 +121,7 @@ export interface InstalledPackage {
116
121
  // ═══════════════════════════════════════════════════════════════════════════
117
122
 
118
123
  export const DEFAULT_CONFIG: Config = {
119
- registry: 'https://registry.cliforai.com',
124
+ registry: 'https://registry.cli4ai.com',
120
125
  localRegistries: [],
121
126
  defaultRuntime: 'bun',
122
127
  mcp: {
@@ -134,7 +139,7 @@ export const DEFAULT_CONFIG: Config = {
134
139
  * Ensure ~/.cli4ai directory exists with required subdirectories
135
140
  */
136
141
  export function ensureCli4aiHome(): void {
137
- const dirs = [CLI4AI_HOME, PACKAGES_DIR, CACHE_DIR, ROUTINES_DIR];
142
+ const dirs = [CLI4AI_HOME, PACKAGES_DIR, CACHE_DIR, ROUTINES_DIR, SCHEDULER_DIR];
138
143
 
139
144
  for (const dir of dirs) {
140
145
  if (!existsSync(dir)) {
@@ -200,30 +205,26 @@ function deepMerge(target: Config, source: Partial<Config>): Config {
200
205
  * Load global config, creating defaults if needed
201
206
  */
202
207
  export function loadConfig(): Config {
203
- ensureCli4aiHome();
204
-
205
- if (!existsSync(CONFIG_FILE)) {
206
- saveConfig(DEFAULT_CONFIG);
207
- return DEFAULT_CONFIG;
208
- }
209
-
210
- try {
211
- const content = readFileSync(CONFIG_FILE, 'utf-8');
212
- const data = JSON.parse(content);
213
- // Deep merge with defaults to handle missing nested fields (e.g., mcp.port)
214
- return deepMerge(DEFAULT_CONFIG, data);
215
- } catch {
216
- log(`Warning: Invalid config file, using defaults`);
217
- return DEFAULT_CONFIG;
218
- }
208
+ return withConfigLock(() => loadConfigUnlocked());
219
209
  }
220
210
 
221
211
  /**
222
212
  * Save global config
223
213
  */
224
214
  export function saveConfig(config: Config): void {
225
- ensureCli4aiHome();
226
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
215
+ withConfigLock(() => saveConfigUnlocked(config));
216
+ }
217
+
218
+ /**
219
+ * Update config with a read-modify-write lock to avoid cross-process races.
220
+ */
221
+ export function updateConfig(mutator: (config: Config) => Config): Config {
222
+ return withConfigLock(() => {
223
+ const current = loadConfigUnlocked();
224
+ const next = mutator(current);
225
+ saveConfigUnlocked(next);
226
+ return next;
227
+ });
227
228
  }
228
229
 
229
230
  /**
@@ -238,9 +239,10 @@ export function getConfigValue<K extends keyof Config>(key: K): Config[K] {
238
239
  * Set a config value
239
240
  */
240
241
  export function setConfigValue<K extends keyof Config>(key: K, value: Config[K]): void {
241
- const config = loadConfig();
242
- config[key] = value;
243
- saveConfig(config);
242
+ updateConfig((config) => {
243
+ config[key] = value;
244
+ return config;
245
+ });
244
246
  }
245
247
 
246
248
  // ═══════════════════════════════════════════════════════════════════════════
@@ -295,7 +297,6 @@ function isRegistryPathSafe(registryPath: string): boolean {
295
297
  * Add a local registry path
296
298
  */
297
299
  export function addLocalRegistry(path: string): void {
298
- const config = loadConfig();
299
300
  const absolutePath = resolve(path);
300
301
 
301
302
  // SECURITY: Validate registry path is not in sensitive system directories
@@ -317,28 +318,190 @@ export function addLocalRegistry(path: string): void {
317
318
  });
318
319
  }
319
320
 
320
- if (!config.localRegistries.includes(safePath)) {
321
- config.localRegistries.push(safePath);
322
- saveConfig(config);
323
- log(`Added local registry: ${safePath}`);
324
- }
321
+ let added = false;
322
+ updateConfig((config) => {
323
+ if (!config.localRegistries.includes(safePath)) {
324
+ config.localRegistries.push(safePath);
325
+ added = true;
326
+ }
327
+ return config;
328
+ });
329
+ if (added) log(`Added local registry: ${safePath}`);
325
330
  }
326
331
 
327
332
  /**
328
333
  * Remove a local registry path
329
334
  */
330
335
  export function removeLocalRegistry(path: string): void {
331
- const config = loadConfig();
332
336
  const absolutePath = resolve(path);
333
- const index = config.localRegistries.indexOf(absolutePath);
337
+ const safePath = validateSymlinkTarget(absolutePath) ?? absolutePath;
338
+
339
+ let removed = false;
340
+ updateConfig((config) => {
341
+ const index = config.localRegistries.indexOf(safePath);
342
+ if (index !== -1) {
343
+ config.localRegistries.splice(index, 1);
344
+ removed = true;
345
+ }
346
+ return config;
347
+ });
348
+ if (removed) log(`Removed local registry: ${safePath}`);
349
+ }
350
+
351
+ function sleepSync(ms: number): void {
352
+ if (ms <= 0) return;
353
+ try {
354
+ const buf = new SharedArrayBuffer(4);
355
+ const view = new Int32Array(buf);
356
+ Atomics.wait(view, 0, 0, ms);
357
+ } catch {
358
+ const end = Date.now() + ms;
359
+ while (Date.now() < end) {
360
+ // busy wait (best effort)
361
+ }
362
+ }
363
+ }
364
+
365
+ function isPidRunning(pid: number): boolean {
366
+ try {
367
+ process.kill(pid, 0);
368
+ return true;
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ function isLockStale(lockPath: string, staleMs: number): boolean {
375
+ try {
376
+ const stat = lstatSync(lockPath);
377
+ if (Date.now() - stat.mtimeMs > staleMs) return true;
378
+ } catch {
379
+ return false;
380
+ }
381
+
382
+ try {
383
+ const raw = readFileSync(lockPath, 'utf-8');
384
+ const parsed = JSON.parse(raw) as { pid?: unknown; createdAt?: unknown };
385
+ const pid = typeof parsed.pid === 'number' ? parsed.pid : null;
386
+ const createdAtMs =
387
+ typeof parsed.createdAt === 'number'
388
+ ? parsed.createdAt
389
+ : typeof parsed.createdAt === 'string'
390
+ ? Date.parse(parsed.createdAt)
391
+ : NaN;
392
+
393
+ if (pid !== null && !isPidRunning(pid)) return true;
394
+ if (Number.isFinite(createdAtMs) && Date.now() - createdAtMs > staleMs) return true;
395
+ } catch {
396
+ // ignore parse errors (fall back to mtime check above)
397
+ }
398
+
399
+ return false;
400
+ }
401
+
402
+ function acquireLock(lockPath: string, timeoutMs: number, staleMs: number): number {
403
+ const start = Date.now();
404
+ while (true) {
405
+ try {
406
+ const fd = openSync(lockPath, 'wx');
407
+ try {
408
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + '\n');
409
+ } catch {
410
+ // best effort
411
+ }
412
+ return fd;
413
+ } catch (err) {
414
+ const code = (err as NodeJS.ErrnoException).code;
415
+ if (code !== 'EEXIST') {
416
+ throw err;
417
+ }
418
+
419
+ if (isLockStale(lockPath, staleMs)) {
420
+ try {
421
+ unlinkSync(lockPath);
422
+ continue;
423
+ } catch {
424
+ // fall through to wait
425
+ }
426
+ }
427
+
428
+ if (Date.now() - start > timeoutMs) {
429
+ throw new Error(`Timed out waiting for config lock: ${lockPath}`);
430
+ }
431
+
432
+ sleepSync(25);
433
+ }
434
+ }
435
+ }
436
+
437
+ function releaseLock(lockPath: string, fd: number): void {
438
+ try {
439
+ closeSync(fd);
440
+ } catch {
441
+ // ignore
442
+ }
443
+ try {
444
+ unlinkSync(lockPath);
445
+ } catch {
446
+ // ignore
447
+ }
448
+ }
449
+
450
+ let configLockDepth = 0;
451
+ let configLockFd: number | null = null;
452
+
453
+ function withConfigLock<T>(fn: () => T): T {
454
+ const timeoutMs = 2000;
455
+ const staleMs = 30000;
456
+
457
+ if (configLockDepth > 0) {
458
+ configLockDepth++;
459
+ try {
460
+ return fn();
461
+ } finally {
462
+ configLockDepth--;
463
+ }
464
+ }
465
+
466
+ configLockDepth = 1;
467
+ const fd = acquireLock(CONFIG_LOCK_FILE, timeoutMs, staleMs);
468
+ configLockFd = fd;
334
469
 
335
- if (index !== -1) {
336
- config.localRegistries.splice(index, 1);
337
- saveConfig(config);
338
- log(`Removed local registry: ${absolutePath}`);
470
+ try {
471
+ return fn();
472
+ } finally {
473
+ configLockFd = null;
474
+ configLockDepth = 0;
475
+ releaseLock(CONFIG_LOCK_FILE, fd);
339
476
  }
340
477
  }
341
478
 
479
+ function loadConfigUnlocked(): Config {
480
+ ensureCli4aiHome();
481
+
482
+ if (!existsSync(CONFIG_FILE)) {
483
+ saveConfigUnlocked(DEFAULT_CONFIG);
484
+ return DEFAULT_CONFIG;
485
+ }
486
+
487
+ try {
488
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
489
+ const data = JSON.parse(content);
490
+ // Deep merge with defaults to handle missing nested fields (e.g., mcp.port)
491
+ return deepMerge(DEFAULT_CONFIG, data);
492
+ } catch {
493
+ log(`Warning: Invalid config file, using defaults`);
494
+ return DEFAULT_CONFIG;
495
+ }
496
+ }
497
+
498
+ function saveConfigUnlocked(config: Config): void {
499
+ ensureCli4aiHome();
500
+ const content = JSON.stringify(config, null, 2) + '\n';
501
+ writeFileSync(CONFIG_TMP_FILE, content);
502
+ renameSync(CONFIG_TMP_FILE, CONFIG_FILE);
503
+ }
504
+
342
505
  // ═══════════════════════════════════════════════════════════════════════════
343
506
  // INSTALLED PACKAGES TRACKING
344
507
  // ═══════════════════════════════════════════════════════════════════════════
@@ -10,13 +10,26 @@ import { spawn, spawnSync } from 'child_process';
10
10
  import { resolve } from 'path';
11
11
  import { existsSync, readFileSync } from 'fs';
12
12
  import { createInterface } from 'readline';
13
- import { platform } from 'os';
13
+ import { platform, homedir } from 'os';
14
14
  import { log } from '../lib/cli.js';
15
15
  import { findPackage } from './config.js';
16
16
  import { loadManifest, type Manifest } from './manifest.js';
17
17
  import { getSecret } from './secrets.js';
18
18
  import { checkPackageIntegrity } from './lockfile.js';
19
19
 
20
+ /**
21
+ * Expand ~ to home directory in paths
22
+ */
23
+ function expandTilde(path: string): string {
24
+ if (path.startsWith('~/')) {
25
+ return resolve(homedir(), path.slice(2));
26
+ }
27
+ if (path === '~') {
28
+ return homedir();
29
+ }
30
+ return path;
31
+ }
32
+
20
33
  export type ExecuteCaptureMode = 'inherit' | 'pipe';
21
34
 
22
35
  export interface ExecuteToolOptions {
@@ -309,8 +322,10 @@ async function checkAndPromptSecrets(pkgPath: string, pkgName: string): Promise<
309
322
  });
310
323
  }
311
324
 
312
- setSecret(key, value);
313
- secretsEnv[key] = value;
325
+ // Expand ~ to home directory for paths
326
+ const expandedValue = expandTilde(value);
327
+ setSecret(key, expandedValue);
328
+ secretsEnv[key] = expandedValue;
314
329
  log(` ✓ ${key} saved to vault\n`);
315
330
  }
316
331
 
@@ -318,8 +333,7 @@ async function checkAndPromptSecrets(pkgPath: string, pkgName: string): Promise<
318
333
  return secretsEnv;
319
334
  }
320
335
 
321
- function buildRuntimeCommand(entryPath: string, manifest: Manifest, cmdArgs: string[]): { execCmd: string; execArgs: string[]; runtime: 'bun' | 'node' } {
322
- const runtime = manifest.runtime || 'bun';
336
+ function buildRuntimeCommand(entryPath: string, runtime: 'bun' | 'node', cmdArgs: string[]): { execCmd: string; execArgs: string[]; runtime: 'bun' | 'node' } {
323
337
  switch (runtime) {
324
338
  case 'node':
325
339
  return { execCmd: 'node', execArgs: [entryPath, ...cmdArgs], runtime: 'node' };
@@ -329,6 +343,40 @@ function buildRuntimeCommand(entryPath: string, manifest: Manifest, cmdArgs: str
329
343
  }
330
344
  }
331
345
 
346
+ function resolveRuntime(manifest: Manifest): 'bun' | 'node' {
347
+ if (manifest.runtime === 'node') return 'node';
348
+ if (manifest.runtime === 'bun') return 'bun';
349
+
350
+ // Unspecified runtime: prefer bun if installed, otherwise fall back to node if available.
351
+ if (commandExists('bun')) return 'bun';
352
+ if (commandExists('node')) return 'node';
353
+
354
+ // Default to bun (will prompt install below).
355
+ return 'bun';
356
+ }
357
+
358
+ async function ensureRuntimeAvailable(runtime: 'bun' | 'node'): Promise<void> {
359
+ if (runtime === 'bun') {
360
+ if (!commandExists('bun')) {
361
+ log('⚠️ bun is required to run this tool\n');
362
+ const installed = await installDependency('bun');
363
+ if (!installed) {
364
+ throw new ExecuteToolError('MISSING_DEPENDENCY', 'bun is required', {
365
+ hint: 'Install bun: curl -fsSL https://bun.sh/install | bash'
366
+ });
367
+ }
368
+ }
369
+ return;
370
+ }
371
+
372
+ // runtime === 'node'
373
+ if (!commandExists('node')) {
374
+ throw new ExecuteToolError('MISSING_DEPENDENCY', 'node is required', {
375
+ hint: 'Install Node.js: https://nodejs.org/en/download/'
376
+ });
377
+ }
378
+ }
379
+
332
380
  function collectStream(stream: NodeJS.ReadableStream): Promise<string> {
333
381
  return new Promise((resolve, reject) => {
334
382
  const chunks: Buffer[] = [];
@@ -378,15 +426,8 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
378
426
  log('');
379
427
  }
380
428
 
381
- if (!commandExists('bun')) {
382
- log('⚠️ bun is required to run cli4ai tools\n');
383
- const installed = await installDependency('bun');
384
- if (!installed) {
385
- throw new ExecuteToolError('MISSING_DEPENDENCY', 'bun is required', {
386
- hint: 'Install bun: curl -fsSL https://bun.sh/install | bash'
387
- });
388
- }
389
- }
429
+ const runtime = resolveRuntime(manifest);
430
+ await ensureRuntimeAvailable(runtime);
390
431
 
391
432
  await checkPeerDependencies(pkg.path);
392
433
 
@@ -403,7 +444,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
403
444
  if (options.command) cmdArgs.push(options.command);
404
445
  cmdArgs.push(...options.args);
405
446
 
406
- const { execCmd, execArgs, runtime } = buildRuntimeCommand(entryPath, manifest, cmdArgs);
447
+ const { execCmd, execArgs } = buildRuntimeCommand(entryPath, runtime, cmdArgs);
407
448
 
408
449
  const teeStderr = options.teeStderr ?? true;
409
450
 
package/src/core/link.ts CHANGED
@@ -81,7 +81,18 @@ exec cli4ai run ${safeName} "$@"
81
81
  `;
82
82
 
83
83
  writeFileSync(binPath, script);
84
- chmodSync(binPath, 0o755);
84
+ if (process.platform !== 'win32') {
85
+ chmodSync(binPath, 0o755);
86
+ }
87
+
88
+ // Windows compatibility: generate .cmd and .ps1 launchers
89
+ if (process.platform === 'win32') {
90
+ const cmdContent = `@echo off\r\ncli4ai run ${manifest.name} %*\r\nexit /b %errorlevel%\r\n`;
91
+ writeFileSync(binPath + '.cmd', cmdContent);
92
+
93
+ const ps1Content = `& cli4ai run ${manifest.name} @args\nexit $LASTEXITCODE\n`;
94
+ writeFileSync(binPath + '.ps1', ps1Content);
95
+ }
85
96
 
86
97
  return binPath;
87
98
  }
@@ -134,7 +145,27 @@ exec ${execCommand} "$@"
134
145
  `;
135
146
 
136
147
  writeFileSync(binPath, script);
137
- chmodSync(binPath, 0o755);
148
+ if (process.platform !== 'win32') {
149
+ chmodSync(binPath, 0o755);
150
+ }
151
+
152
+ // Windows compatibility: generate .cmd and .ps1 launchers
153
+ if (process.platform === 'win32') {
154
+ const quotedEntry = `"${entryPath.replaceAll('"', '""')}"`;
155
+ const runtimeCmd = runtime === 'node' ? 'node' : 'bun';
156
+
157
+ const cmdContent =
158
+ runtime === 'node'
159
+ ? `@echo off\r\n${runtimeCmd} ${quotedEntry} %*\r\nexit /b %errorlevel%\r\n`
160
+ : `@echo off\r\n${runtimeCmd} run ${quotedEntry} %*\r\nexit /b %errorlevel%\r\n`;
161
+ writeFileSync(binPath + '.cmd', cmdContent);
162
+
163
+ const ps1Content =
164
+ runtime === 'node'
165
+ ? `& "${runtimeCmd}" ${quotedEntry} @args\nexit $LASTEXITCODE\n`
166
+ : `& "${runtimeCmd}" run ${quotedEntry} @args\nexit $LASTEXITCODE\n`;
167
+ writeFileSync(binPath + '.ps1', ps1Content);
168
+ }
138
169
 
139
170
  return binPath;
140
171
  }
@@ -143,21 +174,33 @@ exec ${execCommand} "$@"
143
174
  * Remove executable link for a package
144
175
  */
145
176
  export function unlinkPackage(packageName: string): boolean {
146
- const binPath = join(C4AI_BIN, packageName);
147
-
148
- if (existsSync(binPath)) {
149
- unlinkSync(binPath);
150
- return true;
177
+ const basePath = join(C4AI_BIN, packageName);
178
+ const candidates = process.platform === 'win32'
179
+ ? [basePath, basePath + '.cmd', basePath + '.ps1']
180
+ : [basePath];
181
+
182
+ let removed = false;
183
+ for (const path of candidates) {
184
+ if (existsSync(path)) {
185
+ unlinkSync(path);
186
+ removed = true;
187
+ }
151
188
  }
152
189
 
153
- return false;
190
+ return removed;
154
191
  }
155
192
 
156
193
  /**
157
194
  * Check if a package is linked
158
195
  */
159
196
  export function isPackageLinked(packageName: string): boolean {
160
- return existsSync(join(C4AI_BIN, packageName));
197
+ const basePath = join(C4AI_BIN, packageName);
198
+ if (existsSync(basePath)) return true;
199
+ if (process.platform === 'win32') {
200
+ if (existsSync(basePath + '.cmd')) return true;
201
+ if (existsSync(basePath + '.ps1')) return true;
202
+ }
203
+ return false;
161
204
  }
162
205
 
163
206
  /**
@@ -8,7 +8,7 @@
8
8
  * to detect tampered packages.
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
11
+ import { readFileSync, writeFileSync, renameSync, existsSync, readdirSync, statSync } from 'fs';
12
12
  import { resolve, join, relative } from 'path';
13
13
  import { createHash } from 'crypto';
14
14
 
@@ -211,7 +211,9 @@ export function loadLockfile(projectDir: string): Lockfile {
211
211
  export function saveLockfile(projectDir: string, lockfile: Lockfile): void {
212
212
  const lockfilePath = getLockfilePath(projectDir);
213
213
  const content = JSON.stringify(lockfile, null, 2) + '\n';
214
- writeFileSync(lockfilePath, content);
214
+ const tmpPath = lockfilePath + '.tmp';
215
+ writeFileSync(tmpPath, content);
216
+ renameSync(tmpPath, lockfilePath);
215
217
  }
216
218
 
217
219
  /**
@@ -38,6 +38,27 @@ export class RoutineTemplateError extends Error {
38
38
 
39
39
  export type StepCapture = 'inherit' | 'text' | 'json';
40
40
 
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // SCHEDULE TYPES
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
45
+ export interface RoutineSchedule {
46
+ /** Cron expression (e.g., "0 9 * * *" for 9am daily) */
47
+ cron?: string;
48
+ /** Simple interval (e.g., "30s", "5m", "1h", "1d") */
49
+ interval?: string;
50
+ /** IANA timezone (e.g., "Pacific/Auckland"). Defaults to system timezone */
51
+ timezone?: string;
52
+ /** Whether this schedule is active. Defaults to true */
53
+ enabled?: boolean;
54
+ /** Number of retry attempts on failure. Defaults to 0 */
55
+ retries?: number;
56
+ /** Delay between retries in milliseconds. Defaults to 60000 */
57
+ retryDelayMs?: number;
58
+ /** What to do if previous run is still executing. Defaults to 'skip' */
59
+ concurrency?: 'skip' | 'queue';
60
+ }
61
+
41
62
  export interface RoutineVarDef {
42
63
  default?: string;
43
64
  }
@@ -81,6 +102,8 @@ export interface RoutineDefinition {
81
102
  description?: string;
82
103
  mcp?: { expose?: boolean; description?: string };
83
104
  vars?: Record<string, RoutineVarDef>;
105
+ /** Schedule configuration for automatic execution */
106
+ schedule?: RoutineSchedule;
84
107
  steps: RoutineStep[];
85
108
  result?: unknown;
86
109
  }
@@ -140,6 +163,80 @@ export function loadRoutineDefinition(path: string): RoutineDefinition {
140
163
  return validateRoutineDefinition(data, path);
141
164
  }
142
165
 
166
+ const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
167
+
168
+ /**
169
+ * Validate a schedule configuration.
170
+ * Exported for use by scheduler and tests.
171
+ */
172
+ export function validateScheduleConfig(schedule: unknown, source?: string): RoutineSchedule {
173
+ if (!schedule || typeof schedule !== 'object') {
174
+ throw new RoutineValidationError('Schedule must be an object', { source });
175
+ }
176
+
177
+ const s = schedule as Record<string, unknown>;
178
+
179
+ // Must have either cron or interval (or both)
180
+ if (s.cron === undefined && s.interval === undefined) {
181
+ throw new RoutineValidationError('Schedule must have "cron" or "interval"', { source });
182
+ }
183
+
184
+ // Validate cron (basic format check - full validation happens at runtime with cron-parser)
185
+ if (s.cron !== undefined) {
186
+ if (typeof s.cron !== 'string' || s.cron.trim().length === 0) {
187
+ throw new RoutineValidationError('Schedule "cron" must be a non-empty string', { source, got: s.cron });
188
+ }
189
+ // Basic cron format: should have 5 space-separated parts
190
+ const parts = s.cron.trim().split(/\s+/);
191
+ if (parts.length < 5 || parts.length > 6) {
192
+ throw new RoutineValidationError('Schedule "cron" must have 5 or 6 fields', { source, got: s.cron });
193
+ }
194
+ }
195
+
196
+ // Validate interval
197
+ if (s.interval !== undefined) {
198
+ if (typeof s.interval !== 'string') {
199
+ throw new RoutineValidationError('Schedule "interval" must be a string', { source, got: s.interval });
200
+ }
201
+ if (!INTERVAL_PATTERN.test(s.interval)) {
202
+ throw new RoutineValidationError('Schedule "interval" must be like "30s", "5m", "1h", or "1d"', { source, got: s.interval });
203
+ }
204
+ }
205
+
206
+ // Validate timezone (just check it's a string - actual validation happens at runtime)
207
+ if (s.timezone !== undefined && typeof s.timezone !== 'string') {
208
+ throw new RoutineValidationError('Schedule "timezone" must be a string', { source, got: s.timezone });
209
+ }
210
+
211
+ // Validate enabled
212
+ if (s.enabled !== undefined && typeof s.enabled !== 'boolean') {
213
+ throw new RoutineValidationError('Schedule "enabled" must be a boolean', { source, got: s.enabled });
214
+ }
215
+
216
+ // Validate retries
217
+ if (s.retries !== undefined) {
218
+ if (typeof s.retries !== 'number' || !Number.isInteger(s.retries) || s.retries < 0) {
219
+ throw new RoutineValidationError('Schedule "retries" must be a non-negative integer', { source, got: s.retries });
220
+ }
221
+ }
222
+
223
+ // Validate retryDelayMs
224
+ if (s.retryDelayMs !== undefined) {
225
+ if (typeof s.retryDelayMs !== 'number' || !Number.isInteger(s.retryDelayMs) || s.retryDelayMs < 0) {
226
+ throw new RoutineValidationError('Schedule "retryDelayMs" must be a non-negative integer', { source, got: s.retryDelayMs });
227
+ }
228
+ }
229
+
230
+ // Validate concurrency
231
+ if (s.concurrency !== undefined) {
232
+ if (s.concurrency !== 'skip' && s.concurrency !== 'queue') {
233
+ throw new RoutineValidationError('Schedule "concurrency" must be "skip" or "queue"', { source, got: s.concurrency });
234
+ }
235
+ }
236
+
237
+ return s as RoutineSchedule;
238
+ }
239
+
143
240
  function validateRoutineDefinition(value: unknown, source?: string): RoutineDefinition {
144
241
  if (!value || typeof value !== 'object') {
145
242
  throw new RoutineValidationError('Routine must be an object', { source });
@@ -155,6 +252,11 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
155
252
  throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
156
253
  }
157
254
 
255
+ // Validate schedule if present
256
+ if (obj.schedule !== undefined) {
257
+ validateScheduleConfig(obj.schedule, source);
258
+ }
259
+
158
260
  if (!Array.isArray(obj.steps)) {
159
261
  throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
160
262
  }
@@ -255,7 +357,7 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
255
357
  }
256
358
  }
257
359
 
258
- return obj as RoutineDefinition;
360
+ return obj as unknown as RoutineDefinition;
259
361
  }
260
362
 
261
363
  function getPath(root: unknown, segments: string[]): unknown {