@phnx-labs/agents-cli 1.14.7 → 1.15.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.
@@ -3,6 +3,13 @@
3
3
  *
4
4
  * Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
5
5
  * with automatic daily rotation and rich metadata for debugging/auditing.
6
+ *
7
+ * Features:
8
+ * - Rich metadata: hostname, platform, arch, pid, timezone
9
+ * - Timing helpers: measure operation duration automatically
10
+ * - Truncation: long inputs/outputs are trimmed with ellipsis
11
+ * - Permissions: logs dir is 0700, files are 0600 (owner-only)
12
+ * - Performance tracking: withTiming() wrapper for any async function
6
13
  */
7
14
  import * as fs from 'fs';
8
15
  import * as path from 'path';
@@ -12,6 +19,19 @@ const USER_AGENTS_DIR = path.join(os.homedir(), '.agents');
12
19
  const LOGS_DIR = path.join(USER_AGENTS_DIR, 'logs');
13
20
  /** Default retention period in days. */
14
21
  const DEFAULT_RETENTION_DAYS = 30;
22
+ /** Default max length for truncated strings. */
23
+ const DEFAULT_TRUNCATE_LENGTH = 500;
24
+ /** Environment variable to disable event logging. */
25
+ const DISABLE_ENV_VAR = 'AGENTS_DISABLE_EVENT_LOG';
26
+ /** Check if audit logging is disabled via environment variable. */
27
+ function isDisabled() {
28
+ const val = process.env[DISABLE_ENV_VAR];
29
+ return val === '1' || val === 'true';
30
+ }
31
+ /** Directory permissions (owner read/write/execute only). */
32
+ const DIR_MODE = 0o700;
33
+ /** File permissions (owner read/write only). */
34
+ const FILE_MODE = 0o600;
15
35
  // ─── Helpers ──────────────────────────────────────────────────────────────────
16
36
  function getTimezoneOffset() {
17
37
  const offset = new Date().getTimezoneOffset();
@@ -36,8 +56,48 @@ function getLogFilePath(date = new Date()) {
36
56
  }
37
57
  function ensureLogsDir() {
38
58
  if (!fs.existsSync(LOGS_DIR)) {
39
- fs.mkdirSync(LOGS_DIR, { recursive: true });
59
+ fs.mkdirSync(LOGS_DIR, { recursive: true, mode: DIR_MODE });
60
+ }
61
+ else {
62
+ // Ensure permissions are correct on existing dir
63
+ try {
64
+ fs.chmodSync(LOGS_DIR, DIR_MODE);
65
+ }
66
+ catch {
67
+ // May fail if not owner
68
+ }
69
+ }
70
+ }
71
+ // ─── Truncation ───────────────────────────────────────────────────────────────
72
+ /**
73
+ * Truncate a string to maxLength, adding ellipsis if truncated.
74
+ * Returns undefined for null/undefined input.
75
+ */
76
+ export function truncate(str, maxLength = DEFAULT_TRUNCATE_LENGTH) {
77
+ if (str == null)
78
+ return undefined;
79
+ if (str.length <= maxLength)
80
+ return str;
81
+ return str.slice(0, maxLength - 3) + '...';
82
+ }
83
+ /**
84
+ * Truncate all string values in a payload object.
85
+ */
86
+ function truncatePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
87
+ const result = {};
88
+ for (const [key, value] of Object.entries(payload)) {
89
+ if (typeof value === 'string') {
90
+ result[key] = truncate(value, maxLength);
91
+ }
92
+ else if (Array.isArray(value)) {
93
+ // Truncate array to first 10 items, truncate each string item
94
+ result[key] = value.slice(0, 10).map(v => typeof v === 'string' ? truncate(v, maxLength) : v);
95
+ }
96
+ else {
97
+ result[key] = value;
98
+ }
40
99
  }
100
+ return result;
41
101
  }
42
102
  // ─── Core API ─────────────────────────────────────────────────────────────────
43
103
  /**
@@ -47,6 +107,8 @@ function ensureLogsDir() {
47
107
  * @param payload - Event-specific data (agent, version, cwd, etc.)
48
108
  */
49
109
  export function emit(event, payload = {}) {
110
+ if (isDisabled())
111
+ return;
50
112
  try {
51
113
  ensureLogsDir();
52
114
  const record = {
@@ -57,11 +119,20 @@ export function emit(event, payload = {}) {
57
119
  platform: os.platform(),
58
120
  arch: os.arch(),
59
121
  pid: process.pid,
122
+ ppid: process.ppid,
60
123
  event,
61
- ...payload,
124
+ ...truncatePayload(payload),
62
125
  };
63
126
  const line = JSON.stringify(record) + '\n';
64
- fs.appendFileSync(getLogFilePath(), line);
127
+ const logPath = getLogFilePath();
128
+ fs.appendFileSync(logPath, line, { mode: FILE_MODE });
129
+ // Ensure file permissions (appendFileSync doesn't respect mode on existing files)
130
+ try {
131
+ fs.chmodSync(logPath, FILE_MODE);
132
+ }
133
+ catch {
134
+ // May fail if not owner
135
+ }
65
136
  }
66
137
  catch {
67
138
  // Silent failure - logging should never break the CLI
@@ -88,6 +159,166 @@ export function emitStart(startEvent, payload = {}) {
88
159
  });
89
160
  };
90
161
  }
162
+ // ─── Timing Utilities ─────────────────────────────────────────────────────────
163
+ /**
164
+ * Measure execution time of a synchronous function.
165
+ * Emits a perf.timing event with the duration.
166
+ *
167
+ * @example
168
+ * const result = time('parse-config', () => parseConfig(path));
169
+ */
170
+ export function time(label, fn, payload = {}) {
171
+ const start = Date.now();
172
+ try {
173
+ const result = fn();
174
+ emit('perf.timing', {
175
+ ...payload,
176
+ label,
177
+ durationMs: Date.now() - start,
178
+ status: 'success',
179
+ });
180
+ return result;
181
+ }
182
+ catch (err) {
183
+ emit('perf.timing', {
184
+ ...payload,
185
+ label,
186
+ durationMs: Date.now() - start,
187
+ status: 'error',
188
+ error: err instanceof Error ? err.message : String(err),
189
+ });
190
+ throw err;
191
+ }
192
+ }
193
+ /**
194
+ * Measure execution time of an async function.
195
+ * Emits a perf.timing event with the duration.
196
+ *
197
+ * @example
198
+ * const result = await timeAsync('fetch-data', () => fetchData(url));
199
+ */
200
+ export async function timeAsync(label, fn, payload = {}) {
201
+ const start = Date.now();
202
+ try {
203
+ const result = await fn();
204
+ emit('perf.timing', {
205
+ ...payload,
206
+ label,
207
+ durationMs: Date.now() - start,
208
+ status: 'success',
209
+ });
210
+ return result;
211
+ }
212
+ catch (err) {
213
+ emit('perf.timing', {
214
+ ...payload,
215
+ label,
216
+ durationMs: Date.now() - start,
217
+ status: 'error',
218
+ error: err instanceof Error ? err.message : String(err),
219
+ });
220
+ throw err;
221
+ }
222
+ }
223
+ /**
224
+ * Create a timing context for measuring multiple phases of an operation.
225
+ * Useful for tracking startup time vs execution time.
226
+ *
227
+ * @example
228
+ * const timer = createTimer('agent.run', { agent: 'claude' });
229
+ * // ... setup work ...
230
+ * timer.mark('startup'); // records startup time
231
+ * // ... main work ...
232
+ * timer.end({ exitCode: 0 }); // records total time and emits event
233
+ */
234
+ export function createTimer(label, payload = {}) {
235
+ const start = Date.now();
236
+ const marks = {};
237
+ return {
238
+ mark(phase) {
239
+ const elapsed = Date.now() - start;
240
+ marks[phase] = elapsed;
241
+ return elapsed;
242
+ },
243
+ elapsed() {
244
+ return Date.now() - start;
245
+ },
246
+ end(endPayload = {}) {
247
+ const durationMs = Date.now() - start;
248
+ emit('perf.timing', {
249
+ ...payload,
250
+ ...endPayload,
251
+ label,
252
+ durationMs,
253
+ phases: marks,
254
+ });
255
+ },
256
+ };
257
+ }
258
+ /**
259
+ * Higher-order function that wraps an async function with timing.
260
+ * The wrapper emits start/end events automatically.
261
+ *
262
+ * @example
263
+ * const timedFetch = withTiming('fetch', fetchData, { service: 'api' });
264
+ * const result = await timedFetch(url);
265
+ */
266
+ export function withTiming(label, fn, basePayload = {}) {
267
+ return async (...args) => {
268
+ const start = Date.now();
269
+ try {
270
+ const result = await fn(...args);
271
+ emit('perf.timing', {
272
+ ...basePayload,
273
+ label,
274
+ durationMs: Date.now() - start,
275
+ status: 'success',
276
+ });
277
+ return result;
278
+ }
279
+ catch (err) {
280
+ emit('perf.timing', {
281
+ ...basePayload,
282
+ label,
283
+ durationMs: Date.now() - start,
284
+ status: 'error',
285
+ error: err instanceof Error ? err.message : String(err),
286
+ });
287
+ throw err;
288
+ }
289
+ };
290
+ }
291
+ // ─── Command Tracking ─────────────────────────────────────────────────────────
292
+ /**
293
+ * Emit a command.start event with CLI args.
294
+ * Returns a done() function to emit command.end with duration.
295
+ *
296
+ * @example
297
+ * // At CLI entry point:
298
+ * const done = emitCommand('run', process.argv.slice(2));
299
+ * // ... execute command ...
300
+ * done({ exitCode: 0 });
301
+ */
302
+ export function emitCommand(command, args = [], payload = {}) {
303
+ return emitStart('command.start', {
304
+ ...payload,
305
+ command,
306
+ args: args.slice(0, 20), // Limit args to first 20
307
+ cwd: process.cwd(),
308
+ });
309
+ }
310
+ // ─── Error Tracking ───────────────────────────────────────────────────────────
311
+ /**
312
+ * Emit an error event with full details.
313
+ */
314
+ export function emitError(err, payload = {}) {
315
+ const error = err instanceof Error ? err : new Error(err);
316
+ emit('error', {
317
+ ...payload,
318
+ error: error.message,
319
+ errorStack: truncate(error.stack, 1000),
320
+ });
321
+ }
91
322
  // ─── Rotation ─────────────────────────────────────────────────────────────────
92
323
  /**
93
324
  * Remove log files older than the retention period.
@@ -140,7 +371,7 @@ export function maybeRotate() {
140
371
  * @returns Array of event records
141
372
  */
142
373
  export function query(options) {
143
- const { startDate, endDate = new Date(), eventTypes, agent, limit } = options;
374
+ const { startDate, endDate = new Date(), eventTypes, agent, command, limit } = options;
144
375
  const results = [];
145
376
  if (!fs.existsSync(LOGS_DIR))
146
377
  return results;
@@ -167,6 +398,8 @@ export function query(options) {
167
398
  continue;
168
399
  if (agent && record.agent !== agent)
169
400
  continue;
401
+ if (command && record.command !== command)
402
+ continue;
170
403
  results.push(record);
171
404
  if (limit && results.length >= limit) {
172
405
  return results;
@@ -179,5 +412,30 @@ export function query(options) {
179
412
  }
180
413
  return results;
181
414
  }
415
+ // ─── Stats ────────────────────────────────────────────────────────────────────
416
+ /**
417
+ * Get performance stats for a specific label.
418
+ */
419
+ export function getTimingStats(label, options = {}) {
420
+ const days = options.days ?? 7;
421
+ const startDate = new Date();
422
+ startDate.setDate(startDate.getDate() - days);
423
+ const events = query({
424
+ startDate,
425
+ eventTypes: ['perf.timing'],
426
+ }).filter(e => e.label === label && typeof e.durationMs === 'number');
427
+ if (events.length === 0)
428
+ return null;
429
+ const durations = events.map(e => e.durationMs).sort((a, b) => a - b);
430
+ const sum = durations.reduce((a, b) => a + b, 0);
431
+ return {
432
+ count: durations.length,
433
+ avgMs: Math.round(sum / durations.length),
434
+ minMs: durations[0],
435
+ maxMs: durations[durations.length - 1],
436
+ p50Ms: durations[Math.floor(durations.length * 0.5)],
437
+ p95Ms: durations[Math.floor(durations.length * 0.95)],
438
+ };
439
+ }
182
440
  // ─── Exports ──────────────────────────────────────────────────────────────────
183
441
  export const LOGS_PATH = LOGS_DIR;
package/dist/lib/exec.js CHANGED
@@ -10,7 +10,7 @@ import * as path from 'path';
10
10
  import { parseTimeout } from './routines.js';
11
11
  import { getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
12
12
  import { resolveModel, buildReasoningFlags } from './models.js';
13
- import { emitStart, maybeRotate } from './events.js';
13
+ import { maybeRotate, createTimer, truncate } from './events.js';
14
14
  /** Pattern for valid environment variable names (C identifier rules). */
15
15
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
16
  /** Parse a single KEY=VALUE string into a tuple, validating the key name. */
@@ -305,13 +305,17 @@ async function spawnAgent(options) {
305
305
  const piped = !process.stdout.isTTY;
306
306
  const interactive = options.interactive === true || options.prompt === undefined;
307
307
  maybeRotate();
308
- const done = emitStart('agent.run.start', {
308
+ const timer = createTimer('agent.run', {
309
309
  agent: options.agent,
310
310
  version: options.version,
311
311
  cwd: options.cwd || process.cwd(),
312
312
  mode: options.mode,
313
313
  model: options.model,
314
314
  interactive,
315
+ sessionId: options.sessionId,
316
+ prompt: truncate(options.prompt, 200),
317
+ command: executable,
318
+ args: args.slice(0, 10),
315
319
  });
316
320
  return new Promise((resolve, reject) => {
317
321
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
@@ -327,6 +331,8 @@ async function spawnAgent(options) {
327
331
  env: buildExecEnv(options),
328
332
  shell: false,
329
333
  });
334
+ // Mark startup time (time from function call to process spawn)
335
+ timer.mark('startup');
330
336
  if (!interactive && piped && child.stdout) {
331
337
  child.stdout.pipe(process.stdout);
332
338
  }
@@ -343,23 +349,23 @@ async function spawnAgent(options) {
343
349
  }
344
350
  });
345
351
  }
346
- let timer;
352
+ let timeoutTimer;
347
353
  if (timeoutMs) {
348
- timer = setTimeout(() => {
354
+ timeoutTimer = setTimeout(() => {
349
355
  child.kill('SIGTERM');
350
356
  setTimeout(() => child.kill('SIGKILL'), 5000);
351
357
  }, timeoutMs);
352
358
  }
353
359
  child.on('error', (err) => {
354
- if (timer)
355
- clearTimeout(timer);
356
- done({ error: err.message, exitCode: -1 });
360
+ if (timeoutTimer)
361
+ clearTimeout(timeoutTimer);
362
+ timer.end({ error: err.message, exitCode: -1, status: 'error' });
357
363
  reject(err);
358
364
  });
359
365
  child.on('close', (code) => {
360
- if (timer)
361
- clearTimeout(timer);
362
- done({ exitCode: code ?? 0 });
366
+ if (timeoutTimer)
367
+ clearTimeout(timeoutTimer);
368
+ timer.end({ exitCode: code ?? 0, status: code === 0 ? 'success' : 'failed' });
363
369
  resolve({ exitCode: code ?? 0, stderr: stderrBuffer });
364
370
  });
365
371
  });
@@ -72,21 +72,24 @@ export declare function getActivePermissionSetName(): string | null;
72
72
  export declare function buildPermissionsFromGroups(groupNames: string[]): PermissionSet;
73
73
  /**
74
74
  * List installed permission sets from central storage.
75
+ * User dir takes precedence; system entries are surfaced when user has no
76
+ * same-named override.
75
77
  */
76
78
  export declare function listInstalledPermissions(): InstalledPermission[];
77
79
  /**
78
- * Get a specific permission set by name.
80
+ * Get a specific permission set by name. Searches user dir first, then system.
79
81
  */
80
82
  export declare function getPermissionSet(name: string): InstalledPermission | null;
81
83
  /**
82
- * Install a permission set to central storage.
84
+ * Install a permission set to user-level central storage.
83
85
  */
84
86
  export declare function installPermissionSet(sourcePath: string, name: string): {
85
87
  success: boolean;
86
88
  error?: string;
87
89
  };
88
90
  /**
89
- * Remove a permission set from central storage.
91
+ * Remove a permission set from user-level central storage. System-shipped
92
+ * sets are intentionally not deletable from user commands.
90
93
  */
91
94
  export declare function removePermissionSet(name: string): {
92
95
  success: boolean;
@@ -259,55 +259,58 @@ export function buildPermissionsFromGroups(groupNames) {
259
259
  }
260
260
  /**
261
261
  * List installed permission sets from central storage.
262
+ * User dir takes precedence; system entries are surfaced when user has no
263
+ * same-named override.
262
264
  */
263
265
  export function listInstalledPermissions() {
264
266
  ensureAgentsDir();
265
- const dir = getPermissionsDir();
266
- if (!fs.existsSync(dir)) {
267
- return [];
268
- }
267
+ const seen = new Set();
269
268
  const results = [];
270
- try {
271
- const entries = fs.readdirSync(dir, { withFileTypes: true });
272
- for (const entry of entries) {
273
- if (!entry.isFile())
274
- continue;
275
- if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
276
- continue;
277
- const filePath = path.join(dir, entry.name);
278
- const set = parsePermissionSet(filePath);
279
- if (set) {
280
- results.push({
281
- name: set.name,
282
- path: filePath,
283
- set,
284
- });
269
+ for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
270
+ if (!fs.existsSync(dir))
271
+ continue;
272
+ try {
273
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
274
+ for (const entry of entries) {
275
+ if (!entry.isFile())
276
+ continue;
277
+ if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
278
+ continue;
279
+ const filePath = path.join(dir, entry.name);
280
+ const set = parsePermissionSet(filePath);
281
+ if (!set)
282
+ continue;
283
+ if (seen.has(set.name))
284
+ continue;
285
+ seen.add(set.name);
286
+ results.push({ name: set.name, path: filePath, set });
285
287
  }
286
288
  }
287
- }
288
- catch {
289
- // Ignore errors
289
+ catch {
290
+ // Skip inaccessible directory
291
+ }
290
292
  }
291
293
  return results;
292
294
  }
293
295
  /**
294
- * Get a specific permission set by name.
296
+ * Get a specific permission set by name. Searches user dir first, then system.
295
297
  */
296
298
  export function getPermissionSet(name) {
297
- const dir = getPermissionsDir();
298
- for (const ext of ['.yml', '.yaml']) {
299
- const filePath = safeJoin(dir, name + ext);
300
- if (fs.existsSync(filePath)) {
301
- const set = parsePermissionSet(filePath);
302
- if (set) {
303
- return { name: set.name, path: filePath, set };
299
+ for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
300
+ for (const ext of ['.yml', '.yaml']) {
301
+ const filePath = safeJoin(dir, name + ext);
302
+ if (fs.existsSync(filePath)) {
303
+ const set = parsePermissionSet(filePath);
304
+ if (set) {
305
+ return { name: set.name, path: filePath, set };
306
+ }
304
307
  }
305
308
  }
306
309
  }
307
310
  return null;
308
311
  }
309
312
  /**
310
- * Install a permission set to central storage.
313
+ * Install a permission set to user-level central storage.
311
314
  */
312
315
  export function installPermissionSet(sourcePath, name) {
313
316
  ensurePermissionsDir();
@@ -315,7 +318,7 @@ export function installPermissionSet(sourcePath, name) {
315
318
  if (!set) {
316
319
  return { success: false, error: 'Invalid permission file' };
317
320
  }
318
- const targetPath = safeJoin(getPermissionsDir(), name + '.yml');
321
+ const targetPath = safeJoin(getUserPermissionsDir(), name + '.yml');
319
322
  try {
320
323
  fs.copyFileSync(sourcePath, targetPath);
321
324
  return { success: true };
@@ -325,10 +328,11 @@ export function installPermissionSet(sourcePath, name) {
325
328
  }
326
329
  }
327
330
  /**
328
- * Remove a permission set from central storage.
331
+ * Remove a permission set from user-level central storage. System-shipped
332
+ * sets are intentionally not deletable from user commands.
329
333
  */
330
334
  export function removePermissionSet(name) {
331
- const dir = getPermissionsDir();
335
+ const dir = getUserPermissionsDir();
332
336
  for (const ext of ['.yml', '.yaml']) {
333
337
  const filePath = safeJoin(dir, name + ext);
334
338
  if (fs.existsSync(filePath)) {
@@ -96,3 +96,18 @@ export declare function installJobFromSource(sourcePath: string, name: string):
96
96
  success: boolean;
97
97
  error?: string;
98
98
  };
99
+ /** List all job names that have run directories. */
100
+ export declare function listJobsWithRuns(): string[];
101
+ /** Count total runs across all jobs. */
102
+ export declare function countAllRuns(): number;
103
+ /** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
104
+ export declare function previewRunsPrune(keep: number): Array<{
105
+ jobName: string;
106
+ runId: string;
107
+ startedAt: string;
108
+ }>;
109
+ /** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
110
+ export declare function pruneRuns(keep: number): {
111
+ deleted: number;
112
+ bytesFreed: number;
113
+ };
@@ -350,3 +350,71 @@ export function installJobFromSource(sourcePath, name) {
350
350
  return { success: false, error: err.message };
351
351
  }
352
352
  }
353
+ /** List all job names that have run directories. */
354
+ export function listJobsWithRuns() {
355
+ const runsDir = getRunsDir();
356
+ if (!fs.existsSync(runsDir))
357
+ return [];
358
+ return fs.readdirSync(runsDir, { withFileTypes: true })
359
+ .filter((e) => e.isDirectory())
360
+ .map((e) => e.name);
361
+ }
362
+ /** Count total runs across all jobs. */
363
+ export function countAllRuns() {
364
+ let total = 0;
365
+ for (const jobName of listJobsWithRuns()) {
366
+ total += listRuns(jobName).length;
367
+ }
368
+ return total;
369
+ }
370
+ /** Preview runs that would be pruned (keeping only the most recent `keep` per job). */
371
+ export function previewRunsPrune(keep) {
372
+ const toPrune = [];
373
+ for (const jobName of listJobsWithRuns()) {
374
+ const runs = listRuns(jobName);
375
+ if (runs.length > keep) {
376
+ const toRemove = runs.slice(0, runs.length - keep);
377
+ for (const run of toRemove) {
378
+ toPrune.push({ jobName, runId: run.runId, startedAt: run.startedAt });
379
+ }
380
+ }
381
+ }
382
+ return toPrune;
383
+ }
384
+ /** Delete old runs, keeping only the most recent `keep` per job. Returns bytes freed and run count. */
385
+ export function pruneRuns(keep) {
386
+ let deleted = 0;
387
+ let bytesFreed = 0;
388
+ for (const jobName of listJobsWithRuns()) {
389
+ const runs = listRuns(jobName);
390
+ if (runs.length <= keep)
391
+ continue;
392
+ const toRemove = runs.slice(0, runs.length - keep);
393
+ for (const run of toRemove) {
394
+ const runDir = getRunDir(jobName, run.runId);
395
+ bytesFreed += getDirSize(runDir);
396
+ fs.rmSync(runDir, { recursive: true, force: true });
397
+ deleted++;
398
+ }
399
+ }
400
+ return { deleted, bytesFreed };
401
+ }
402
+ function getDirSize(dirPath) {
403
+ if (!fs.existsSync(dirPath))
404
+ return 0;
405
+ let size = 0;
406
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
407
+ for (const entry of entries) {
408
+ const fullPath = path.join(dirPath, entry.name);
409
+ if (entry.isDirectory()) {
410
+ size += getDirSize(fullPath);
411
+ }
412
+ else {
413
+ try {
414
+ size += fs.statSync(fullPath).size;
415
+ }
416
+ catch { /* ignore */ }
417
+ }
418
+ }
419
+ return size;
420
+ }