gdrive-syncer 2.2.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 +101 -11
- package/package.json +1 -1
- package/run.js +10 -6
- package/src/envSync.js +649 -83
- package/src/gdriveCmd.js +301 -0
- package/src/list.js +20 -11
- package/src/sync.js +19 -4
- package/gdrive.config.json +0 -16
package/src/envSync.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const shell = require('shelljs');
|
|
4
4
|
const fs = require('fs-extra');
|
|
5
|
+
const gdrive = require('./gdriveCmd');
|
|
5
6
|
const path = require('path');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const { select, multiselect, text, isCancel, cancel, spinner, confirm, note, log } = require('@clack/prompts');
|
|
@@ -564,6 +565,168 @@ const envShow = async () => {
|
|
|
564
565
|
}
|
|
565
566
|
};
|
|
566
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
|
+
|
|
567
730
|
/**
|
|
568
731
|
* Convert a glob pattern to a regex pattern
|
|
569
732
|
* Supports:
|
|
@@ -739,6 +902,10 @@ const envRun = async (presetAction, presetConfigType) => {
|
|
|
739
902
|
log.error('No syncs in global config.');
|
|
740
903
|
return;
|
|
741
904
|
}
|
|
905
|
+
if (presetConfigType === 'registered' && registeredRefs.length === 0) {
|
|
906
|
+
log.error('No registered local configs found.');
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
742
909
|
configType = presetConfigType;
|
|
743
910
|
log.info(`Using ${configType} config`);
|
|
744
911
|
} else {
|
|
@@ -903,81 +1070,101 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
903
1070
|
fs.ensureDirSync(tempDir);
|
|
904
1071
|
|
|
905
1072
|
try {
|
|
906
|
-
// Fetch file list from Drive
|
|
1073
|
+
// Fetch file list from Drive (recursive, including folders for cleanup)
|
|
907
1074
|
const s = spinner();
|
|
908
|
-
s.start('Fetching files from Google Drive...');
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const driveFiles = [];
|
|
922
|
-
const stdout = listResult.stdout.trim();
|
|
923
|
-
if (stdout) {
|
|
924
|
-
stdout.split('\n').forEach((line) => {
|
|
925
|
-
// gdrive output format: ID NAME TYPE SIZE DATE
|
|
926
|
-
// Split by 2+ spaces to handle filenames with single spaces
|
|
927
|
-
const parts = line.trim().split(/\s{2,}/);
|
|
928
|
-
if (parts.length >= 2) {
|
|
929
|
-
const fileId = parts[0].trim();
|
|
930
|
-
const fileName = parts[1].trim();
|
|
931
|
-
if (matchPattern(fileName, pattern, ignore)) {
|
|
932
|
-
driveFiles.push({ id: fileId, name: fileName });
|
|
933
|
-
}
|
|
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,
|
|
935
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;
|
|
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
|
+
}
|
|
1027
1221
|
console.log('');
|
|
1028
1222
|
}
|
|
1029
1223
|
|
|
1030
|
-
for (const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
+
}
|
|
1235
|
+
console.log('');
|
|
1236
|
+
}
|
|
1237
|
+
|
|
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,24 +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
|
-
|
|
1106
|
-
silent: true,
|
|
1107
|
-
});
|
|
1348
|
+
gdrive.update(driveFile.id, path.join(envDir, relativePath));
|
|
1108
1349
|
replaced++;
|
|
1109
1350
|
}
|
|
1110
1351
|
}
|
|
1111
1352
|
|
|
1112
|
-
for (const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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 });
|
|
1116
1364
|
uploaded++;
|
|
1117
1365
|
}
|
|
1118
1366
|
|
|
1119
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
|
+
}
|
|
1120
1467
|
}
|
|
1121
1468
|
} catch (e) {
|
|
1122
1469
|
log.error(e.message);
|
|
@@ -1125,6 +1472,221 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
|
|
|
1125
1472
|
}
|
|
1126
1473
|
};
|
|
1127
1474
|
|
|
1475
|
+
/**
|
|
1476
|
+
* Migrate legacy syncs to Files Sync config
|
|
1477
|
+
*/
|
|
1478
|
+
const envMigrate = async () => {
|
|
1479
|
+
try {
|
|
1480
|
+
const { paths } = require('./config');
|
|
1481
|
+
const legacyConfigPath = paths.cfgFile;
|
|
1482
|
+
|
|
1483
|
+
// Check if legacy config exists
|
|
1484
|
+
if (!fs.existsSync(legacyConfigPath)) {
|
|
1485
|
+
log.error('No legacy config found at ~/.gdrive_syncer/gdrive.config.json');
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const legacyConfig = JSON.parse(fs.readFileSync(legacyConfigPath, 'utf-8'));
|
|
1490
|
+
const legacySyncs = legacyConfig.syncs || [];
|
|
1491
|
+
|
|
1492
|
+
if (legacySyncs.length === 0) {
|
|
1493
|
+
log.warn('No syncs found in legacy config.');
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Show legacy syncs for selection
|
|
1498
|
+
const syncOptions = legacySyncs.map((s) => ({
|
|
1499
|
+
value: s.name,
|
|
1500
|
+
label: s.name,
|
|
1501
|
+
hint: `${s.fullpath} → ${s.driveId.slice(0, 12)}...`,
|
|
1502
|
+
}));
|
|
1503
|
+
|
|
1504
|
+
const selectedNames = await multiselect({
|
|
1505
|
+
message: 'Select syncs to migrate (space to toggle, enter to confirm)',
|
|
1506
|
+
options: syncOptions,
|
|
1507
|
+
required: true,
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
if (isCancel(selectedNames)) {
|
|
1511
|
+
cancel('Migration cancelled.');
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const selectedSyncs = legacySyncs.filter((s) => selectedNames.includes(s.name));
|
|
1516
|
+
|
|
1517
|
+
// Process each selected sync
|
|
1518
|
+
for (const legacySync of selectedSyncs) {
|
|
1519
|
+
console.log('');
|
|
1520
|
+
note(
|
|
1521
|
+
`${color.cyan('Name:')} ${legacySync.name}\n${color.cyan('Path:')} ${legacySync.fullpath}\n${color.cyan('Drive ID:')} ${legacySync.driveId}`,
|
|
1522
|
+
`Migrating: ${legacySync.name}`
|
|
1523
|
+
);
|
|
1524
|
+
|
|
1525
|
+
// Ask: Local or Global?
|
|
1526
|
+
const destination = await select({
|
|
1527
|
+
message: `Where to migrate "${legacySync.name}"?`,
|
|
1528
|
+
options: [
|
|
1529
|
+
{ value: 'local', label: 'Local', hint: '.gdrive-sync.json in a project directory' },
|
|
1530
|
+
{ value: 'global', label: 'Global', hint: '~/.gdrive_syncer/env-sync.json' },
|
|
1531
|
+
],
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
if (isCancel(destination)) {
|
|
1535
|
+
cancel('Migration cancelled.');
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Get pattern
|
|
1540
|
+
const pattern = await text({
|
|
1541
|
+
message: 'File pattern to sync',
|
|
1542
|
+
placeholder: '*',
|
|
1543
|
+
defaultValue: '*',
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
if (isCancel(pattern)) {
|
|
1547
|
+
cancel('Migration cancelled.');
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
let configPath;
|
|
1552
|
+
let localDir;
|
|
1553
|
+
let projectRoot;
|
|
1554
|
+
|
|
1555
|
+
if (destination === 'local') {
|
|
1556
|
+
// Ask where to create/use local config
|
|
1557
|
+
const configLocation = await text({
|
|
1558
|
+
message: 'Project root for local config (where .gdrive-sync.json will be)',
|
|
1559
|
+
placeholder: process.cwd(),
|
|
1560
|
+
defaultValue: process.cwd(),
|
|
1561
|
+
validate: (v) => {
|
|
1562
|
+
if (!v.trim()) return 'Path is required';
|
|
1563
|
+
if (!fs.existsSync(v.trim())) return 'Directory does not exist';
|
|
1564
|
+
},
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
if (isCancel(configLocation)) {
|
|
1568
|
+
cancel('Migration cancelled.');
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
projectRoot = configLocation.trim();
|
|
1573
|
+
configPath = path.join(projectRoot, LOCAL_CONFIG_FILE);
|
|
1574
|
+
|
|
1575
|
+
// Calculate relative path
|
|
1576
|
+
const relativePath = path.relative(projectRoot, legacySync.fullpath);
|
|
1577
|
+
|
|
1578
|
+
// Check if path goes outside project
|
|
1579
|
+
if (relativePath.startsWith('..')) {
|
|
1580
|
+
log.warn(`Path "${legacySync.fullpath}" is outside the project root.`);
|
|
1581
|
+
log.info(`Relative path would be: ${relativePath}`);
|
|
1582
|
+
|
|
1583
|
+
const proceed = await confirm({
|
|
1584
|
+
message: 'Use this relative path anyway? (No = switch to global)',
|
|
1585
|
+
initialValue: false,
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
if (isCancel(proceed)) {
|
|
1589
|
+
cancel('Migration cancelled.');
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (!proceed) {
|
|
1594
|
+
// Switch to global
|
|
1595
|
+
log.info('Switching to global config...');
|
|
1596
|
+
configPath = GLOBAL_CONFIG_FILE;
|
|
1597
|
+
localDir = legacySync.fullpath; // Use absolute path for global
|
|
1598
|
+
projectRoot = null;
|
|
1599
|
+
} else {
|
|
1600
|
+
localDir = relativePath;
|
|
1601
|
+
}
|
|
1602
|
+
} else {
|
|
1603
|
+
localDir = relativePath;
|
|
1604
|
+
}
|
|
1605
|
+
} else {
|
|
1606
|
+
// Global config - use absolute path
|
|
1607
|
+
configPath = GLOBAL_CONFIG_FILE;
|
|
1608
|
+
localDir = legacySync.fullpath;
|
|
1609
|
+
projectRoot = null;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Preview the new sync entry
|
|
1613
|
+
const newSync = {
|
|
1614
|
+
name: legacySync.name,
|
|
1615
|
+
localDir: localDir,
|
|
1616
|
+
folderId: legacySync.driveId,
|
|
1617
|
+
pattern: pattern.trim(),
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
note(
|
|
1621
|
+
`${color.cyan('Name:')} ${newSync.name}\n${color.cyan('Local Dir:')} ${newSync.localDir}\n${color.cyan('Folder ID:')} ${newSync.folderId}\n${color.cyan('Pattern:')} ${newSync.pattern}\n${color.cyan('Config:')} ${configPath}`,
|
|
1622
|
+
'Preview'
|
|
1623
|
+
);
|
|
1624
|
+
|
|
1625
|
+
const confirmMigrate = await confirm({
|
|
1626
|
+
message: 'Add this sync to the config?',
|
|
1627
|
+
initialValue: true,
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
if (isCancel(confirmMigrate) || !confirmMigrate) {
|
|
1631
|
+
log.info(`Skipped "${legacySync.name}"`);
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Load or create config
|
|
1636
|
+
let config = { ...defaultConfig };
|
|
1637
|
+
if (fs.existsSync(configPath)) {
|
|
1638
|
+
config = loadConfig(configPath);
|
|
1639
|
+
}
|
|
1640
|
+
if (!config.syncs) {
|
|
1641
|
+
config.syncs = [];
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Check for duplicate name
|
|
1645
|
+
const existingIndex = config.syncs.findIndex((s) => s.name === newSync.name);
|
|
1646
|
+
if (existingIndex !== -1) {
|
|
1647
|
+
const overwrite = await confirm({
|
|
1648
|
+
message: `Sync "${newSync.name}" already exists in config. Overwrite?`,
|
|
1649
|
+
initialValue: false,
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
if (isCancel(overwrite)) {
|
|
1653
|
+
cancel('Migration cancelled.');
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (overwrite) {
|
|
1658
|
+
config.syncs[existingIndex] = newSync;
|
|
1659
|
+
} else {
|
|
1660
|
+
log.info(`Skipped "${legacySync.name}" (already exists)`);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
} else {
|
|
1664
|
+
config.syncs.push(newSync);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
saveConfig(configPath, config);
|
|
1668
|
+
log.success(`Migrated "${legacySync.name}" to ${configPath === GLOBAL_CONFIG_FILE ? 'global' : 'local'} config`);
|
|
1669
|
+
|
|
1670
|
+
// Ask if user wants to remove from legacy config
|
|
1671
|
+
const removeFromLegacy = await confirm({
|
|
1672
|
+
message: 'Remove from legacy config?',
|
|
1673
|
+
initialValue: false,
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
if (!isCancel(removeFromLegacy) && removeFromLegacy) {
|
|
1677
|
+
legacyConfig.syncs = legacyConfig.syncs.filter((s) => s.name !== legacySync.name);
|
|
1678
|
+
fs.writeFileSync(legacyConfigPath, JSON.stringify(legacyConfig, null, 2));
|
|
1679
|
+
log.success(`Removed "${legacySync.name}" from legacy config`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
console.log('');
|
|
1684
|
+
log.success('Migration complete!');
|
|
1685
|
+
} catch (e) {
|
|
1686
|
+
log.error(e.message);
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1128
1690
|
module.exports = {
|
|
1129
1691
|
envInit,
|
|
1130
1692
|
envRun,
|
|
@@ -1132,7 +1694,11 @@ module.exports = {
|
|
|
1132
1694
|
envRemove,
|
|
1133
1695
|
envRegister,
|
|
1134
1696
|
envUnregister,
|
|
1697
|
+
envMigrate,
|
|
1135
1698
|
// Exported for testing
|
|
1136
1699
|
globToRegex,
|
|
1137
1700
|
matchPattern,
|
|
1701
|
+
listDriveFilesRecursive,
|
|
1702
|
+
listLocalFilesRecursive,
|
|
1703
|
+
findOrCreateDriveFolder,
|
|
1138
1704
|
};
|