gdrive-syncer 2.1.0 → 2.1.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.
- package/Readme.md +35 -1
- package/package.json +5 -3
- package/src/envSync.js +190 -39
- package/src/envSync.test.js +189 -0
package/Readme.md
CHANGED
|
@@ -132,10 +132,44 @@ Global config at `~/.gdrive_syncer/env-sync.json` uses absolute paths:
|
|
|
132
132
|
| `name` | Unique name for the sync |
|
|
133
133
|
| `folderId` | Google Drive folder ID |
|
|
134
134
|
| `localDir` | Local directory path (relative for local config, absolute for global) |
|
|
135
|
-
| `pattern` | File pattern to sync (
|
|
135
|
+
| `pattern` | File pattern to sync (see [Pattern Syntax](#pattern-syntax)) |
|
|
136
|
+
| `ignore` | Optional pattern to exclude files (see [Pattern Syntax](#pattern-syntax)) |
|
|
136
137
|
| `backupDir` (per-sync) | Override backup directory for this sync only |
|
|
137
138
|
| `backupDir` (root) | Default backup directory (local: `gdrive-backups`, global: `~/gdrive-backups`) |
|
|
138
139
|
|
|
140
|
+
### Pattern Syntax
|
|
141
|
+
|
|
142
|
+
Patterns support glob-style matching with the following features:
|
|
143
|
+
|
|
144
|
+
| Pattern | Description | Example |
|
|
145
|
+
|---------|-------------|---------|
|
|
146
|
+
| `*` | Matches any characters | `.env.*` matches `.env.development`, `.env.production` |
|
|
147
|
+
| `?` | Matches single character | `file?.txt` matches `file1.txt`, `fileA.txt` |
|
|
148
|
+
| `[abc]` | Matches specific characters | `file[123].txt` matches `file1.txt`, `file2.txt` |
|
|
149
|
+
| `[a-z]` | Matches character range | `[0-9]` matches any digit |
|
|
150
|
+
| `[!abc]` | Negated character class | `[!.]` matches any non-dot character |
|
|
151
|
+
| `regex:` | Raw regex (advanced) | `regex:\.env\.[^.]+$` for precise matching |
|
|
152
|
+
|
|
153
|
+
#### Examples
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
// Match all .env files
|
|
157
|
+
"pattern": ".env.*"
|
|
158
|
+
|
|
159
|
+
// Match .env files but exclude .template files
|
|
160
|
+
"pattern": ".env.*",
|
|
161
|
+
"ignore": "*.template"
|
|
162
|
+
|
|
163
|
+
// Match .env files without additional extensions (no dots after env name)
|
|
164
|
+
"pattern": ".env.[!.]*"
|
|
165
|
+
|
|
166
|
+
// Match using raw regex (excludes .env.xx.template)
|
|
167
|
+
"pattern": "regex:\\.env\\.[^.]+$"
|
|
168
|
+
|
|
169
|
+
// Match specific file types
|
|
170
|
+
"pattern": "*.[jt]s" // matches .js and .ts files
|
|
171
|
+
```
|
|
172
|
+
|
|
139
173
|
### Features
|
|
140
174
|
|
|
141
175
|
- **Multiple sync folders** - Configure multiple sync pairs in one config
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdrive-syncer",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "Google Drive Syncer",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"bin": "./run.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "
|
|
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/src/envSync.js
CHANGED
|
@@ -184,7 +184,7 @@ const envInit = async () => {
|
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
const pattern = await text({
|
|
187
|
-
message: 'File pattern to sync (
|
|
187
|
+
message: 'File pattern to sync (* any, ? single char, [abc] class, [!x] negated, regex:... raw)',
|
|
188
188
|
placeholder: '.env.*',
|
|
189
189
|
defaultValue: '.env.*',
|
|
190
190
|
});
|
|
@@ -194,6 +194,16 @@ const envInit = async () => {
|
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
const ignore = await text({
|
|
198
|
+
message: 'Ignore pattern (leave empty for none)',
|
|
199
|
+
placeholder: 'e.g., *.template, *.backup',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (isCancel(ignore)) {
|
|
203
|
+
cancel('Init cancelled.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
197
207
|
// For global config, ask for absolute backup path
|
|
198
208
|
let syncBackupDir;
|
|
199
209
|
if (saveLocation === 'global') {
|
|
@@ -222,6 +232,11 @@ const envInit = async () => {
|
|
|
222
232
|
pattern: pattern.trim(),
|
|
223
233
|
};
|
|
224
234
|
|
|
235
|
+
// Only add ignore if specified
|
|
236
|
+
if (ignore && ignore.trim()) {
|
|
237
|
+
newSync.ignore = ignore.trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
225
240
|
// Only add backupDir if specified
|
|
226
241
|
if (syncBackupDir && syncBackupDir.trim()) {
|
|
227
242
|
newSync.backupDir = cleanPath(syncBackupDir);
|
|
@@ -231,8 +246,11 @@ const envInit = async () => {
|
|
|
231
246
|
saveConfig(configPath, config);
|
|
232
247
|
|
|
233
248
|
log.success(`Config saved: ${configPath}`);
|
|
249
|
+
const ignoreInfo = ignore && ignore.trim() ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
|
|
234
250
|
note(
|
|
235
|
-
`${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan(
|
|
251
|
+
`${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan(
|
|
252
|
+
'Local Dir:'
|
|
253
|
+
)} ${destDir}\n${color.cyan('Pattern:')} ${pattern}${ignoreInfo}`,
|
|
236
254
|
'New Sync Added'
|
|
237
255
|
);
|
|
238
256
|
} catch (e) {
|
|
@@ -278,8 +296,16 @@ const envRemove = async () => {
|
|
|
278
296
|
configType = await select({
|
|
279
297
|
message: 'Which config?',
|
|
280
298
|
options: [
|
|
281
|
-
{
|
|
282
|
-
|
|
299
|
+
{
|
|
300
|
+
value: 'local',
|
|
301
|
+
label: 'Local',
|
|
302
|
+
hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
value: 'global',
|
|
306
|
+
label: 'Global',
|
|
307
|
+
hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
|
|
308
|
+
},
|
|
283
309
|
],
|
|
284
310
|
});
|
|
285
311
|
|
|
@@ -353,7 +379,12 @@ const envShow = async () => {
|
|
|
353
379
|
const lines = config.syncs
|
|
354
380
|
.map((s) => {
|
|
355
381
|
const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
|
|
356
|
-
|
|
382
|
+
const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
|
|
383
|
+
return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
|
|
384
|
+
20
|
|
385
|
+
)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
|
|
386
|
+
`(${s.pattern})`
|
|
387
|
+
)}${ignoreInfo}${backupInfo}`;
|
|
357
388
|
})
|
|
358
389
|
.join('\n');
|
|
359
390
|
|
|
@@ -373,7 +404,12 @@ const envShow = async () => {
|
|
|
373
404
|
const lines = config.syncs
|
|
374
405
|
.map((s) => {
|
|
375
406
|
const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
|
|
376
|
-
|
|
407
|
+
const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
|
|
408
|
+
return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
|
|
409
|
+
20
|
|
410
|
+
)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
|
|
411
|
+
`(${s.pattern})`
|
|
412
|
+
)}${ignoreInfo}${backupInfo}`;
|
|
377
413
|
})
|
|
378
414
|
.join('\n');
|
|
379
415
|
|
|
@@ -397,12 +433,93 @@ const envShow = async () => {
|
|
|
397
433
|
};
|
|
398
434
|
|
|
399
435
|
/**
|
|
400
|
-
*
|
|
436
|
+
* Convert a glob pattern to a regex pattern
|
|
437
|
+
* Supports:
|
|
438
|
+
* - * (matches any characters)
|
|
439
|
+
* - ? (matches single character)
|
|
440
|
+
* - [abc] (character class)
|
|
441
|
+
* - [!abc] or [^abc] (negated character class)
|
|
442
|
+
* - regex: prefix for raw regex patterns
|
|
401
443
|
*/
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
444
|
+
const globToRegex = (pattern) => {
|
|
445
|
+
// Raw regex mode: prefix with "regex:"
|
|
446
|
+
if (pattern.startsWith('regex:')) {
|
|
447
|
+
return pattern.slice(6);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let result = '';
|
|
451
|
+
let i = 0;
|
|
452
|
+
while (i < pattern.length) {
|
|
453
|
+
const char = pattern[i];
|
|
454
|
+
|
|
455
|
+
if (char === '*') {
|
|
456
|
+
result += '.*';
|
|
457
|
+
} else if (char === '?') {
|
|
458
|
+
result += '.';
|
|
459
|
+
} else if (char === '[') {
|
|
460
|
+
// Find the closing bracket
|
|
461
|
+
let j = i + 1;
|
|
462
|
+
let charClass = '[';
|
|
463
|
+
|
|
464
|
+
// Handle negation [! or [^
|
|
465
|
+
if (pattern[j] === '!' || pattern[j] === '^') {
|
|
466
|
+
charClass += '^';
|
|
467
|
+
j++;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Find closing bracket
|
|
471
|
+
while (j < pattern.length && pattern[j] !== ']') {
|
|
472
|
+
// Escape special regex chars inside character class (except - and ^)
|
|
473
|
+
if (pattern[j] === '\\' && j + 1 < pattern.length) {
|
|
474
|
+
charClass += '\\' + pattern[j + 1];
|
|
475
|
+
j += 2;
|
|
476
|
+
} else {
|
|
477
|
+
charClass += pattern[j];
|
|
478
|
+
j++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (j < pattern.length) {
|
|
483
|
+
charClass += ']';
|
|
484
|
+
result += charClass;
|
|
485
|
+
i = j;
|
|
486
|
+
} else {
|
|
487
|
+
// No closing bracket found, treat [ as literal
|
|
488
|
+
result += '\\[';
|
|
489
|
+
}
|
|
490
|
+
} else if ('.+^${}|()\\'.includes(char)) {
|
|
491
|
+
// Escape special regex characters
|
|
492
|
+
result += '\\' + char;
|
|
493
|
+
} else {
|
|
494
|
+
result += char;
|
|
495
|
+
}
|
|
496
|
+
i++;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return result;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Match files against pattern, with optional ignore pattern
|
|
504
|
+
* @param {string} filename - The filename to test
|
|
505
|
+
* @param {string} pattern - Glob pattern or regex: prefixed pattern
|
|
506
|
+
* @param {string} [ignore] - Optional ignore pattern
|
|
507
|
+
* @returns {boolean} - True if filename matches pattern and doesn't match ignore
|
|
508
|
+
*/
|
|
509
|
+
const matchPattern = (filename, pattern, ignore) => {
|
|
510
|
+
if (pattern === '*' && !ignore) return true;
|
|
511
|
+
|
|
512
|
+
const regexPattern = globToRegex(pattern);
|
|
513
|
+
const regex = new RegExp('^' + regexPattern + '$');
|
|
514
|
+
if (!regex.test(filename)) return false;
|
|
515
|
+
|
|
516
|
+
if (ignore) {
|
|
517
|
+
const ignoreRegexPattern = globToRegex(ignore);
|
|
518
|
+
const ignoreRegex = new RegExp('^' + ignoreRegexPattern + '$');
|
|
519
|
+
if (ignoreRegex.test(filename)) return false;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return true;
|
|
406
523
|
};
|
|
407
524
|
|
|
408
525
|
/**
|
|
@@ -445,11 +562,23 @@ const envRun = async (presetAction, presetConfigType) => {
|
|
|
445
562
|
|
|
446
563
|
if (localConfig) {
|
|
447
564
|
const lc = loadConfig(localConfig.configPath);
|
|
448
|
-
localSyncs = (lc.syncs || []).map((s) => ({
|
|
565
|
+
localSyncs = (lc.syncs || []).map((s) => ({
|
|
566
|
+
...s,
|
|
567
|
+
_configType: 'local',
|
|
568
|
+
_configPath: localConfig.configPath,
|
|
569
|
+
_projectRoot: localConfig.projectRoot,
|
|
570
|
+
_globalBackupDir: lc.backupDir,
|
|
571
|
+
}));
|
|
449
572
|
}
|
|
450
573
|
if (globalConfig) {
|
|
451
574
|
const gc = loadConfig(globalConfig.configPath);
|
|
452
|
-
globalSyncs = (gc.syncs || []).map((s) => ({
|
|
575
|
+
globalSyncs = (gc.syncs || []).map((s) => ({
|
|
576
|
+
...s,
|
|
577
|
+
_configType: 'global',
|
|
578
|
+
_configPath: globalConfig.configPath,
|
|
579
|
+
_projectRoot: globalConfig.projectRoot,
|
|
580
|
+
_globalBackupDir: gc.backupDir,
|
|
581
|
+
}));
|
|
453
582
|
}
|
|
454
583
|
|
|
455
584
|
if (localSyncs.length === 0 && globalSyncs.length === 0) {
|
|
@@ -477,8 +606,16 @@ const envRun = async (presetAction, presetConfigType) => {
|
|
|
477
606
|
configType = await select({
|
|
478
607
|
message: 'Which config?',
|
|
479
608
|
options: [
|
|
480
|
-
{
|
|
481
|
-
|
|
609
|
+
{
|
|
610
|
+
value: 'local',
|
|
611
|
+
label: 'Local',
|
|
612
|
+
hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
value: 'global',
|
|
616
|
+
label: 'Global',
|
|
617
|
+
hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
|
|
618
|
+
},
|
|
482
619
|
],
|
|
483
620
|
});
|
|
484
621
|
|
|
@@ -533,9 +670,10 @@ const envRun = async (presetAction, presetConfigType) => {
|
|
|
533
670
|
for (const syncConfig of selectedSyncs) {
|
|
534
671
|
const projectRoot = syncConfig._configType === 'global' ? '' : syncConfig._projectRoot;
|
|
535
672
|
const globalBackupDir = syncConfig._globalBackupDir || 'gdrive-backups';
|
|
536
|
-
const backupPath =
|
|
537
|
-
|
|
538
|
-
|
|
673
|
+
const backupPath =
|
|
674
|
+
syncConfig._configType === 'global'
|
|
675
|
+
? syncConfig.backupDir || path.join(os.homedir(), 'gdrive-backups')
|
|
676
|
+
: path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
|
|
539
677
|
|
|
540
678
|
await runSyncOperation(syncConfig, action, projectRoot, backupPath, syncConfig._configType);
|
|
541
679
|
}
|
|
@@ -548,14 +686,19 @@ const envRun = async (presetAction, presetConfigType) => {
|
|
|
548
686
|
* Run a single sync operation
|
|
549
687
|
*/
|
|
550
688
|
const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, configType) => {
|
|
551
|
-
const { folderId, pattern, name } = syncConfig;
|
|
689
|
+
const { folderId, pattern, name, ignore } = syncConfig;
|
|
552
690
|
// Strip quotes from paths (in case manually added)
|
|
553
691
|
const localDir = syncConfig.localDir.replace(/^['"]|['"]$/g, '');
|
|
554
692
|
// For global config, localDir is absolute; for local, it's relative
|
|
555
693
|
const envDir = configType === 'global' ? localDir : path.join(projectRoot, localDir);
|
|
556
694
|
|
|
695
|
+
const ignoreInfo = ignore ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
|
|
557
696
|
note(
|
|
558
|
-
`${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan(
|
|
697
|
+
`${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan(
|
|
698
|
+
'Folder ID:'
|
|
699
|
+
)} ${folderId}\n${color.cyan('Local Dir:')} ${envDir}\n${color.cyan(
|
|
700
|
+
'Pattern:'
|
|
701
|
+
)} ${pattern}${ignoreInfo}\n${color.cyan('Backup Dir:')} ${backupPath}`,
|
|
559
702
|
'Processing'
|
|
560
703
|
);
|
|
561
704
|
|
|
@@ -568,10 +711,9 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
568
711
|
const s = spinner();
|
|
569
712
|
s.start('Fetching files from Google Drive...');
|
|
570
713
|
|
|
571
|
-
const listResult = shell.exec(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
);
|
|
714
|
+
const listResult = shell.exec(`gdrive list -q "'${folderId}' in parents" --no-header`, {
|
|
715
|
+
silent: true,
|
|
716
|
+
});
|
|
575
717
|
|
|
576
718
|
if (listResult.code !== 0) {
|
|
577
719
|
s.stop(color.red('Failed to fetch from Drive'));
|
|
@@ -590,7 +732,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
590
732
|
if (parts.length >= 2) {
|
|
591
733
|
const fileId = parts[0].trim();
|
|
592
734
|
const fileName = parts[1].trim();
|
|
593
|
-
if (matchPattern(fileName, pattern)) {
|
|
735
|
+
if (matchPattern(fileName, pattern, ignore)) {
|
|
594
736
|
driveFiles.push({ id: fileId, name: fileName });
|
|
595
737
|
}
|
|
596
738
|
}
|
|
@@ -606,7 +748,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
606
748
|
|
|
607
749
|
// Get local files
|
|
608
750
|
fs.ensureDirSync(envDir);
|
|
609
|
-
const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
|
|
751
|
+
const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
|
|
610
752
|
|
|
611
753
|
// Compare files
|
|
612
754
|
const changes = {
|
|
@@ -639,16 +781,16 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
639
781
|
}
|
|
640
782
|
|
|
641
783
|
const hasChanges =
|
|
642
|
-
changes.modified.length > 0 ||
|
|
643
|
-
changes.localOnly.length > 0 ||
|
|
644
|
-
changes.driveOnly.length > 0;
|
|
784
|
+
changes.modified.length > 0 || changes.localOnly.length > 0 || changes.driveOnly.length > 0;
|
|
645
785
|
|
|
646
786
|
if (!hasChanges) {
|
|
647
787
|
log.success('No changes detected. Local and Drive are in sync.');
|
|
648
788
|
return;
|
|
649
789
|
}
|
|
650
790
|
|
|
651
|
-
// Show diff
|
|
791
|
+
// Show diff (for upload: local is new (+), drive is old (-))
|
|
792
|
+
// (for download: drive is new (+), local is old (-))
|
|
793
|
+
const isUpload = action === 'upload';
|
|
652
794
|
console.log('');
|
|
653
795
|
log.info(color.bold('Changes Preview:'));
|
|
654
796
|
console.log('');
|
|
@@ -656,15 +798,18 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
656
798
|
for (const filename of changes.modified) {
|
|
657
799
|
const localFile = path.join(envDir, filename);
|
|
658
800
|
const driveFile = path.join(tempDir, filename);
|
|
659
|
-
|
|
801
|
+
// Swap order based on action: upload shows drive->local, download shows local->drive
|
|
802
|
+
const diffResult = isUpload
|
|
803
|
+
? shell.exec(`diff -u "${driveFile}" "${localFile}"`, { silent: true })
|
|
804
|
+
: shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
|
|
660
805
|
|
|
661
806
|
if (diffResult.stdout) {
|
|
662
807
|
const lines = diffResult.stdout.split('\n');
|
|
663
808
|
lines.forEach((line) => {
|
|
664
809
|
if (line.startsWith('---')) {
|
|
665
|
-
console.log(color.cyan(`--- ${filename} (Local)`));
|
|
810
|
+
console.log(color.cyan(`--- ${filename} (${isUpload ? 'Drive' : 'Local'})`));
|
|
666
811
|
} else if (line.startsWith('+++')) {
|
|
667
|
-
console.log(color.cyan(`+++ ${filename} (Drive)`));
|
|
812
|
+
console.log(color.cyan(`+++ ${filename} (${isUpload ? 'Local' : 'Drive'})`));
|
|
668
813
|
} else if (line.startsWith('@@')) {
|
|
669
814
|
console.log(color.cyan(line));
|
|
670
815
|
} else if (line.startsWith('-')) {
|
|
@@ -682,14 +827,14 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
682
827
|
for (const filename of changes.localOnly) {
|
|
683
828
|
console.log(color.cyan(`--- /dev/null`));
|
|
684
829
|
console.log(color.cyan(`+++ ${filename} (Local only)`));
|
|
685
|
-
console.log(color.
|
|
830
|
+
console.log(color.green(` [New local file - will be uploaded]`));
|
|
686
831
|
console.log('');
|
|
687
832
|
}
|
|
688
833
|
|
|
689
834
|
for (const filename of changes.driveOnly) {
|
|
690
835
|
console.log(color.cyan(`--- /dev/null`));
|
|
691
836
|
console.log(color.cyan(`+++ ${filename} (Drive only)`));
|
|
692
|
-
console.log(color.
|
|
837
|
+
console.log(color.green(` [New Drive file - will be downloaded]`));
|
|
693
838
|
console.log('');
|
|
694
839
|
}
|
|
695
840
|
|
|
@@ -723,7 +868,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
723
868
|
|
|
724
869
|
if (action === 'download') {
|
|
725
870
|
// Create backup first
|
|
726
|
-
const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
|
|
871
|
+
const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
|
|
727
872
|
if (existingFiles.length > 0) {
|
|
728
873
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
729
874
|
const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
|
|
@@ -751,7 +896,6 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
751
896
|
}
|
|
752
897
|
|
|
753
898
|
downloadSpinner.stop(color.green(`Downloaded ${downloaded} file(s)`));
|
|
754
|
-
|
|
755
899
|
} else if (action === 'upload') {
|
|
756
900
|
const uploadSpinner = spinner();
|
|
757
901
|
uploadSpinner.start('Uploading files...');
|
|
@@ -762,13 +906,17 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
762
906
|
for (const filename of changes.modified) {
|
|
763
907
|
const driveFile = driveFiles.find((f) => f.name === filename);
|
|
764
908
|
if (driveFile) {
|
|
765
|
-
shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, {
|
|
909
|
+
shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, {
|
|
910
|
+
silent: true,
|
|
911
|
+
});
|
|
766
912
|
replaced++;
|
|
767
913
|
}
|
|
768
914
|
}
|
|
769
915
|
|
|
770
916
|
for (const filename of changes.localOnly) {
|
|
771
|
-
shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, {
|
|
917
|
+
shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, {
|
|
918
|
+
silent: true,
|
|
919
|
+
});
|
|
772
920
|
uploaded++;
|
|
773
921
|
}
|
|
774
922
|
|
|
@@ -786,4 +934,7 @@ module.exports = {
|
|
|
786
934
|
envRun,
|
|
787
935
|
envShow,
|
|
788
936
|
envRemove,
|
|
937
|
+
// Exported for testing
|
|
938
|
+
globToRegex,
|
|
939
|
+
matchPattern,
|
|
789
940
|
};
|
|
@@ -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
|
+
});
|