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/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 listResult = shell.exec(`gdrive list -q "'${folderId}' in parents" --no-header`, {
911
- silent: true,
912
- });
913
-
914
- if (listResult.code !== 0) {
915
- s.stop(color.red('Failed to fetch from Drive'));
916
- log.error('Could not list files from Google Drive. Check folder ID and permissions.');
917
- if (listResult.stderr) log.error(listResult.stderr);
918
- return;
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
- shell.exec(`gdrive download "${file.id}" --path "${tempDir}" --force`, { silent: true });
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 localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
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 filename of localFiles) {
958
- const localFile = path.join(envDir, filename);
959
- const driveFile = path.join(tempDir, filename);
960
-
961
- if (fs.existsSync(driveFile)) {
962
- const localContent = fs.readFileSync(localFile, 'utf-8');
963
- const driveContent = fs.readFileSync(driveFile, 'utf-8');
964
- if (localContent !== driveContent) {
965
- changes.modified.push(filename);
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(filename);
1139
+ changes.localOnly.push(localFile.relativePath);
969
1140
  }
970
1141
  }
971
1142
 
972
1143
  // Check Drive-only files
973
- for (const file of driveFiles) {
974
- if (!localFiles.includes(file.name)) {
975
- changes.driveOnly.push(file.name);
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 || changes.localOnly.length > 0 || changes.driveOnly.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 filename of changes.modified) {
995
- const localFile = path.join(envDir, filename);
996
- const driveFile = path.join(tempDir, filename);
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 "${driveFile}" "${localFile}"`, { silent: true })
1000
- : shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
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(`--- ${filename} (${isUpload ? 'Drive' : 'Local'})`));
1193
+ console.log(color.cyan(`--- ${relativePath} (${isUpload ? 'Drive' : 'Local'})`));
1007
1194
  } else if (line.startsWith('+++')) {
1008
- console.log(color.cyan(`+++ ${filename} (${isUpload ? 'Local' : 'Drive'})`));
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 filename of changes.localOnly) {
1024
- console.log(color.cyan(`--- /dev/null`));
1025
- console.log(color.cyan(`+++ ${filename} (Local only)`));
1026
- console.log(color.green(` [New local file - will be uploaded]`));
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 filename of changes.driveOnly) {
1031
- console.log(color.cyan(`--- /dev/null`));
1032
- console.log(color.cyan(`+++ ${filename} (Drive only)`));
1033
- console.log(color.green(` [New Drive file - will be downloaded]`));
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.join(', ')}`);
1253
+ summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.length} file(s)`);
1041
1254
  }
1042
1255
  if (changes.localOnly.length > 0) {
1043
- summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.join(', ')}`);
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
- summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.join(', ')}`);
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 existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
1068
- if (existingFiles.length > 0) {
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 existingFiles) {
1074
- fs.copyFileSync(path.join(envDir, file), path.join(backupSubdir, file));
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 filename of changes.modified) {
1085
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
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 filename of changes.driveOnly) {
1090
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
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
- for (const filename of changes.modified) {
1103
- const driveFile = driveFiles.find((f) => f.name === filename);
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
- shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, {
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 filename of changes.localOnly) {
1113
- shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, {
1114
- silent: true,
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
  };