claw-dashboard 1.8.4 → 2.0.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.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5331 -512
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1941 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1057 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Animated Loading States Module
3
+ * Provides spinners, progress indicators, and animated transitions
4
+ * for widget loading, data fetching, and initialization states
5
+ */
6
+
7
+ import logger from './logger.js';
8
+ import { getCurrentTheme } from './themes.js';
9
+
10
+ /**
11
+ * Spinner frame sets for different animation styles
12
+ */
13
+ const SPINNER_FRAMES = {
14
+ dots: {
15
+ frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
16
+ interval: 80
17
+ },
18
+ line: {
19
+ frames: ['-', '\\', '|', '/'],
20
+ interval: 100
21
+ },
22
+ pulse: {
23
+ frames: ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔'],
24
+ interval: 100
25
+ },
26
+ blocks: {
27
+ frames: ['▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'],
28
+ interval: 80
29
+ },
30
+ arrows: {
31
+ frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
32
+ interval: 100
33
+ },
34
+ bouncing: {
35
+ frames: ['( ● )', '( ● )', '( ● )', '( ● )', '( ●)', '( ● )', '( ● )', '( ● )', '( ● )', '(● )'],
36
+ interval: 80
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Progress bar styles
42
+ */
43
+ const PROGRESS_STYLES = {
44
+ blocks: ['░', '▒', '▓', '█'],
45
+ bars: [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'],
46
+ ascii: [' ', '=', '=','=','=','=','=','=','#'],
47
+ dots: [' ', '·', '∙', '●'],
48
+ minimal: ['○', '◐', '◑', '●']
49
+ };
50
+
51
+ /**
52
+ * Loading state manager for tracking active loading operations
53
+ */
54
+ class LoadingStateManager {
55
+ constructor() {
56
+ this.activeStates = new Map();
57
+ this.globalSpinner = null;
58
+ this.animationFrameId = null;
59
+ }
60
+
61
+ /**
62
+ * Create a new loading state
63
+ * @param {string} id - Unique identifier for this loading state
64
+ * @param {Object} options - Loading state options
65
+ * @param {string} options.type - Type: 'spinner', 'progress', 'pulse', 'custom'
66
+ * @param {string} options.message - Loading message to display
67
+ * @param {string} options.style - Spinner/progress style name
68
+ * @param {number} options.total - Total for progress bars
69
+ * @returns {Object} Loading state controller
70
+ */
71
+ create(id, options = {}) {
72
+ const {
73
+ type = 'spinner',
74
+ message = 'Loading...',
75
+ style = 'dots',
76
+ total = 100
77
+ } = options;
78
+
79
+ const state = {
80
+ id,
81
+ type,
82
+ message,
83
+ style,
84
+ total,
85
+ current: 0,
86
+ frames: SPINNER_FRAMES[style]?.frames || SPINNER_FRAMES.dots.frames,
87
+ frameIndex: 0,
88
+ interval: SPINNER_FRAMES[style]?.interval || 80,
89
+ startTime: Date.now(),
90
+ timerId: null,
91
+ listeners: new Set(),
92
+ isComplete: false
93
+ };
94
+
95
+ this.activeStates.set(id, state);
96
+
97
+ // Start animation
98
+ if (type === 'spinner') {
99
+ this._startSpinnerAnimation(state);
100
+ }
101
+
102
+ logger.debug(`Loading state created: ${id} (${type})`);
103
+
104
+ return {
105
+ id,
106
+ update: (newMessage) => this.updateMessage(id, newMessage),
107
+ progress: (current, newTotal) => this.updateProgress(id, current, newTotal),
108
+ complete: (finalMessage) => this.complete(id, finalMessage),
109
+ onUpdate: (callback) => this._addListener(id, callback),
110
+ getFrame: () => this._getCurrentFrame(state),
111
+ elapsed: () => Date.now() - state.startTime
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Start spinner animation timer
117
+ * @private
118
+ */
119
+ _startSpinnerAnimation(state) {
120
+ state.timerId = setInterval(() => {
121
+ state.frameIndex = (state.frameIndex + 1) % state.frames.length;
122
+ this._notifyListeners(state);
123
+ }, state.interval);
124
+ }
125
+
126
+ /**
127
+ * Get current spinner frame
128
+ * @private
129
+ */
130
+ _getCurrentFrame(state) {
131
+ const theme = getCurrentTheme();
132
+ const colors = theme.colors;
133
+ const frame = state.frames[state.frameIndex];
134
+
135
+ if (state.type === 'progress') {
136
+ return this._renderProgressBar(state, colors);
137
+ }
138
+
139
+ // Return formatted spinner with message
140
+ return {
141
+ frame,
142
+ message: state.message,
143
+ elapsed: this._formatElapsed(Date.now() - state.startTime),
144
+ color: colors.branding.logo
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Render progress bar
150
+ * @private
151
+ */
152
+ _renderProgressBar(state, colors) {
153
+ const { bars } = PROGRESS_STYLES;
154
+ const percentage = Math.min(100, Math.max(0, (state.current / state.total) * 100));
155
+ const filledLength = Math.floor((percentage / 100) * bars.length);
156
+
157
+ const filled = bars[bars.length - 1].repeat(filledLength);
158
+ const empty = bars[0].repeat(bars.length - filledLength);
159
+
160
+ return {
161
+ bar: `[${filled}${empty}]`,
162
+ percentage: percentage.toFixed(1),
163
+ current: state.current,
164
+ total: state.total,
165
+ message: state.message,
166
+ color: percentage < 30 ? colors.gauge.low :
167
+ percentage < 70 ? colors.gauge.medium :
168
+ percentage < 90 ? colors.gauge.high : colors.gauge.critical
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Format elapsed time
174
+ * @private
175
+ */
176
+ _formatElapsed(ms) {
177
+ if (ms < 1000) return `${ms}ms`;
178
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
179
+ const mins = Math.floor(ms / 60000);
180
+ const secs = ((ms % 60000) / 1000).toFixed(0);
181
+ return `${mins}m ${secs}s`;
182
+ }
183
+
184
+ /**
185
+ * Add update listener
186
+ * @private
187
+ */
188
+ _addListener(id, callback) {
189
+ const state = this.activeStates.get(id);
190
+ if (state) {
191
+ state.listeners.add(callback);
192
+ return () => state.listeners.delete(callback);
193
+ }
194
+ return () => {};
195
+ }
196
+
197
+ /**
198
+ * Notify all listeners of state update
199
+ * @private
200
+ */
201
+ _notifyListeners(state) {
202
+ const frame = this._getCurrentFrame(state);
203
+ state.listeners.forEach(callback => {
204
+ try {
205
+ callback(frame, state);
206
+ } catch (err) {
207
+ logger.debug(`Loading state listener error: ${err.message}`);
208
+ }
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Update loading message
214
+ * @param {string} id - Loading state ID
215
+ * @param {string} newMessage - New message to display
216
+ */
217
+ updateMessage(id, newMessage) {
218
+ const state = this.activeStates.get(id);
219
+ if (state) {
220
+ state.message = newMessage;
221
+ this._notifyListeners(state);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Update progress bar
227
+ * @param {string} id - Loading state ID
228
+ * @param {number} current - Current progress value
229
+ * @param {number} newTotal - Optional new total
230
+ */
231
+ updateProgress(id, current, newTotal) {
232
+ const state = this.activeStates.get(id);
233
+ if (state) {
234
+ state.current = current;
235
+ if (newTotal !== undefined) state.total = newTotal;
236
+ this._notifyListeners(state);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Mark loading state as complete
242
+ * @param {string} id - Loading state ID
243
+ * @param {string} finalMessage - Optional final message
244
+ */
245
+ complete(id, finalMessage) {
246
+ const state = this.activeStates.get(id);
247
+ if (state) {
248
+ state.isComplete = true;
249
+ if (state.timerId) {
250
+ clearInterval(state.timerId);
251
+ state.timerId = null;
252
+ }
253
+ if (finalMessage) {
254
+ state.message = finalMessage;
255
+ }
256
+ this._notifyListeners(state);
257
+ logger.debug(`Loading state completed: ${id} (${this._formatElapsed(Date.now() - state.startTime)})`);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Remove a loading state
263
+ * @param {string} id - Loading state ID
264
+ */
265
+ remove(id) {
266
+ const state = this.activeStates.get(id);
267
+ if (state) {
268
+ if (state.timerId) {
269
+ clearInterval(state.timerId);
270
+ }
271
+ this.activeStates.delete(id);
272
+ logger.debug(`Loading state removed: ${id}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Get all active loading states
278
+ * @returns {Array} Array of active state IDs
279
+ */
280
+ getActive() {
281
+ return Array.from(this.activeStates.keys());
282
+ }
283
+
284
+ /**
285
+ * Clear all loading states
286
+ */
287
+ clearAll() {
288
+ for (const [id, state] of this.activeStates) {
289
+ if (state.timerId) {
290
+ clearInterval(state.timerId);
291
+ }
292
+ }
293
+ this.activeStates.clear();
294
+ logger.debug('All loading states cleared');
295
+ }
296
+ }
297
+
298
+ // Create singleton instance
299
+ const loadingStates = new LoadingStateManager();
300
+
301
+ /**
302
+ * Create a loading spinner for widget initialization
303
+ * @param {string} widgetName - Name of the widget being loaded
304
+ * @param {Object} blessed - Blessed library instance
305
+ * @param {Object} parent - Parent element
306
+ * @returns {Object} Spinner controller with attach/detach methods
307
+ */
308
+ export function createWidgetSpinner(widgetName, blessed, parent) {
309
+ const state = loadingStates.create(`widget:${widgetName}`, {
310
+ type: 'spinner',
311
+ message: `Loading ${widgetName}...`,
312
+ style: 'dots'
313
+ });
314
+
315
+ let spinnerElement = null;
316
+
317
+ const controller = {
318
+ /**
319
+ * Attach spinner to UI
320
+ * @param {Object} options - Position options
321
+ */
322
+ attach(options = {}) {
323
+ const theme = getCurrentTheme();
324
+ const { top = 'center', left = 'center', width = 30, height = 3 } = options;
325
+
326
+ spinnerElement = blessed.box({
327
+ parent,
328
+ top,
329
+ left,
330
+ width,
331
+ height,
332
+ content: `{center}${state.getFrame().frame} ${state.message}{/center}`,
333
+ tags: true,
334
+ style: {
335
+ fg: theme.colors.branding.logo,
336
+ bg: theme.colors.footer.bg
337
+ },
338
+ border: {
339
+ type: 'line',
340
+ fg: theme.colors.border.modal
341
+ }
342
+ });
343
+
344
+ // Subscribe to updates
345
+ state.onUpdate((frame) => {
346
+ if (spinnerElement && !spinnerElement.destroyed) {
347
+ spinnerElement.setContent(`{center}${frame.frame} ${frame.message}{/center}`);
348
+ spinnerElement.style.fg = frame.color;
349
+ }
350
+ });
351
+
352
+ parent.screen.render();
353
+ return controller;
354
+ },
355
+
356
+ /**
357
+ * Update spinner message
358
+ * @param {string} message - New message
359
+ */
360
+ update(message) {
361
+ state.update(message);
362
+ return controller;
363
+ },
364
+
365
+ /**
366
+ * Complete and remove spinner
367
+ * @param {string} finalMessage - Optional final message
368
+ */
369
+ complete(finalMessage) {
370
+ state.complete(finalMessage);
371
+ if (spinnerElement) {
372
+ if (finalMessage) {
373
+ spinnerElement.setContent(`{center}✓ ${finalMessage}{/center}`);
374
+ spinnerElement.style.fg = 'green';
375
+ parent.screen.render();
376
+ // Auto-remove after delay
377
+ setTimeout(() => controller.detach(), 500);
378
+ } else {
379
+ controller.detach();
380
+ }
381
+ }
382
+ return controller;
383
+ },
384
+
385
+ /**
386
+ * Detach and destroy spinner
387
+ */
388
+ detach() {
389
+ if (spinnerElement) {
390
+ spinnerElement.destroy();
391
+ spinnerElement = null;
392
+ parent.screen.render();
393
+ }
394
+ loadingStates.remove(state.id);
395
+ return controller;
396
+ }
397
+ };
398
+
399
+ return controller;
400
+ }
401
+
402
+ /**
403
+ * Create a progress bar for data fetching operations
404
+ * @param {string} operationName - Name of the operation
405
+ * @param {number} total - Total items to process
406
+ * @returns {Object} Progress controller
407
+ */
408
+ export function createProgressBar(operationName, total = 100) {
409
+ return loadingStates.create(`progress:${operationName}`, {
410
+ type: 'progress',
411
+ message: operationName,
412
+ style: 'blocks',
413
+ total
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Create a sequential loading animation for multiple items
419
+ * @param {Array} items - Array of items to load
420
+ * @param {Function} loader - Async function to load each item
421
+ * @param {Object} options - Options
422
+ * @returns {Promise<Array>} Results from all loaders
423
+ */
424
+ export async function loadSequentially(items, loader, options = {}) {
425
+ const { onProgress, onItemComplete, delay = 0 } = options;
426
+ const results = [];
427
+
428
+ const state = loadingStates.create('sequential', {
429
+ type: 'progress',
430
+ message: 'Loading...',
431
+ total: items.length
432
+ });
433
+
434
+ for (let i = 0; i < items.length; i++) {
435
+ const item = items[i];
436
+ state.update(`Loading ${item.name || item.id || item}...`);
437
+ state.progress(i);
438
+
439
+ try {
440
+ const result = await loader(item, i);
441
+ results.push({ success: true, result, item, index: i });
442
+ if (onItemComplete) {
443
+ onItemComplete(item, result, i);
444
+ }
445
+ } catch (err) {
446
+ results.push({ success: false, error: err, item, index: i });
447
+ logger.warn(`Failed to load item ${i}: ${err.message}`);
448
+ }
449
+
450
+ if (onProgress) {
451
+ onProgress(i + 1, items.length, item);
452
+ }
453
+
454
+ if (delay > 0 && i < items.length - 1) {
455
+ await new Promise(r => setTimeout(r, delay));
456
+ }
457
+ }
458
+
459
+ state.progress(items.length, items.length);
460
+ state.complete('Complete');
461
+ loadingStates.remove('sequential');
462
+
463
+ return results;
464
+ }
465
+
466
+ /**
467
+ * Create a staggered loading animation for multiple widgets
468
+ * @param {Array} widgets - Array of widget configurations
469
+ * @param {Function} factory - Widget factory function
470
+ * @param {Object} options - Options
471
+ * @returns {Promise<Array>} Created widgets
472
+ */
473
+ export async function loadStaggered(widgets, factory, options = {}) {
474
+ const { staggerDelay = 100, onWidgetLoaded } = options;
475
+
476
+ return loadSequentially(widgets, async (widgetConfig, index) => {
477
+ await new Promise(r => setTimeout(r, staggerDelay * index));
478
+ const widget = await factory(widgetConfig);
479
+ if (onWidgetLoaded) {
480
+ onWidgetLoaded(widget, widgetConfig, index);
481
+ }
482
+ return widget;
483
+ }, options);
484
+ }
485
+
486
+ /**
487
+ * Get a simple spinner animation frame
488
+ * @param {string} style - Spinner style name
489
+ * @param {number} frame - Frame index (auto-increments if not provided)
490
+ * @returns {string} Spinner character
491
+ */
492
+ export function getSpinnerFrame(style = 'dots', frame) {
493
+ const spinner = SPINNER_FRAMES[style] || SPINNER_FRAMES.dots;
494
+ if (frame === undefined) {
495
+ frame = Math.floor(Date.now() / spinner.interval) % spinner.frames.length;
496
+ }
497
+ return spinner.frames[frame % spinner.frames.length];
498
+ }
499
+
500
+ /**
501
+ * Get all available spinner styles
502
+ * @returns {Array} Array of style names
503
+ */
504
+ export function getSpinnerStyles() {
505
+ return Object.keys(SPINNER_FRAMES);
506
+ }
507
+
508
+ export default loadingStates;
509
+ export { SPINNER_FRAMES, PROGRESS_STYLES, LoadingStateManager };
package/src/logger.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Logger module with timestamp support and file output
3
+ * Replaces direct console.error usage throughout the codebase
4
+ *
5
+ * IMPORTANT: Logs are written to file only to avoid interfering with blessed TUI
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import { setSecurePermissionsSync } from './security.js';
10
+ import os from 'os';
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname, join } from 'path';
13
+
14
+ // Get the directory of this module to resolve the log path
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ // Log file path - using ~/.openclaw/claw-dashboard.log
19
+ const LOG_FILE_PATH = os.homedir() + '/.openclaw/claw-dashboard.log';
20
+
21
+ // Ensure log directory exists
22
+ function ensureLogDir() {
23
+ const logDir = os.homedir() + '/.openclaw';
24
+ if (!fs.existsSync(logDir)) {
25
+ fs.mkdirSync(logDir, { recursive: true });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Sanitize user-controlled input to prevent log injection attacks
31
+ * - Escapes newlines and carriage returns
32
+ * - Removes or escapes ANSI control codes
33
+ * - Escapes other special characters that could affect log formatting
34
+ *
35
+ * @param {any} value - The value to sanitize
36
+ * @returns {string} Sanitized string representation
37
+ */
38
+ function sanitize(value) {
39
+ if (value === null || value === undefined) {
40
+ return String(value);
41
+ }
42
+
43
+ // Convert to string
44
+ let str = String(value);
45
+
46
+ // Remove ANSI escape sequences (color codes, cursor movement, etc.)
47
+ // These patterns match common ANSI control sequences
48
+ str = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
49
+ str = str.replace(/\x1b\][^\x07]*\x07/g, ''); // OSC sequences
50
+ str = str.replace(/\x1b[P][a-zA-Z0-9]/g, ''); // DCS sequences
51
+ str = str.replace(/\x1b\[[0-9;]*[@-~]/g, ''); // Generic CSI sequences
52
+
53
+ // Escape or remove control characters (except common safe ones)
54
+ str = str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, (char) => {
55
+ // Allow tab and newline but escape them for safety
56
+ if (char === '\t') return '\t';
57
+ if (char === '\n') return '\\n';
58
+ if (char === '\r') return '\\r';
59
+ // Replace other control chars with hex representation
60
+ return '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0');
61
+ });
62
+
63
+ // Replace newlines with escaped versions to prevent log injection
64
+ // But keep them readable in the log
65
+ str = str.replace(/\r\n/g, '\\r\\n');
66
+ str = str.replace(/\n/g, '\\n');
67
+ str = str.replace(/\r/g, '\\r');
68
+
69
+ return str;
70
+ }
71
+
72
+ /**
73
+ * Sanitize all arguments in a log call
74
+ * @param {any[]} args - Arguments to sanitize
75
+ * @returns {string[]} Array of sanitized strings
76
+ */
77
+ function sanitizeArgs(args) {
78
+ return args.map(arg => {
79
+ if (typeof arg === 'object') {
80
+ // For objects, try to serialize and sanitize
81
+ try {
82
+ return sanitize(JSON.stringify(arg));
83
+ } catch {
84
+ return sanitize(String(arg));
85
+ }
86
+ }
87
+ return sanitize(arg);
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Write a formatted log line to the log file
93
+ * @param {string} level - Log level (ERROR, WARN, INFO, DEBUG)
94
+ * @param {any[]} args - Arguments to log
95
+ */
96
+ function writeLog(level, args) {
97
+ const timestamp = getTimestamp();
98
+ const sanitizedArgs = sanitizeArgs(args);
99
+ const message = sanitizedArgs.join(' ');
100
+ const logLine = `${timestamp} [${level}] ${message}\n`;
101
+
102
+ try {
103
+ ensureLogDir();
104
+ // Avoid TOCTOU by using appendFileSync which creates if needed
105
+ // Track if this is likely first write via file existence
106
+ let isNewFile = false;
107
+ try {
108
+ fs.accessSync(LOG_FILE_PATH, fs.constants.F_OK);
109
+ } catch {
110
+ isNewFile = true;
111
+ }
112
+
113
+ fs.appendFileSync(LOG_FILE_PATH, logLine);
114
+
115
+ // Set secure permissions on new files (not TOCTOU vulnerable since we just created it)
116
+ if (isNewFile) {
117
+ setSecurePermissionsSync(LOG_FILE_PATH);
118
+ }
119
+ } catch (err) {
120
+ // Silently fail if we can't write to log file - don't disrupt the dashboard
121
+ // Could also try console.error for critical errors, but that defeats the purpose
122
+ if (level === 'ERROR') {
123
+ // For critical errors, at least try to indicate something is wrong
124
+ process.stderr.write(`[Log Error] Failed to write ERROR log: ${err.message}\n`);
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Format a timestamp for log messages
131
+ * @returns {string} Formatted timestamp [YYYY-MM-DD HH:mm:ss]
132
+ */
133
+ function getTimestamp() {
134
+ const now = new Date();
135
+ const year = now.getFullYear();
136
+ const month = String(now.getMonth() + 1).padStart(2, '0');
137
+ const day = String(now.getDate()).padStart(2, '0');
138
+ const hours = String(now.getHours()).padStart(2, '0');
139
+ const minutes = String(now.getMinutes()).padStart(2, '0');
140
+ const seconds = String(now.getSeconds()).padStart(2, '0');
141
+ return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`;
142
+ }
143
+
144
+ /**
145
+ * Logger object with level-based logging methods
146
+ * All logs are written to file only to prevent interfering with blessed TUI
147
+ */
148
+ const logger = {
149
+ /**
150
+ * Log error level messages to file
151
+ * @param {...any} args - Arguments to log
152
+ */
153
+ error(...args) {
154
+ writeLog('ERROR', args);
155
+ },
156
+
157
+ /**
158
+ * Log warning level messages to file
159
+ * @param {...any} args - Arguments to log
160
+ */
161
+ warn(...args) {
162
+ writeLog('WARN', args);
163
+ },
164
+
165
+ /**
166
+ * Log info level messages to file
167
+ * @param {...any} args - Arguments to log
168
+ */
169
+ info(...args) {
170
+ writeLog('INFO', args);
171
+ },
172
+
173
+ /**
174
+ * Log debug level messages to file (only when DEBUG env var is set)
175
+ * @param {...any} args - Arguments to log
176
+ */
177
+ debug(...args) {
178
+ if (process.env.DEBUG) {
179
+ writeLog('DEBUG', args);
180
+ }
181
+ }
182
+ };
183
+
184
+ export default logger;
185
+ export { logger, LOG_FILE_PATH };