cli4ai 1.2.9 → 1.2.11

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.
@@ -19,20 +19,7 @@ const CLI4AI_SCOPED_PKG_PATTERN = /^@cli4ai\/[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
19
19
  const URL_LIKE_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
20
20
  function validatePackageSpecifier(pkg) {
21
21
  if (URL_LIKE_PATTERN.test(pkg)) {
22
- let parsed;
23
- try {
24
- parsed = new URL(pkg);
25
- }
26
- catch {
27
- outputError('INVALID_INPUT', 'Invalid URL', { url: pkg });
28
- }
29
- if (parsed.protocol !== 'https:') {
30
- outputError('INVALID_INPUT', 'Unsupported URL protocol', {
31
- url: pkg,
32
- protocol: parsed.protocol,
33
- allowed: ['https:']
34
- });
35
- }
22
+ // URLs are not supported - reject immediately
36
23
  outputError('INVALID_INPUT', 'Installing from URLs is not supported', {
37
24
  url: pkg,
38
25
  hint: 'Use a local path (./path) or a package name (e.g. github, @cli4ai/github)'
@@ -476,8 +476,10 @@ export function getGlobalPackages() {
476
476
  installedAt: new Date().toISOString()
477
477
  });
478
478
  }
479
- catch {
480
- // Skip invalid packages
479
+ catch (err) {
480
+ // Log warning for debugging, but continue to other packages
481
+ const errMessage = err instanceof Error ? err.message : String(err);
482
+ console.error(`Warning: Failed to load package manifest at ${manifestPath}: ${errMessage}`);
481
483
  }
482
484
  }
483
485
  }
@@ -512,8 +514,10 @@ export function getLocalPackages(projectDir) {
512
514
  installedAt: new Date().toISOString()
513
515
  });
514
516
  }
515
- catch {
516
- // Skip invalid packages
517
+ catch (err) {
518
+ // Log warning for debugging, but continue to other packages
519
+ const errMessage = err instanceof Error ? err.message : String(err);
520
+ console.error(`Warning: Failed to load package manifest at ${manifestPath}: ${errMessage}`);
517
521
  }
518
522
  }
519
523
  }
@@ -610,7 +614,11 @@ function getPackagesFromGlobalDir(globalDir) {
610
614
  });
611
615
  continue;
612
616
  }
613
- catch { }
617
+ catch (err) {
618
+ // Log warning but continue - cli4ai.json is invalid, try package.json
619
+ const errMessage = err instanceof Error ? err.message : String(err);
620
+ console.error(`Warning: Failed to load cli4ai.json at ${cli4aiJsonPath}: ${errMessage}`);
621
+ }
614
622
  }
615
623
  if (existsSync(pkgJsonPath)) {
616
624
  try {
@@ -623,11 +631,19 @@ function getPackagesFromGlobalDir(globalDir) {
623
631
  installedAt: new Date().toISOString()
624
632
  });
625
633
  }
626
- catch { }
634
+ catch (err) {
635
+ // Log warning but continue to next package
636
+ const errMessage = err instanceof Error ? err.message : String(err);
637
+ console.error(`Warning: Failed to load package.json at ${pkgJsonPath}: ${errMessage}`);
638
+ }
627
639
  }
628
640
  }
629
641
  }
630
- catch { }
642
+ catch (err) {
643
+ // Log error but return partial results
644
+ const errMessage = err instanceof Error ? err.message : String(err);
645
+ console.error(`Warning: Error scanning global packages directory ${cli4aiDir}: ${errMessage}`);
646
+ }
631
647
  return packages;
632
648
  }
633
649
  /**
@@ -685,7 +701,11 @@ function findPackageInGlobalDir(globalDir, name) {
685
701
  installedAt: new Date().toISOString()
686
702
  };
687
703
  }
688
- catch { }
704
+ catch (err) {
705
+ // Log warning but continue to try package.json
706
+ const errMessage = err instanceof Error ? err.message : String(err);
707
+ console.error(`Warning: Failed to load cli4ai.json at ${manifestPath}: ${errMessage}`);
708
+ }
689
709
  }
690
710
  // Even without cli4ai.json, try package.json
691
711
  const pkgJsonPath = resolve(scopedPath, 'package.json');
@@ -700,7 +720,11 @@ function findPackageInGlobalDir(globalDir, name) {
700
720
  installedAt: new Date().toISOString()
701
721
  };
702
722
  }
703
- catch { }
723
+ catch (err) {
724
+ // Log warning - package has no valid manifest
725
+ const errMessage = err instanceof Error ? err.message : String(err);
726
+ console.error(`Warning: Failed to load package.json at ${pkgJsonPath}: ${errMessage}`);
727
+ }
704
728
  }
705
729
  return null;
706
730
  }
@@ -429,22 +429,31 @@ export async function executeTool(options) {
429
429
  ...(options.env ?? {})
430
430
  }
431
431
  });
432
+ // Handle stdin - add error handler to prevent unhandled errors on broken pipes
433
+ proc.stdin.on('error', (err) => {
434
+ // Ignore EPIPE errors - they occur when the child process exits before reading stdin
435
+ if (err.code !== 'EPIPE') {
436
+ // Log other errors but don't throw - the process will handle termination
437
+ console.error(`stdin error: ${err.message}`);
438
+ }
439
+ });
432
440
  if (options.stdin !== undefined) {
433
441
  proc.stdin.write(options.stdin);
434
442
  proc.stdin.end();
435
443
  }
436
444
  else {
437
- // Dont block on stdin if nothing is provided
445
+ // Don't block on stdin if nothing is provided
438
446
  proc.stdin.end();
439
447
  }
440
448
  // Set up line-by-line streaming for callbacks
441
449
  const stdoutLines = [];
442
450
  const stderrLines = [];
443
- const readlineInterfaces = [];
451
+ // Track readline interfaces so we can close them on timeout/exit
452
+ let stdoutRl;
453
+ let stderrRl;
444
454
  if (proc.stdout) {
445
- const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
446
- readlineInterfaces.push(rl);
447
- rl.on('line', (line) => {
455
+ stdoutRl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
456
+ stdoutRl.on('line', (line) => {
448
457
  stdoutLines.push(line);
449
458
  if (options.onStdoutLine) {
450
459
  options.onStdoutLine(line);
@@ -452,9 +461,8 @@ export async function executeTool(options) {
452
461
  });
453
462
  }
454
463
  if (proc.stderr) {
455
- const rl = createInterface({ input: proc.stderr, crlfDelay: Infinity });
456
- readlineInterfaces.push(rl);
457
- rl.on('line', (line) => {
464
+ stderrRl = createInterface({ input: proc.stderr, crlfDelay: Infinity });
465
+ stderrRl.on('line', (line) => {
458
466
  stderrLines.push(line);
459
467
  if (teeStderr) {
460
468
  process.stderr.write(line + '\n');
@@ -464,9 +472,21 @@ export async function executeTool(options) {
464
472
  }
465
473
  });
466
474
  }
475
+ // Helper to clean up readline interfaces
476
+ const cleanupReadlines = () => {
477
+ if (stdoutRl) {
478
+ stdoutRl.close();
479
+ stdoutRl = undefined;
480
+ }
481
+ if (stderrRl) {
482
+ stderrRl.close();
483
+ stderrRl = undefined;
484
+ }
485
+ };
467
486
  let timeout;
468
487
  if (options.timeoutMs && options.timeoutMs > 0) {
469
488
  timeout = setTimeout(() => {
489
+ cleanupReadlines();
470
490
  try {
471
491
  proc.kill('SIGTERM');
472
492
  }
@@ -485,13 +505,10 @@ export async function executeTool(options) {
485
505
  }).finally(() => {
486
506
  if (timeout)
487
507
  clearTimeout(timeout);
488
- // Close all readline interfaces
489
- for (const rl of readlineInterfaces) {
490
- rl.close();
491
- }
492
508
  });
493
- // Small delay to ensure all readline events are processed
509
+ // Small delay to ensure all readline events are processed before cleanup
494
510
  await new Promise(r => setTimeout(r, 10));
511
+ cleanupReadlines();
495
512
  return {
496
513
  exitCode,
497
514
  durationMs: Date.now() - startTime,
@@ -34,6 +34,24 @@ export class RemoteApiError extends Error {
34
34
  this.name = 'RemoteApiError';
35
35
  }
36
36
  }
37
+ /**
38
+ * Safely parse JSON response body, returning null on parse errors
39
+ */
40
+ function safeJsonParse(body) {
41
+ try {
42
+ return JSON.parse(body);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * Extract error info from response body
50
+ */
51
+ function extractErrorInfo(body) {
52
+ const parsed = safeJsonParse(body);
53
+ return parsed?.error ?? {};
54
+ }
37
55
  function makeRequest(url, method, headers, body, timeoutMs = 30000) {
38
56
  return new Promise((resolve, reject) => {
39
57
  const isHttps = url.protocol === 'https:';
@@ -97,10 +115,13 @@ export async function remoteHealth(remoteName) {
97
115
  try {
98
116
  const response = await makeRequest(url, 'GET', buildHeaders(remote));
99
117
  if (response.statusCode !== 200) {
100
- const error = JSON.parse(response.body)?.error;
101
- throw new RemoteApiError(remoteName, response.statusCode, error?.code ?? 'API_ERROR', error?.message ?? 'Unknown error', error?.details);
118
+ const error = extractErrorInfo(response.body);
119
+ throw new RemoteApiError(remoteName, response.statusCode, error.code ?? 'API_ERROR', error.message ?? 'Unknown error', error.details);
120
+ }
121
+ const data = safeJsonParse(response.body);
122
+ if (!data) {
123
+ throw new RemoteApiError(remoteName, response.statusCode, 'PARSE_ERROR', 'Invalid JSON response from server');
102
124
  }
103
- const data = JSON.parse(response.body);
104
125
  updateRemoteLastConnected(remoteName);
105
126
  return data;
106
127
  }
@@ -119,11 +140,15 @@ export async function remoteListPackages(remoteName) {
119
140
  try {
120
141
  const response = await makeRequest(url, 'GET', buildHeaders(remote));
121
142
  if (response.statusCode !== 200) {
122
- const error = JSON.parse(response.body)?.error;
123
- throw new RemoteApiError(remoteName, response.statusCode, error?.code ?? 'API_ERROR', error?.message ?? 'Unknown error', error?.details);
143
+ const error = extractErrorInfo(response.body);
144
+ throw new RemoteApiError(remoteName, response.statusCode, error.code ?? 'API_ERROR', error.message ?? 'Unknown error', error.details);
145
+ }
146
+ const data = safeJsonParse(response.body);
147
+ if (!data) {
148
+ throw new RemoteApiError(remoteName, response.statusCode, 'PARSE_ERROR', 'Invalid JSON response from server');
124
149
  }
125
150
  updateRemoteLastConnected(remoteName);
126
- return JSON.parse(response.body);
151
+ return data;
127
152
  }
128
153
  catch (err) {
129
154
  if (err instanceof RemoteApiError)
@@ -143,11 +168,15 @@ export async function remotePackageInfo(remoteName, packageName) {
143
168
  return null;
144
169
  }
145
170
  if (response.statusCode !== 200) {
146
- const error = JSON.parse(response.body)?.error;
147
- throw new RemoteApiError(remoteName, response.statusCode, error?.code ?? 'API_ERROR', error?.message ?? 'Unknown error', error?.details);
171
+ const error = extractErrorInfo(response.body);
172
+ throw new RemoteApiError(remoteName, response.statusCode, error.code ?? 'API_ERROR', error.message ?? 'Unknown error', error.details);
173
+ }
174
+ const data = safeJsonParse(response.body);
175
+ if (!data) {
176
+ throw new RemoteApiError(remoteName, response.statusCode, 'PARSE_ERROR', 'Invalid JSON response from server');
148
177
  }
149
178
  updateRemoteLastConnected(remoteName);
150
- return JSON.parse(response.body);
179
+ return data;
151
180
  }
152
181
  catch (err) {
153
182
  if (err instanceof RemoteApiError)
@@ -174,9 +203,12 @@ export async function remoteRunTool(remoteName, options) {
174
203
  const requestTimeout = (options.timeout ?? 30000) + 10000;
175
204
  try {
176
205
  const response = await makeRequest(url, 'POST', buildHeaders(remote), body, requestTimeout);
177
- const data = JSON.parse(response.body);
178
- // Check for API-level error
179
- if (data.error && response.statusCode >= 400 && response.statusCode !== 500) {
206
+ const data = safeJsonParse(response.body);
207
+ if (!data) {
208
+ throw new RemoteApiError(remoteName, response.statusCode, 'PARSE_ERROR', 'Invalid JSON response from server');
209
+ }
210
+ // Check for API-level error (4xx errors except 500)
211
+ if (data.error && response.statusCode >= 400 && response.statusCode < 500) {
180
212
  throw new RemoteApiError(remoteName, response.statusCode, data.error.code ?? 'API_ERROR', data.error.message ?? 'Unknown error', data.error.details);
181
213
  }
182
214
  updateRemoteLastConnected(remoteName);
@@ -201,7 +233,10 @@ export async function remoteRunRoutine(remoteName, routineName, vars) {
201
233
  if (response.statusCode === 404) {
202
234
  throw new RemoteApiError(remoteName, 404, 'NOT_FOUND', `Routine not found: ${routineName}`);
203
235
  }
204
- const data = JSON.parse(response.body);
236
+ const data = safeJsonParse(response.body);
237
+ if (!data) {
238
+ throw new RemoteApiError(remoteName, response.statusCode, 'PARSE_ERROR', 'Invalid JSON response from server');
239
+ }
205
240
  if (data.error && response.statusCode >= 400) {
206
241
  throw new RemoteApiError(remoteName, response.statusCode, data.error.code ?? 'API_ERROR', data.error.message ?? 'Unknown error', data.error.details);
207
242
  }
@@ -25,6 +25,7 @@ export interface RoutineStepEvent {
25
25
  status: 'running' | 'success' | 'failed' | 'skipped' | 'caught';
26
26
  exitCode?: number;
27
27
  durationMs?: number;
28
+ input?: unknown;
28
29
  stdout?: string;
29
30
  stderr?: string;
30
31
  }
@@ -17,7 +17,7 @@
17
17
  * This ensures that even if an attacker knows the hostname and username,
18
18
  * they cannot reconstruct the key without access to the salt file.
19
19
  */
20
- import { readFileSync, writeFileSync, existsSync, chmodSync, statSync, mkdirSync } from 'fs';
20
+ import { readFileSync, writeFileSync, existsSync, chmodSync, statSync, mkdirSync, renameSync } from 'fs';
21
21
  import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync } from 'crypto';
22
22
  import { hostname, userInfo, platform } from 'os';
23
23
  import { dirname, resolve } from 'path';
@@ -224,6 +224,15 @@ function checkFilePermissions(filePath, fileName) {
224
224
  // Ignore errors
225
225
  }
226
226
  }
227
+ /**
228
+ * Atomically write to a file using write-then-rename pattern.
229
+ * This prevents race conditions and ensures the file is never in a partial state.
230
+ */
231
+ function atomicWriteFile(filePath, content, mode) {
232
+ const tmpPath = filePath + '.tmp.' + process.pid + '.' + Date.now();
233
+ writeFileSync(tmpPath, content, { mode });
234
+ renameSync(tmpPath, filePath);
235
+ }
227
236
  /**
228
237
  * Load all secrets from vault
229
238
  */
@@ -259,13 +268,14 @@ function loadSecrets() {
259
268
  }
260
269
  }
261
270
  // If we successfully decrypted legacy secrets, rewrite those entries using the current scheme.
271
+ // Use atomic write to prevent race conditions with concurrent processes.
262
272
  if (Object.keys(migrated).length > 0) {
263
273
  try {
264
274
  const updated = { ...encrypted };
265
275
  for (const [k, v] of Object.entries(migrated)) {
266
276
  updated[k] = encrypt(v);
267
277
  }
268
- writeFileSync(secretsFilePath, JSON.stringify(updated, null, 2), { mode: 0o600 });
278
+ atomicWriteFile(secretsFilePath, JSON.stringify(updated, null, 2), 0o600);
269
279
  }
270
280
  catch {
271
281
  // Best-effort migration only. If we can't write (e.g. restricted FS), still return decrypted secrets.
@@ -278,7 +288,7 @@ function loadSecrets() {
278
288
  }
279
289
  }
280
290
  /**
281
- * Save all secrets to vault
291
+ * Save all secrets to vault using atomic write
282
292
  */
283
293
  function saveSecrets(secrets) {
284
294
  const secretsFilePath = getSecretsFilePath();
@@ -287,7 +297,7 @@ function saveSecrets(secrets) {
287
297
  for (const [key, value] of Object.entries(secrets)) {
288
298
  encrypted[key] = encrypt(value);
289
299
  }
290
- writeFileSync(secretsFilePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
300
+ atomicWriteFile(secretsFilePath, JSON.stringify(encrypted, null, 2), 0o600);
291
301
  }
292
302
  /**
293
303
  * Get a secret value (env var takes precedence)
@@ -47,6 +47,7 @@ function initEventBridge() {
47
47
  status: event.status,
48
48
  exitCode: event.exitCode,
49
49
  durationMs: event.durationMs,
50
+ inputJson: event.input,
50
51
  stdout: event.stdout,
51
52
  stderr: event.stderr,
52
53
  });
@@ -24,6 +24,7 @@ export interface CreateStepInput {
24
24
  status: StepStatus;
25
25
  exitCode?: number;
26
26
  durationMs?: number;
27
+ inputJson?: unknown;
27
28
  stdout?: string;
28
29
  stderr?: string;
29
30
  jsonOutput?: unknown;
@@ -109,7 +110,7 @@ export declare function upsertStep(input: CreateStepInput): RunStepRecord;
109
110
  /**
110
111
  * Update a step
111
112
  */
112
- export declare function updateStep(id: string, updates: Partial<Pick<CreateStepInput, 'status' | 'exitCode' | 'durationMs' | 'stdout' | 'stderr' | 'jsonOutput' | 'errorCode' | 'errorMessage'>>): RunStepRecord | null;
113
+ export declare function updateStep(id: string, updates: Partial<Pick<CreateStepInput, 'status' | 'exitCode' | 'durationMs' | 'inputJson' | 'stdout' | 'stderr' | 'jsonOutput' | 'errorCode' | 'errorMessage'>>): RunStepRecord | null;
113
114
  /**
114
115
  * Append a log line
115
116
  */
@@ -204,10 +204,10 @@ export function createStep(input) {
204
204
  const stmt = db.prepare(`
205
205
  INSERT INTO run_steps (
206
206
  id, run_id, step_id, step_type, status, exit_code, duration_ms,
207
- stdout, stderr, json_output, error_code, error_message, created_at
208
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
207
+ input_json, stdout, stderr, json_output, error_code, error_message, created_at
208
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
209
  `);
210
- stmt.run(id, input.runId, input.stepId, input.stepType, input.status, input.exitCode ?? null, input.durationMs ?? null, input.stdout ?? null, input.stderr ?? null, input.jsonOutput ? JSON.stringify(input.jsonOutput) : null, input.errorCode ?? null, input.errorMessage ?? null, now);
210
+ stmt.run(id, input.runId, input.stepId, input.stepType, input.status, input.exitCode ?? null, input.durationMs ?? null, input.inputJson ? JSON.stringify(input.inputJson) : null, input.stdout ?? null, input.stderr ?? null, input.jsonOutput ? JSON.stringify(input.jsonOutput) : null, input.errorCode ?? null, input.errorMessage ?? null, now);
211
211
  return getStep(id);
212
212
  }
213
213
  catch (error) {
@@ -249,6 +249,7 @@ export function upsertStep(input) {
249
249
  status: input.status,
250
250
  exitCode: input.exitCode,
251
251
  durationMs: input.durationMs,
252
+ inputJson: input.inputJson,
252
253
  stdout: input.stdout,
253
254
  stderr: input.stderr,
254
255
  jsonOutput: input.jsonOutput,
@@ -280,6 +281,10 @@ export function updateStep(id, updates) {
280
281
  fields.push('duration_ms = ?');
281
282
  values.push(updates.durationMs);
282
283
  }
284
+ if (updates.inputJson !== undefined) {
285
+ fields.push('input_json = ?');
286
+ values.push(JSON.stringify(updates.inputJson));
287
+ }
283
288
  if (updates.stdout !== undefined) {
284
289
  fields.push('stdout = ?');
285
290
  values.push(updates.stdout);
@@ -30,6 +30,7 @@ export interface RunStepRecord {
30
30
  status: StepStatus;
31
31
  exit_code: number | null;
32
32
  duration_ms: number | null;
33
+ input_json: string | null;
33
34
  stdout: string | null;
34
35
  stderr: string | null;
35
36
  json_output: string | null;
@@ -93,6 +93,7 @@ function createTables(db) {
93
93
  status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'skipped', 'caught', 'running')),
94
94
  exit_code INTEGER,
95
95
  duration_ms INTEGER,
96
+ input_json TEXT,
96
97
  stdout TEXT,
97
98
  stderr TEXT,
98
99
  json_output TEXT,