start-command 0.13.0 → 0.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +28 -231
  2. package/bun.lock +5 -0
  3. package/eslint.config.mjs +1 -1
  4. package/package.json +11 -6
  5. package/src/bin/cli.js +275 -137
  6. package/src/lib/args-parser.js +118 -0
  7. package/src/lib/execution-store.js +722 -0
  8. package/src/lib/isolation.js +51 -0
  9. package/src/lib/status-formatter.js +121 -0
  10. package/src/lib/version.js +143 -0
  11. package/test/args-parser.test.js +107 -0
  12. package/test/cli.test.js +11 -1
  13. package/test/docker-autoremove.test.js +11 -16
  14. package/test/execution-store.test.js +483 -0
  15. package/test/isolation-cleanup.test.js +11 -16
  16. package/test/isolation.test.js +11 -17
  17. package/test/public-exports.test.js +105 -0
  18. package/test/status-query.test.js +195 -0
  19. package/.github/workflows/release.yml +0 -352
  20. package/.husky/pre-commit +0 -1
  21. package/ARCHITECTURE.md +0 -297
  22. package/LICENSE +0 -24
  23. package/README.md +0 -339
  24. package/REQUIREMENTS.md +0 -299
  25. package/docs/PIPES.md +0 -243
  26. package/docs/USAGE.md +0 -194
  27. package/docs/case-studies/issue-15/README.md +0 -208
  28. package/docs/case-studies/issue-18/README.md +0 -343
  29. package/docs/case-studies/issue-18/issue-comments.json +0 -1
  30. package/docs/case-studies/issue-18/issue-data.json +0 -7
  31. package/docs/case-studies/issue-22/analysis.md +0 -547
  32. package/docs/case-studies/issue-22/issue-data.json +0 -12
  33. package/docs/case-studies/issue-25/README.md +0 -232
  34. package/docs/case-studies/issue-25/issue-data.json +0 -21
  35. package/docs/case-studies/issue-28/README.md +0 -405
  36. package/docs/case-studies/issue-28/issue-data.json +0 -105
  37. package/docs/case-studies/issue-28/raw-issue-data.md +0 -92
  38. package/experiments/debug-regex.js +0 -49
  39. package/experiments/isolation-design.md +0 -131
  40. package/experiments/screen-output-test.js +0 -265
  41. package/experiments/test-cli.sh +0 -42
  42. package/experiments/test-command-stream-cjs.cjs +0 -30
  43. package/experiments/test-command-stream-wrapper.js +0 -54
  44. package/experiments/test-command-stream.mjs +0 -56
  45. package/experiments/test-screen-attached.js +0 -126
  46. package/experiments/test-screen-logfile.js +0 -286
  47. package/experiments/test-screen-modes.js +0 -128
  48. package/experiments/test-screen-output.sh +0 -27
  49. package/experiments/test-screen-tee-debug.js +0 -237
  50. package/experiments/test-screen-tee-fallback.js +0 -230
  51. package/experiments/test-substitution.js +0 -143
  52. package/experiments/user-isolation-research.md +0 -83
  53. package/scripts/changeset-version.mjs +0 -38
  54. package/scripts/check-file-size.mjs +0 -103
  55. package/scripts/create-github-release.mjs +0 -93
  56. package/scripts/create-manual-changeset.mjs +0 -89
  57. package/scripts/format-github-release.mjs +0 -83
  58. package/scripts/format-release-notes.mjs +0 -219
  59. package/scripts/instant-version-bump.mjs +0 -121
  60. package/scripts/publish-to-npm.mjs +0 -129
  61. package/scripts/setup-npm.mjs +0 -37
  62. package/scripts/validate-changeset.mjs +0 -107
  63. package/scripts/version-and-commit.mjs +0 -237
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Execution Store - Dual storage for command execution records
3
+ *
4
+ * Stores command execution data in:
5
+ * 1. Text format (.lino files) using lino-objects-codec
6
+ * 2. Binary format (.links database) using clink if available
7
+ *
8
+ * Each execution record contains:
9
+ * - uuid: Unique identifier for the command call
10
+ * - pid: Process ID
11
+ * - status: 'executing' or 'executed'
12
+ * - exitCode: Return status code (null while executing)
13
+ * - command: The command string that was executed
14
+ * - logPath: Path to the log file
15
+ * - startTime: Timestamp when execution started
16
+ * - endTime: Timestamp when execution completed (null while executing)
17
+ * - options: Execution options (isolation mode, etc.)
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const { execSync, spawnSync } = require('child_process');
24
+ const crypto = require('crypto');
25
+
26
+ // Synchronous wrapper using Bun's native ESM support
27
+ // This works because Bun handles ESM/CJS interop
28
+ function encodeSync(data) {
29
+ // Use synchronous require with Bun's ESM support
30
+ try {
31
+ const codec = require('lino-objects-codec');
32
+ return codec.encode({ obj: data });
33
+ } catch {
34
+ return JSON.stringify(data);
35
+ }
36
+ }
37
+
38
+ function decodeSync(notation) {
39
+ try {
40
+ const codec = require('lino-objects-codec');
41
+ return codec.decode({ notation });
42
+ } catch {
43
+ return JSON.parse(notation);
44
+ }
45
+ }
46
+
47
+ // Configuration
48
+ const DEFAULT_APP_FOLDER = path.join(os.homedir(), '.start-command');
49
+ const LINO_DB_FILE = 'executions.lino';
50
+ const LINKS_DB_FILE = 'executions.links';
51
+ const LOCK_FILE = 'executions.lock';
52
+ const LOCK_TIMEOUT_MS = 30000; // 30 second timeout for lock acquisition
53
+ const LOCK_STALE_MS = 60000; // Consider lock stale after 60 seconds
54
+
55
+ /**
56
+ * Execution status enumeration
57
+ */
58
+ const ExecutionStatus = {
59
+ EXECUTING: 'executing',
60
+ EXECUTED: 'executed',
61
+ };
62
+
63
+ /**
64
+ * Command Execution Record
65
+ */
66
+ class ExecutionRecord {
67
+ constructor(options = {}) {
68
+ this.uuid = options.uuid || crypto.randomUUID();
69
+ this.pid = options.pid || null;
70
+ this.status = options.status || ExecutionStatus.EXECUTING;
71
+ this.exitCode = options.exitCode !== undefined ? options.exitCode : null;
72
+ this.command = options.command || '';
73
+ this.logPath = options.logPath || '';
74
+ this.startTime = options.startTime || new Date().toISOString();
75
+ this.endTime = options.endTime || null;
76
+ this.workingDirectory = options.workingDirectory || process.cwd();
77
+ this.shell = options.shell || process.env.SHELL || '/bin/sh';
78
+ this.platform = options.platform || process.platform;
79
+ this.options = options.options || {};
80
+ }
81
+
82
+ /**
83
+ * Mark execution as completed
84
+ * @param {number} exitCode - Exit code from the process
85
+ */
86
+ complete(exitCode) {
87
+ this.status = ExecutionStatus.EXECUTED;
88
+ this.exitCode = exitCode;
89
+ this.endTime = new Date().toISOString();
90
+ }
91
+
92
+ /**
93
+ * Convert to plain object for serialization
94
+ */
95
+ toObject() {
96
+ return {
97
+ uuid: this.uuid,
98
+ pid: this.pid,
99
+ status: this.status,
100
+ exitCode: this.exitCode,
101
+ command: this.command,
102
+ logPath: this.logPath,
103
+ startTime: this.startTime,
104
+ endTime: this.endTime,
105
+ workingDirectory: this.workingDirectory,
106
+ shell: this.shell,
107
+ platform: this.platform,
108
+ options: this.options,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Create from plain object
114
+ */
115
+ static fromObject(obj) {
116
+ return new ExecutionRecord(obj);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * File-based lock manager
122
+ */
123
+ class LockManager {
124
+ constructor(lockFilePath) {
125
+ this.lockFilePath = lockFilePath;
126
+ this.lockAcquired = false;
127
+ }
128
+
129
+ /**
130
+ * Acquire an exclusive lock
131
+ * @param {number} timeout - Maximum time to wait for lock in ms
132
+ * @returns {boolean} True if lock acquired
133
+ */
134
+ acquire(timeout = LOCK_TIMEOUT_MS) {
135
+ const startTime = Date.now();
136
+
137
+ while (Date.now() - startTime < timeout) {
138
+ try {
139
+ // Check if existing lock is stale
140
+ if (fs.existsSync(this.lockFilePath)) {
141
+ const lockData = this.readLockFile();
142
+ if (lockData && this.isLockStale(lockData)) {
143
+ // Remove stale lock
144
+ fs.unlinkSync(this.lockFilePath);
145
+ }
146
+ }
147
+
148
+ // Try to create lock file exclusively
149
+ const lockData = {
150
+ pid: process.pid,
151
+ timestamp: Date.now(),
152
+ hostname: os.hostname(),
153
+ };
154
+
155
+ fs.writeFileSync(this.lockFilePath, JSON.stringify(lockData), {
156
+ flag: 'wx', // Fail if file exists
157
+ });
158
+
159
+ this.lockAcquired = true;
160
+ return true;
161
+ } catch (err) {
162
+ if (err.code === 'EEXIST') {
163
+ // Lock file exists, wait and retry
164
+ this.sleep(100);
165
+ continue;
166
+ }
167
+ throw err;
168
+ }
169
+ }
170
+
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * Release the lock
176
+ */
177
+ release() {
178
+ if (this.lockAcquired) {
179
+ try {
180
+ fs.unlinkSync(this.lockFilePath);
181
+ } catch {
182
+ // Ignore errors during release
183
+ }
184
+ this.lockAcquired = false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Read lock file data
190
+ */
191
+ readLockFile() {
192
+ try {
193
+ const content = fs.readFileSync(this.lockFilePath, 'utf8');
194
+ return JSON.parse(content);
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check if lock is stale
202
+ */
203
+ isLockStale(lockData) {
204
+ if (!lockData || !lockData.timestamp) {
205
+ return true;
206
+ }
207
+
208
+ // Check if lock is too old
209
+ if (Date.now() - lockData.timestamp > LOCK_STALE_MS) {
210
+ return true;
211
+ }
212
+
213
+ // Check if the process that holds the lock is still running
214
+ if (lockData.pid && lockData.hostname === os.hostname()) {
215
+ try {
216
+ process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
217
+ return false; // Process exists, lock is valid
218
+ } catch {
219
+ return true; // Process doesn't exist, lock is stale
220
+ }
221
+ }
222
+
223
+ return false;
224
+ }
225
+
226
+ /**
227
+ * Simple sleep function
228
+ */
229
+ sleep(ms) {
230
+ const end = Date.now() + ms;
231
+ while (Date.now() < end) {
232
+ // Busy wait (not ideal but works for short durations)
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Check if clink is installed
239
+ * @returns {boolean}
240
+ */
241
+ function isClinkInstalled() {
242
+ try {
243
+ const result = spawnSync('clink', ['--version'], {
244
+ encoding: 'utf8',
245
+ timeout: 5000,
246
+ stdio: ['pipe', 'pipe', 'pipe'],
247
+ });
248
+ return result.status === 0;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * ExecutionStore - Main store class for managing execution records
256
+ */
257
+ class ExecutionStore {
258
+ constructor(options = {}) {
259
+ this.appFolder = options.appFolder || DEFAULT_APP_FOLDER;
260
+ this.linoDbPath = path.join(this.appFolder, LINO_DB_FILE);
261
+ this.linksDbPath = path.join(this.appFolder, LINKS_DB_FILE);
262
+ this.lockFilePath = path.join(this.appFolder, LOCK_FILE);
263
+ this.useLinks = options.useLinks !== false && isClinkInstalled();
264
+ this.verbose = options.verbose || false;
265
+
266
+ // Ensure app folder exists
267
+ this.ensureAppFolder();
268
+ }
269
+
270
+ /**
271
+ * Ensure the application folder exists
272
+ */
273
+ ensureAppFolder() {
274
+ if (!fs.existsSync(this.appFolder)) {
275
+ fs.mkdirSync(this.appFolder, { recursive: true });
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Log verbose message
281
+ */
282
+ log(message) {
283
+ if (this.verbose) {
284
+ console.log(`[ExecutionStore] ${message}`);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Read all execution records from lino file
290
+ * @returns {ExecutionRecord[]}
291
+ */
292
+ readLinoRecords() {
293
+ if (!fs.existsSync(this.linoDbPath)) {
294
+ return [];
295
+ }
296
+
297
+ try {
298
+ const content = fs.readFileSync(this.linoDbPath, 'utf8');
299
+ if (!content.trim()) {
300
+ return [];
301
+ }
302
+
303
+ const data = decodeSync(content);
304
+ if (!Array.isArray(data)) {
305
+ return [];
306
+ }
307
+
308
+ return data.map((obj) => ExecutionRecord.fromObject(obj));
309
+ } catch (err) {
310
+ this.log(`Error reading lino records: ${err.message}`);
311
+ return [];
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Write execution records to lino file
317
+ * @param {ExecutionRecord[]} records
318
+ */
319
+ writeLinoRecords(records) {
320
+ const data = records.map((r) => r.toObject());
321
+ const content = encodeSync(data);
322
+ fs.writeFileSync(this.linoDbPath, content, 'utf8');
323
+ this.log(`Wrote ${records.length} records to lino file`);
324
+ }
325
+
326
+ /**
327
+ * Convert execution record to clink links notation format
328
+ * Uses string aliases for readable IDs
329
+ * @param {ExecutionRecord} record
330
+ * @returns {string}
331
+ */
332
+ recordToLinksNotation(record) {
333
+ // Using clink's string alias feature for readable identifiers
334
+ // Format: (uuid: uuid-value) (pid: pid-value) etc.
335
+ const obj = record.toObject();
336
+ const parts = [];
337
+
338
+ for (const [key, value] of Object.entries(obj)) {
339
+ if (value !== null && value !== undefined) {
340
+ // Escape value properly for links notation
341
+ const escapedValue =
342
+ typeof value === 'object' ? JSON.stringify(value) : String(value);
343
+ parts.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
344
+ }
345
+ }
346
+
347
+ return parts.join(' ');
348
+ }
349
+
350
+ /**
351
+ * Build clink query for creating/updating a record
352
+ * @param {ExecutionRecord} record
353
+ * @returns {string}
354
+ */
355
+ buildClinkCreateQuery(record) {
356
+ const obj = record.toObject();
357
+ const links = [];
358
+
359
+ // Create main record link
360
+ links.push(`(${record.uuid}: ExecutionRecord ${record.uuid})`);
361
+
362
+ // Create property links
363
+ for (const [key, value] of Object.entries(obj)) {
364
+ if (value !== null && value !== undefined) {
365
+ const escapedValue =
366
+ typeof value === 'object' ? JSON.stringify(value) : String(value);
367
+ // Using format: (uuid.property: property "value")
368
+ links.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
369
+ }
370
+ }
371
+
372
+ // Format: () ((links)) - creates new links
373
+ return `() ((${links.join(') (')}))`;
374
+ }
375
+
376
+ /**
377
+ * Execute clink command
378
+ * @param {string} query
379
+ * @returns {{success: boolean, output: string}}
380
+ */
381
+ execClink(query) {
382
+ try {
383
+ const result = execSync(`clink '${query}' --db "${this.linksDbPath}"`, {
384
+ encoding: 'utf8',
385
+ timeout: 10000,
386
+ stdio: ['pipe', 'pipe', 'pipe'],
387
+ });
388
+ return { success: true, output: result };
389
+ } catch (err) {
390
+ this.log(`Clink error: ${err.message}`);
391
+ return { success: false, output: err.message };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Write a record to the links database using clink
397
+ * @param {ExecutionRecord} record
398
+ * @returns {boolean}
399
+ */
400
+ writeLinksRecord(record) {
401
+ if (!this.useLinks) {
402
+ return false;
403
+ }
404
+
405
+ const query = this.buildClinkCreateQuery(record);
406
+ const result = this.execClink(query);
407
+
408
+ if (result.success) {
409
+ this.log(`Wrote record ${record.uuid} to links database`);
410
+ }
411
+
412
+ return result.success;
413
+ }
414
+
415
+ /**
416
+ * Delete a record from links database
417
+ * @param {string} uuid
418
+ * @returns {boolean}
419
+ */
420
+ deleteLinksRecord(uuid) {
421
+ if (!this.useLinks) {
422
+ return false;
423
+ }
424
+
425
+ // Delete all links with this UUID prefix
426
+ const query = `(($id: ${uuid} $any)) ()`;
427
+ const result = this.execClink(query);
428
+ return result.success;
429
+ }
430
+
431
+ /**
432
+ * Save an execution record (creates or updates)
433
+ * @param {ExecutionRecord} record
434
+ * @returns {boolean}
435
+ */
436
+ save(record) {
437
+ const lock = new LockManager(this.lockFilePath);
438
+
439
+ if (!lock.acquire()) {
440
+ throw new Error('Failed to acquire lock for database write');
441
+ }
442
+
443
+ try {
444
+ // Read existing records
445
+ const records = this.readLinoRecords();
446
+
447
+ // Find existing record index
448
+ const existingIndex = records.findIndex((r) => r.uuid === record.uuid);
449
+
450
+ if (existingIndex >= 0) {
451
+ // Update existing record
452
+ records[existingIndex] = record;
453
+ } else {
454
+ // Add new record
455
+ records.push(record);
456
+ }
457
+
458
+ // Write to lino file
459
+ this.writeLinoRecords(records);
460
+
461
+ // Also write to links database if available
462
+ if (this.useLinks) {
463
+ this.writeLinksRecord(record);
464
+ }
465
+
466
+ return true;
467
+ } finally {
468
+ lock.release();
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Get an execution record by UUID
474
+ * @param {string} uuid
475
+ * @returns {ExecutionRecord|null}
476
+ */
477
+ get(uuid) {
478
+ const records = this.readLinoRecords();
479
+ const found = records.find((r) => r.uuid === uuid);
480
+ return found || null;
481
+ }
482
+
483
+ /**
484
+ * Get all execution records
485
+ * @returns {ExecutionRecord[]}
486
+ */
487
+ getAll() {
488
+ return this.readLinoRecords();
489
+ }
490
+
491
+ /**
492
+ * Get records filtered by status
493
+ * @param {string} status
494
+ * @returns {ExecutionRecord[]}
495
+ */
496
+ getByStatus(status) {
497
+ return this.readLinoRecords().filter((r) => r.status === status);
498
+ }
499
+
500
+ /**
501
+ * Get currently executing commands
502
+ * @returns {ExecutionRecord[]}
503
+ */
504
+ getExecuting() {
505
+ return this.getByStatus(ExecutionStatus.EXECUTING);
506
+ }
507
+
508
+ /**
509
+ * Get recently executed commands
510
+ * @param {number} limit
511
+ * @returns {ExecutionRecord[]}
512
+ */
513
+ getRecent(limit = 10) {
514
+ const records = this.readLinoRecords();
515
+ // Sort by startTime descending
516
+ records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
517
+ return records.slice(0, limit);
518
+ }
519
+
520
+ /**
521
+ * Delete an execution record
522
+ * @param {string} uuid
523
+ * @returns {boolean}
524
+ */
525
+ delete(uuid) {
526
+ const lock = new LockManager(this.lockFilePath);
527
+
528
+ if (!lock.acquire()) {
529
+ throw new Error('Failed to acquire lock for database write');
530
+ }
531
+
532
+ try {
533
+ const records = this.readLinoRecords();
534
+ const filteredRecords = records.filter((r) => r.uuid !== uuid);
535
+
536
+ if (filteredRecords.length === records.length) {
537
+ return false; // Record not found
538
+ }
539
+
540
+ this.writeLinoRecords(filteredRecords);
541
+
542
+ // Also delete from links database
543
+ if (this.useLinks) {
544
+ this.deleteLinksRecord(uuid);
545
+ }
546
+
547
+ return true;
548
+ } finally {
549
+ lock.release();
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Delete all records
555
+ */
556
+ clear() {
557
+ const lock = new LockManager(this.lockFilePath);
558
+
559
+ if (!lock.acquire()) {
560
+ throw new Error('Failed to acquire lock for database write');
561
+ }
562
+
563
+ try {
564
+ this.writeLinoRecords([]);
565
+
566
+ // Clear links database by removing the file
567
+ if (this.useLinks && fs.existsSync(this.linksDbPath)) {
568
+ fs.unlinkSync(this.linksDbPath);
569
+ }
570
+ } finally {
571
+ lock.release();
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Verify that both databases have consistent data
577
+ * @returns {{consistent: boolean, linoCount: number, linksCount: number, errors: string[]}}
578
+ */
579
+ verifyConsistency() {
580
+ const result = {
581
+ consistent: true,
582
+ linoCount: 0,
583
+ linksCount: 0,
584
+ errors: [],
585
+ };
586
+
587
+ // Read lino records
588
+ const linoRecords = this.readLinoRecords();
589
+ result.linoCount = linoRecords.length;
590
+
591
+ if (!this.useLinks) {
592
+ // If clink is not available, just report lino count
593
+ result.linksCount = 0;
594
+ result.errors.push('clink not installed - links database not available');
595
+ return result;
596
+ }
597
+
598
+ // Query links database for all ExecutionRecord links
599
+ try {
600
+ const queryResult = this.execClink(
601
+ `((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))`
602
+ );
603
+
604
+ if (queryResult.success) {
605
+ // Count unique UUIDs in the links output
606
+ const output = queryResult.output || '';
607
+ const uuidMatches = output.match(/ExecutionRecord\s+([a-f0-9-]{36})/gi);
608
+ const uniqueUuids = new Set(
609
+ (uuidMatches || []).map((m) =>
610
+ m.replace(/ExecutionRecord\s+/i, '').toLowerCase()
611
+ )
612
+ );
613
+ result.linksCount = uniqueUuids.size;
614
+
615
+ // Check if counts match
616
+ if (result.linoCount !== result.linksCount) {
617
+ result.consistent = false;
618
+ result.errors.push(
619
+ `Record count mismatch: lino=${result.linoCount}, links=${result.linksCount}`
620
+ );
621
+ }
622
+
623
+ // Verify each lino record exists in links
624
+ for (const record of linoRecords) {
625
+ if (!uniqueUuids.has(record.uuid.toLowerCase())) {
626
+ result.consistent = false;
627
+ result.errors.push(
628
+ `Record ${record.uuid} missing from links database`
629
+ );
630
+ }
631
+ }
632
+ } else {
633
+ result.errors.push(
634
+ `Failed to query links database: ${queryResult.output}`
635
+ );
636
+ result.consistent = false;
637
+ }
638
+ } catch (err) {
639
+ result.errors.push(`Links verification error: ${err.message}`);
640
+ result.consistent = false;
641
+ }
642
+
643
+ return result;
644
+ }
645
+
646
+ /**
647
+ * Sync lino records to links database (repair operation)
648
+ * @returns {{synced: number, errors: string[]}}
649
+ */
650
+ syncToLinks() {
651
+ if (!this.useLinks) {
652
+ return { synced: 0, errors: ['clink not installed'] };
653
+ }
654
+
655
+ const lock = new LockManager(this.lockFilePath);
656
+ if (!lock.acquire()) {
657
+ throw new Error('Failed to acquire lock for sync operation');
658
+ }
659
+
660
+ try {
661
+ const records = this.readLinoRecords();
662
+ let synced = 0;
663
+ const errors = [];
664
+
665
+ for (const record of records) {
666
+ if (this.writeLinksRecord(record)) {
667
+ synced++;
668
+ } else {
669
+ errors.push(`Failed to sync record ${record.uuid}`);
670
+ }
671
+ }
672
+
673
+ return { synced, errors };
674
+ } finally {
675
+ lock.release();
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Get database statistics
681
+ * @returns {object}
682
+ */
683
+ getStats() {
684
+ const records = this.readLinoRecords();
685
+ const executing = records.filter(
686
+ (r) => r.status === ExecutionStatus.EXECUTING
687
+ ).length;
688
+ const executed = records.filter(
689
+ (r) => r.status === ExecutionStatus.EXECUTED
690
+ ).length;
691
+ const successful = records.filter(
692
+ (r) => r.status === ExecutionStatus.EXECUTED && r.exitCode === 0
693
+ ).length;
694
+ const failed = records.filter(
695
+ (r) => r.status === ExecutionStatus.EXECUTED && r.exitCode !== 0
696
+ ).length;
697
+
698
+ return {
699
+ total: records.length,
700
+ executing,
701
+ executed,
702
+ successful,
703
+ failed,
704
+ clinkAvailable: this.useLinks,
705
+ linoDbPath: this.linoDbPath,
706
+ linksDbPath: this.linksDbPath,
707
+ };
708
+ }
709
+ }
710
+
711
+ // Export everything
712
+ module.exports = {
713
+ ExecutionStore,
714
+ ExecutionRecord,
715
+ ExecutionStatus,
716
+ LockManager,
717
+ isClinkInstalled,
718
+ DEFAULT_APP_FOLDER,
719
+ LINO_DB_FILE,
720
+ LINKS_DB_FILE,
721
+ LOCK_FILE,
722
+ };