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 +67 -1
- package/package.json +5 -3
- package/run.js +7 -1
- package/src/envSync.js +392 -48
- package/src/envSync.test.js +189 -0
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 (
|
|
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.
|
|
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": "
|
|
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,
|
|
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 (
|
|
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(
|
|
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
|
-
{
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
405
|
-
|
|
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) => ({
|
|
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) => ({
|
|
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
|
-
|
|
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
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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(
|
|
799
|
+
if (isCancel(selectedRefs)) {
|
|
486
800
|
cancel('Operation cancelled.');
|
|
487
801
|
return;
|
|
488
802
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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 =
|
|
537
|
-
|
|
538
|
-
|
|
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(
|
|
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
|
-
|
|
573
|
-
|
|
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)}"`, {
|
|
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}"`, {
|
|
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
|
+
});
|