@aloth/olcli 0.7.0 → 0.9.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 CHANGED
@@ -103,6 +103,8 @@ olcli push
103
103
 
104
104
  ```bash
105
105
  olcli pdf
106
+ # Compile a specific .tex file (for multi-doc projects):
107
+ olcli pdf -r appendix.tex
106
108
  ```
107
109
 
108
110
  ### 5. Or use native git commands
@@ -128,12 +130,14 @@ All commands auto-detect the project when run from a synced directory (contains
128
130
  | `olcli list` | List all projects |
129
131
  | `olcli info [project]` | Show project details and file list |
130
132
  | `olcli pull [project] [dir]` | Download project files to local directory |
131
- | `olcli push [dir]` | Upload local changes to Overleaf |
133
+ | `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
132
134
  | `olcli sync [dir]` | Bidirectional sync (pull + push) |
133
- | `olcli upload <file> [project]` | Upload a single file |
135
+ | `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
134
136
  | `olcli download <file> [project]` | Download a single file |
135
137
  | `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
136
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`) |
137
141
  | `olcli compile [project]` | Trigger PDF compilation |
138
142
  | `olcli pdf [project]` | Compile and download PDF |
139
143
  | `olcli output [type]` | Download compile output files |
@@ -150,6 +154,16 @@ All commands auto-detect the project when run from a synced directory (contains
150
154
  | `olcli config set-timeout <ms>` | Set default HTTP timeout |
151
155
  | `olcli check` | Show config paths and credential sources |
152
156
 
157
+ ### Compile Options
158
+
159
+ The compile-related commands (`compile`, `pdf`, `output`) accept:
160
+
161
+ | Flag | Description |
162
+ |------|-------------|
163
+ | `-r, --resource <path>` | Compile a specific `.tex` file as the root document (e.g. `appendix.tex`, `folder/test.tex`) |
164
+
165
+ Useful in multi-doc projects: each `-r` run compiles the file as if it were the main document.
166
+
153
167
  ### Global Options
154
168
 
155
169
  | Flag | Description |
@@ -252,11 +266,18 @@ olcli sync && olcli pdf -o draft.pdf
252
266
  # Quick PDF download
253
267
  olcli pdf "Conference Paper" -o paper.pdf
254
268
 
269
+ # Compile a specific root document
270
+ olcli pdf "Conference Paper" -r appendix.tex -o appendix.pdf
271
+ olcli compile "Conference Paper" -r folder/test.tex
272
+
255
273
  # Upload figures
256
- olcli upload figures/diagram.png
274
+ olcli upload figures/diagram.png # relative path is preserved
275
+ olcli upload /tmp/build/diagram.png # absolute path lands in the project root
276
+ olcli upload /tmp/build/diagram.png --to figures/diagram.png # explicit destination
257
277
 
258
278
  # arXiv submission prep
259
279
  olcli output bbl -o main.bbl
280
+ olcli output bbl -r folder/test.tex -o main.bbl # compile artifacts from a specific root doc
260
281
  olcli zip -o arxiv-submission.zip
261
282
 
262
283
  # Backup all projects
package/dist/cli.js CHANGED
@@ -12,6 +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';
16
+ import { planProjectRenames } from './rename-plan.js';
15
17
  import { loadIgnore, shouldIgnore, buildTexSiblingSet, } from './ignore.js';
16
18
  // Read version from package.json
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -545,6 +547,7 @@ program
545
547
  .command('pdf [project]')
546
548
  .description('Compile and download PDF')
547
549
  .option('-o, --output <path>', 'Output path (default: <project-name>.pdf)')
550
+ .option('-r, --resource <path>', 'Compile this .tex file as root document (e.g. paper.tex, folder/test.tex)')
548
551
  .option('--cookie <session>', 'Session cookie override')
549
552
  .action(async (project, options) => {
550
553
  const spinner = ora('Compiling project...').start();
@@ -552,7 +555,7 @@ program
552
555
  const client = await getClient(options.cookie);
553
556
  const proj = await resolveProject(client, project);
554
557
  spinner.text = 'Compiling...';
555
- const pdf = await client.downloadPdf(proj.id);
558
+ const pdf = await client.downloadPdf(proj.id, undefined, options.resource);
556
559
  const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.pdf`;
557
560
  writeFileSync(outputPath, pdf);
558
561
  spinner.succeed(`Downloaded PDF: ${outputPath} (${(pdf.length / 1024).toFixed(1)} KB)`);
@@ -567,6 +570,7 @@ program
567
570
  .command('output [type]')
568
571
  .description('Download compile output files (bbl, log, aux, etc.)')
569
572
  .option('-o, --output <path>', 'Output path')
573
+ .option('-r, --resource <path>', 'Compile this .tex file as root document (e.g. paper.tex, folder/test.tex)')
570
574
  .option('--list', 'List available output files')
571
575
  .option('--project <name>', 'Project name or ID')
572
576
  .option('--cookie <session>', 'Session cookie override')
@@ -587,9 +591,9 @@ program
587
591
  }
588
592
  }
589
593
  const proj = await resolveProject(client, projectArg);
590
- const result = await client.compileWithOutputs(proj.id);
594
+ const result = await client.compileWithOutputs(proj.id, options.resource);
591
595
  if (result.status !== 'success') {
592
- spinner.warn(`Compilation ${result.status}, but output files may still be available`);
596
+ spinner.warn(`Compilation ${result.status}, but output files may still be available${result.failureHint ?? ''}`);
593
597
  }
594
598
  if (options.list || !actualType) {
595
599
  spinner.stop();
@@ -627,6 +631,7 @@ program
627
631
  program
628
632
  .command('upload <file> [project]')
629
633
  .description('Upload a file to a project')
634
+ .option('--to <path>', 'Destination path within the project (default: derived from <file>)')
630
635
  .option('--folder <id>', 'Target folder ID (default: root)')
631
636
  .option('--cookie <session>', 'Session cookie override')
632
637
  .action(async (file, project, options) => {
@@ -639,11 +644,11 @@ program
639
644
  process.exit(1);
640
645
  }
641
646
  const content = readFileSync(file);
642
- // Preserve the relative path (e.g. 'figures/fig01.png') so the file lands
643
- // in the correct subfolder, not in project root. uploadFile() will
644
- // lazy-resolve the folder tree when no folderId/tree is supplied.
645
- // Normalize: strip leading './' and any leading slashes.
646
- const fileName = file.replace(/^(\.\/)+/, '').replace(/^\/+/, '');
647
+ // Derive the remote path: an explicit --to wins, absolute paths collapse
648
+ // to their basename, relative paths keep their directory part so
649
+ // 'figures/fig01.png' still lands in the 'figures' folder. uploadFile()
650
+ // lazy-resolves the folder tree when no folderId/tree is supplied.
651
+ const fileName = resolveRemotePath(file, options.to);
647
652
  // Pass folder ID or null for root folder (client will compute it)
648
653
  const folderId = options.folder || null;
649
654
  const result = await client.uploadFile(proj.id, folderId, fileName, content);
@@ -705,18 +710,153 @@ program
705
710
  }
706
711
  });
707
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
+ // ─────────────────────────────────────────────────────────────────────────────
708
847
  // COMPILE COMMAND
709
848
  // ─────────────────────────────────────────────────────────────────────────────
710
849
  program
711
850
  .command('compile [project]')
712
851
  .description('Compile a project (trigger PDF generation)')
852
+ .option('-r, --resource <path>', 'Compile this .tex file as root document (e.g. paper.tex, folder/test.tex)')
713
853
  .option('--cookie <session>', 'Session cookie override')
714
854
  .action(async (project, options) => {
715
855
  const spinner = ora('Compiling...').start();
716
856
  try {
717
857
  const client = await getClient(options.cookie);
718
858
  const proj = await resolveProject(client, project);
719
- const result = await client.compileProject(proj.id);
859
+ const result = await client.compileProject(proj.id, options.resource);
720
860
  spinner.succeed(`Compiled "${proj.name}"`);
721
861
  console.log(chalk.dim(`PDF URL: ${result.pdfUrl}`));
722
862
  setLastProject(proj.id);
@@ -858,6 +998,7 @@ program
858
998
  .description('Upload local changes to Overleaf project')
859
999
  .option('--project <name>', 'Project name or ID (overrides .olcli.json)')
860
1000
  .option('--all', 'Upload all files (not just changed)')
1001
+ .option('--delete', 'Propagate local deletions to the remote (opt-in; see docs)')
861
1002
  .option('--dry-run', 'Show what would be uploaded without uploading')
862
1003
  .option('--probe-folder', 'Probe for correct folder ID (use if uploads fail with folder_not_found)')
863
1004
  .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
@@ -872,12 +1013,21 @@ program
872
1013
  let projectName;
873
1014
  let lastPull;
874
1015
  let rootFolderId;
1016
+ let previousPushManifest = [];
875
1017
  if (existsSync(metaPath)) {
876
1018
  const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
877
1019
  projectId = meta.projectId;
878
1020
  projectName = meta.projectName;
879
1021
  lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
880
1022
  rootFolderId = meta.rootFolderId;
1023
+ // Written by a previous push (pushManifest) or pull (remoteManifest).
1024
+ // Either is a valid baseline for "what did this directory last put there".
1025
+ if (Array.isArray(meta.pushManifest)) {
1026
+ previousPushManifest = meta.pushManifest;
1027
+ }
1028
+ else if (Array.isArray(meta.remoteManifest)) {
1029
+ previousPushManifest = meta.remoteManifest;
1030
+ }
881
1031
  }
882
1032
  if (options.project) {
883
1033
  // Override with command line option
@@ -915,6 +1065,10 @@ program
915
1065
  const { readdirSync, statSync } = await import('node:fs');
916
1066
  const filesToUpload = [];
917
1067
  const filesIgnored = [];
1068
+ // Every local file that survives ignore filtering, regardless of mtime.
1069
+ // filesToUpload is mtime-filtered and therefore useless as a deletion
1070
+ // baseline: an unchanged file would look "absent" and get deleted.
1071
+ const allLocalPaths = new Set();
918
1072
  function scanDir(currentDir, relativeBase = '') {
919
1073
  const entries = readdirSync(currentDir, { withFileTypes: true });
920
1074
  // Pre-compute sibling .tex set for the PDF special rule.
@@ -938,6 +1092,7 @@ program
938
1092
  filesIgnored.push(relativePath);
939
1093
  continue;
940
1094
  }
1095
+ allLocalPaths.add(relativePath);
941
1096
  // Check if file is newer than last pull (unless --all)
942
1097
  if (options.all || !lastPull) {
943
1098
  filesToUpload.push({ path: fullPath, relativePath });
@@ -960,15 +1115,46 @@ program
960
1115
  }
961
1116
  spinner.start('Scanning files...');
962
1117
  }
963
- if (filesToUpload.length === 0) {
1118
+ // Deletion candidates: tracked by a previous push/pull from THIS directory,
1119
+ // gone locally now. Never derived from the remote listing - that would also
1120
+ // sweep away files someone else uploaded through the Overleaf editor.
1121
+ //
1122
+ // Skipped entirely without a baseline manifest: on a first push we cannot
1123
+ // tell "deleted locally" from "never existed here", and guessing wrong
1124
+ // destroys remote work.
1125
+ const filesToDelete = [];
1126
+ if (options.delete && previousPushManifest.length > 0) {
1127
+ for (const path of previousPushManifest) {
1128
+ if (path === 'output.pdf' || path.endsWith('/output.pdf'))
1129
+ continue;
1130
+ if (!allLocalPaths.has(path))
1131
+ filesToDelete.push(path);
1132
+ }
1133
+ }
1134
+ const noBaseline = options.delete === true && previousPushManifest.length === 0;
1135
+ if (filesToUpload.length === 0 && filesToDelete.length === 0) {
964
1136
  spinner.info('No files to upload');
1137
+ if (noBaseline) {
1138
+ console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
1139
+ }
965
1140
  return;
966
1141
  }
967
1142
  if (options.dryRun) {
968
1143
  spinner.stop();
969
- console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
970
- for (const f of filesToUpload) {
971
- console.log(` ${chalk.cyan(f.relativePath)}`);
1144
+ if (filesToUpload.length > 0) {
1145
+ console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
1146
+ for (const f of filesToUpload) {
1147
+ console.log(` ${chalk.cyan(f.relativePath)}`);
1148
+ }
1149
+ }
1150
+ if (filesToDelete.length > 0) {
1151
+ console.log(chalk.bold(`Would delete ${filesToDelete.length} file(s) on "${projectName}":`));
1152
+ for (const p of filesToDelete) {
1153
+ console.log(` ${chalk.red(p)}`);
1154
+ }
1155
+ }
1156
+ if (noBaseline) {
1157
+ console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
972
1158
  }
973
1159
  return;
974
1160
  }
@@ -1019,10 +1205,29 @@ program
1019
1205
  }
1020
1206
  }
1021
1207
  }
1022
- // Update last push time
1208
+ // Deletions run after uploads: a rename arrives as add+remove, and doing
1209
+ // it in this order never leaves the remote without the file.
1210
+ let deleted = 0;
1211
+ const deleteSkipped = [];
1212
+ if (filesToDelete.length > 0) {
1213
+ spinner.text = `Deleting ${filesToDelete.length} remote file(s)...`;
1214
+ for (const path of filesToDelete) {
1215
+ try {
1216
+ await client.deleteByPath(projectId, path);
1217
+ deleted++;
1218
+ }
1219
+ catch (error) {
1220
+ // Already gone remotely is the common case and not an error worth
1221
+ // failing the push over.
1222
+ deleteSkipped.push({ path, reason: error.message || String(error) });
1223
+ }
1224
+ }
1225
+ }
1226
+ // Update last push time and the manifest that the next --delete reads.
1023
1227
  if (existsSync(metaPath)) {
1024
1228
  const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
1025
1229
  meta.lastPush = new Date().toISOString();
1230
+ meta.pushManifest = Array.from(allLocalPaths).sort();
1026
1231
  writeFileSync(metaPath, JSON.stringify(meta, null, 2));
1027
1232
  }
1028
1233
  if (failed > 0) {
@@ -1034,6 +1239,18 @@ program
1034
1239
  else {
1035
1240
  spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
1036
1241
  }
1242
+ if (deleted > 0) {
1243
+ console.log(chalk.dim(` ✖ ${deleted} deleted on remote`));
1244
+ }
1245
+ if (deleteSkipped.length > 0) {
1246
+ console.log(chalk.yellow(` ${deleteSkipped.length} deletion(s) skipped:`));
1247
+ for (const s of deleteSkipped) {
1248
+ console.log(chalk.dim(` ${s.path}: ${s.reason}`));
1249
+ }
1250
+ }
1251
+ if (noBaseline) {
1252
+ console.log(chalk.dim(' --delete skipped: no manifest yet; next push has a baseline'));
1253
+ }
1037
1254
  setLastProject(projectId);
1038
1255
  }
1039
1256
  catch (error) {