cli4ai 0.8.2 → 0.9.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/package.json +11 -10
- package/src/bin.ts +21 -21
- package/src/cli.ts +51 -1
- package/src/commands/add.ts +47 -6
- package/src/commands/browse.ts +1 -1
- package/src/commands/config.ts +31 -3
- package/src/commands/info.ts +1 -1
- package/src/commands/init.test.ts +61 -13
- package/src/commands/init.ts +177 -223
- 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 +19 -4
- 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 +3 -3
- package/src/core/config.ts +213 -39
- package/src/core/execute.ts +54 -30
- package/src/core/link.test.ts +5 -5
- package/src/core/link.ts +60 -20
- package/src/core/lockfile.test.ts +1 -1
- package/src/core/lockfile.ts +4 -2
- package/src/core/manifest.test.ts +2 -2
- package/src/core/manifest.ts +12 -4
- package/src/core/routine-engine.test.ts +1 -1
- 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 +606 -0
- package/src/core/secrets.test.ts +1 -1
- package/src/lib/cli.ts +25 -5
- package/src/mcp/adapter.test.ts +1 -1
- package/src/mcp/adapter.ts +14 -6
- package/src/mcp/config-gen.test.ts +1 -1
- package/src/mcp/server.ts +11 -17
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';
|
|
@@ -91,7 +96,7 @@ export interface Config {
|
|
|
91
96
|
localRegistries: string[];
|
|
92
97
|
|
|
93
98
|
// Runtime defaults
|
|
94
|
-
defaultRuntime: '
|
|
99
|
+
defaultRuntime: 'node';
|
|
95
100
|
|
|
96
101
|
// MCP defaults
|
|
97
102
|
mcp: {
|
|
@@ -116,9 +121,9 @@ 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
|
-
defaultRuntime: '
|
|
126
|
+
defaultRuntime: 'node',
|
|
122
127
|
mcp: {
|
|
123
128
|
transport: 'stdio',
|
|
124
129
|
port: 3100
|
|
@@ -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
|
+
}
|
|
334
350
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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;
|
|
339
371
|
}
|
|
340
372
|
}
|
|
341
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;
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
return fn();
|
|
472
|
+
} finally {
|
|
473
|
+
configLockFd = null;
|
|
474
|
+
configLockDepth = 0;
|
|
475
|
+
releaseLock(CONFIG_LOCK_FILE, fd);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -580,7 +743,18 @@ export function getNpmGlobalPackages(): InstalledPackage[] {
|
|
|
580
743
|
* Try to find a package in a global directory
|
|
581
744
|
*/
|
|
582
745
|
function findPackageInGlobalDir(globalDir: string, name: string): InstalledPackage | null {
|
|
746
|
+
// SECURITY: Validate name to prevent path traversal
|
|
747
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\') || name.startsWith('.')) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
583
751
|
const scopedPath = resolve(globalDir, '@cli4ai', name);
|
|
752
|
+
|
|
753
|
+
// SECURITY: Verify resolved path is under globalDir
|
|
754
|
+
if (!scopedPath.startsWith(resolve(globalDir))) {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
|
|
584
758
|
if (!existsSync(scopedPath)) return null;
|
|
585
759
|
|
|
586
760
|
const manifestPath = resolve(scopedPath, 'cli4ai.json');
|
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 {
|
|
@@ -38,7 +51,7 @@ export interface ExecuteToolResult {
|
|
|
38
51
|
stderr?: string;
|
|
39
52
|
packagePath: string;
|
|
40
53
|
entryPath: string;
|
|
41
|
-
runtime: '
|
|
54
|
+
runtime: 'node';
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
export class ExecuteToolError extends Error {
|
|
@@ -81,12 +94,12 @@ const INSTALL_COMMANDS: Record<string, { check: string; install: Record<string,
|
|
|
81
94
|
},
|
|
82
95
|
description: 'Media processing tool'
|
|
83
96
|
},
|
|
84
|
-
'
|
|
85
|
-
check: '
|
|
97
|
+
'node': {
|
|
98
|
+
check: 'node --version',
|
|
86
99
|
install: {
|
|
87
|
-
darwin: '
|
|
88
|
-
linux: 'curl -fsSL https://
|
|
89
|
-
win32: '
|
|
100
|
+
darwin: 'brew install node',
|
|
101
|
+
linux: 'curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs',
|
|
102
|
+
win32: 'winget install OpenJS.NodeJS.LTS'
|
|
90
103
|
},
|
|
91
104
|
description: 'JavaScript runtime'
|
|
92
105
|
}
|
|
@@ -145,6 +158,12 @@ async function installDependency(name: string): Promise<boolean> {
|
|
|
145
158
|
log(`\n📦 ${name} - ${info.description}`);
|
|
146
159
|
log(` Install command: ${installCmd}\n`);
|
|
147
160
|
|
|
161
|
+
// SECURITY: Warn about curl|bash pattern
|
|
162
|
+
if (installCmd.includes('curl') && (installCmd.includes('| bash') || installCmd.includes('|bash'))) {
|
|
163
|
+
log(`⚠️ SECURITY WARNING: This command downloads and executes a script from the internet.`);
|
|
164
|
+
log(` Only proceed if you trust the source (${name}).\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
const shouldInstall = await confirm(`Install ${name}?`);
|
|
149
168
|
if (!shouldInstall) return false;
|
|
150
169
|
|
|
@@ -282,7 +301,8 @@ async function checkAndPromptSecrets(pkgPath: string, pkgName: string): Promise<
|
|
|
282
301
|
const missingRequired: Array<{ key: string; description?: string }> = [];
|
|
283
302
|
|
|
284
303
|
for (const [key, def] of Object.entries(envDefs)) {
|
|
285
|
-
|
|
304
|
+
// SECURITY: Use package-scoped secret lookup (tries scoped first, then global)
|
|
305
|
+
const value = getSecret(key, pkgName);
|
|
286
306
|
|
|
287
307
|
if (value) {
|
|
288
308
|
secretsEnv[key] = value;
|
|
@@ -305,27 +325,39 @@ async function checkAndPromptSecrets(pkgPath: string, pkgName: string): Promise<
|
|
|
305
325
|
throw new ExecuteToolError('ENV_MISSING', `${key} is required to run ${pkgName}`, {
|
|
306
326
|
package: pkgName,
|
|
307
327
|
secret: key,
|
|
308
|
-
hint: `Set it with: cli4ai secrets set ${key}`
|
|
328
|
+
hint: `Set it with: cli4ai secrets set ${key} --scope ${pkgName}`
|
|
309
329
|
});
|
|
310
330
|
}
|
|
311
331
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
332
|
+
// Expand ~ to home directory for paths
|
|
333
|
+
const expandedValue = expandTilde(value);
|
|
334
|
+
// SECURITY: Store secret scoped to package
|
|
335
|
+
setSecret(key, expandedValue, pkgName);
|
|
336
|
+
secretsEnv[key] = expandedValue;
|
|
337
|
+
log(` ✓ ${key} saved to vault (scoped to ${pkgName})\n`);
|
|
315
338
|
}
|
|
316
339
|
|
|
317
340
|
log('');
|
|
318
341
|
return secretsEnv;
|
|
319
342
|
}
|
|
320
343
|
|
|
321
|
-
function buildRuntimeCommand(entryPath: string,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
function buildRuntimeCommand(entryPath: string, cmdArgs: string[]): { execCmd: string; execArgs: string[]; runtime: 'node' } {
|
|
345
|
+
// Use tsx for TypeScript files, node for JavaScript
|
|
346
|
+
if (entryPath.endsWith('.ts') || entryPath.endsWith('.tsx')) {
|
|
347
|
+
return { execCmd: 'npx', execArgs: ['tsx', entryPath, ...cmdArgs], runtime: 'node' };
|
|
348
|
+
}
|
|
349
|
+
return { execCmd: 'node', execArgs: [entryPath, ...cmdArgs], runtime: 'node' };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function ensureRuntimeAvailable(): Promise<void> {
|
|
353
|
+
if (!commandExists('node')) {
|
|
354
|
+
log('⚠️ Node.js is required to run this tool\n');
|
|
355
|
+
const installed = await installDependency('node');
|
|
356
|
+
if (!installed) {
|
|
357
|
+
throw new ExecuteToolError('MISSING_DEPENDENCY', 'Node.js is required', {
|
|
358
|
+
hint: 'Install Node.js: https://nodejs.org/en/download/'
|
|
359
|
+
});
|
|
360
|
+
}
|
|
329
361
|
}
|
|
330
362
|
}
|
|
331
363
|
|
|
@@ -378,15 +410,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
|
|
|
378
410
|
log('');
|
|
379
411
|
}
|
|
380
412
|
|
|
381
|
-
|
|
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
|
-
}
|
|
413
|
+
await ensureRuntimeAvailable();
|
|
390
414
|
|
|
391
415
|
await checkPeerDependencies(pkg.path);
|
|
392
416
|
|
|
@@ -403,7 +427,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
|
|
|
403
427
|
if (options.command) cmdArgs.push(options.command);
|
|
404
428
|
cmdArgs.push(...options.args);
|
|
405
429
|
|
|
406
|
-
const { execCmd, execArgs, runtime } = buildRuntimeCommand(entryPath,
|
|
430
|
+
const { execCmd, execArgs, runtime } = buildRuntimeCommand(entryPath, cmdArgs);
|
|
407
431
|
|
|
408
432
|
const teeStderr = options.teeStderr ?? true;
|
|
409
433
|
|
package/src/core/link.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for link.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, test, expect, beforeEach, afterEach } from '
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
7
7
|
import { join, resolve } from 'path';
|
|
8
8
|
import { tmpdir, homedir } from 'os';
|
|
@@ -36,7 +36,7 @@ describe('link', () => {
|
|
|
36
36
|
name,
|
|
37
37
|
version,
|
|
38
38
|
entry: 'run.ts',
|
|
39
|
-
runtime: '
|
|
39
|
+
runtime: 'node'
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
describe('C4AI_BIN constant', () => {
|
|
@@ -101,7 +101,7 @@ describe('link', () => {
|
|
|
101
101
|
expect(existsSync(binPath)).toBe(true);
|
|
102
102
|
const content = readFileSync(binPath, 'utf-8');
|
|
103
103
|
expect(content).toContain('#!/bin/sh');
|
|
104
|
-
expect(content).toContain('
|
|
104
|
+
expect(content).toContain('npx tsx');
|
|
105
105
|
expect(content).toContain('run.ts');
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -123,7 +123,7 @@ describe('link', () => {
|
|
|
123
123
|
expect(content).not.toContain('node run');
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
test('defaults to
|
|
126
|
+
test('defaults to node runtime with tsx for TypeScript', () => {
|
|
127
127
|
const manifest: Manifest = {
|
|
128
128
|
name: 'no-runtime',
|
|
129
129
|
version: '1.0.0',
|
|
@@ -135,7 +135,7 @@ describe('link', () => {
|
|
|
135
135
|
const binPath = linkPackageDirect(manifest, packagePath);
|
|
136
136
|
|
|
137
137
|
const content = readFileSync(binPath, 'utf-8');
|
|
138
|
-
expect(content).toContain('
|
|
138
|
+
expect(content).toContain('npx tsx');
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test('includes full path to entry', () => {
|
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
|
}
|
|
@@ -104,25 +115,18 @@ export function linkPackageDirect(manifest: Manifest, packagePath: string): stri
|
|
|
104
115
|
|
|
105
116
|
const binPath = join(C4AI_BIN, manifest.name);
|
|
106
117
|
const entryPath = resolve(packagePath, manifest.entry);
|
|
107
|
-
const runtime = manifest.runtime || 'bun';
|
|
108
118
|
|
|
109
119
|
// SECURITY: Shell-escape the entry path to prevent injection
|
|
110
120
|
const safeEntryPath = shellEscape(entryPath);
|
|
111
121
|
const safeName = shellEscape(manifest.name);
|
|
112
122
|
const safeVersion = shellEscape(manifest.version);
|
|
113
123
|
|
|
114
|
-
// Build runtime command
|
|
115
|
-
// - bun: bun run <file>
|
|
116
|
-
// - node: node <file>
|
|
124
|
+
// Build runtime command - use tsx for TypeScript, node for JavaScript
|
|
117
125
|
let execCommand: string;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
case 'bun':
|
|
123
|
-
default:
|
|
124
|
-
execCommand = `bun run ${safeEntryPath}`;
|
|
125
|
-
break;
|
|
126
|
+
if (entryPath.endsWith('.ts') || entryPath.endsWith('.tsx')) {
|
|
127
|
+
execCommand = `npx tsx ${safeEntryPath}`;
|
|
128
|
+
} else {
|
|
129
|
+
execCommand = `node ${safeEntryPath}`;
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
// Create wrapper script that runs the tool directly
|
|
@@ -134,7 +138,26 @@ exec ${execCommand} "$@"
|
|
|
134
138
|
`;
|
|
135
139
|
|
|
136
140
|
writeFileSync(binPath, script);
|
|
137
|
-
|
|
141
|
+
if (process.platform !== 'win32') {
|
|
142
|
+
chmodSync(binPath, 0o755);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Windows compatibility: generate .cmd and .ps1 launchers
|
|
146
|
+
if (process.platform === 'win32') {
|
|
147
|
+
const quotedEntry = `"${entryPath.replaceAll('"', '""')}"`;
|
|
148
|
+
|
|
149
|
+
let cmdContent: string;
|
|
150
|
+
let ps1Content: string;
|
|
151
|
+
if (entryPath.endsWith('.ts') || entryPath.endsWith('.tsx')) {
|
|
152
|
+
cmdContent = `@echo off\r\nnpx tsx ${quotedEntry} %*\r\nexit /b %errorlevel%\r\n`;
|
|
153
|
+
ps1Content = `& npx tsx ${quotedEntry} @args\nexit $LASTEXITCODE\n`;
|
|
154
|
+
} else {
|
|
155
|
+
cmdContent = `@echo off\r\nnode ${quotedEntry} %*\r\nexit /b %errorlevel%\r\n`;
|
|
156
|
+
ps1Content = `& node ${quotedEntry} @args\nexit $LASTEXITCODE\n`;
|
|
157
|
+
}
|
|
158
|
+
writeFileSync(binPath + '.cmd', cmdContent);
|
|
159
|
+
writeFileSync(binPath + '.ps1', ps1Content);
|
|
160
|
+
}
|
|
138
161
|
|
|
139
162
|
return binPath;
|
|
140
163
|
}
|
|
@@ -143,21 +166,38 @@ exec ${execCommand} "$@"
|
|
|
143
166
|
* Remove executable link for a package
|
|
144
167
|
*/
|
|
145
168
|
export function unlinkPackage(packageName: string): boolean {
|
|
146
|
-
|
|
169
|
+
// SECURITY: Validate package name to prevent path traversal
|
|
170
|
+
if (!isShellSafe(packageName) || packageName.includes('..')) {
|
|
171
|
+
throw new Error(`Invalid package name: ${packageName}`);
|
|
172
|
+
}
|
|
147
173
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
const basePath = join(C4AI_BIN, packageName);
|
|
175
|
+
const candidates = process.platform === 'win32'
|
|
176
|
+
? [basePath, basePath + '.cmd', basePath + '.ps1']
|
|
177
|
+
: [basePath];
|
|
178
|
+
|
|
179
|
+
let removed = false;
|
|
180
|
+
for (const path of candidates) {
|
|
181
|
+
if (existsSync(path)) {
|
|
182
|
+
unlinkSync(path);
|
|
183
|
+
removed = true;
|
|
184
|
+
}
|
|
151
185
|
}
|
|
152
186
|
|
|
153
|
-
return
|
|
187
|
+
return removed;
|
|
154
188
|
}
|
|
155
189
|
|
|
156
190
|
/**
|
|
157
191
|
* Check if a package is linked
|
|
158
192
|
*/
|
|
159
193
|
export function isPackageLinked(packageName: string): boolean {
|
|
160
|
-
|
|
194
|
+
const basePath = join(C4AI_BIN, packageName);
|
|
195
|
+
if (existsSync(basePath)) return true;
|
|
196
|
+
if (process.platform === 'win32') {
|
|
197
|
+
if (existsSync(basePath + '.cmd')) return true;
|
|
198
|
+
if (existsSync(basePath + '.ps1')) return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
161
201
|
}
|
|
162
202
|
|
|
163
203
|
/**
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for lockfile.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, test, expect, beforeEach, afterEach } from '
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { tmpdir } from 'os';
|