gdrive-syncer 2.1.1 → 2.2.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.
package/Readme.md CHANGED
@@ -39,6 +39,8 @@ Run without arguments for interactive menu, or use direct commands.
39
39
  | `filesync:init` | Create or add to `.gdrive-sync.json` config |
40
40
  | `filesync:show` | Show sync configurations |
41
41
  | `filesync:remove` | Remove a sync from config |
42
+ | `filesync:register` | Register local config to global registry |
43
+ | `filesync:unregister` | Remove from global registry |
42
44
 
43
45
  ### Drive Operations
44
46
 
@@ -132,10 +134,44 @@ Global config at `~/.gdrive_syncer/env-sync.json` uses absolute paths:
132
134
  | `name` | Unique name for the sync |
133
135
  | `folderId` | Google Drive folder ID |
134
136
  | `localDir` | Local directory path (relative for local config, absolute for global) |
135
- | `pattern` | File pattern to sync (e.g., `.env.*`, `*`, `*.json`) |
137
+ | `pattern` | File pattern to sync (see [Pattern Syntax](#pattern-syntax)) |
138
+ | `ignore` | Optional pattern to exclude files (see [Pattern Syntax](#pattern-syntax)) |
136
139
  | `backupDir` (per-sync) | Override backup directory for this sync only |
137
140
  | `backupDir` (root) | Default backup directory (local: `gdrive-backups`, global: `~/gdrive-backups`) |
138
141
 
142
+ ### Pattern Syntax
143
+
144
+ Patterns support glob-style matching with the following features:
145
+
146
+ | Pattern | Description | Example |
147
+ |---------|-------------|---------|
148
+ | `*` | Matches any characters | `.env.*` matches `.env.development`, `.env.production` |
149
+ | `?` | Matches single character | `file?.txt` matches `file1.txt`, `fileA.txt` |
150
+ | `[abc]` | Matches specific characters | `file[123].txt` matches `file1.txt`, `file2.txt` |
151
+ | `[a-z]` | Matches character range | `[0-9]` matches any digit |
152
+ | `[!abc]` | Negated character class | `[!.]` matches any non-dot character |
153
+ | `regex:` | Raw regex (advanced) | `regex:\.env\.[^.]+$` for precise matching |
154
+
155
+ #### Examples
156
+
157
+ ```json
158
+ // Match all .env files
159
+ "pattern": ".env.*"
160
+
161
+ // Match .env files but exclude .template files
162
+ "pattern": ".env.*",
163
+ "ignore": "*.template"
164
+
165
+ // Match .env files without additional extensions (no dots after env name)
166
+ "pattern": ".env.[!.]*"
167
+
168
+ // Match using raw regex (excludes .env.xx.template)
169
+ "pattern": "regex:\\.env\\.[^.]+$"
170
+
171
+ // Match specific file types
172
+ "pattern": "*.[jt]s" // matches .js and .ts files
173
+ ```
174
+
139
175
  ### Features
140
176
 
141
177
  - **Multiple sync folders** - Configure multiple sync pairs in one config
@@ -146,6 +182,36 @@ Global config at `~/.gdrive_syncer/env-sync.json` uses absolute paths:
146
182
  - **Automatic backups** - Creates timestamped backups before download
147
183
  - **Two-way sync** - Upload local changes or download from Drive
148
184
  - **Sync All** - Run operations on all syncs within selected config
185
+ - **Registered Local Configs** - Run sync on multiple projects from anywhere
186
+
187
+ ### Registered Local Configs
188
+
189
+ Register local configs to a global registry, then run sync operations on multiple projects from any directory.
190
+
191
+ ```bash
192
+ # Register current project's local config
193
+ cd ~/projects/my-app
194
+ gdrive-syncer filesync:register
195
+ # Suggests name from directory, stores path in global registry
196
+
197
+ # Later, from any directory
198
+ cd ~/random-place
199
+ gdrive-syncer filesync:diff
200
+ # Shows: Local, Global, and "Registered Local Configs" option
201
+ # Select "Registered Local Configs" → multi-select projects → run diff on all
202
+ ```
203
+
204
+ **Commands:**
205
+ - `filesync:register` - Register current local config (auto-suggests directory name)
206
+ - `filesync:unregister` - Remove from registry
207
+ - `filesync:show` - Shows registered configs with ✓/✗ status
208
+
209
+ **Workflow:**
210
+ 1. Go to each project directory and run `filesync:register`
211
+ 2. From any terminal, run `filesync:diff` (or upload/download)
212
+ 3. Select "Registered Local Configs"
213
+ 4. Multi-select which projects to sync
214
+ 5. Operation runs on all selected projects
149
215
 
150
216
  ## Legacy Sync Configuration
151
217
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "gdrive-syncer",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Google Drive Syncer",
5
5
  "main": "./index.js",
6
6
  "bin": "./run.js",
7
7
  "scripts": {
8
- "test": "echo \"No test specified\""
8
+ "test": "jest"
9
9
  },
10
10
  "keywords": [
11
11
  "Google",
@@ -25,7 +25,9 @@
25
25
  "picocolors": "^1.1.1",
26
26
  "shelljs": "^0.8.5"
27
27
  },
28
- "devDependencies": {},
28
+ "devDependencies": {
29
+ "jest": "^30.2.0"
30
+ },
29
31
  "np": {
30
32
  "yarn": false,
31
33
  "2fa": false
package/run.js CHANGED
@@ -5,7 +5,7 @@ const color = require('picocolors');
5
5
  const { cfgAdd, cfgRm, cfgShow } = require('./src/cfgManager');
6
6
  const { runSync } = require('./src/sync');
7
7
  const { runList, runSearch, runDelete, runMkdir, runListSync } = require('./src/list');
8
- const { envInit, envRun, envShow, envRemove, envDiff, envDownload, envUpload } = require('./src/envSync');
8
+ const { envInit, envRun, envShow, envRemove, envRegister, envUnregister } = require('./src/envSync');
9
9
 
10
10
  const [, , ...args] = process.argv;
11
11
  const [firstArg] = args;
@@ -27,6 +27,8 @@ const commands = {
27
27
  'filesync:init': { handler: envInit, desc: 'Create/add sync config' },
28
28
  'filesync:show': { handler: envShow, desc: 'Show sync configurations' },
29
29
  'filesync:remove': { handler: envRemove, desc: 'Remove sync config' },
30
+ 'filesync:register': { handler: envRegister, desc: 'Register local config to global' },
31
+ 'filesync:unregister': { handler: envUnregister, desc: 'Unregister local config' },
30
32
 
31
33
  // Drive Operations
32
34
  'drive:search': { handler: runSearch, desc: 'Search files in Drive' },
@@ -53,6 +55,8 @@ const showHelp = () => {
53
55
  ` filesync:init ${color.dim('Create/add sync config')}`,
54
56
  ` filesync:show ${color.dim('Show configurations')}`,
55
57
  ` filesync:remove ${color.dim('Remove sync config')}`,
58
+ ` filesync:register ${color.dim('Register local config to global')}`,
59
+ ` filesync:unregister ${color.dim('Unregister local config')}`,
56
60
  ``,
57
61
  `${color.bold('Drive Operations')}`,
58
62
  ` drive:search ${color.dim('Search files')}`,
@@ -130,6 +134,8 @@ const showHelp = () => {
130
134
  { value: 'filesync:init', label: 'Init', hint: 'Create/add to .gdrive-sync.json' },
131
135
  { value: 'filesync:show', label: 'Show', hint: 'Show sync configurations' },
132
136
  { value: 'filesync:remove', label: 'Remove', hint: 'Remove a sync from config' },
137
+ { value: 'filesync:register', label: 'Register', hint: 'Register local config to global' },
138
+ { value: 'filesync:unregister', label: 'Unregister', hint: 'Unregister local config' },
133
139
  ],
134
140
  });
135
141
  } else if (category === 'drive') {
package/src/envSync.js CHANGED
@@ -4,7 +4,7 @@ const shell = require('shelljs');
4
4
  const fs = require('fs-extra');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const { select, text, isCancel, cancel, spinner, confirm, note, log } = require('@clack/prompts');
7
+ const { select, multiselect, text, isCancel, cancel, spinner, confirm, note, log } = require('@clack/prompts');
8
8
  const color = require('picocolors');
9
9
 
10
10
  // Config file names
@@ -18,6 +18,7 @@ const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, 'env-sync.json');
18
18
  const defaultConfig = {
19
19
  syncs: [],
20
20
  backupDir: 'gdrive-backups',
21
+ localRefs: [], // Registered local config references
21
22
  };
22
23
 
23
24
  /**
@@ -184,7 +185,7 @@ const envInit = async () => {
184
185
  }
185
186
 
186
187
  const pattern = await text({
187
- message: 'File pattern to sync (e.g., ".env.*", "*")',
188
+ message: 'File pattern to sync (* any, ? single char, [abc] class, [!x] negated, regex:... raw)',
188
189
  placeholder: '.env.*',
189
190
  defaultValue: '.env.*',
190
191
  });
@@ -194,6 +195,16 @@ const envInit = async () => {
194
195
  return;
195
196
  }
196
197
 
198
+ const ignore = await text({
199
+ message: 'Ignore pattern (leave empty for none)',
200
+ placeholder: 'e.g., *.template, *.backup',
201
+ });
202
+
203
+ if (isCancel(ignore)) {
204
+ cancel('Init cancelled.');
205
+ return;
206
+ }
207
+
197
208
  // For global config, ask for absolute backup path
198
209
  let syncBackupDir;
199
210
  if (saveLocation === 'global') {
@@ -222,6 +233,11 @@ const envInit = async () => {
222
233
  pattern: pattern.trim(),
223
234
  };
224
235
 
236
+ // Only add ignore if specified
237
+ if (ignore && ignore.trim()) {
238
+ newSync.ignore = ignore.trim();
239
+ }
240
+
225
241
  // Only add backupDir if specified
226
242
  if (syncBackupDir && syncBackupDir.trim()) {
227
243
  newSync.backupDir = cleanPath(syncBackupDir);
@@ -231,8 +247,11 @@ const envInit = async () => {
231
247
  saveConfig(configPath, config);
232
248
 
233
249
  log.success(`Config saved: ${configPath}`);
250
+ const ignoreInfo = ignore && ignore.trim() ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
234
251
  note(
235
- `${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan('Local Dir:')} ${destDir}\n${color.cyan('Pattern:')} ${pattern}`,
252
+ `${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan(
253
+ 'Local Dir:'
254
+ )} ${destDir}\n${color.cyan('Pattern:')} ${pattern}${ignoreInfo}`,
236
255
  'New Sync Added'
237
256
  );
238
257
  } catch (e) {
@@ -278,8 +297,16 @@ const envRemove = async () => {
278
297
  configType = await select({
279
298
  message: 'Which config?',
280
299
  options: [
281
- { value: 'local', label: 'Local', hint: `${localSyncs.length} sync(s) in .gdrive-sync.json` },
282
- { value: 'global', label: 'Global', hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/` },
300
+ {
301
+ value: 'local',
302
+ label: 'Local',
303
+ hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
304
+ },
305
+ {
306
+ value: 'global',
307
+ label: 'Global',
308
+ hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
309
+ },
283
310
  ],
284
311
  });
285
312
 
@@ -333,6 +360,120 @@ const envRemove = async () => {
333
360
  }
334
361
  };
335
362
 
363
+ /**
364
+ * Register current local config to global registry
365
+ */
366
+ const envRegister = async () => {
367
+ try {
368
+ const localConfig = findLocalConfig();
369
+
370
+ if (!localConfig) {
371
+ log.error('No local config found in current directory tree.');
372
+ log.info('Run "filesync:init" to create one first.');
373
+ return;
374
+ }
375
+
376
+ // Ensure global config exists
377
+ fs.ensureDirSync(GLOBAL_CONFIG_DIR);
378
+ let globalConfig = { ...defaultConfig };
379
+ if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
380
+ globalConfig = loadConfig(GLOBAL_CONFIG_FILE);
381
+ }
382
+ if (!globalConfig.localRefs) {
383
+ globalConfig.localRefs = [];
384
+ }
385
+
386
+ // Check if already registered
387
+ const existing = globalConfig.localRefs.find((r) => r.configPath === localConfig.configPath);
388
+ if (existing) {
389
+ log.warn(`Already registered as "${existing.name}"`);
390
+ log.info(`Path: ${localConfig.configPath}`);
391
+ return;
392
+ }
393
+
394
+ // Auto-suggest name from directory
395
+ const dirName = path.basename(localConfig.projectRoot);
396
+ const name = await text({
397
+ message: 'Name for this registered config',
398
+ placeholder: dirName,
399
+ defaultValue: dirName,
400
+ validate: (v) => {
401
+ if (!v.trim()) return 'Name is required';
402
+ if (globalConfig.localRefs.find((r) => r.name === v.trim())) return 'Name already exists';
403
+ },
404
+ });
405
+
406
+ if (isCancel(name)) {
407
+ cancel('Registration cancelled.');
408
+ return;
409
+ }
410
+
411
+ globalConfig.localRefs.push({
412
+ name: name.trim(),
413
+ configPath: localConfig.configPath,
414
+ });
415
+
416
+ saveConfig(GLOBAL_CONFIG_FILE, globalConfig);
417
+
418
+ log.success(`Registered "${name}" → ${localConfig.configPath}`);
419
+ } catch (e) {
420
+ log.error(e.message);
421
+ }
422
+ };
423
+
424
+ /**
425
+ * Unregister a local config from global registry
426
+ */
427
+ const envUnregister = async () => {
428
+ try {
429
+ if (!fs.existsSync(GLOBAL_CONFIG_FILE)) {
430
+ log.error('No global config found.');
431
+ return;
432
+ }
433
+
434
+ const globalConfig = loadConfig(GLOBAL_CONFIG_FILE);
435
+ const refs = globalConfig.localRefs || [];
436
+
437
+ if (refs.length === 0) {
438
+ log.warn('No registered local configs.');
439
+ return;
440
+ }
441
+
442
+ const options = refs.map((r) => ({
443
+ value: r.name,
444
+ label: r.name,
445
+ hint: r.configPath,
446
+ }));
447
+
448
+ const picked = await select({
449
+ message: 'Select config to unregister',
450
+ options,
451
+ });
452
+
453
+ if (isCancel(picked)) {
454
+ cancel('Cancelled.');
455
+ return;
456
+ }
457
+
458
+ const shouldRemove = await confirm({
459
+ message: `Unregister "${picked}"?`,
460
+ initialValue: false,
461
+ });
462
+
463
+ if (isCancel(shouldRemove) || !shouldRemove) {
464
+ cancel('Cancelled.');
465
+ return;
466
+ }
467
+
468
+ globalConfig.localRefs = refs.filter((r) => r.name !== picked);
469
+ saveConfig(GLOBAL_CONFIG_FILE, globalConfig);
470
+
471
+ log.success(`Unregistered "${picked}"`);
472
+ } catch (e) {
473
+ log.error(e.message);
474
+ }
475
+ };
476
+
336
477
  /**
337
478
  * Show current config
338
479
  */
@@ -353,7 +494,12 @@ const envShow = async () => {
353
494
  const lines = config.syncs
354
495
  .map((s) => {
355
496
  const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
356
- return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(20)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(`(${s.pattern})`)}${backupInfo}`;
497
+ const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
498
+ return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
499
+ 20
500
+ )} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
501
+ `(${s.pattern})`
502
+ )}${ignoreInfo}${backupInfo}`;
357
503
  })
358
504
  .join('\n');
359
505
 
@@ -373,7 +519,12 @@ const envShow = async () => {
373
519
  const lines = config.syncs
374
520
  .map((s) => {
375
521
  const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
376
- return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(20)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(`(${s.pattern})`)}${backupInfo}`;
522
+ const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
523
+ return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
524
+ 20
525
+ )} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
526
+ `(${s.pattern})`
527
+ )}${ignoreInfo}${backupInfo}`;
377
528
  })
378
529
  .join('\n');
379
530
 
@@ -391,18 +542,116 @@ const envShow = async () => {
391
542
  if (!globalConfig) {
392
543
  log.info(color.dim('No global config found.'));
393
544
  }
545
+
546
+ // Show registered local configs
547
+ if (globalConfig) {
548
+ const gc = loadConfig(globalConfig.configPath);
549
+ const refs = gc.localRefs || [];
550
+ if (refs.length > 0) {
551
+ console.log(''); // spacing
552
+ const lines = refs
553
+ .map((r) => {
554
+ const exists = fs.existsSync(r.configPath);
555
+ const status = exists ? color.green('✓') : color.red('✗ missing');
556
+ return `${color.cyan(r.name.padEnd(20))} ${status} ${color.dim(r.configPath)}`;
557
+ })
558
+ .join('\n');
559
+ note(lines, `Registered Local Configs (${refs.length})`);
560
+ }
561
+ }
394
562
  } catch (e) {
395
563
  log.error(e.message);
396
564
  }
397
565
  };
398
566
 
399
567
  /**
400
- * Match files against pattern
568
+ * Convert a glob pattern to a regex pattern
569
+ * Supports:
570
+ * - * (matches any characters)
571
+ * - ? (matches single character)
572
+ * - [abc] (character class)
573
+ * - [!abc] or [^abc] (negated character class)
574
+ * - regex: prefix for raw regex patterns
575
+ */
576
+ const globToRegex = (pattern) => {
577
+ // Raw regex mode: prefix with "regex:"
578
+ if (pattern.startsWith('regex:')) {
579
+ return pattern.slice(6);
580
+ }
581
+
582
+ let result = '';
583
+ let i = 0;
584
+ while (i < pattern.length) {
585
+ const char = pattern[i];
586
+
587
+ if (char === '*') {
588
+ result += '.*';
589
+ } else if (char === '?') {
590
+ result += '.';
591
+ } else if (char === '[') {
592
+ // Find the closing bracket
593
+ let j = i + 1;
594
+ let charClass = '[';
595
+
596
+ // Handle negation [! or [^
597
+ if (pattern[j] === '!' || pattern[j] === '^') {
598
+ charClass += '^';
599
+ j++;
600
+ }
601
+
602
+ // Find closing bracket
603
+ while (j < pattern.length && pattern[j] !== ']') {
604
+ // Escape special regex chars inside character class (except - and ^)
605
+ if (pattern[j] === '\\' && j + 1 < pattern.length) {
606
+ charClass += '\\' + pattern[j + 1];
607
+ j += 2;
608
+ } else {
609
+ charClass += pattern[j];
610
+ j++;
611
+ }
612
+ }
613
+
614
+ if (j < pattern.length) {
615
+ charClass += ']';
616
+ result += charClass;
617
+ i = j;
618
+ } else {
619
+ // No closing bracket found, treat [ as literal
620
+ result += '\\[';
621
+ }
622
+ } else if ('.+^${}|()\\'.includes(char)) {
623
+ // Escape special regex characters
624
+ result += '\\' + char;
625
+ } else {
626
+ result += char;
627
+ }
628
+ i++;
629
+ }
630
+
631
+ return result;
632
+ };
633
+
634
+ /**
635
+ * Match files against pattern, with optional ignore pattern
636
+ * @param {string} filename - The filename to test
637
+ * @param {string} pattern - Glob pattern or regex: prefixed pattern
638
+ * @param {string} [ignore] - Optional ignore pattern
639
+ * @returns {boolean} - True if filename matches pattern and doesn't match ignore
401
640
  */
402
- const matchPattern = (filename, pattern) => {
403
- if (pattern === '*') return true;
404
- const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
405
- return regex.test(filename);
641
+ const matchPattern = (filename, pattern, ignore) => {
642
+ if (pattern === '*' && !ignore) return true;
643
+
644
+ const regexPattern = globToRegex(pattern);
645
+ const regex = new RegExp('^' + regexPattern + '$');
646
+ if (!regex.test(filename)) return false;
647
+
648
+ if (ignore) {
649
+ const ignoreRegexPattern = globToRegex(ignore);
650
+ const ignoreRegex = new RegExp('^' + ignoreRegexPattern + '$');
651
+ if (ignoreRegex.test(filename)) return false;
652
+ }
653
+
654
+ return true;
406
655
  };
407
656
 
408
657
  /**
@@ -445,19 +694,38 @@ const envRun = async (presetAction, presetConfigType) => {
445
694
 
446
695
  if (localConfig) {
447
696
  const lc = loadConfig(localConfig.configPath);
448
- localSyncs = (lc.syncs || []).map((s) => ({ ...s, _configType: 'local', _configPath: localConfig.configPath, _projectRoot: localConfig.projectRoot, _globalBackupDir: lc.backupDir }));
697
+ localSyncs = (lc.syncs || []).map((s) => ({
698
+ ...s,
699
+ _configType: 'local',
700
+ _configPath: localConfig.configPath,
701
+ _projectRoot: localConfig.projectRoot,
702
+ _globalBackupDir: lc.backupDir,
703
+ }));
449
704
  }
450
705
  if (globalConfig) {
451
706
  const gc = loadConfig(globalConfig.configPath);
452
- globalSyncs = (gc.syncs || []).map((s) => ({ ...s, _configType: 'global', _configPath: globalConfig.configPath, _projectRoot: globalConfig.projectRoot, _globalBackupDir: gc.backupDir }));
707
+ globalSyncs = (gc.syncs || []).map((s) => ({
708
+ ...s,
709
+ _configType: 'global',
710
+ _configPath: globalConfig.configPath,
711
+ _projectRoot: globalConfig.projectRoot,
712
+ _globalBackupDir: gc.backupDir,
713
+ }));
453
714
  }
454
715
 
455
- if (localSyncs.length === 0 && globalSyncs.length === 0) {
716
+ // Load registered local configs
717
+ let registeredRefs = [];
718
+ if (globalConfig) {
719
+ const gc = loadConfig(globalConfig.configPath);
720
+ registeredRefs = (gc.localRefs || []).filter((r) => fs.existsSync(r.configPath));
721
+ }
722
+
723
+ if (localSyncs.length === 0 && globalSyncs.length === 0 && registeredRefs.length === 0) {
456
724
  log.warn('No syncs configured. Run "env:init" to add one.');
457
725
  return;
458
726
  }
459
727
 
460
- // First: pick Local or Global (if both exist)
728
+ // First: pick Local, Global, or Registered
461
729
  let selectedSyncs = [];
462
730
  let configType;
463
731
 
@@ -473,25 +741,90 @@ const envRun = async (presetAction, presetConfigType) => {
473
741
  }
474
742
  configType = presetConfigType;
475
743
  log.info(`Using ${configType} config`);
476
- } else if (localSyncs.length > 0 && globalSyncs.length > 0) {
477
- configType = await select({
478
- message: 'Which config?',
479
- options: [
480
- { value: 'local', label: 'Local', hint: `${localSyncs.length} sync(s) in .gdrive-sync.json` },
481
- { value: 'global', label: 'Global', hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/` },
482
- ],
744
+ } else {
745
+ // Build options dynamically
746
+ const configOptions = [];
747
+ if (localSyncs.length > 0) {
748
+ configOptions.push({
749
+ value: 'local',
750
+ label: 'Local',
751
+ hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
752
+ });
753
+ }
754
+ if (globalSyncs.length > 0) {
755
+ configOptions.push({
756
+ value: 'global',
757
+ label: 'Global',
758
+ hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
759
+ });
760
+ }
761
+ if (registeredRefs.length > 0) {
762
+ configOptions.push({
763
+ value: 'registered',
764
+ label: 'Registered Local Configs',
765
+ hint: `${registeredRefs.length} registered project(s)`,
766
+ });
767
+ }
768
+
769
+ if (configOptions.length === 1) {
770
+ configType = configOptions[0].value;
771
+ log.info(`Using ${configType} config`);
772
+ } else {
773
+ configType = await select({
774
+ message: 'Which config?',
775
+ options: configOptions,
776
+ });
777
+
778
+ if (isCancel(configType)) {
779
+ cancel('Operation cancelled.');
780
+ return;
781
+ }
782
+ }
783
+ }
784
+
785
+ // Handle registered configs with multi-select
786
+ if (configType === 'registered') {
787
+ const refOptions = registeredRefs.map((r) => ({
788
+ value: r.configPath,
789
+ label: r.name,
790
+ hint: path.dirname(r.configPath),
791
+ }));
792
+
793
+ const selectedRefs = await multiselect({
794
+ message: 'Select configs to run (space to toggle, enter to confirm)',
795
+ options: refOptions,
796
+ required: true,
483
797
  });
484
798
 
485
- if (isCancel(configType)) {
799
+ if (isCancel(selectedRefs)) {
486
800
  cancel('Operation cancelled.');
487
801
  return;
488
802
  }
489
- } else if (localSyncs.length > 0) {
490
- configType = 'local';
491
- log.info(`Using local config`);
492
- } else {
493
- configType = 'global';
494
- log.info(`Using global config`);
803
+
804
+ // Load syncs from each selected registered config
805
+ for (const refPath of selectedRefs) {
806
+ const ref = registeredRefs.find((r) => r.configPath === refPath);
807
+ const refConfig = loadConfig(refPath);
808
+ const refProjectRoot = path.dirname(refPath);
809
+ const refSyncs = (refConfig.syncs || []).map((s) => ({
810
+ ...s,
811
+ _configType: 'registered',
812
+ _configPath: refPath,
813
+ _projectRoot: refProjectRoot,
814
+ _globalBackupDir: refConfig.backupDir,
815
+ _refName: ref.name,
816
+ }));
817
+ selectedSyncs.push(...refSyncs);
818
+ }
819
+
820
+ // Process all selected syncs
821
+ for (const syncConfig of selectedSyncs) {
822
+ const projectRoot = syncConfig._projectRoot;
823
+ const globalBackupDir = syncConfig._globalBackupDir || 'gdrive-backups';
824
+ const backupPath = path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
825
+ await runSyncOperation(syncConfig, action, projectRoot, backupPath, 'registered');
826
+ }
827
+ return;
495
828
  }
496
829
 
497
830
  const syncs = configType === 'local' ? localSyncs : globalSyncs;
@@ -533,9 +866,10 @@ const envRun = async (presetAction, presetConfigType) => {
533
866
  for (const syncConfig of selectedSyncs) {
534
867
  const projectRoot = syncConfig._configType === 'global' ? '' : syncConfig._projectRoot;
535
868
  const globalBackupDir = syncConfig._globalBackupDir || 'gdrive-backups';
536
- const backupPath = syncConfig._configType === 'global'
537
- ? (syncConfig.backupDir || path.join(os.homedir(), 'gdrive-backups'))
538
- : path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
869
+ const backupPath =
870
+ syncConfig._configType === 'global'
871
+ ? syncConfig.backupDir || path.join(os.homedir(), 'gdrive-backups')
872
+ : path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
539
873
 
540
874
  await runSyncOperation(syncConfig, action, projectRoot, backupPath, syncConfig._configType);
541
875
  }
@@ -548,14 +882,19 @@ const envRun = async (presetAction, presetConfigType) => {
548
882
  * Run a single sync operation
549
883
  */
550
884
  const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, configType) => {
551
- const { folderId, pattern, name } = syncConfig;
885
+ const { folderId, pattern, name, ignore } = syncConfig;
552
886
  // Strip quotes from paths (in case manually added)
553
887
  const localDir = syncConfig.localDir.replace(/^['"]|['"]$/g, '');
554
888
  // For global config, localDir is absolute; for local, it's relative
555
889
  const envDir = configType === 'global' ? localDir : path.join(projectRoot, localDir);
556
890
 
891
+ const ignoreInfo = ignore ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
557
892
  note(
558
- `${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan('Local Dir:')} ${envDir}\n${color.cyan('Pattern:')} ${pattern}\n${color.cyan('Backup Dir:')} ${backupPath}`,
893
+ `${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan(
894
+ 'Folder ID:'
895
+ )} ${folderId}\n${color.cyan('Local Dir:')} ${envDir}\n${color.cyan(
896
+ 'Pattern:'
897
+ )} ${pattern}${ignoreInfo}\n${color.cyan('Backup Dir:')} ${backupPath}`,
559
898
  'Processing'
560
899
  );
561
900
 
@@ -568,10 +907,9 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
568
907
  const s = spinner();
569
908
  s.start('Fetching files from Google Drive...');
570
909
 
571
- const listResult = shell.exec(
572
- `gdrive list -q "'${folderId}' in parents" --no-header`,
573
- { silent: true }
574
- );
910
+ const listResult = shell.exec(`gdrive list -q "'${folderId}' in parents" --no-header`, {
911
+ silent: true,
912
+ });
575
913
 
576
914
  if (listResult.code !== 0) {
577
915
  s.stop(color.red('Failed to fetch from Drive'));
@@ -590,7 +928,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
590
928
  if (parts.length >= 2) {
591
929
  const fileId = parts[0].trim();
592
930
  const fileName = parts[1].trim();
593
- if (matchPattern(fileName, pattern)) {
931
+ if (matchPattern(fileName, pattern, ignore)) {
594
932
  driveFiles.push({ id: fileId, name: fileName });
595
933
  }
596
934
  }
@@ -606,7 +944,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
606
944
 
607
945
  // Get local files
608
946
  fs.ensureDirSync(envDir);
609
- const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
947
+ const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
610
948
 
611
949
  // Compare files
612
950
  const changes = {
@@ -639,9 +977,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
639
977
  }
640
978
 
641
979
  const hasChanges =
642
- changes.modified.length > 0 ||
643
- changes.localOnly.length > 0 ||
644
- changes.driveOnly.length > 0;
980
+ changes.modified.length > 0 || changes.localOnly.length > 0 || changes.driveOnly.length > 0;
645
981
 
646
982
  if (!hasChanges) {
647
983
  log.success('No changes detected. Local and Drive are in sync.');
@@ -728,7 +1064,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
728
1064
 
729
1065
  if (action === 'download') {
730
1066
  // Create backup first
731
- const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
1067
+ const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
732
1068
  if (existingFiles.length > 0) {
733
1069
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
734
1070
  const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
@@ -756,7 +1092,6 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
756
1092
  }
757
1093
 
758
1094
  downloadSpinner.stop(color.green(`Downloaded ${downloaded} file(s)`));
759
-
760
1095
  } else if (action === 'upload') {
761
1096
  const uploadSpinner = spinner();
762
1097
  uploadSpinner.start('Uploading files...');
@@ -767,13 +1102,17 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
767
1102
  for (const filename of changes.modified) {
768
1103
  const driveFile = driveFiles.find((f) => f.name === filename);
769
1104
  if (driveFile) {
770
- shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, { silent: true });
1105
+ shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, {
1106
+ silent: true,
1107
+ });
771
1108
  replaced++;
772
1109
  }
773
1110
  }
774
1111
 
775
1112
  for (const filename of changes.localOnly) {
776
- shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, { silent: true });
1113
+ shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, {
1114
+ silent: true,
1115
+ });
777
1116
  uploaded++;
778
1117
  }
779
1118
 
@@ -791,4 +1130,9 @@ module.exports = {
791
1130
  envRun,
792
1131
  envShow,
793
1132
  envRemove,
1133
+ envRegister,
1134
+ envUnregister,
1135
+ // Exported for testing
1136
+ globToRegex,
1137
+ matchPattern,
794
1138
  };
@@ -0,0 +1,189 @@
1
+ const { globToRegex, matchPattern } = require('./envSync');
2
+
3
+ describe('globToRegex', () => {
4
+ describe('basic glob patterns (backward compatibility)', () => {
5
+ test('* matches any characters', () => {
6
+ expect(globToRegex('*')).toBe('.*');
7
+ expect(globToRegex('.env.*')).toBe('\\.env\\..*');
8
+ expect(globToRegex('*.js')).toBe('.*\\.js');
9
+ });
10
+
11
+ test('dots are escaped', () => {
12
+ expect(globToRegex('.env')).toBe('\\.env');
13
+ expect(globToRegex('file.txt')).toBe('file\\.txt');
14
+ });
15
+
16
+ test('multiple wildcards', () => {
17
+ expect(globToRegex('*.*')).toBe('.*\\..*');
18
+ });
19
+ });
20
+
21
+ describe('? single character wildcard', () => {
22
+ test('? matches single character', () => {
23
+ expect(globToRegex('file?.txt')).toBe('file.\\.txt');
24
+ expect(globToRegex('???.js')).toBe('...\\.js');
25
+ });
26
+
27
+ test('combined with *', () => {
28
+ expect(globToRegex('file?.*')).toBe('file.\\..*');
29
+ });
30
+ });
31
+
32
+ describe('character classes [abc]', () => {
33
+ test('basic character class', () => {
34
+ expect(globToRegex('[abc]')).toBe('[abc]');
35
+ expect(globToRegex('file[123].txt')).toBe('file[123]\\.txt');
36
+ });
37
+
38
+ test('character range', () => {
39
+ expect(globToRegex('[a-z]')).toBe('[a-z]');
40
+ expect(globToRegex('[0-9]')).toBe('[0-9]');
41
+ });
42
+
43
+ test('negated character class with !', () => {
44
+ expect(globToRegex('[!abc]')).toBe('[^abc]');
45
+ expect(globToRegex('[!0-9]')).toBe('[^0-9]');
46
+ });
47
+
48
+ test('negated character class with ^', () => {
49
+ expect(globToRegex('[^abc]')).toBe('[^abc]');
50
+ });
51
+
52
+ test('character class with special chars', () => {
53
+ expect(globToRegex('[.-]')).toBe('[.-]');
54
+ });
55
+
56
+ test('unclosed bracket treated as literal', () => {
57
+ expect(globToRegex('[abc')).toBe('\\[abc');
58
+ });
59
+ });
60
+
61
+ describe('regex: prefix for raw regex', () => {
62
+ test('passes through raw regex unchanged', () => {
63
+ expect(globToRegex('regex:\\.env\\.[^.]+$')).toBe('\\.env\\.[^.]+$');
64
+ expect(globToRegex('regex:.*\\.js$')).toBe('.*\\.js$');
65
+ });
66
+
67
+ test('complex regex patterns', () => {
68
+ expect(globToRegex('regex:^(foo|bar)\\.(js|ts)$')).toBe('^(foo|bar)\\.(js|ts)$');
69
+ });
70
+ });
71
+
72
+ describe('special regex characters are escaped', () => {
73
+ test('escapes + ^ $ { } | ( ) \\', () => {
74
+ expect(globToRegex('file+name')).toBe('file\\+name');
75
+ expect(globToRegex('file^name')).toBe('file\\^name');
76
+ expect(globToRegex('file$name')).toBe('file\\$name');
77
+ expect(globToRegex('file(1)')).toBe('file\\(1\\)');
78
+ expect(globToRegex('file{1}')).toBe('file\\{1\\}');
79
+ expect(globToRegex('a|b')).toBe('a\\|b');
80
+ });
81
+ });
82
+ });
83
+
84
+ describe('matchPattern', () => {
85
+ describe('backward compatibility - basic glob', () => {
86
+ test('* matches everything', () => {
87
+ expect(matchPattern('anything.txt', '*')).toBe(true);
88
+ expect(matchPattern('.env.development', '*')).toBe(true);
89
+ });
90
+
91
+ test('.env.* pattern', () => {
92
+ expect(matchPattern('.env.development', '.env.*')).toBe(true);
93
+ expect(matchPattern('.env.production', '.env.*')).toBe(true);
94
+ expect(matchPattern('.env.local', '.env.*')).toBe(true);
95
+ expect(matchPattern('.env.development.template', '.env.*')).toBe(true);
96
+ expect(matchPattern('.envrc', '.env.*')).toBe(false);
97
+ expect(matchPattern('config.env.js', '.env.*')).toBe(false);
98
+ });
99
+
100
+ test('*.js pattern', () => {
101
+ expect(matchPattern('index.js', '*.js')).toBe(true);
102
+ expect(matchPattern('app.test.js', '*.js')).toBe(true);
103
+ expect(matchPattern('index.ts', '*.js')).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('? single character', () => {
108
+ test('matches single character', () => {
109
+ expect(matchPattern('file1.txt', 'file?.txt')).toBe(true);
110
+ expect(matchPattern('fileA.txt', 'file?.txt')).toBe(true);
111
+ expect(matchPattern('file12.txt', 'file?.txt')).toBe(false);
112
+ expect(matchPattern('file.txt', 'file?.txt')).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('character classes', () => {
117
+ test('[abc] matches specific characters', () => {
118
+ expect(matchPattern('filea.txt', 'file[abc].txt')).toBe(true);
119
+ expect(matchPattern('fileb.txt', 'file[abc].txt')).toBe(true);
120
+ expect(matchPattern('filed.txt', 'file[abc].txt')).toBe(false);
121
+ });
122
+
123
+ test('[0-9] matches digit range', () => {
124
+ expect(matchPattern('file5.txt', 'file[0-9].txt')).toBe(true);
125
+ expect(matchPattern('filea.txt', 'file[0-9].txt')).toBe(false);
126
+ });
127
+
128
+ test('[!abc] negated class', () => {
129
+ expect(matchPattern('filed.txt', 'file[!abc].txt')).toBe(true);
130
+ expect(matchPattern('filea.txt', 'file[!abc].txt')).toBe(false);
131
+ });
132
+
133
+ test('[!.] matches non-dot (for excluding .template)', () => {
134
+ expect(matchPattern('.env.development', '.env.[!.]*')).toBe(true);
135
+ expect(matchPattern('.env.production', '.env.[!.]*')).toBe(true);
136
+ // This won't fully exclude .template because [!.]* only affects first char
137
+ // Use ignore pattern instead for this use case
138
+ });
139
+ });
140
+
141
+ describe('regex: prefix', () => {
142
+ test('raw regex for complex patterns', () => {
143
+ expect(matchPattern('.env.development', 'regex:\\.env\\.[^.]+$')).toBe(true);
144
+ expect(matchPattern('.env.production', 'regex:\\.env\\.[^.]+$')).toBe(true);
145
+ expect(matchPattern('.env.development.template', 'regex:\\.env\\.[^.]+$')).toBe(false);
146
+ });
147
+
148
+ test('regex with alternation', () => {
149
+ expect(matchPattern('app.js', 'regex:.*\\.(js|ts)$')).toBe(true);
150
+ expect(matchPattern('app.ts', 'regex:.*\\.(js|ts)$')).toBe(true);
151
+ expect(matchPattern('app.css', 'regex:.*\\.(js|ts)$')).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe('ignore pattern', () => {
156
+ test('excludes files matching ignore', () => {
157
+ expect(matchPattern('.env.development', '.env.*', '*.template')).toBe(true);
158
+ expect(matchPattern('.env.development.template', '.env.*', '*.template')).toBe(false);
159
+ expect(matchPattern('.env.production.template', '.env.*', '*.template')).toBe(false);
160
+ });
161
+
162
+ test('ignore with character class', () => {
163
+ expect(matchPattern('file1.txt', '*.txt', 'file[0-9].txt')).toBe(false);
164
+ expect(matchPattern('fileA.txt', '*.txt', 'file[0-9].txt')).toBe(true);
165
+ });
166
+
167
+ test('ignore with regex:', () => {
168
+ expect(matchPattern('.env.dev', '.env.*', 'regex:.*\\.template$')).toBe(true);
169
+ expect(matchPattern('.env.dev.template', '.env.*', 'regex:.*\\.template$')).toBe(false);
170
+ });
171
+ });
172
+
173
+ describe('edge cases', () => {
174
+ test('empty pattern', () => {
175
+ expect(matchPattern('', '')).toBe(true);
176
+ expect(matchPattern('file.txt', '')).toBe(false);
177
+ });
178
+
179
+ test('exact match', () => {
180
+ expect(matchPattern('.env', '.env')).toBe(true);
181
+ expect(matchPattern('.env.local', '.env')).toBe(false);
182
+ });
183
+
184
+ test('pattern with no wildcards', () => {
185
+ expect(matchPattern('config.json', 'config.json')).toBe(true);
186
+ expect(matchPattern('config.yaml', 'config.json')).toBe(false);
187
+ });
188
+ });
189
+ });