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.
- package/package.json +4 -3
- package/src/bin.ts +21 -21
- package/src/cli.ts +50 -0
- package/src/commands/add.ts +43 -0
- package/src/commands/browse.ts +1 -1
- package/src/commands/config.ts +31 -3
- package/src/commands/list.ts +11 -28
- package/src/commands/mcp-config.ts +14 -4
- package/src/commands/remove.ts +6 -0
- package/src/commands/routines.ts +1 -1
- package/src/commands/run.ts +4 -2
- package/src/commands/scheduler.ts +438 -0
- package/src/commands/secrets.ts +21 -2
- package/src/commands/update.ts +7 -8
- package/src/core/config.test.ts +1 -1
- package/src/core/config.ts +200 -37
- package/src/core/execute.ts +56 -15
- package/src/core/link.ts +52 -9
- package/src/core/lockfile.ts +4 -2
- package/src/core/routine-engine.ts +103 -1
- package/src/core/routines.ts +58 -1
- package/src/core/scheduler-daemon.ts +94 -0
- package/src/core/scheduler.test.ts +291 -0
- package/src/core/scheduler.ts +601 -0
- package/src/lib/cli.ts +25 -5
- package/src/mcp/adapter.ts +14 -6
package/src/core/config.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/core/execute.ts
CHANGED
|
@@ -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
|
-
|
|
313
|
-
|
|
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,
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/core/lockfile.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|