gdrive-syncer 3.0.0 → 3.1.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 +23 -3
- package/package.json +1 -1
- package/src/envSync.js +428 -74
package/Readme.md
CHANGED
|
@@ -216,15 +216,35 @@ Patterns support glob-style matching with the following features:
|
|
|
216
216
|
|
|
217
217
|
### Features
|
|
218
218
|
|
|
219
|
+
- **Nested folder support** - Recursively syncs files in subdirectories
|
|
219
220
|
- **Multiple sync folders** - Configure multiple sync pairs in one config
|
|
220
221
|
- **Local & Global configs** - Per-project or machine-wide configurations
|
|
221
|
-
- **File patterns** - Filter files by pattern
|
|
222
|
+
- **File patterns** - Filter files by pattern (applied to filenames, not paths)
|
|
222
223
|
- **Auto-discovery** - Finds local config in current or parent directories
|
|
223
|
-
- **Git-style diffs** - Colored diff output showing changes
|
|
224
|
-
- **Automatic backups** - Creates timestamped backups before download
|
|
224
|
+
- **Git-style diffs** - Colored diff output showing changes with full paths
|
|
225
|
+
- **Automatic backups** - Creates timestamped backups before download (preserves folder structure)
|
|
225
226
|
- **Two-way sync** - Upload local changes or download from Drive
|
|
227
|
+
- **Optional deletion** - Prompts to delete orphan files/folders after sync
|
|
226
228
|
- **Sync All** - Run operations on all syncs within selected config
|
|
227
229
|
- **Registered Local Configs** - Run sync on multiple projects from anywhere
|
|
230
|
+
- **Auto-create folders** - Creates missing folders on Drive during upload
|
|
231
|
+
|
|
232
|
+
### Sync Behavior
|
|
233
|
+
|
|
234
|
+
The sync compares files between local and Drive, showing:
|
|
235
|
+
|
|
236
|
+
| Status | Diff | Upload | Download |
|
|
237
|
+
|--------|------|--------|----------|
|
|
238
|
+
| **Modified** | Shows diff | Updates on Drive | Updates locally |
|
|
239
|
+
| **Local only** | Listed | Uploads to Drive | Prompts to delete |
|
|
240
|
+
| **Drive only** | Listed | Prompts to delete | Downloads locally |
|
|
241
|
+
| **Empty folders** | Listed | Prompts to delete | — |
|
|
242
|
+
|
|
243
|
+
**Delete prompts:**
|
|
244
|
+
- **Upload**: After uploading, prompts to delete files/folders on Drive that don't exist locally
|
|
245
|
+
- **Download**: After downloading, prompts to delete local files that don't exist on Drive (backup is created first)
|
|
246
|
+
|
|
247
|
+
This ensures you can keep Drive in sync with local (or vice versa) without accumulating orphan files.
|
|
228
248
|
|
|
229
249
|
### Registered Local Configs
|
|
230
250
|
|
package/package.json
CHANGED
package/src/envSync.js
CHANGED
|
@@ -565,6 +565,168 @@ const envShow = async () => {
|
|
|
565
565
|
}
|
|
566
566
|
};
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Recursively list files and folders from Google Drive
|
|
570
|
+
* @param {string} folderId - Root folder ID
|
|
571
|
+
* @param {string} pattern - File pattern to match
|
|
572
|
+
* @param {string} [ignore] - Ignore pattern
|
|
573
|
+
* @param {string} [prefix=''] - Path prefix for nested files
|
|
574
|
+
* @param {boolean} [includeFolders=false] - Whether to include folders in results
|
|
575
|
+
* @returns {Array<{id: string, name: string, relativePath: string, isFolder: boolean}>}
|
|
576
|
+
*/
|
|
577
|
+
const listDriveFilesRecursive = (folderId, pattern, ignore, prefix = '', includeFolders = false) => {
|
|
578
|
+
const results = [];
|
|
579
|
+
|
|
580
|
+
const listResult = gdrive.list({
|
|
581
|
+
query: `'${folderId}' in parents and trashed = false`,
|
|
582
|
+
noHeader: true,
|
|
583
|
+
max: 1000,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (listResult.code !== 0) {
|
|
587
|
+
return results;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const stdout = listResult.stdout.trim();
|
|
591
|
+
if (!stdout) return results;
|
|
592
|
+
|
|
593
|
+
const parsed = gdrive.parseListOutput(stdout);
|
|
594
|
+
|
|
595
|
+
for (const file of parsed) {
|
|
596
|
+
if (!file.id || !file.name) continue;
|
|
597
|
+
|
|
598
|
+
const relativePath = prefix ? `${prefix}/${file.name}` : file.name;
|
|
599
|
+
const isFolder = file.type && file.type.toLowerCase().includes('folder');
|
|
600
|
+
|
|
601
|
+
if (isFolder) {
|
|
602
|
+
// Add folder to results if requested
|
|
603
|
+
if (includeFolders) {
|
|
604
|
+
results.push({
|
|
605
|
+
id: file.id,
|
|
606
|
+
name: file.name,
|
|
607
|
+
relativePath,
|
|
608
|
+
isFolder: true,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// Recursively list folder contents
|
|
612
|
+
const subFiles = listDriveFilesRecursive(file.id, pattern, ignore, relativePath, includeFolders);
|
|
613
|
+
results.push(...subFiles);
|
|
614
|
+
} else {
|
|
615
|
+
// Only add files that match the pattern
|
|
616
|
+
if (matchPattern(file.name, pattern, ignore)) {
|
|
617
|
+
results.push({
|
|
618
|
+
id: file.id,
|
|
619
|
+
name: file.name,
|
|
620
|
+
relativePath,
|
|
621
|
+
isFolder: false,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return results;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Recursively list local files and folders
|
|
632
|
+
* @param {string} dir - Directory to list
|
|
633
|
+
* @param {string} pattern - File pattern to match
|
|
634
|
+
* @param {string} [ignore] - Ignore pattern
|
|
635
|
+
* @param {string} [prefix=''] - Path prefix for nested files
|
|
636
|
+
* @param {boolean} [includeFolders=false] - Whether to include folders in results
|
|
637
|
+
* @returns {Array<{name: string, relativePath: string, isFolder: boolean}>}
|
|
638
|
+
*/
|
|
639
|
+
const listLocalFilesRecursive = (dir, pattern, ignore, prefix = '', includeFolders = false) => {
|
|
640
|
+
const results = [];
|
|
641
|
+
|
|
642
|
+
if (!fs.existsSync(dir)) {
|
|
643
|
+
return results;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
647
|
+
|
|
648
|
+
for (const entry of entries) {
|
|
649
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
650
|
+
|
|
651
|
+
if (entry.isDirectory()) {
|
|
652
|
+
// Add folder to results if requested
|
|
653
|
+
if (includeFolders) {
|
|
654
|
+
results.push({
|
|
655
|
+
name: entry.name,
|
|
656
|
+
relativePath,
|
|
657
|
+
isFolder: true,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// Recursively list subdirectory
|
|
661
|
+
const subDir = path.join(dir, entry.name);
|
|
662
|
+
const subFiles = listLocalFilesRecursive(subDir, pattern, ignore, relativePath, includeFolders);
|
|
663
|
+
results.push(...subFiles);
|
|
664
|
+
} else if (entry.isFile()) {
|
|
665
|
+
// Only add files that match the pattern
|
|
666
|
+
if (matchPattern(entry.name, pattern, ignore)) {
|
|
667
|
+
results.push({
|
|
668
|
+
name: entry.name,
|
|
669
|
+
relativePath,
|
|
670
|
+
isFolder: false,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return results;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Find or create a folder in Google Drive by path
|
|
681
|
+
* @param {string} parentId - Parent folder ID
|
|
682
|
+
* @param {string} folderPath - Folder path like "a/b/c"
|
|
683
|
+
* @returns {string} - The folder ID of the deepest folder
|
|
684
|
+
*/
|
|
685
|
+
const findOrCreateDriveFolder = (parentId, folderPath) => {
|
|
686
|
+
const parts = folderPath.split('/').filter(Boolean);
|
|
687
|
+
let currentParentId = parentId;
|
|
688
|
+
|
|
689
|
+
for (const folderName of parts) {
|
|
690
|
+
// Check if folder exists
|
|
691
|
+
const listResult = gdrive.list({
|
|
692
|
+
query: `'${currentParentId}' in parents and name = '${folderName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
|
|
693
|
+
noHeader: true,
|
|
694
|
+
max: 1,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
let folderId = null;
|
|
698
|
+
|
|
699
|
+
if (listResult.code === 0 && listResult.stdout.trim()) {
|
|
700
|
+
const parsed = gdrive.parseListOutput(listResult.stdout.trim());
|
|
701
|
+
if (parsed.length > 0 && parsed[0].id) {
|
|
702
|
+
folderId = parsed[0].id;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (!folderId) {
|
|
707
|
+
// Create the folder
|
|
708
|
+
const mkdirResult = gdrive.mkdir(folderName, { parent: currentParentId });
|
|
709
|
+
if (mkdirResult.code === 0) {
|
|
710
|
+
// Parse the created folder ID from output
|
|
711
|
+
// gdrive@3 outputs: "Directory created: ID"
|
|
712
|
+
// gdrive@2 outputs: "Directory ID created"
|
|
713
|
+
const match = mkdirResult.stdout.match(/([a-zA-Z0-9_-]{20,})/);
|
|
714
|
+
if (match) {
|
|
715
|
+
folderId = match[1];
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (!folderId) {
|
|
721
|
+
throw new Error(`Failed to find or create folder: ${folderName}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
currentParentId = folderId;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return currentParentId;
|
|
728
|
+
};
|
|
729
|
+
|
|
568
730
|
/**
|
|
569
731
|
* Convert a glob pattern to a regex pattern
|
|
570
732
|
* Supports:
|
|
@@ -908,76 +1070,101 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
908
1070
|
fs.ensureDirSync(tempDir);
|
|
909
1071
|
|
|
910
1072
|
try {
|
|
911
|
-
// Fetch file list from Drive
|
|
1073
|
+
// Fetch file list from Drive (recursive, including folders for cleanup)
|
|
912
1074
|
const s = spinner();
|
|
913
|
-
s.start('Fetching files from Google Drive...');
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
for (const file of parsed) {
|
|
932
|
-
if (file.id && file.name && matchPattern(file.name, pattern, ignore)) {
|
|
933
|
-
driveFiles.push({ id: file.id, name: file.name });
|
|
934
|
-
}
|
|
1075
|
+
s.start('Fetching files from Google Drive (including nested folders)...');
|
|
1076
|
+
|
|
1077
|
+
const driveItems = listDriveFilesRecursive(folderId, pattern, ignore, '', true);
|
|
1078
|
+
const driveFiles = driveItems.filter((f) => !f.isFolder);
|
|
1079
|
+
const driveFolders = driveItems.filter((f) => f.isFolder);
|
|
1080
|
+
|
|
1081
|
+
if (driveFiles.length === 0) {
|
|
1082
|
+
// Check if the folder exists at all
|
|
1083
|
+
const testResult = gdrive.list({
|
|
1084
|
+
query: `'${folderId}' in parents and trashed = false`,
|
|
1085
|
+
noHeader: true,
|
|
1086
|
+
max: 1,
|
|
1087
|
+
});
|
|
1088
|
+
if (testResult.code !== 0) {
|
|
1089
|
+
s.stop(color.red('Failed to fetch from Drive'));
|
|
1090
|
+
log.error('Could not list files from Google Drive. Check folder ID and permissions.');
|
|
1091
|
+
if (testResult.stderr) log.error(testResult.stderr);
|
|
1092
|
+
return;
|
|
935
1093
|
}
|
|
936
1094
|
}
|
|
937
1095
|
|
|
938
|
-
// Download Drive files to temp for comparison
|
|
1096
|
+
// Download Drive files to temp for comparison (preserving folder structure)
|
|
939
1097
|
s.message('Downloading Drive files for comparison...');
|
|
940
1098
|
for (const file of driveFiles) {
|
|
941
|
-
|
|
1099
|
+
const destPath = path.join(tempDir, path.dirname(file.relativePath));
|
|
1100
|
+
fs.ensureDirSync(destPath);
|
|
1101
|
+
gdrive.download(file.id, { destination: destPath, overwrite: true });
|
|
942
1102
|
}
|
|
943
1103
|
s.stop(color.green(`Found ${driveFiles.length} matching file(s) on Drive`));
|
|
944
1104
|
|
|
945
|
-
// Get local files
|
|
1105
|
+
// Get local files (recursive, including folders for cleanup)
|
|
946
1106
|
fs.ensureDirSync(envDir);
|
|
947
|
-
const
|
|
1107
|
+
const localItems = listLocalFilesRecursive(envDir, pattern, ignore, '', true);
|
|
1108
|
+
const localFiles = localItems.filter((f) => !f.isFolder);
|
|
1109
|
+
const localFolders = localItems.filter((f) => f.isFolder);
|
|
948
1110
|
|
|
949
|
-
// Compare files
|
|
1111
|
+
// Compare files using relativePath
|
|
950
1112
|
const changes = {
|
|
951
1113
|
modified: [],
|
|
952
1114
|
localOnly: [],
|
|
953
1115
|
driveOnly: [],
|
|
1116
|
+
orphanDriveFolders: [], // Folders on Drive with no local equivalent
|
|
954
1117
|
};
|
|
955
1118
|
|
|
1119
|
+
// Build a set of drive file paths for quick lookup
|
|
1120
|
+
const driveFilePaths = new Set(driveFiles.map((f) => f.relativePath));
|
|
1121
|
+
const localFilePaths = new Set(localFiles.map((f) => f.relativePath));
|
|
1122
|
+
const localFolderPaths = new Set(localFolders.map((f) => f.relativePath));
|
|
1123
|
+
|
|
956
1124
|
// Check local files
|
|
957
|
-
for (const
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1125
|
+
for (const localFile of localFiles) {
|
|
1126
|
+
const localFilePath = path.join(envDir, localFile.relativePath);
|
|
1127
|
+
const driveFilePath = path.join(tempDir, localFile.relativePath);
|
|
1128
|
+
|
|
1129
|
+
if (driveFilePaths.has(localFile.relativePath)) {
|
|
1130
|
+
// File exists on both - check for modifications
|
|
1131
|
+
if (fs.existsSync(driveFilePath)) {
|
|
1132
|
+
const localContent = fs.readFileSync(localFilePath, 'utf-8');
|
|
1133
|
+
const driveContent = fs.readFileSync(driveFilePath, 'utf-8');
|
|
1134
|
+
if (localContent !== driveContent) {
|
|
1135
|
+
changes.modified.push(localFile.relativePath);
|
|
1136
|
+
}
|
|
966
1137
|
}
|
|
967
1138
|
} else {
|
|
968
|
-
changes.localOnly.push(
|
|
1139
|
+
changes.localOnly.push(localFile.relativePath);
|
|
969
1140
|
}
|
|
970
1141
|
}
|
|
971
1142
|
|
|
972
1143
|
// Check Drive-only files
|
|
973
|
-
for (const
|
|
974
|
-
if (!
|
|
975
|
-
changes.driveOnly.push(
|
|
1144
|
+
for (const driveFile of driveFiles) {
|
|
1145
|
+
if (!localFilePaths.has(driveFile.relativePath)) {
|
|
1146
|
+
changes.driveOnly.push(driveFile.relativePath);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Check for orphan Drive folders (folders on Drive with no local equivalent)
|
|
1151
|
+
for (const driveFolder of driveFolders) {
|
|
1152
|
+
if (!localFolderPaths.has(driveFolder.relativePath)) {
|
|
1153
|
+
// Only mark as orphan if no local files exist under this folder path
|
|
1154
|
+
const hasLocalFilesUnder = localFiles.some((f) =>
|
|
1155
|
+
f.relativePath.startsWith(driveFolder.relativePath + '/')
|
|
1156
|
+
);
|
|
1157
|
+
if (!hasLocalFilesUnder) {
|
|
1158
|
+
changes.orphanDriveFolders.push(driveFolder);
|
|
1159
|
+
}
|
|
976
1160
|
}
|
|
977
1161
|
}
|
|
978
1162
|
|
|
979
1163
|
const hasChanges =
|
|
980
|
-
changes.modified.length > 0 ||
|
|
1164
|
+
changes.modified.length > 0 ||
|
|
1165
|
+
changes.localOnly.length > 0 ||
|
|
1166
|
+
changes.driveOnly.length > 0 ||
|
|
1167
|
+
changes.orphanDriveFolders.length > 0;
|
|
981
1168
|
|
|
982
1169
|
if (!hasChanges) {
|
|
983
1170
|
log.success('No changes detected. Local and Drive are in sync.');
|
|
@@ -991,21 +1178,21 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
991
1178
|
log.info(color.bold('Changes Preview:'));
|
|
992
1179
|
console.log('');
|
|
993
1180
|
|
|
994
|
-
for (const
|
|
995
|
-
const
|
|
996
|
-
const
|
|
1181
|
+
for (const relativePath of changes.modified) {
|
|
1182
|
+
const localFilePath = path.join(envDir, relativePath);
|
|
1183
|
+
const driveFilePath = path.join(tempDir, relativePath);
|
|
997
1184
|
// Swap order based on action: upload shows drive->local, download shows local->drive
|
|
998
1185
|
const diffResult = isUpload
|
|
999
|
-
? shell.exec(`diff -u "${
|
|
1000
|
-
: shell.exec(`diff -u "${
|
|
1186
|
+
? shell.exec(`diff -u "${driveFilePath}" "${localFilePath}"`, { silent: true })
|
|
1187
|
+
: shell.exec(`diff -u "${localFilePath}" "${driveFilePath}"`, { silent: true });
|
|
1001
1188
|
|
|
1002
1189
|
if (diffResult.stdout) {
|
|
1003
1190
|
const lines = diffResult.stdout.split('\n');
|
|
1004
1191
|
lines.forEach((line) => {
|
|
1005
1192
|
if (line.startsWith('---')) {
|
|
1006
|
-
console.log(color.cyan(`--- ${
|
|
1193
|
+
console.log(color.cyan(`--- ${relativePath} (${isUpload ? 'Drive' : 'Local'})`));
|
|
1007
1194
|
} else if (line.startsWith('+++')) {
|
|
1008
|
-
console.log(color.cyan(`+++ ${
|
|
1195
|
+
console.log(color.cyan(`+++ ${relativePath} (${isUpload ? 'Local' : 'Drive'})`));
|
|
1009
1196
|
} else if (line.startsWith('@@')) {
|
|
1010
1197
|
console.log(color.cyan(line));
|
|
1011
1198
|
} else if (line.startsWith('-')) {
|
|
@@ -1020,30 +1207,71 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
1020
1207
|
}
|
|
1021
1208
|
}
|
|
1022
1209
|
|
|
1023
|
-
for (const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1210
|
+
for (const relativePath of changes.localOnly) {
|
|
1211
|
+
if (action === 'diff') {
|
|
1212
|
+
console.log(color.green(`+ ${relativePath}`));
|
|
1213
|
+
console.log(color.dim(` Local only`));
|
|
1214
|
+
} else if (isUpload) {
|
|
1215
|
+
console.log(color.green(`+ ${relativePath}`));
|
|
1216
|
+
console.log(color.dim(` Local only → will be uploaded to Drive`));
|
|
1217
|
+
} else {
|
|
1218
|
+
console.log(color.red(`- ${relativePath}`));
|
|
1219
|
+
console.log(color.dim(` Local only → can be deleted (not on Drive)`));
|
|
1220
|
+
}
|
|
1221
|
+
console.log('');
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
for (const relativePath of changes.driveOnly) {
|
|
1225
|
+
if (action === 'diff') {
|
|
1226
|
+
console.log(color.yellow(`+ ${relativePath}`));
|
|
1227
|
+
console.log(color.dim(` Drive only`));
|
|
1228
|
+
} else if (isUpload) {
|
|
1229
|
+
console.log(color.red(`- ${relativePath}`));
|
|
1230
|
+
console.log(color.dim(` Drive only → can be deleted from Drive (not local)`));
|
|
1231
|
+
} else {
|
|
1232
|
+
console.log(color.green(`+ ${relativePath}`));
|
|
1233
|
+
console.log(color.dim(` Drive only → will be downloaded to local`));
|
|
1234
|
+
}
|
|
1027
1235
|
console.log('');
|
|
1028
1236
|
}
|
|
1029
1237
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1238
|
+
// Show orphan Drive folders
|
|
1239
|
+
for (const folder of changes.orphanDriveFolders) {
|
|
1240
|
+
if (action === 'diff') {
|
|
1241
|
+
console.log(color.yellow(`📁 ${folder.relativePath}/`));
|
|
1242
|
+
console.log(color.dim(` Empty folder on Drive only`));
|
|
1243
|
+
} else if (isUpload) {
|
|
1244
|
+
console.log(color.red(`- 📁 ${folder.relativePath}/`));
|
|
1245
|
+
console.log(color.dim(` Empty folder on Drive → can be deleted`));
|
|
1246
|
+
}
|
|
1034
1247
|
console.log('');
|
|
1035
1248
|
}
|
|
1036
1249
|
|
|
1037
1250
|
// Summary
|
|
1038
1251
|
const summaryLines = [];
|
|
1039
1252
|
if (changes.modified.length > 0) {
|
|
1040
|
-
summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.
|
|
1253
|
+
summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.length} file(s)`);
|
|
1041
1254
|
}
|
|
1042
1255
|
if (changes.localOnly.length > 0) {
|
|
1043
|
-
|
|
1256
|
+
if (action === 'diff') {
|
|
1257
|
+
summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.length} file(s)`);
|
|
1258
|
+
} else if (isUpload) {
|
|
1259
|
+
summaryLines.push(`${color.green('To upload:')} ${changes.localOnly.length} file(s)`);
|
|
1260
|
+
} else {
|
|
1261
|
+
summaryLines.push(`${color.red('Local only (can delete):')} ${changes.localOnly.length} file(s)`);
|
|
1262
|
+
}
|
|
1044
1263
|
}
|
|
1045
1264
|
if (changes.driveOnly.length > 0) {
|
|
1046
|
-
|
|
1265
|
+
if (action === 'diff') {
|
|
1266
|
+
summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.length} file(s)`);
|
|
1267
|
+
} else if (isUpload) {
|
|
1268
|
+
summaryLines.push(`${color.red('Drive only (can delete):')} ${changes.driveOnly.length} file(s)`);
|
|
1269
|
+
} else {
|
|
1270
|
+
summaryLines.push(`${color.green('To download:')} ${changes.driveOnly.length} file(s)`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (changes.orphanDriveFolders.length > 0 && (action === 'diff' || isUpload)) {
|
|
1274
|
+
summaryLines.push(`${color.red('Empty folders on Drive:')} ${changes.orphanDriveFolders.length} folder(s)`);
|
|
1047
1275
|
}
|
|
1048
1276
|
note(summaryLines.join('\n'), 'Summary');
|
|
1049
1277
|
|
|
@@ -1063,15 +1291,18 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
1063
1291
|
}
|
|
1064
1292
|
|
|
1065
1293
|
if (action === 'download') {
|
|
1066
|
-
// Create backup first
|
|
1067
|
-
const
|
|
1068
|
-
if (
|
|
1294
|
+
// Create backup first (recursive)
|
|
1295
|
+
const existingLocalFiles = listLocalFilesRecursive(envDir, pattern, ignore);
|
|
1296
|
+
if (existingLocalFiles.length > 0) {
|
|
1069
1297
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
1070
1298
|
const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
|
|
1071
1299
|
fs.ensureDirSync(backupSubdir);
|
|
1072
1300
|
|
|
1073
|
-
for (const file of
|
|
1074
|
-
|
|
1301
|
+
for (const file of existingLocalFiles) {
|
|
1302
|
+
const srcPath = path.join(envDir, file.relativePath);
|
|
1303
|
+
const destPath = path.join(backupSubdir, file.relativePath);
|
|
1304
|
+
fs.ensureDirSync(path.dirname(destPath));
|
|
1305
|
+
fs.copyFileSync(srcPath, destPath);
|
|
1075
1306
|
}
|
|
1076
1307
|
log.info(`Backup created: ${backupSubdir}`);
|
|
1077
1308
|
}
|
|
@@ -1081,13 +1312,19 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
1081
1312
|
|
|
1082
1313
|
let downloaded = 0;
|
|
1083
1314
|
|
|
1084
|
-
for (const
|
|
1085
|
-
|
|
1315
|
+
for (const relativePath of changes.modified) {
|
|
1316
|
+
const srcPath = path.join(tempDir, relativePath);
|
|
1317
|
+
const destPath = path.join(envDir, relativePath);
|
|
1318
|
+
fs.ensureDirSync(path.dirname(destPath));
|
|
1319
|
+
fs.copyFileSync(srcPath, destPath);
|
|
1086
1320
|
downloaded++;
|
|
1087
1321
|
}
|
|
1088
1322
|
|
|
1089
|
-
for (const
|
|
1090
|
-
|
|
1323
|
+
for (const relativePath of changes.driveOnly) {
|
|
1324
|
+
const srcPath = path.join(tempDir, relativePath);
|
|
1325
|
+
const destPath = path.join(envDir, relativePath);
|
|
1326
|
+
fs.ensureDirSync(path.dirname(destPath));
|
|
1327
|
+
fs.copyFileSync(srcPath, destPath);
|
|
1091
1328
|
downloaded++;
|
|
1092
1329
|
}
|
|
1093
1330
|
|
|
@@ -1099,20 +1336,134 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
1099
1336
|
let uploaded = 0;
|
|
1100
1337
|
let replaced = 0;
|
|
1101
1338
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1339
|
+
// Build a map of relativePath -> driveFile for quick lookup
|
|
1340
|
+
const driveFileMap = new Map();
|
|
1341
|
+
for (const df of driveFiles) {
|
|
1342
|
+
driveFileMap.set(df.relativePath, df);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
for (const relativePath of changes.modified) {
|
|
1346
|
+
const driveFile = driveFileMap.get(relativePath);
|
|
1104
1347
|
if (driveFile) {
|
|
1105
|
-
gdrive.update(driveFile.id, path.join(envDir,
|
|
1348
|
+
gdrive.update(driveFile.id, path.join(envDir, relativePath));
|
|
1106
1349
|
replaced++;
|
|
1107
1350
|
}
|
|
1108
1351
|
}
|
|
1109
1352
|
|
|
1110
|
-
for (const
|
|
1111
|
-
|
|
1353
|
+
for (const relativePath of changes.localOnly) {
|
|
1354
|
+
const localFilePath = path.join(envDir, relativePath);
|
|
1355
|
+
const folderPath = path.dirname(relativePath);
|
|
1356
|
+
|
|
1357
|
+
// Find or create the parent folder on Drive
|
|
1358
|
+
let parentId = folderId;
|
|
1359
|
+
if (folderPath && folderPath !== '.') {
|
|
1360
|
+
parentId = findOrCreateDriveFolder(folderId, folderPath);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
gdrive.upload(localFilePath, { parent: parentId });
|
|
1112
1364
|
uploaded++;
|
|
1113
1365
|
}
|
|
1114
1366
|
|
|
1115
1367
|
uploadSpinner.stop(color.green(`Replaced: ${replaced}, New: ${uploaded}`));
|
|
1368
|
+
|
|
1369
|
+
// Ask about deleting Drive-only files and orphan folders
|
|
1370
|
+
const hasOrphanItems = changes.driveOnly.length > 0 || changes.orphanDriveFolders.length > 0;
|
|
1371
|
+
if (hasOrphanItems) {
|
|
1372
|
+
console.log('');
|
|
1373
|
+
const totalItems = changes.driveOnly.length + changes.orphanDriveFolders.length;
|
|
1374
|
+
log.warn(`${totalItems} item(s) exist on Drive but not locally:`);
|
|
1375
|
+
for (const relativePath of changes.driveOnly) {
|
|
1376
|
+
console.log(color.red(` - ${relativePath}`));
|
|
1377
|
+
}
|
|
1378
|
+
for (const folder of changes.orphanDriveFolders) {
|
|
1379
|
+
console.log(color.red(` - 📁 ${folder.relativePath}/`));
|
|
1380
|
+
}
|
|
1381
|
+
console.log('');
|
|
1382
|
+
|
|
1383
|
+
const deleteFromDrive = await confirm({
|
|
1384
|
+
message: `Delete these ${totalItems} item(s) from Drive?`,
|
|
1385
|
+
initialValue: false,
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
if (!isCancel(deleteFromDrive) && deleteFromDrive) {
|
|
1389
|
+
const deleteSpinner = spinner();
|
|
1390
|
+
deleteSpinner.start('Deleting items from Drive...');
|
|
1391
|
+
|
|
1392
|
+
let deletedFiles = 0;
|
|
1393
|
+
let deletedFolders = 0;
|
|
1394
|
+
|
|
1395
|
+
// Delete orphan files first
|
|
1396
|
+
for (const relativePath of changes.driveOnly) {
|
|
1397
|
+
const driveFile = driveFileMap.get(relativePath);
|
|
1398
|
+
if (driveFile) {
|
|
1399
|
+
gdrive.remove(driveFile.id);
|
|
1400
|
+
deletedFiles++;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Delete orphan folders (sort by depth, deepest first)
|
|
1405
|
+
const sortedFolders = [...changes.orphanDriveFolders].sort((a, b) =>
|
|
1406
|
+
b.relativePath.split('/').length - a.relativePath.split('/').length
|
|
1407
|
+
);
|
|
1408
|
+
|
|
1409
|
+
for (const folder of sortedFolders) {
|
|
1410
|
+
gdrive.remove(folder.id, { recursive: true });
|
|
1411
|
+
deletedFolders++;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const resultParts = [];
|
|
1415
|
+
if (deletedFiles > 0) resultParts.push(`${deletedFiles} file(s)`);
|
|
1416
|
+
if (deletedFolders > 0) resultParts.push(`${deletedFolders} folder(s)`);
|
|
1417
|
+
deleteSpinner.stop(color.green(`Deleted ${resultParts.join(' and ')} from Drive`));
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Ask about deleting local-only files during download
|
|
1423
|
+
if (action === 'download' && changes.localOnly.length > 0) {
|
|
1424
|
+
console.log('');
|
|
1425
|
+
log.warn(`${changes.localOnly.length} file(s) exist locally but not on Drive:`);
|
|
1426
|
+
for (const relativePath of changes.localOnly) {
|
|
1427
|
+
console.log(color.red(` - ${relativePath}`));
|
|
1428
|
+
}
|
|
1429
|
+
console.log('');
|
|
1430
|
+
log.info(color.dim('(These files are included in the backup)'));
|
|
1431
|
+
|
|
1432
|
+
const deleteLocal = await confirm({
|
|
1433
|
+
message: `Delete these ${changes.localOnly.length} local file(s)?`,
|
|
1434
|
+
initialValue: false,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
if (!isCancel(deleteLocal) && deleteLocal) {
|
|
1438
|
+
const deleteSpinner = spinner();
|
|
1439
|
+
deleteSpinner.start('Deleting local files...');
|
|
1440
|
+
|
|
1441
|
+
let deleted = 0;
|
|
1442
|
+
for (const relativePath of changes.localOnly) {
|
|
1443
|
+
const filePath = path.join(envDir, relativePath);
|
|
1444
|
+
if (fs.existsSync(filePath)) {
|
|
1445
|
+
fs.removeSync(filePath);
|
|
1446
|
+
deleted++;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Clean up empty directories
|
|
1451
|
+
const cleanEmptyDirs = (dir) => {
|
|
1452
|
+
if (!fs.existsSync(dir)) return;
|
|
1453
|
+
const entries = fs.readdirSync(dir);
|
|
1454
|
+
if (entries.length === 0 && dir !== envDir) {
|
|
1455
|
+
fs.rmdirSync(dir);
|
|
1456
|
+
cleanEmptyDirs(path.dirname(dir));
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
for (const relativePath of changes.localOnly) {
|
|
1461
|
+
const dirPath = path.dirname(path.join(envDir, relativePath));
|
|
1462
|
+
cleanEmptyDirs(dirPath);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
deleteSpinner.stop(color.green(`Deleted ${deleted} local file(s)`));
|
|
1466
|
+
}
|
|
1116
1467
|
}
|
|
1117
1468
|
} catch (e) {
|
|
1118
1469
|
log.error(e.message);
|
|
@@ -1347,4 +1698,7 @@ module.exports = {
|
|
|
1347
1698
|
// Exported for testing
|
|
1348
1699
|
globToRegex,
|
|
1349
1700
|
matchPattern,
|
|
1701
|
+
listDriveFilesRecursive,
|
|
1702
|
+
listLocalFilesRecursive,
|
|
1703
|
+
findOrCreateDriveFolder,
|
|
1350
1704
|
};
|