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.
@@ -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';
@@ -91,7 +96,7 @@ export interface Config {
91
96
  localRegistries: string[];
92
97
 
93
98
  // Runtime defaults
94
- defaultRuntime: 'bun' | 'node';
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.cliforai.com',
124
+ registry: 'https://registry.cli4ai.com',
120
125
  localRegistries: [],
121
- defaultRuntime: 'bun',
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
- 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
+ }
334
350
 
335
- if (index !== -1) {
336
- config.localRegistries.splice(index, 1);
337
- saveConfig(config);
338
- log(`Removed local registry: ${absolutePath}`);
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');
@@ -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: 'bun' | 'node';
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
- 'bun': {
85
- check: 'bun --version',
97
+ 'node': {
98
+ check: 'node --version',
86
99
  install: {
87
- darwin: 'curl -fsSL https://bun.sh/install | bash',
88
- linux: 'curl -fsSL https://bun.sh/install | bash',
89
- win32: 'powershell -c "irm bun.sh/install.ps1 | iex"'
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
- const value = getSecret(key);
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
- setSecret(key, value);
313
- secretsEnv[key] = value;
314
- log(` ✓ ${key} saved to vault\n`);
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, manifest: Manifest, cmdArgs: string[]): { execCmd: string; execArgs: string[]; runtime: 'bun' | 'node' } {
322
- const runtime = manifest.runtime || 'bun';
323
- switch (runtime) {
324
- case 'node':
325
- return { execCmd: 'node', execArgs: [entryPath, ...cmdArgs], runtime: 'node' };
326
- case 'bun':
327
- default:
328
- return { execCmd: 'bun', execArgs: ['run', entryPath, ...cmdArgs], runtime: 'bun' };
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
- 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
- }
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, manifest, cmdArgs);
430
+ const { execCmd, execArgs, runtime } = buildRuntimeCommand(entryPath, cmdArgs);
407
431
 
408
432
  const teeStderr = options.teeStderr ?? true;
409
433
 
@@ -2,7 +2,7 @@
2
2
  * Tests for link.ts
3
3
  */
4
4
 
5
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
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: 'bun'
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('bun run');
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 bun runtime', () => {
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('bun run');
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
- 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
  }
@@ -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 based on runtime type
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
- switch (runtime) {
119
- case 'node':
120
- execCommand = `node ${safeEntryPath}`;
121
- break;
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
- chmodSync(binPath, 0o755);
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
- const binPath = join(C4AI_BIN, packageName);
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
- if (existsSync(binPath)) {
149
- unlinkSync(binPath);
150
- return true;
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 false;
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
- return existsSync(join(C4AI_BIN, packageName));
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 'bun:test';
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';