@aloth/olcli 0.8.0 → 0.9.1

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 CHANGED
@@ -130,12 +130,14 @@ All commands auto-detect the project when run from a synced directory (contains
130
130
  | `olcli list` | List all projects |
131
131
  | `olcli info [project]` | Show project details and file list |
132
132
  | `olcli pull [project] [dir]` | Download project files to local directory |
133
- | `olcli push [dir]` | Upload local changes to Overleaf |
133
+ | `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
134
134
  | `olcli sync [dir]` | Bidirectional sync (pull + push) |
135
135
  | `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
136
136
  | `olcli download <file> [project]` | Download a single file |
137
137
  | `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
138
138
  | `olcli rename <old> <new> [project]` | Rename a remote file or folder (alias: `mv`) |
139
+ | `olcli project rename <new> [project]` | Rename the project itself (`--dry-run`) |
140
+ | `olcli project rename-bulk` | Rename many projects by pattern (dry-run unless `--apply`) |
139
141
  | `olcli compile [project]` | Trigger PDF compilation |
140
142
  | `olcli pdf [project]` | Compile and download PDF |
141
143
  | `olcli output [type]` | Download compile output files |
package/dist/cli.js CHANGED
@@ -12,7 +12,8 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
12
12
  import { join, dirname, basename } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { OverleafClient } from './client.js';
15
- import { resolveRemotePath } from './paths.js';
15
+ import { resolveRemotePath, resolveWithin } from './paths.js';
16
+ import { planProjectRenames } from './rename-plan.js';
16
17
  import { loadIgnore, shouldIgnore, buildTexSiblingSet, } from './ignore.js';
17
18
  // Read version from package.json
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -709,6 +710,140 @@ program
709
710
  }
710
711
  });
711
712
  // ─────────────────────────────────────────────────────────────────────────────
713
+ // PROJECT COMMANDS (rename the project itself, not files inside it)
714
+ // ─────────────────────────────────────────────────────────────────────────────
715
+ const projectCmd = program
716
+ .command('project')
717
+ .description('Operate on projects themselves (rename, bulk rename)');
718
+ projectCmd
719
+ .command('rename <newname> [project]')
720
+ .description('Rename a project')
721
+ .option('--dry-run', 'Show what would change without applying')
722
+ .option('--cookie <session>', 'Session cookie override')
723
+ .action(async (newname, project, options) => {
724
+ const spinner = ora('Renaming project...').start();
725
+ try {
726
+ const client = await getClient(options.cookie);
727
+ const proj = await resolveProject(client, project);
728
+ if (proj.name === newname) {
729
+ spinner.info(`Project is already named "${newname}"`);
730
+ return;
731
+ }
732
+ if (options.dryRun) {
733
+ spinner.stop();
734
+ console.log(chalk.bold('Would rename project:'));
735
+ console.log(` ${chalk.cyan(proj.name)} \u2192 ${chalk.cyan(newname)} ${chalk.dim(`(${proj.id})`)}`);
736
+ return;
737
+ }
738
+ await client.renameProject(proj.id, newname);
739
+ spinner.succeed(`Renamed project: ${proj.name} \u2192 ${newname}`);
740
+ setLastProject(proj.id);
741
+ }
742
+ catch (error) {
743
+ spinner.fail(`Failed: ${error.message}`);
744
+ process.exit(1);
745
+ }
746
+ });
747
+ projectCmd
748
+ .command('rename-bulk')
749
+ .description('Rename many projects at once by pattern (dry-run unless --apply)')
750
+ .option('--match <regex>', 'Only consider projects whose name matches this regex')
751
+ .option('--search <text>', 'Literal substring to replace in the name')
752
+ .option('--replace <text>', 'Replacement for --search or --match (supports $1, $2 backrefs)')
753
+ .option('--prefix <text>', 'Prepend this to the name')
754
+ .option('--suffix <text>', 'Append this to the name')
755
+ .option('--apply', 'Actually rename. Without this flag nothing is changed.')
756
+ .option('--max <n>', 'Refuse to apply if more than n projects would change', parseInt)
757
+ .option('--cookie <session>', 'Session cookie override')
758
+ .action(async (options) => {
759
+ // Inverted default on purpose: for a single project a dry-run flag is a
760
+ // convenience, but a bulk rename that fires on a typo is unrecoverable
761
+ // (Overleaf keeps no project-name history). So doing nothing is the
762
+ // default and --apply is the deliberate act.
763
+ const spinner = ora('Fetching projects...').start();
764
+ try {
765
+ const client = await getClient(options.cookie);
766
+ const projects = await client.listProjects();
767
+ let plan;
768
+ try {
769
+ plan = planProjectRenames(projects, {
770
+ match: options.match,
771
+ search: options.search,
772
+ replace: options.replace,
773
+ prefix: options.prefix,
774
+ suffix: options.suffix,
775
+ });
776
+ }
777
+ catch (err) {
778
+ spinner.fail(err.message);
779
+ process.exit(1);
780
+ }
781
+ const { planned, skipped, collisions } = plan;
782
+ spinner.stop();
783
+ if (planned.length === 0) {
784
+ console.log(chalk.yellow('No project names would change.'));
785
+ for (const s of skipped) {
786
+ console.log(chalk.dim(` skipped ${s.name}: ${s.reason}`));
787
+ }
788
+ return;
789
+ }
790
+ console.log(chalk.bold(`${planned.length} project(s) would be renamed:`));
791
+ for (const p of planned) {
792
+ console.log(` ${chalk.cyan(p.from)} ${chalk.dim('\u2192')} ${chalk.cyan(p.to)} ${chalk.dim(`(${p.id})`)}`);
793
+ }
794
+ for (const s of skipped) {
795
+ console.log(chalk.dim(` skipped ${s.name}: ${s.reason}`));
796
+ }
797
+ if (collisions.length > 0) {
798
+ console.log();
799
+ console.log(chalk.red(`Name collisions (${collisions.length}):`));
800
+ for (const c of collisions)
801
+ console.log(chalk.red(` ${c}`));
802
+ console.log(chalk.red('Refusing to apply. Overleaf allows duplicate names, so this would'));
803
+ console.log(chalk.red('succeed silently and leave projects you cannot tell apart.'));
804
+ process.exit(1);
805
+ }
806
+ if (options.max !== undefined && planned.length > options.max) {
807
+ console.log();
808
+ console.log(chalk.red(`Refusing to apply: ${planned.length} changes exceed --max ${options.max}.`));
809
+ process.exit(1);
810
+ }
811
+ if (!options.apply) {
812
+ console.log();
813
+ console.log(chalk.yellow('Dry run. Nothing was changed. Re-run with --apply to rename.'));
814
+ return;
815
+ }
816
+ const applySpinner = ora(`Renaming ${planned.length} project(s)...`).start();
817
+ let renamed = 0;
818
+ const failures = [];
819
+ for (const p of planned) {
820
+ try {
821
+ await client.renameProject(p.id, p.to);
822
+ renamed++;
823
+ applySpinner.text = `Renaming... (${renamed}/${planned.length})`;
824
+ }
825
+ catch (error) {
826
+ // Keep going: a partial rename is recoverable by re-running, while
827
+ // aborting midway leaves the same partial state plus no report.
828
+ failures.push({ from: p.from, reason: error.message || String(error) });
829
+ }
830
+ }
831
+ if (failures.length > 0) {
832
+ applySpinner.warn(`Renamed ${renamed} project(s), ${failures.length} failed`);
833
+ for (const f of failures) {
834
+ console.log(chalk.yellow(` ${f.from}: ${f.reason}`));
835
+ }
836
+ }
837
+ else {
838
+ applySpinner.succeed(`Renamed ${renamed} project(s)`);
839
+ }
840
+ }
841
+ catch (error) {
842
+ spinner.fail(`Failed: ${error.message}`);
843
+ process.exit(1);
844
+ }
845
+ });
846
+ // ─────────────────────────────────────────────────────────────────────────────
712
847
  // COMPILE COMMAND
713
848
  // ─────────────────────────────────────────────────────────────────────────────
714
849
  program
@@ -799,9 +934,15 @@ program
799
934
  let fileCount = 0;
800
935
  let skippedCount = 0;
801
936
  const skippedFiles = [];
937
+ const unsafeEntries = [];
802
938
  for (const entry of entries) {
803
939
  if (!entry.isDirectory) {
804
- const filePath = join(targetDir, entry.entryName);
940
+ const filePath = resolveWithin(targetDir, entry.entryName);
941
+ if (!filePath) {
942
+ // Entry would escape the target directory (zip-slip) - never extract
943
+ unsafeEntries.push(entry.entryName);
944
+ continue;
945
+ }
805
946
  const fileDir = dirname(filePath);
806
947
  // Check if local file exists and is newer than last pull
807
948
  if (!options.force && existsSync(filePath) && lastPull) {
@@ -825,11 +966,18 @@ program
825
966
  fileCount++;
826
967
  }
827
968
  }
969
+ if (unsafeEntries.length > 0) {
970
+ console.log(chalk.yellow(` Skipped ${unsafeEntries.length} unsafe archive entr${unsafeEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory):`));
971
+ for (const name of unsafeEntries.slice(0, 5)) {
972
+ console.log(chalk.dim(` ${name}`));
973
+ }
974
+ }
828
975
  // Save project metadata (with manifest of remote files for sync deletion tracking)
829
976
  const remoteManifest = [];
830
977
  for (const e of entries) {
831
- if (!e.isDirectory)
978
+ if (!e.isDirectory && resolveWithin(targetDir, e.entryName)) {
832
979
  remoteManifest.push(e.entryName);
980
+ }
833
981
  }
834
982
  writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
835
983
  projectId,
@@ -863,6 +1011,7 @@ program
863
1011
  .description('Upload local changes to Overleaf project')
864
1012
  .option('--project <name>', 'Project name or ID (overrides .olcli.json)')
865
1013
  .option('--all', 'Upload all files (not just changed)')
1014
+ .option('--delete', 'Propagate local deletions to the remote (opt-in; see docs)')
866
1015
  .option('--dry-run', 'Show what would be uploaded without uploading')
867
1016
  .option('--probe-folder', 'Probe for correct folder ID (use if uploads fail with folder_not_found)')
868
1017
  .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
@@ -877,12 +1026,21 @@ program
877
1026
  let projectName;
878
1027
  let lastPull;
879
1028
  let rootFolderId;
1029
+ let previousPushManifest = [];
880
1030
  if (existsSync(metaPath)) {
881
1031
  const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
882
1032
  projectId = meta.projectId;
883
1033
  projectName = meta.projectName;
884
1034
  lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
885
1035
  rootFolderId = meta.rootFolderId;
1036
+ // Written by a previous push (pushManifest) or pull (remoteManifest).
1037
+ // Either is a valid baseline for "what did this directory last put there".
1038
+ if (Array.isArray(meta.pushManifest)) {
1039
+ previousPushManifest = meta.pushManifest;
1040
+ }
1041
+ else if (Array.isArray(meta.remoteManifest)) {
1042
+ previousPushManifest = meta.remoteManifest;
1043
+ }
886
1044
  }
887
1045
  if (options.project) {
888
1046
  // Override with command line option
@@ -920,6 +1078,10 @@ program
920
1078
  const { readdirSync, statSync } = await import('node:fs');
921
1079
  const filesToUpload = [];
922
1080
  const filesIgnored = [];
1081
+ // Every local file that survives ignore filtering, regardless of mtime.
1082
+ // filesToUpload is mtime-filtered and therefore useless as a deletion
1083
+ // baseline: an unchanged file would look "absent" and get deleted.
1084
+ const allLocalPaths = new Set();
923
1085
  function scanDir(currentDir, relativeBase = '') {
924
1086
  const entries = readdirSync(currentDir, { withFileTypes: true });
925
1087
  // Pre-compute sibling .tex set for the PDF special rule.
@@ -943,6 +1105,7 @@ program
943
1105
  filesIgnored.push(relativePath);
944
1106
  continue;
945
1107
  }
1108
+ allLocalPaths.add(relativePath);
946
1109
  // Check if file is newer than last pull (unless --all)
947
1110
  if (options.all || !lastPull) {
948
1111
  filesToUpload.push({ path: fullPath, relativePath });
@@ -965,15 +1128,46 @@ program
965
1128
  }
966
1129
  spinner.start('Scanning files...');
967
1130
  }
968
- if (filesToUpload.length === 0) {
1131
+ // Deletion candidates: tracked by a previous push/pull from THIS directory,
1132
+ // gone locally now. Never derived from the remote listing - that would also
1133
+ // sweep away files someone else uploaded through the Overleaf editor.
1134
+ //
1135
+ // Skipped entirely without a baseline manifest: on a first push we cannot
1136
+ // tell "deleted locally" from "never existed here", and guessing wrong
1137
+ // destroys remote work.
1138
+ const filesToDelete = [];
1139
+ if (options.delete && previousPushManifest.length > 0) {
1140
+ for (const path of previousPushManifest) {
1141
+ if (path === 'output.pdf' || path.endsWith('/output.pdf'))
1142
+ continue;
1143
+ if (!allLocalPaths.has(path))
1144
+ filesToDelete.push(path);
1145
+ }
1146
+ }
1147
+ const noBaseline = options.delete === true && previousPushManifest.length === 0;
1148
+ if (filesToUpload.length === 0 && filesToDelete.length === 0) {
969
1149
  spinner.info('No files to upload');
1150
+ if (noBaseline) {
1151
+ console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
1152
+ }
970
1153
  return;
971
1154
  }
972
1155
  if (options.dryRun) {
973
1156
  spinner.stop();
974
- console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
975
- for (const f of filesToUpload) {
976
- console.log(` ${chalk.cyan(f.relativePath)}`);
1157
+ if (filesToUpload.length > 0) {
1158
+ console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
1159
+ for (const f of filesToUpload) {
1160
+ console.log(` ${chalk.cyan(f.relativePath)}`);
1161
+ }
1162
+ }
1163
+ if (filesToDelete.length > 0) {
1164
+ console.log(chalk.bold(`Would delete ${filesToDelete.length} file(s) on "${projectName}":`));
1165
+ for (const p of filesToDelete) {
1166
+ console.log(` ${chalk.red(p)}`);
1167
+ }
1168
+ }
1169
+ if (noBaseline) {
1170
+ console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
977
1171
  }
978
1172
  return;
979
1173
  }
@@ -1024,10 +1218,29 @@ program
1024
1218
  }
1025
1219
  }
1026
1220
  }
1027
- // Update last push time
1221
+ // Deletions run after uploads: a rename arrives as add+remove, and doing
1222
+ // it in this order never leaves the remote without the file.
1223
+ let deleted = 0;
1224
+ const deleteSkipped = [];
1225
+ if (filesToDelete.length > 0) {
1226
+ spinner.text = `Deleting ${filesToDelete.length} remote file(s)...`;
1227
+ for (const path of filesToDelete) {
1228
+ try {
1229
+ await client.deleteByPath(projectId, path);
1230
+ deleted++;
1231
+ }
1232
+ catch (error) {
1233
+ // Already gone remotely is the common case and not an error worth
1234
+ // failing the push over.
1235
+ deleteSkipped.push({ path, reason: error.message || String(error) });
1236
+ }
1237
+ }
1238
+ }
1239
+ // Update last push time and the manifest that the next --delete reads.
1028
1240
  if (existsSync(metaPath)) {
1029
1241
  const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
1030
1242
  meta.lastPush = new Date().toISOString();
1243
+ meta.pushManifest = Array.from(allLocalPaths).sort();
1031
1244
  writeFileSync(metaPath, JSON.stringify(meta, null, 2));
1032
1245
  }
1033
1246
  if (failed > 0) {
@@ -1039,6 +1252,18 @@ program
1039
1252
  else {
1040
1253
  spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
1041
1254
  }
1255
+ if (deleted > 0) {
1256
+ console.log(chalk.dim(` ✖ ${deleted} deleted on remote`));
1257
+ }
1258
+ if (deleteSkipped.length > 0) {
1259
+ console.log(chalk.yellow(` ${deleteSkipped.length} deletion(s) skipped:`));
1260
+ for (const s of deleteSkipped) {
1261
+ console.log(chalk.dim(` ${s.path}: ${s.reason}`));
1262
+ }
1263
+ }
1264
+ if (noBaseline) {
1265
+ console.log(chalk.dim(' --delete skipped: no manifest yet; next push has a baseline'));
1266
+ }
1042
1267
  setLastProject(projectId);
1043
1268
  }
1044
1269
  catch (error) {
@@ -1153,13 +1378,23 @@ program
1153
1378
  }
1154
1379
  spinner.start();
1155
1380
  }
1156
- // Extract remote files
1381
+ // Extract remote files (skipping entries that would escape the target
1382
+ // directory - zip-slip protection)
1157
1383
  const remoteFiles = new Map();
1384
+ const unsafeRemoteEntries = [];
1158
1385
  for (const entry of zip.getEntries()) {
1159
1386
  if (!entry.isDirectory) {
1387
+ if (!resolveWithin(targetDir, entry.entryName)) {
1388
+ unsafeRemoteEntries.push(entry.entryName);
1389
+ continue;
1390
+ }
1160
1391
  remoteFiles.set(entry.entryName, entry.getData());
1161
1392
  }
1162
1393
  }
1394
+ if (unsafeRemoteEntries.length > 0) {
1395
+ spinner.warn(`Skipped ${unsafeRemoteEntries.length} unsafe archive entr${unsafeRemoteEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory)`);
1396
+ spinner.start();
1397
+ }
1163
1398
  // Merge: local changes take precedence for files modified after last pull
1164
1399
  let lastPull;
1165
1400
  let previousManifest = [];
@@ -1215,7 +1450,9 @@ program
1215
1450
  spinner.text = 'Comparing files...';
1216
1451
  // Write remote files, but preserve local modifications
1217
1452
  for (const [path, remoteContent] of remoteFiles) {
1218
- const filePath = join(targetDir, path);
1453
+ const filePath = resolveWithin(targetDir, path);
1454
+ if (!filePath)
1455
+ continue; // already filtered above; defense in depth
1219
1456
  const fileDir = dirname(filePath);
1220
1457
  if (!existsSync(fileDir)) {
1221
1458
  mkdirSync(fileDir, { recursive: true });