synap 0.8.0 → 0.8.2

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.
@@ -409,36 +409,65 @@ View or update configuration.
409
409
  ```bash
410
410
  synap config # Show all settings + paths
411
411
  synap config dataDir # Show data directory
412
- synap config dataDir ~/synap-data # Set custom data directory
413
412
  synap config --reset # Reset to defaults
414
413
  ```
415
414
 
415
+ #### `synap save`, `synap pull`, `synap sync`
416
+ Git sync commands for multi-device workflow.
417
+
418
+ ```bash
419
+ # Save (commit + push)
420
+ synap save # Commit + push with auto timestamp
421
+ synap save "message" # Commit + push with custom message
422
+ synap save -m "message" # Same as above
423
+ synap save --dry-run # Preview changes without committing
424
+ synap save --no-push # Commit locally, don't push
425
+
426
+ # Pull
427
+ synap pull # Pull latest from remote
428
+ synap pull --force # Pull even with uncommitted local changes
429
+
430
+ # Sync (pull + save)
431
+ synap sync # Pull then save (full round-trip)
432
+ synap sync "end of day" # Sync with custom commit message
433
+ synap sync --dry-run # Preview what would happen
434
+ synap sync --no-push # Pull and commit, but don't push
435
+ ```
436
+
437
+ **Git sync error codes:**
438
+ | Code | Meaning |
439
+ |------|---------|
440
+ | `NOT_GIT_REPO` | Data directory is not a git repository |
441
+ | `DIRTY_WORKING_TREE` | Uncommitted changes block pull (use `--force`) |
442
+ | `MERGE_CONFLICT` | Pull resulted in merge conflicts |
443
+ | `NO_REMOTE` | No git remote configured |
444
+ | `PUSH_FAILED` | Remote exists but push failed |
445
+
416
446
  ## Workflow Patterns
417
447
 
418
448
  ### Multi-Device Sync Setup
419
449
 
420
- For users who want to sync their synap across devices:
421
-
422
- 1. **Set custom data directory:**
450
+ 1. **Set data directory:**
423
451
  ```bash
424
452
  synap config dataDir ~/synap-data
425
453
  ```
426
454
 
427
- 2. **Initialize git (optional):**
455
+ 2. **Initialize git (one-time):**
428
456
  ```bash
429
- cd ~/synap-data
457
+ cd $(synap config dataDir)
430
458
  git init
431
459
  git remote add origin git@github.com:user/synap-data.git
460
+ synap save "Initial commit"
432
461
  ```
433
462
 
434
- 3. **Daily sync workflow:**
463
+ 3. **Daily workflow:**
435
464
  ```bash
436
- cd ~/synap-data && git pull # Start of day
437
- # ... use synap normally ...
438
- cd ~/synap-data && git add . && git commit -m "sync" && git push # End of day
465
+ synap pull # Start of day
466
+ synap save # End of day
467
+ synap sync # Or full round-trip
439
468
  ```
440
469
 
441
- 4. **On a new device:**
470
+ 4. **New device:**
442
471
  ```bash
443
472
  git clone git@github.com:user/synap-data.git ~/synap-data
444
473
  synap config dataDir ~/synap-data
@@ -568,6 +597,13 @@ For projects requiring ongoing progress logging (standups, journals, learning lo
568
597
  4. **Confirm bulk operations** - Operations affecting >10 entries require confirmation
569
598
  5. **Don't over-organize** - Simple thoughts don't need tags, priorities, and parents
570
599
 
600
+ **Git sync safety**:
601
+
602
+ 6. **Preview before sync** - Use `--dry-run` to preview changes before committing
603
+ 7. **Handle conflicts carefully** - `MERGE_CONFLICT` errors require manual resolution
604
+ 8. **Protect uncommitted work** - `pull` checks for dirty tree; use `--force` only when safe
605
+ 9. **Commit messages are safe** - Never executed as shell commands (stdin-based commit)
606
+
571
607
  ## Proactive Recommendation Patterns
572
608
 
573
609
  - If raw entries are piling up, suggest `synap triage`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synap",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "A CLI for externalizing your working memory",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -19,6 +19,11 @@ async function main() {
19
19
  const chalk = (await import('chalk')).default;
20
20
  const boxen = (await import('boxen')).default;
21
21
 
22
+ // Common requires used across commands
23
+ const { execSync, spawnSync } = require('child_process');
24
+ const os = require('os');
25
+ const path = require('path');
26
+
22
27
  // Check for updates (non-blocking)
23
28
  const updateNotifier = (await import('update-notifier')).default;
24
29
  updateNotifier({ pkg }).notify();
@@ -93,6 +98,81 @@ async function main() {
93
98
  return '(<1m)';
94
99
  };
95
100
 
101
+ // Helper: Check if data directory is a git repo
102
+ const isGitRepo = () => {
103
+ const path = require('path');
104
+ const gitDir = path.join(storage.getDataDir(), '.git');
105
+ return fs.existsSync(gitDir);
106
+ };
107
+
108
+ // Helper: Check if working tree has uncommitted changes
109
+ const isDirty = () => {
110
+ const dataDir = storage.getDataDir();
111
+ try {
112
+ execSync('git diff --quiet', { cwd: dataDir, stdio: 'pipe' });
113
+ execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
114
+ return false;
115
+ } catch {
116
+ return true;
117
+ }
118
+ };
119
+
120
+ // Helper: Check if there are merge conflicts (UU, AA, DD, etc.)
121
+ const hasConflicts = () => {
122
+ const dataDir = storage.getDataDir();
123
+ try {
124
+ const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
125
+ const conflictPatterns = /^(UU|AA|DD|AU|UA|DU|UD) /m;
126
+ return conflictPatterns.test(status);
127
+ } catch {
128
+ return false;
129
+ }
130
+ };
131
+
132
+ // Helper: Get list of conflicted files
133
+ const getConflictFiles = () => {
134
+ const dataDir = storage.getDataDir();
135
+ try {
136
+ const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
137
+ return status.split('\n')
138
+ .filter(line => /^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
139
+ .map(line => line.slice(3).trim());
140
+ } catch {
141
+ return [];
142
+ }
143
+ };
144
+
145
+ // Helper: Check if git remote is configured
146
+ const hasRemote = () => {
147
+ const dataDir = storage.getDataDir();
148
+ try {
149
+ const remotes = execSync('git remote', { cwd: dataDir, encoding: 'utf-8' });
150
+ return remotes.trim().length > 0;
151
+ } catch {
152
+ return false;
153
+ }
154
+ };
155
+
156
+ // Helper: Get diff stats for preview without staging/unstaging
157
+ const getDiffStats = () => {
158
+ const dataDir = storage.getDataDir();
159
+ try {
160
+ const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
161
+ if (!status.trim()) return '';
162
+
163
+ const unstaged = execSync('git diff --stat', { cwd: dataDir, encoding: 'utf-8' });
164
+ const untracked = status.split('\n').filter(l => l.startsWith('??')).length;
165
+
166
+ let result = unstaged.trim();
167
+ if (untracked > 0) {
168
+ result += `\n ${untracked} untracked file(s)`;
169
+ }
170
+ return result;
171
+ } catch {
172
+ return '';
173
+ }
174
+ };
175
+
96
176
  // ============================================
97
177
  // CAPTURE COMMANDS
98
178
  // ============================================
@@ -675,6 +755,7 @@ async function main() {
675
755
  .option('-s, --status <status>', 'Filter by status')
676
756
  .option('--not-type <type>', 'Exclude entries of this type')
677
757
  .option('--since <duration>', 'Only search recent entries')
758
+ .option('--include-archived', 'Include archived entries in search')
678
759
  .option('-n, --limit <n>', 'Max results', '20')
679
760
  .option('--json', 'Output as JSON')
680
761
  .action(async (queryParts, options) => {
@@ -684,6 +765,7 @@ async function main() {
684
765
  status: options.status,
685
766
  notType: options.notType,
686
767
  since: options.since,
768
+ includeArchived: options.includeArchived,
687
769
  limit: parseInt(options.limit, 10)
688
770
  });
689
771
 
@@ -748,11 +830,6 @@ async function main() {
748
830
 
749
831
  if (Object.keys(updates).length === 0) {
750
832
  // Interactive edit - open $EDITOR
751
- const { execSync } = require('child_process');
752
- const fs = require('fs');
753
- const os = require('os');
754
- const path = require('path');
755
-
756
833
  const tmpFile = path.join(os.tmpdir(), `synap-${entry.id.slice(0, 8)}.txt`);
757
834
  fs.writeFileSync(tmpFile, entry.content);
758
835
 
@@ -1780,8 +1857,6 @@ async function main() {
1780
1857
  }
1781
1858
 
1782
1859
  preferences.loadPreferences();
1783
- const { execSync } = require('child_process');
1784
- const fs = require('fs');
1785
1860
 
1786
1861
  const editor = config.editor || process.env.EDITOR || 'vi';
1787
1862
  const preferencesPath = preferences.getPreferencesPath();
@@ -2035,16 +2110,6 @@ async function main() {
2035
2110
  const os = require('os');
2036
2111
  const path = require('path');
2037
2112
 
2038
- let hasEntries = false;
2039
- if (fs.existsSync(storage.ENTRIES_FILE)) {
2040
- try {
2041
- const data = JSON.parse(fs.readFileSync(storage.ENTRIES_FILE, 'utf8'));
2042
- hasEntries = Array.isArray(data.entries) && data.entries.length > 0;
2043
- } catch {
2044
- hasEntries = false;
2045
- }
2046
- }
2047
-
2048
2113
  let skillResult = { prompted: false };
2049
2114
  let dataLocationResult = { configured: false };
2050
2115
 
@@ -2070,7 +2135,7 @@ async function main() {
2070
2135
  message: 'Where should synap store your data?',
2071
2136
  choices: [
2072
2137
  {
2073
- name: `Default (${path.join(os.homedir(), '.config', 'synap')})`,
2138
+ name: `Default (${storage.DATA_DIR})`,
2074
2139
  value: 'default',
2075
2140
  description: 'Recommended for most users'
2076
2141
  },
@@ -2083,10 +2148,8 @@ async function main() {
2083
2148
  });
2084
2149
 
2085
2150
  if (dataChoice === 'custom') {
2086
- const defaultSuggestion = path.join(os.homedir(), 'synap-data');
2087
2151
  const customPath = await input({
2088
2152
  message: 'Enter path for synap data:',
2089
- default: defaultSuggestion,
2090
2153
  validate: (val) => {
2091
2154
  if (!val.trim()) return 'Path is required';
2092
2155
  const expanded = val.replace(/^~/, os.homedir());
@@ -2121,20 +2184,10 @@ async function main() {
2121
2184
  }
2122
2185
  }
2123
2186
 
2124
- // Step 2: Quick capture test
2125
- console.log('\n[2/4] Quick capture test...');
2126
- let createdEntry = null;
2127
- if (!hasEntries) {
2128
- createdEntry = await storage.addEntry({
2129
- content: 'My first thought',
2130
- type: config.defaultType || 'idea',
2131
- source: 'setup'
2132
- });
2133
- console.log(` synap add "My first thought"`);
2134
- console.log(` ${chalk.green('✓')} Created entry ${createdEntry.id.slice(0, 8)}`);
2135
- } else {
2136
- console.log(` ${chalk.gray('Existing entries detected, skipping.')}`);
2137
- }
2187
+ // Step 2: Quick capture example
2188
+ console.log('\n[2/4] Quick capture...');
2189
+ console.log(` Try: ${chalk.cyan('synap add "My first thought"')}`);
2190
+ console.log(` ${chalk.green('✓')} Ready to capture`)
2138
2191
 
2139
2192
  // Step 3: Configuration
2140
2193
  console.log('\n[3/4] Configuration...');
@@ -2185,19 +2238,8 @@ async function main() {
2185
2238
  }
2186
2239
 
2187
2240
  // JSON mode - minimal interaction
2188
- let createdEntry = null;
2189
- if (!hasEntries) {
2190
- createdEntry = await storage.addEntry({
2191
- content: 'My first thought',
2192
- type: config.defaultType || 'idea',
2193
- source: 'setup'
2194
- });
2195
- }
2196
-
2197
2241
  console.log(JSON.stringify({
2198
2242
  success: true,
2199
- mode: hasEntries ? 'existing' : 'first-run',
2200
- entry: createdEntry,
2201
2243
  config: { defaultType: config.defaultType || 'idea' },
2202
2244
  skill: {
2203
2245
  prompted: false,
@@ -2434,6 +2476,419 @@ async function main() {
2434
2476
  }
2435
2477
  });
2436
2478
 
2479
+ // ============================================
2480
+ // SYNC COMMANDS
2481
+ // ============================================
2482
+
2483
+ program
2484
+ .command('save [message...]')
2485
+ .description('Commit and push synap data to git remote')
2486
+ .option('-m, --message <message>', 'Commit message')
2487
+ .option('--dry-run', 'Show what would be committed without committing')
2488
+ .option('--no-push', 'Commit but do not push to remote')
2489
+ .option('--json', 'Output as JSON')
2490
+ .action(async (messageParts, options) => {
2491
+ const dataDir = storage.getDataDir();
2492
+
2493
+ if (!isGitRepo()) {
2494
+ if (options.json) {
2495
+ console.log(JSON.stringify({
2496
+ success: false,
2497
+ error: 'Data directory is not a git repository',
2498
+ code: 'NOT_GIT_REPO',
2499
+ setup: [
2500
+ `cd ${dataDir}`,
2501
+ 'git init',
2502
+ 'git remote add origin git@github.com:user/synap-data.git',
2503
+ 'synap save "Initial commit"'
2504
+ ]
2505
+ }, null, 2));
2506
+ } else {
2507
+ console.error(chalk.red('Data directory is not a git repository'));
2508
+ console.log(chalk.gray('\nSetup git sync:'));
2509
+ console.log(chalk.cyan(` cd ${dataDir}`));
2510
+ console.log(chalk.cyan(' git init'));
2511
+ console.log(chalk.cyan(' git remote add origin git@github.com:user/synap-data.git'));
2512
+ console.log(chalk.cyan(' synap save "Initial commit"'));
2513
+ }
2514
+ process.exit(1);
2515
+ }
2516
+
2517
+ // Build commit message
2518
+ let commitMessage = options.message;
2519
+ if (!commitMessage && messageParts && messageParts.length > 0) {
2520
+ commitMessage = messageParts.join(' ');
2521
+ }
2522
+ if (!commitMessage) {
2523
+ const now = new Date();
2524
+ const timestamp = now.toISOString().replace('T', ' ').slice(0, 16);
2525
+ commitMessage = `synap sync ${timestamp}`;
2526
+ }
2527
+
2528
+ // Dry-run mode: show preview only
2529
+ if (options.dryRun) {
2530
+ const stats = getDiffStats();
2531
+ if (options.json) {
2532
+ console.log(JSON.stringify({
2533
+ success: true,
2534
+ dryRun: true,
2535
+ message: commitMessage,
2536
+ changes: stats || 'No changes to commit'
2537
+ }, null, 2));
2538
+ } else {
2539
+ console.log(chalk.cyan('Dry run - would commit with message:'));
2540
+ console.log(chalk.yellow(` "${commitMessage}"`));
2541
+ if (stats) {
2542
+ console.log(chalk.cyan('\nChanges:'));
2543
+ console.log(stats);
2544
+ } else {
2545
+ console.log(chalk.yellow('No changes to commit'));
2546
+ }
2547
+ }
2548
+ return;
2549
+ }
2550
+
2551
+ try {
2552
+ // Stage all changes
2553
+ execSync('git add .', { cwd: dataDir, stdio: 'pipe' });
2554
+
2555
+ // Check if there are changes to commit
2556
+ let hasChanges = true;
2557
+ try {
2558
+ execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
2559
+ hasChanges = false;
2560
+ } catch {
2561
+ // Non-zero exit means there are staged changes
2562
+ hasChanges = true;
2563
+ }
2564
+
2565
+ if (!hasChanges) {
2566
+ if (options.json) {
2567
+ console.log(JSON.stringify({ success: true, message: 'Nothing to commit', pushed: false }));
2568
+ } else {
2569
+ console.log(chalk.yellow('Nothing to commit'));
2570
+ }
2571
+ return;
2572
+ }
2573
+
2574
+ // Commit using stdin to prevent shell injection
2575
+ const commitResult = spawnSync('git', ['commit', '-F', '-'], {
2576
+ cwd: dataDir,
2577
+ input: commitMessage,
2578
+ encoding: 'utf-8',
2579
+ stdio: ['pipe', 'pipe', 'pipe']
2580
+ });
2581
+
2582
+ if (commitResult.status !== 0) {
2583
+ throw new Error(commitResult.stderr || 'Commit failed');
2584
+ }
2585
+
2586
+ // Push (unless --no-push flag)
2587
+ let pushed = false;
2588
+ let pushError = null;
2589
+ let pushErrorCode = null;
2590
+
2591
+ if (options.push !== false) {
2592
+ // Check if remote exists
2593
+ if (!hasRemote()) {
2594
+ pushError = 'No git remote configured';
2595
+ pushErrorCode = 'NO_REMOTE';
2596
+ } else {
2597
+ try {
2598
+ execSync('git push', { cwd: dataDir, stdio: 'pipe' });
2599
+ pushed = true;
2600
+ } catch (err) {
2601
+ pushError = 'Push failed (check network or remote permissions)';
2602
+ pushErrorCode = 'PUSH_FAILED';
2603
+ }
2604
+ }
2605
+ } else {
2606
+ pushError = 'Push skipped (--no-push flag)';
2607
+ }
2608
+
2609
+ if (options.json) {
2610
+ const result = {
2611
+ success: true,
2612
+ message: commitMessage,
2613
+ pushed
2614
+ };
2615
+ if (pushError) result.pushError = pushError;
2616
+ if (pushErrorCode) result.pushErrorCode = pushErrorCode;
2617
+ console.log(JSON.stringify(result, null, 2));
2618
+ } else {
2619
+ console.log(chalk.green(`Committed: "${commitMessage}"`));
2620
+ if (pushed) {
2621
+ console.log(chalk.green('Pushed to remote'));
2622
+ } else {
2623
+ console.log(chalk.yellow(pushError));
2624
+ }
2625
+ }
2626
+ } catch (err) {
2627
+ if (options.json) {
2628
+ console.log(JSON.stringify({ success: false, error: err.message, code: 'GIT_ERROR' }));
2629
+ } else {
2630
+ console.error(chalk.red(`Git error: ${err.message}`));
2631
+ }
2632
+ process.exit(1);
2633
+ }
2634
+ });
2635
+
2636
+ program
2637
+ .command('pull')
2638
+ .description('Pull latest synap data from git remote')
2639
+ .option('--force', 'Pull even if there are uncommitted local changes')
2640
+ .option('--json', 'Output as JSON')
2641
+ .action(async (options) => {
2642
+ const dataDir = storage.getDataDir();
2643
+
2644
+ if (!isGitRepo()) {
2645
+ if (options.json) {
2646
+ console.log(JSON.stringify({
2647
+ success: false,
2648
+ error: 'Data directory is not a git repository',
2649
+ code: 'NOT_GIT_REPO'
2650
+ }, null, 2));
2651
+ } else {
2652
+ console.error(chalk.red('Data directory is not a git repository'));
2653
+ console.log(chalk.gray('Run: synap save --help for setup instructions'));
2654
+ }
2655
+ process.exit(1);
2656
+ }
2657
+
2658
+ // Check for uncommitted changes
2659
+ if (isDirty() && !options.force) {
2660
+ if (options.json) {
2661
+ console.log(JSON.stringify({
2662
+ success: false,
2663
+ error: 'Working tree has uncommitted changes',
2664
+ code: 'DIRTY_WORKING_TREE',
2665
+ hint: 'Run `synap save` first or use `--force`'
2666
+ }, null, 2));
2667
+ } else {
2668
+ console.error(chalk.red('Working tree has uncommitted changes'));
2669
+ console.log(chalk.gray('Run `synap save` first or use `synap pull --force`'));
2670
+ }
2671
+ process.exit(1);
2672
+ }
2673
+
2674
+ try {
2675
+ const output = execSync('git pull', { cwd: dataDir, encoding: 'utf-8' });
2676
+
2677
+ if (options.json) {
2678
+ console.log(JSON.stringify({
2679
+ success: true,
2680
+ message: output.trim() || 'Already up to date',
2681
+ updated: !output.includes('Already up to date')
2682
+ }, null, 2));
2683
+ } else {
2684
+ console.log(chalk.green(output.trim() || 'Already up to date'));
2685
+ }
2686
+ } catch (err) {
2687
+ // Check for merge conflicts after failed pull
2688
+ if (hasConflicts()) {
2689
+ const conflictFiles = getConflictFiles();
2690
+ if (options.json) {
2691
+ console.log(JSON.stringify({
2692
+ success: false,
2693
+ error: 'Merge conflicts detected',
2694
+ code: 'MERGE_CONFLICT',
2695
+ conflictFiles,
2696
+ hint: `Resolve conflicts: cd ${dataDir} && git status`
2697
+ }, null, 2));
2698
+ } else {
2699
+ console.error(chalk.red('Merge conflicts detected'));
2700
+ console.log(chalk.yellow('Conflicted files:'));
2701
+ for (const file of conflictFiles) {
2702
+ console.log(chalk.yellow(` - ${file}`));
2703
+ }
2704
+ console.log(chalk.gray(`\nResolve conflicts: cd ${dataDir} && git status`));
2705
+ }
2706
+ process.exit(1);
2707
+ }
2708
+
2709
+ if (options.json) {
2710
+ console.log(JSON.stringify({ success: false, error: err.message, code: 'GIT_ERROR' }));
2711
+ } else {
2712
+ console.error(chalk.red(`Git error: ${err.message}`));
2713
+ }
2714
+ process.exit(1);
2715
+ }
2716
+ });
2717
+
2718
+ program
2719
+ .command('sync [message...]')
2720
+ .description('Pull latest, then commit and push (full round-trip)')
2721
+ .option('-m, --message <message>', 'Commit message')
2722
+ .option('--dry-run', 'Show what would be committed without committing')
2723
+ .option('--no-push', 'Commit but do not push to remote')
2724
+ .option('--json', 'Output as JSON')
2725
+ .action(async (messageParts, options) => {
2726
+ const dataDir = storage.getDataDir();
2727
+
2728
+ if (!isGitRepo()) {
2729
+ if (options.json) {
2730
+ console.log(JSON.stringify({
2731
+ success: false,
2732
+ error: 'Data directory is not a git repository',
2733
+ code: 'NOT_GIT_REPO'
2734
+ }, null, 2));
2735
+ } else {
2736
+ console.error(chalk.red('Data directory is not a git repository'));
2737
+ console.log(chalk.gray('Run: synap save --help for setup instructions'));
2738
+ }
2739
+ process.exit(1);
2740
+ }
2741
+
2742
+ // Build commit message
2743
+ let commitMessage = options.message;
2744
+ if (!commitMessage && messageParts && messageParts.length > 0) {
2745
+ commitMessage = messageParts.join(' ');
2746
+ }
2747
+ if (!commitMessage) {
2748
+ const now = new Date();
2749
+ const timestamp = now.toISOString().replace('T', ' ').slice(0, 16);
2750
+ commitMessage = `synap sync ${timestamp}`;
2751
+ }
2752
+
2753
+ // Dry-run mode: show preview only
2754
+ if (options.dryRun) {
2755
+ const stats = getDiffStats();
2756
+ if (options.json) {
2757
+ console.log(JSON.stringify({
2758
+ success: true,
2759
+ dryRun: true,
2760
+ message: commitMessage,
2761
+ changes: stats || 'No changes to commit'
2762
+ }, null, 2));
2763
+ } else {
2764
+ console.log(chalk.cyan('Dry run - would sync with message:'));
2765
+ console.log(chalk.yellow(` "${commitMessage}"`));
2766
+ if (stats) {
2767
+ console.log(chalk.cyan('\nLocal changes:'));
2768
+ console.log(stats);
2769
+ } else {
2770
+ console.log(chalk.yellow('No local changes to commit'));
2771
+ }
2772
+ }
2773
+ return;
2774
+ }
2775
+
2776
+ const results = { pulled: false, committed: false, pushed: false, errors: [] };
2777
+
2778
+ // Pull first
2779
+ try {
2780
+ const pullOutput = execSync('git pull', { cwd: dataDir, encoding: 'utf-8' });
2781
+ results.pulled = true;
2782
+ results.pullMessage = pullOutput.trim() || 'Already up to date';
2783
+ } catch (err) {
2784
+ // Check for merge conflicts
2785
+ if (hasConflicts()) {
2786
+ const conflictFiles = getConflictFiles();
2787
+ results.errors.push({
2788
+ step: 'pull',
2789
+ error: 'Merge conflicts detected',
2790
+ code: 'MERGE_CONFLICT',
2791
+ conflictFiles,
2792
+ hint: `Resolve conflicts: cd ${dataDir} && git status`
2793
+ });
2794
+ } else {
2795
+ results.errors.push({ step: 'pull', error: err.message, code: 'GIT_ERROR' });
2796
+ }
2797
+ }
2798
+
2799
+ // Stage and commit (only if pull succeeded)
2800
+ if (results.pulled) {
2801
+ try {
2802
+ execSync('git add .', { cwd: dataDir, stdio: 'pipe' });
2803
+
2804
+ let hasChanges = true;
2805
+ try {
2806
+ execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
2807
+ hasChanges = false;
2808
+ } catch {
2809
+ hasChanges = true;
2810
+ }
2811
+
2812
+ if (hasChanges) {
2813
+ // Commit using stdin to prevent shell injection
2814
+ const commitResult = spawnSync('git', ['commit', '-F', '-'], {
2815
+ cwd: dataDir,
2816
+ input: commitMessage,
2817
+ encoding: 'utf-8',
2818
+ stdio: ['pipe', 'pipe', 'pipe']
2819
+ });
2820
+
2821
+ if (commitResult.status !== 0) {
2822
+ throw new Error(commitResult.stderr || 'Commit failed');
2823
+ }
2824
+
2825
+ results.committed = true;
2826
+ results.commitMessage = commitMessage;
2827
+ } else {
2828
+ results.commitMessage = 'Nothing to commit';
2829
+ }
2830
+ } catch (err) {
2831
+ results.errors.push({ step: 'commit', error: err.message, code: 'GIT_ERROR' });
2832
+ }
2833
+ }
2834
+
2835
+ // Push (unless --no-push flag or nothing committed)
2836
+ if (results.committed && options.push !== false) {
2837
+ if (!hasRemote()) {
2838
+ results.errors.push({ step: 'push', error: 'No git remote configured', code: 'NO_REMOTE' });
2839
+ } else {
2840
+ try {
2841
+ execSync('git push', { cwd: dataDir, stdio: 'pipe' });
2842
+ results.pushed = true;
2843
+ } catch (err) {
2844
+ results.errors.push({ step: 'push', error: 'Push failed (check network or remote permissions)', code: 'PUSH_FAILED' });
2845
+ }
2846
+ }
2847
+ } else if (results.committed && options.push === false) {
2848
+ results.pushSkipped = true;
2849
+ }
2850
+
2851
+ if (options.json) {
2852
+ console.log(JSON.stringify({
2853
+ success: results.errors.length === 0,
2854
+ ...results
2855
+ }, null, 2));
2856
+ } else {
2857
+ if (results.pulled) {
2858
+ console.log(chalk.green(`Pulled: ${results.pullMessage}`));
2859
+ }
2860
+ if (results.committed) {
2861
+ console.log(chalk.green(`Committed: "${results.commitMessage}"`));
2862
+ } else if (results.pullMessage !== 'Already up to date' || !results.pulled) {
2863
+ console.log(chalk.yellow(results.commitMessage || 'Nothing to commit'));
2864
+ }
2865
+ if (results.pushed) {
2866
+ console.log(chalk.green('Pushed to remote'));
2867
+ } else if (results.pushSkipped) {
2868
+ console.log(chalk.yellow('Push skipped (--no-push flag)'));
2869
+ } else if (results.committed) {
2870
+ const pushErr = results.errors.find(e => e.step === 'push');
2871
+ console.log(chalk.yellow(pushErr ? pushErr.error : 'Push failed'));
2872
+ }
2873
+ for (const err of results.errors) {
2874
+ if (err.step !== 'push' || !results.committed) {
2875
+ console.error(chalk.red(`${err.step}: ${err.error}`));
2876
+ }
2877
+ if (err.conflictFiles) {
2878
+ console.log(chalk.yellow('Conflicted files:'));
2879
+ for (const file of err.conflictFiles) {
2880
+ console.log(chalk.yellow(` - ${file}`));
2881
+ }
2882
+ console.log(chalk.gray(`\n${err.hint}`));
2883
+ }
2884
+ }
2885
+ }
2886
+
2887
+ if (results.errors.length > 0) {
2888
+ process.exit(1);
2889
+ }
2890
+ });
2891
+
2437
2892
  // Parse and execute
2438
2893
  await program.parseAsync(process.argv);
2439
2894
  }
@@ -8,6 +8,8 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const storage = require('./storage');
10
10
 
11
+ const MAX_DELETION_LOG_ENTRIES = 1000;
12
+
11
13
  /**
12
14
  * Get deletion log file path (dynamic, based on DATA_DIR)
13
15
  */
@@ -73,9 +75,9 @@ async function logDeletions(entries) {
73
75
  });
74
76
  }
75
77
 
76
- // Keep last 1000 deletions
77
- if (log.length > 1000) {
78
- log.length = 1000;
78
+ // Keep last N deletions
79
+ if (log.length > MAX_DELETION_LOG_ENTRIES) {
80
+ log.length = MAX_DELETION_LOG_ENTRIES;
79
81
  }
80
82
 
81
83
  saveLog(log);
package/src/storage.js CHANGED
@@ -127,9 +127,10 @@ const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
127
127
 
128
128
  /**
129
129
  * Atomic file write - write to temp file then rename
130
+ * Uses PID + timestamp to avoid race conditions between processes
130
131
  */
131
132
  function atomicWriteSync(filePath, data) {
132
- const tmpPath = filePath + '.tmp';
133
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
133
134
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
134
135
  fs.renameSync(tmpPath, filePath);
135
136
  }
@@ -144,7 +145,9 @@ function loadEntries() {
144
145
  }
145
146
  try {
146
147
  return JSON.parse(fs.readFileSync(ENTRIES_FILE, 'utf8'));
147
- } catch {
148
+ } catch (err) {
149
+ console.error(`Warning: entries.json is corrupted: ${err.message}`);
150
+ console.error(`File location: ${ENTRIES_FILE}`);
148
151
  return { version: 1, entries: [] };
149
152
  }
150
153
  }
@@ -167,7 +170,9 @@ function loadArchive() {
167
170
  }
168
171
  try {
169
172
  return JSON.parse(fs.readFileSync(ARCHIVE_FILE, 'utf8'));
170
- } catch {
173
+ } catch (err) {
174
+ console.error(`Warning: archive.json is corrupted: ${err.message}`);
175
+ console.error(`File location: ${ARCHIVE_FILE}`);
171
176
  return { version: 1, entries: [] };
172
177
  }
173
178
  }
@@ -422,15 +427,13 @@ function parseDate(input) {
422
427
  }
423
428
 
424
429
  /**
425
- * Add a new entry
430
+ * Create an entry object from options (shared by addEntry and addEntries)
426
431
  */
427
- async function addEntry(options) {
428
- const data = loadEntries();
429
-
432
+ function createEntry(options, existingEntries, timestamp) {
430
433
  // Resolve partial parent ID to full ID
431
434
  let parentId = options.parent;
432
435
  if (parentId) {
433
- const parentEntry = data.entries.find(e => e.id.startsWith(parentId));
436
+ const parentEntry = existingEntries.find(e => e.id.startsWith(parentId));
434
437
  if (parentEntry) {
435
438
  parentId = parentEntry.id;
436
439
  }
@@ -446,7 +449,6 @@ async function addEntry(options) {
446
449
  }
447
450
  }
448
451
 
449
- const now = new Date().toISOString();
450
452
  const entry = {
451
453
  id: uuidv4(),
452
454
  content: options.content,
@@ -458,8 +460,8 @@ async function addEntry(options) {
458
460
  parent: parentId || undefined,
459
461
  related: [],
460
462
  due: dueDate,
461
- createdAt: now,
462
- updatedAt: now,
463
+ createdAt: timestamp,
464
+ updatedAt: timestamp,
463
465
  source: options.source || 'cli'
464
466
  };
465
467
 
@@ -468,6 +470,17 @@ async function addEntry(options) {
468
470
  if (entry[key] === undefined) delete entry[key];
469
471
  });
470
472
 
473
+ return entry;
474
+ }
475
+
476
+ /**
477
+ * Add a new entry
478
+ */
479
+ async function addEntry(options) {
480
+ const data = loadEntries();
481
+ const now = new Date().toISOString();
482
+ const entry = createEntry(options, data.entries, now);
483
+
471
484
  data.entries.push(entry);
472
485
  saveEntries(data);
473
486
 
@@ -485,46 +498,7 @@ async function addEntries(entriesData) {
485
498
  const now = new Date().toISOString();
486
499
 
487
500
  for (const options of entriesData) {
488
- // Resolve partial parent ID to full ID
489
- let parentId = options.parent;
490
- if (parentId) {
491
- const parentEntry = data.entries.find(e => e.id.startsWith(parentId));
492
- if (parentEntry) {
493
- parentId = parentEntry.id;
494
- }
495
- }
496
-
497
- // Parse due date if provided
498
- let dueDate = undefined;
499
- const dueInput = typeof options.due === 'string' ? options.due.trim() : options.due;
500
- if (dueInput) {
501
- dueDate = parseDate(dueInput);
502
- if (!dueDate) {
503
- throw new Error(`Invalid due date: ${options.due}`);
504
- }
505
- }
506
-
507
- const entry = {
508
- id: uuidv4(),
509
- content: options.content,
510
- title: options.title || extractTitle(options.content),
511
- type: VALID_TYPES.includes(options.type) ? options.type : 'idea',
512
- status: VALID_STATUSES.includes(options.status) ? options.status : (options.priority ? 'active' : 'raw'),
513
- priority: options.priority && [1, 2, 3].includes(options.priority) ? options.priority : undefined,
514
- tags: options.tags || [],
515
- parent: parentId || undefined,
516
- related: [],
517
- due: dueDate,
518
- createdAt: now,
519
- updatedAt: now,
520
- source: options.source || 'cli'
521
- };
522
-
523
- // Clean up undefined fields
524
- Object.keys(entry).forEach(key => {
525
- if (entry[key] === undefined) delete entry[key];
526
- });
527
-
501
+ const entry = createEntry(options, data.entries, now);
528
502
  data.entries.push(entry);
529
503
  created.push(entry);
530
504
  }
@@ -552,9 +526,15 @@ async function getEntry(id) {
552
526
  let entry = data.entries.find(e => e.id === id);
553
527
  if (entry) return entry;
554
528
 
555
- // Try partial match (first 8 chars)
529
+ // Try partial match
556
530
  const matches = data.entries.filter(e => e.id.startsWith(id));
557
531
  if (matches.length === 1) return matches[0];
532
+ if (matches.length > 1) {
533
+ const error = new Error(`Ambiguous ID: ${id} matches ${matches.length} entries`);
534
+ error.code = 'AMBIGUOUS_ID';
535
+ error.matches = matches.map(e => ({ id: e.id, title: e.title }));
536
+ throw error;
537
+ }
558
538
 
559
539
  // Check archive
560
540
  const archive = loadArchive();
@@ -563,6 +543,12 @@ async function getEntry(id) {
563
543
 
564
544
  const archiveMatches = archive.entries.filter(e => e.id.startsWith(id));
565
545
  if (archiveMatches.length === 1) return archiveMatches[0];
546
+ if (archiveMatches.length > 1) {
547
+ const error = new Error(`Ambiguous ID: ${id} matches ${archiveMatches.length} archived entries`);
548
+ error.code = 'AMBIGUOUS_ID';
549
+ error.matches = archiveMatches.map(e => ({ id: e.id, title: e.title }));
550
+ throw error;
551
+ }
566
552
 
567
553
  return null;
568
554
  }
@@ -584,8 +570,7 @@ async function getEntriesByIds(ids) {
584
570
  */
585
571
  async function getChildren(parentId) {
586
572
  const data = loadEntries();
587
- // Match if parent starts with the given ID OR if the given ID starts with parent
588
- return data.entries.filter(e => e.parent && (e.parent.startsWith(parentId) || parentId.startsWith(e.parent)));
573
+ return data.entries.filter(e => e.parent && e.parent.startsWith(parentId));
589
574
  }
590
575
 
591
576
  /**
@@ -764,6 +749,12 @@ async function searchEntries(query, options = {}) {
764
749
  const data = loadEntries();
765
750
  let entries = [...data.entries];
766
751
 
752
+ // Include archived entries if requested
753
+ if (options.includeArchived) {
754
+ const archive = loadArchive();
755
+ entries = [...entries, ...archive.entries];
756
+ }
757
+
767
758
  const lowerQuery = query.toLowerCase();
768
759
 
769
760
  // Text search (content, title, and tags)
@@ -1006,6 +997,7 @@ async function renameTag(oldTag, newTag) {
1006
997
  const idx = entry.tags.indexOf(oldTag);
1007
998
  if (idx !== -1) {
1008
999
  entry.tags[idx] = newTag;
1000
+ entry.tags = [...new Set(entry.tags)];
1009
1001
  entry.updatedAt = new Date().toISOString();
1010
1002
  count++;
1011
1003
  }
@@ -1015,6 +1007,7 @@ async function renameTag(oldTag, newTag) {
1015
1007
  const idx = entry.tags.indexOf(oldTag);
1016
1008
  if (idx !== -1) {
1017
1009
  entry.tags[idx] = newTag;
1010
+ entry.tags = [...new Set(entry.tags)];
1018
1011
  entry.updatedAt = new Date().toISOString();
1019
1012
  count++;
1020
1013
  }
@@ -1091,8 +1084,17 @@ async function importEntries(entries, options = {}) {
1091
1084
  const data = loadEntries();
1092
1085
  let added = 0;
1093
1086
  let updated = 0;
1087
+ let skipped = 0;
1094
1088
 
1095
1089
  for (const entry of entries) {
1090
+ // Validate required fields
1091
+ if (!entry.id || typeof entry.id !== 'string' ||
1092
+ !entry.content || typeof entry.content !== 'string' ||
1093
+ !entry.createdAt || typeof entry.createdAt !== 'string') {
1094
+ skipped++;
1095
+ continue;
1096
+ }
1097
+
1096
1098
  const existing = data.entries.find(e => e.id === entry.id);
1097
1099
 
1098
1100
  if (existing) {
@@ -1109,7 +1111,7 @@ async function importEntries(entries, options = {}) {
1109
1111
 
1110
1112
  saveEntries(data);
1111
1113
 
1112
- return { added, updated };
1114
+ return { added, updated, skipped };
1113
1115
  }
1114
1116
 
1115
1117
  module.exports = {