cli4ai 0.8.1 → 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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Scheduler CLI commands.
3
+ *
4
+ * cli4ai scheduler start [--foreground] # Start daemon
5
+ * cli4ai scheduler stop # Stop daemon
6
+ * cli4ai scheduler status # Show status + upcoming runs
7
+ * cli4ai scheduler logs [routine] # View logs
8
+ * cli4ai scheduler history [routine] # View execution history
9
+ * cli4ai scheduler run <routine> # Manual trigger
10
+ */
11
+
12
+ import { spawn } from 'child_process';
13
+ import { readFileSync, existsSync, createReadStream } from 'fs';
14
+ import { resolve, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import { output, outputError, log } from '../lib/cli.js';
17
+ import {
18
+ isDaemonRunning,
19
+ getDaemonPid,
20
+ removeDaemonPid,
21
+ loadSchedulerState,
22
+ getRunHistory,
23
+ getNextRunTime,
24
+ Scheduler,
25
+ SCHEDULER_LOG_FILE,
26
+ SCHEDULER_PID_FILE
27
+ } from '../core/scheduler.js';
28
+ import { getScheduledRoutines } from '../core/routines.js';
29
+
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+ // SCHEDULER START
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+
34
+ interface SchedulerStartOptions {
35
+ foreground?: boolean;
36
+ }
37
+
38
+ export async function schedulerStartCommand(options: SchedulerStartOptions): Promise<void> {
39
+ // Check if already running
40
+ if (isDaemonRunning()) {
41
+ const pid = getDaemonPid();
42
+ outputError('INVALID_INPUT', `Scheduler is already running (PID ${pid})`, {
43
+ hint: 'Use "cli4ai scheduler stop" to stop it first'
44
+ });
45
+ return;
46
+ }
47
+
48
+ // Clean up stale PID file
49
+ if (existsSync(SCHEDULER_PID_FILE)) {
50
+ removeDaemonPid();
51
+ }
52
+
53
+ if (options.foreground) {
54
+ // Run in foreground (blocking)
55
+ log('Starting scheduler in foreground mode...');
56
+ log('Press Ctrl+C to stop');
57
+
58
+ const scheduler = new Scheduler({ projectDir: process.cwd() });
59
+ await scheduler.start();
60
+
61
+ // Keep running until signal
62
+ await new Promise<void>((resolve) => {
63
+ process.on('SIGINT', async () => {
64
+ log('\nStopping scheduler...');
65
+ await scheduler.stop();
66
+ resolve();
67
+ });
68
+ process.on('SIGTERM', async () => {
69
+ await scheduler.stop();
70
+ resolve();
71
+ });
72
+ });
73
+
74
+ output({ status: 'stopped' });
75
+ return;
76
+ }
77
+
78
+ // Run as daemon (background)
79
+ const daemonScript = resolve(dirname(fileURLToPath(import.meta.url)), '../core/scheduler-daemon.ts');
80
+
81
+ // Spawn with detached mode
82
+ const child = spawn('bun', [daemonScript, '--project-dir', process.cwd()], {
83
+ detached: true,
84
+ stdio: 'ignore',
85
+ env: {
86
+ ...process.env,
87
+ CLI4AI_DAEMON: 'true'
88
+ }
89
+ });
90
+
91
+ // Unref to allow parent to exit
92
+ child.unref();
93
+
94
+ // Wait a moment for the daemon to write its PID
95
+ await new Promise(r => setTimeout(r, 500));
96
+
97
+ const pid = getDaemonPid();
98
+ if (pid && isDaemonRunning()) {
99
+ log(`Scheduler daemon started (PID ${pid})`);
100
+ output({ status: 'started', pid });
101
+ } else {
102
+ outputError('API_ERROR', 'Failed to start scheduler daemon', {
103
+ hint: 'Check logs with "cli4ai scheduler logs"'
104
+ });
105
+ }
106
+ }
107
+
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+ // SCHEDULER STOP
110
+ // ═══════════════════════════════════════════════════════════════════════════
111
+
112
+ export async function schedulerStopCommand(): Promise<void> {
113
+ const pid = getDaemonPid();
114
+
115
+ if (!pid) {
116
+ outputError('NOT_FOUND', 'Scheduler daemon is not running');
117
+ return;
118
+ }
119
+
120
+ if (!isDaemonRunning()) {
121
+ // Clean up stale PID file
122
+ removeDaemonPid();
123
+ log('Scheduler was not running (cleaned up stale PID file)');
124
+ output({ status: 'stopped', pid: null });
125
+ return;
126
+ }
127
+
128
+ log(`Stopping scheduler daemon (PID ${pid})...`);
129
+
130
+ try {
131
+ // Send SIGTERM for graceful shutdown
132
+ process.kill(pid, 'SIGTERM');
133
+
134
+ // Wait for process to exit (up to 10 seconds)
135
+ const maxWait = 10000;
136
+ const checkInterval = 100;
137
+ let waited = 0;
138
+
139
+ while (waited < maxWait) {
140
+ await new Promise(r => setTimeout(r, checkInterval));
141
+ waited += checkInterval;
142
+
143
+ if (!isDaemonRunning()) {
144
+ break;
145
+ }
146
+ }
147
+
148
+ if (isDaemonRunning()) {
149
+ // Force kill if still running
150
+ log('Scheduler did not stop gracefully, sending SIGKILL...');
151
+ process.kill(pid, 'SIGKILL');
152
+ await new Promise(r => setTimeout(r, 500));
153
+ }
154
+
155
+ removeDaemonPid();
156
+ log('Scheduler daemon stopped');
157
+ output({ status: 'stopped', pid });
158
+ } catch (err) {
159
+ if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
160
+ // Process doesn't exist
161
+ removeDaemonPid();
162
+ log('Scheduler was not running (cleaned up stale PID file)');
163
+ output({ status: 'stopped', pid: null });
164
+ } else {
165
+ outputError('API_ERROR', `Failed to stop scheduler: ${err instanceof Error ? err.message : String(err)}`);
166
+ }
167
+ }
168
+ }
169
+
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+ // SCHEDULER STATUS
172
+ // ═══════════════════════════════════════════════════════════════════════════
173
+
174
+ export async function schedulerStatusCommand(): Promise<void> {
175
+ const pid = getDaemonPid();
176
+ const running = isDaemonRunning();
177
+
178
+ // Get scheduled routines
179
+ const scheduledRoutines = getScheduledRoutines(process.cwd());
180
+ const state = loadSchedulerState();
181
+
182
+ if (!running) {
183
+ log('Scheduler: not running');
184
+
185
+ if (scheduledRoutines.length === 0) {
186
+ log('\nNo scheduled routines found.');
187
+ log('Add a "schedule" field to your .routine.json files to enable scheduling.');
188
+ } else {
189
+ log(`\nFound ${scheduledRoutines.length} scheduled routine(s):`);
190
+ for (const routine of scheduledRoutines) {
191
+ const scheduleStr = routine.schedule.cron
192
+ ? `cron: ${routine.schedule.cron}`
193
+ : `interval: ${routine.schedule.interval}`;
194
+ log(` - ${routine.name} (${scheduleStr})`);
195
+ }
196
+ log('\nStart the scheduler with: cli4ai scheduler start');
197
+ }
198
+
199
+ output({
200
+ running: false,
201
+ pid: null,
202
+ routines: scheduledRoutines.map(r => ({
203
+ name: r.name,
204
+ schedule: r.schedule,
205
+ path: r.path
206
+ }))
207
+ });
208
+ return;
209
+ }
210
+
211
+ log(`Scheduler: running (PID ${pid})`);
212
+
213
+ if (state) {
214
+ log(`Started: ${state.startedAt}`);
215
+
216
+ const routineStates = Object.values(state.routines);
217
+ if (routineStates.length === 0) {
218
+ log('\nNo scheduled routines.');
219
+ } else {
220
+ log(`\nScheduled routines (${routineStates.length}):`);
221
+ log('');
222
+
223
+ for (const routine of routineStates) {
224
+ const scheduleStr = routine.schedule.cron
225
+ ? `cron: ${routine.schedule.cron}`
226
+ : `interval: ${routine.schedule.interval}`;
227
+
228
+ const statusIcon = routine.running ? '⏳' :
229
+ routine.lastStatus === 'success' ? '✓' :
230
+ routine.lastStatus === 'failed' ? '✗' : '○';
231
+
232
+ log(` ${statusIcon} ${routine.name}`);
233
+ log(` Schedule: ${scheduleStr}`);
234
+
235
+ if (routine.nextRunAt) {
236
+ const nextRun = new Date(routine.nextRunAt);
237
+ const now = new Date();
238
+ const diffMs = nextRun.getTime() - now.getTime();
239
+ const diffMins = Math.round(diffMs / 60000);
240
+
241
+ if (diffMins < 1) {
242
+ log(` Next run: in < 1 minute`);
243
+ } else if (diffMins < 60) {
244
+ log(` Next run: in ${diffMins} minute${diffMins === 1 ? '' : 's'}`);
245
+ } else {
246
+ const diffHours = Math.round(diffMins / 60);
247
+ log(` Next run: in ${diffHours} hour${diffHours === 1 ? '' : 's'}`);
248
+ }
249
+ }
250
+
251
+ if (routine.lastRunAt) {
252
+ log(` Last run: ${routine.lastRunAt} (${routine.lastStatus})`);
253
+ }
254
+
255
+ log('');
256
+ }
257
+ }
258
+ }
259
+
260
+ output({
261
+ running: true,
262
+ pid,
263
+ startedAt: state?.startedAt,
264
+ routines: state ? Object.values(state.routines) : []
265
+ });
266
+ }
267
+
268
+ // ═══════════════════════════════════════════════════════════════════════════
269
+ // SCHEDULER LOGS
270
+ // ═══════════════════════════════════════════════════════════════════════════
271
+
272
+ interface SchedulerLogsOptions {
273
+ follow?: boolean;
274
+ lines?: number;
275
+ }
276
+
277
+ export async function schedulerLogsCommand(options: SchedulerLogsOptions): Promise<void> {
278
+ if (!existsSync(SCHEDULER_LOG_FILE)) {
279
+ log('No scheduler logs found.');
280
+ output({ logs: [] });
281
+ return;
282
+ }
283
+
284
+ const lines = options.lines ?? 50;
285
+
286
+ if (options.follow) {
287
+ // Tail -f mode
288
+ log(`Tailing scheduler logs (${SCHEDULER_LOG_FILE})...`);
289
+ log('Press Ctrl+C to stop\n');
290
+
291
+ // Read initial content
292
+ const content = readFileSync(SCHEDULER_LOG_FILE, 'utf-8');
293
+ const allLines = content.split('\n');
294
+ const lastLines = allLines.slice(-lines);
295
+ process.stdout.write(lastLines.join('\n') + '\n');
296
+
297
+ // Watch for new content
298
+ const { watch } = await import('fs');
299
+ let lastSize = content.length;
300
+
301
+ const watcher = watch(SCHEDULER_LOG_FILE, (eventType) => {
302
+ if (eventType === 'change') {
303
+ const newContent = readFileSync(SCHEDULER_LOG_FILE, 'utf-8');
304
+ if (newContent.length > lastSize) {
305
+ process.stdout.write(newContent.slice(lastSize));
306
+ lastSize = newContent.length;
307
+ }
308
+ }
309
+ });
310
+
311
+ // Keep running until interrupted
312
+ await new Promise<void>((resolve) => {
313
+ let closed = false;
314
+ const closeWatcher = () => {
315
+ if (closed) return;
316
+ closed = true;
317
+ try {
318
+ watcher.close();
319
+ } catch {
320
+ // ignore
321
+ }
322
+ };
323
+
324
+ const stop = () => {
325
+ closeWatcher();
326
+ resolve();
327
+ };
328
+
329
+ process.once('exit', closeWatcher);
330
+ process.once('SIGINT', stop);
331
+ process.once('SIGTERM', stop);
332
+ });
333
+ } else {
334
+ // Read last N lines
335
+ const content = readFileSync(SCHEDULER_LOG_FILE, 'utf-8');
336
+ const allLines = content.split('\n').filter(l => l.trim());
337
+ const lastLines = allLines.slice(-lines);
338
+
339
+ for (const line of lastLines) {
340
+ log(line);
341
+ }
342
+
343
+ output({ logs: lastLines });
344
+ }
345
+ }
346
+
347
+ // ═══════════════════════════════════════════════════════════════════════════
348
+ // SCHEDULER HISTORY
349
+ // ═══════════════════════════════════════════════════════════════════════════
350
+
351
+ interface SchedulerHistoryOptions {
352
+ limit?: number;
353
+ }
354
+
355
+ export async function schedulerHistoryCommand(routineName?: string, options: SchedulerHistoryOptions = {}): Promise<void> {
356
+ const limit = options.limit ?? 20;
357
+ const history = getRunHistory(routineName, limit);
358
+
359
+ if (history.length === 0) {
360
+ log(routineName
361
+ ? `No execution history found for routine: ${routineName}`
362
+ : 'No execution history found.');
363
+ output({ history: [] });
364
+ return;
365
+ }
366
+
367
+ log(routineName
368
+ ? `Execution history for ${routineName} (last ${history.length}):`
369
+ : `Execution history (last ${history.length}):`);
370
+ log('');
371
+
372
+ for (const record of history) {
373
+ const statusIcon = record.status === 'success' ? '✓' : '✗';
374
+ const duration = record.durationMs < 1000
375
+ ? `${record.durationMs}ms`
376
+ : `${(record.durationMs / 1000).toFixed(1)}s`;
377
+
378
+ log(` ${statusIcon} ${record.routine}`);
379
+ log(` Started: ${record.startedAt}`);
380
+ log(` Duration: ${duration}`);
381
+ if (record.retryAttempt > 0) {
382
+ log(` Retry: #${record.retryAttempt}`);
383
+ }
384
+ if (record.error) {
385
+ log(` Error: ${record.error}`);
386
+ }
387
+ log('');
388
+ }
389
+
390
+ output({ history });
391
+ }
392
+
393
+ // ═══════════════════════════════════════════════════════════════════════════
394
+ // SCHEDULER RUN
395
+ // ═══════════════════════════════════════════════════════════════════════════
396
+
397
+ export async function schedulerRunCommand(routineName: string): Promise<void> {
398
+ // Check if routine exists and is scheduled
399
+ const scheduledRoutines = getScheduledRoutines(process.cwd());
400
+ const routine = scheduledRoutines.find(r => r.name === routineName);
401
+
402
+ if (!routine) {
403
+ // Check if it exists but isn't scheduled
404
+ const { resolveRoutine } = await import('../core/routines.js');
405
+ const exists = resolveRoutine(routineName, process.cwd());
406
+
407
+ if (exists) {
408
+ outputError('NOT_FOUND', `Routine "${routineName}" exists but has no schedule configured`, {
409
+ hint: 'Add a "schedule" field to enable scheduling, or use "cli4ai routines run" for one-time execution'
410
+ });
411
+ } else {
412
+ outputError('NOT_FOUND', `Routine not found: ${routineName}`);
413
+ }
414
+ return;
415
+ }
416
+
417
+ log(`Running routine: ${routineName}`);
418
+
419
+ const scheduler = new Scheduler({ projectDir: process.cwd() });
420
+ scheduler.refreshRoutines();
421
+
422
+ try {
423
+ const record = await scheduler.runNow(routineName);
424
+
425
+ if (record.status === 'success') {
426
+ log(`Routine completed successfully in ${record.durationMs}ms`);
427
+ } else {
428
+ log(`Routine failed (exit code ${record.exitCode})`);
429
+ if (record.error) {
430
+ log(`Error: ${record.error}`);
431
+ }
432
+ }
433
+
434
+ output(record);
435
+ } catch (err) {
436
+ outputError('API_ERROR', `Failed to run routine: ${err instanceof Error ? err.message : String(err)}`);
437
+ }
438
+ }
@@ -5,7 +5,9 @@
5
5
  import { createInterface } from 'readline';
6
6
  import { resolve } from 'path';
7
7
  import { existsSync, readFileSync } from 'fs';
8
+ import { homedir } from 'os';
8
9
  import { output, outputError, log } from '../lib/cli.js';
10
+
9
11
  import {
10
12
  getSecret,
11
13
  setSecret,
@@ -16,6 +18,19 @@ import {
16
18
  } from '../core/secrets.js';
17
19
  import { findPackage } from '../core/config.js';
18
20
 
21
+ /**
22
+ * Expand ~ to home directory in paths
23
+ */
24
+ function expandTilde(path: string): string {
25
+ if (path.startsWith('~/')) {
26
+ return resolve(homedir(), path.slice(2));
27
+ }
28
+ if (path === '~') {
29
+ return homedir();
30
+ }
31
+ return path;
32
+ }
33
+
19
34
  interface SecretsOptions {
20
35
  package?: string;
21
36
  }
@@ -86,7 +101,9 @@ export async function secretsSetCommand(key: string, value?: string): Promise<vo
86
101
  outputError('INVALID_INPUT', 'Secret value cannot be empty');
87
102
  }
88
103
 
89
- setSecret(key, value);
104
+ // Expand ~ to home directory for paths
105
+ const expandedValue = expandTilde(value);
106
+ setSecret(key, expandedValue);
90
107
  log(`✓ Secret '${key}' saved to vault`);
91
108
 
92
109
  output({ key, status: 'saved' });
@@ -259,7 +276,9 @@ export async function secretsInitCommand(packageName?: string, options?: Secrets
259
276
  const value = await prompt(` Enter ${key}: `, true);
260
277
 
261
278
  if (value) {
262
- setSecret(key, value);
279
+ // Expand ~ to home directory for paths
280
+ const expandedValue = expandTilde(value);
281
+ setSecret(key, expandedValue);
263
282
  log(` ✓ ${key} saved`);
264
283
  configured.push(key);
265
284
  } else {
@@ -179,14 +179,13 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
179
179
 
180
180
  // Update all packages in parallel
181
181
  if (toUpdate.length > 0) {
182
- log(`\n${BOLD}Updating ${toUpdate.length} package${toUpdate.length !== 1 ? 's' : ''} in parallel...${RESET}`);
182
+ log(`\n${BOLD}Updating ${toUpdate.length} package${toUpdate.length !== 1 ? 's' : ''}...${RESET}`);
183
183
 
184
- const updatePromises = toUpdate.map(async (pkg) => {
184
+ const updateResults: Array<(typeof toUpdate)[number] & { success: boolean }> = [];
185
+ for (const pkg of toUpdate) {
185
186
  const success = await updatePackageAsync(pkg.npmName);
186
- return { ...pkg, success };
187
- });
188
-
189
- const updateResults = await Promise.all(updatePromises);
187
+ updateResults.push({ ...pkg, success });
188
+ }
190
189
 
191
190
  log('');
192
191
  for (const result of updateResults) {
@@ -204,7 +203,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
204
203
  const updated = results.filter(r => r.status === 'updated').length;
205
204
  const failed = results.filter(r => r.status === 'failed').length;
206
205
 
207
- console.log('');
206
+ log('');
208
207
  if (updated > 0) {
209
208
  log(`${GREEN}${updated} package${updated !== 1 ? 's' : ''} updated${RESET}`);
210
209
  }
@@ -214,5 +213,5 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
214
213
  if (updated === 0 && failed === 0) {
215
214
  log(`${GREEN}All packages are up to date${RESET}`);
216
215
  }
217
- console.log('');
216
+ log('');
218
217
  }
@@ -29,7 +29,7 @@ describe('config', () => {
29
29
 
30
30
  describe('DEFAULT_CONFIG', () => {
31
31
  test('has expected defaults', () => {
32
- expect(DEFAULT_CONFIG.registry).toBe('https://registry.cliforai.com');
32
+ expect(DEFAULT_CONFIG.registry).toBe('https://registry.cli4ai.com');
33
33
  expect(DEFAULT_CONFIG.localRegistries).toEqual([]);
34
34
  expect(DEFAULT_CONFIG.defaultRuntime).toBe('bun');
35
35
  expect(DEFAULT_CONFIG.mcp.transport).toBe('stdio');