@aloth/olcli 0.1.8 → 0.3.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
@@ -21,11 +21,13 @@ Work with Overleaf projects directly from your command line. Edit locally with y
21
21
  - ⬇️ **Pull** project files to local directory for offline editing
22
22
  - ⬆️ **Push** local changes back to Overleaf
23
23
  - 🔄 **Sync** bidirectionally with smart conflict detection
24
+ - ✌️ **Two-way deletions** — files removed locally are deleted on Overleaf on the next sync (opt out with `--no-delete`)
25
+ - 🗑️ **Delete** and ✏️ **rename** remote files by path
26
+ - 🚫 **Smart ignore** — LaTeX build artifacts (`.aux`, `.bbl`, `.log`, `.synctex.gz`, …) and OS noise are filtered out automatically; extend with `.olignore` (gitignore-style)
24
27
  - 📄 **Compile** PDFs using Overleaf's remote compiler
25
28
  - 📦 **Download** individual files or full project archives
26
29
  - 📤 **Upload** files to projects
27
30
  - 🗂️ **Preserve folder structure** when pushing nested files
28
- - 🧹 **Skip generated `output.pdf`** during push/sync so compiled artifacts are not uploaded back to Overleaf
29
31
  - ⚙️ **Support self-hosted Overleaf/ShareLaTeX instances** via configurable base URL and session cookie name
30
32
  - 📊 **Output** compile artifacts (`.bbl`, `.log`, `.aux` for arXiv submissions)
31
33
 
@@ -165,8 +167,9 @@ All commands auto-detect the project when run from a synced directory (contains
165
167
  | `olcli sync [dir]` | Bidirectional sync (pull + push) |
166
168
  | `olcli upload <file> [project]` | Upload a single file |
167
169
  | `olcli download <file> [project]` | Download a single file |
168
- | `olcli delete <file> [project]` | Delete a remote file by path |
169
- | `olcli rename <oldname> <newname> [project]` | Rename a remote file by path |
170
+ | `olcli delete <file> [project]` | Delete a remote file or folder by path (alias: `rm`) |
171
+ | `olcli rename <oldname> <newname> [project]` | Rename a remote file or folder by path (alias: `mv`) |
172
+ | `olcli ignored [dir]` | List ignore patterns currently in effect |
170
173
  | `olcli zip [project]` | Download project as zip archive |
171
174
  | `olcli compile [project]` | Trigger PDF compilation |
172
175
  | `olcli pdf [project]` | Compile and download PDF |
@@ -252,16 +255,69 @@ olcli output --list
252
255
  ### Push
253
256
  - Uploads files modified after last pull
254
257
  - Preserves nested folder structure when uploading
255
- - Skips generated `output.pdf`
258
+ - Filters out LaTeX build artifacts and OS noise (see [Ignoring files](#ignoring-files))
256
259
  - Use `--all` to upload all files
257
260
  - Use `--dry-run` to preview changes
261
+ - Use `--show-ignored` to see what was filtered out
258
262
 
259
263
  ### Sync
260
264
  - Pulls remote changes
261
265
  - Preserves local modifications (local wins if newer)
262
266
  - Pushes local changes to remote
263
- - Skips generated `output.pdf` during upload phase
267
+ - **Propagates local deletions to the remote** — if you delete a file locally, it's deleted on Overleaf on the next sync. Use `--no-delete` to opt out.
268
+ - Filters out LaTeX build artifacts and OS noise
264
269
  - Use `--verbose` to see detailed file operations
270
+ - Use `--dry-run` to preview without applying
271
+
272
+ #### How deletion propagation works
273
+
274
+ On every sync, `olcli` records a manifest of remote files in `.olcli.json`. The next sync compares the manifest against your local working tree:
275
+
276
+ - File missing locally **and** still present on remote → deleted on Overleaf
277
+ - File new locally → uploaded
278
+ - File modified locally after last pull → uploaded (local wins)
279
+ - File only on remote → downloaded
280
+
281
+ First-time syncs skip the deletion phase (no manifest exists yet to distinguish "never had it" from "deleted it").
282
+
283
+ ## Ignoring files
284
+
285
+ `olcli` automatically filters local files through a layered ignore list before uploading. This keeps LaTeX build artifacts (from local `pdflatex`/`latexmk` runs) and OS noise out of your Overleaf project.
286
+
287
+ ### Three layers
288
+
289
+ | Layer | File | Purpose |
290
+ |---|---|---|
291
+ | 1 | (built-in) | LaTeX intermediates (`.aux`, `.bbl`, `.log`, `.fls`, `.synctex.gz`, beamer/biber/glossaries/minted), OS noise (`.DS_Store`, `Thumbs.db`, `*.swp`), common build dirs (`build/`, `out/`, `_minted-*/`). Always on; opt out with `--no-default-ignore`. |
292
+ | 2 | `.olignore` | Project-level patterns, gitignore syntax. Commit alongside your `.tex` sources. |
293
+ | 3 | `.olignore.local` | Machine-specific patterns. Add to `.gitignore`. |
294
+
295
+ Later layers override earlier ones, just like git. Negation (`!important.aux`) is supported.
296
+
297
+ ### Special PDF rule
298
+
299
+ `X.pdf` is ignored only if a same-named `X.tex` (or `.ltx`) exists in the same folder. So `thesis.pdf` next to `thesis.tex` is filtered, but a hand-uploaded `figures/diagram.pdf` still syncs.
300
+
301
+ ### Example `.olignore`
302
+
303
+ ```gitignore
304
+ # Drafts that should never reach Overleaf
305
+ *.draft.tex
306
+ notes/
307
+ chapters/scratch/
308
+
309
+ # But keep this one auxiliary file
310
+ !important.aux
311
+ ```
312
+
313
+ ### Inspecting and overriding
314
+
315
+ ```bash
316
+ olcli ignored # list patterns currently in effect
317
+ olcli push --show-ignored # see what was skipped on this run
318
+ olcli sync --no-default-ignore # only .olignore applies
319
+ olcli sync --no-ignore # escape hatch — upload everything
320
+ ```
265
321
 
266
322
  ## Configuration
267
323
 
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ 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 { loadIgnore, shouldIgnore, buildTexSiblingSet, } from './ignore.js';
15
16
  // Read version from package.json
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -387,47 +388,49 @@ program
387
388
  process.exit(1);
388
389
  }
389
390
  });
390
- // NOTE: delete and rename commands are disabled - they require entity IDs
391
- // which are not exposed via the current Overleaf API without Socket.IO.
392
- // Use the Overleaf web UI for these operations.
393
- //
394
- // program
395
- // .command('delete <file> [project]')
396
- // .alias('rm')
397
- // .description('Delete a file from a project')
398
- // .option('--cookie <session>', 'Session cookie override')
399
- // .action(async (file, project, options) => {
400
- // const spinner = ora('Deleting file...').start();
401
- // try {
402
- // const client = await getClient(options.cookie);
403
- // const proj = await resolveProject(client, project);
404
- // await client.deleteByPath(proj.id, file);
405
- // spinner.succeed(`Deleted: ${file}`);
406
- // setLastProject(proj.id);
407
- // } catch (error: any) {
408
- // spinner.fail(`Failed: ${error.message}`);
409
- // process.exit(1);
410
- // }
411
- // });
412
- //
413
- // program
414
- // .command('rename <oldname> <newname> [project]')
415
- // .alias('mv')
416
- // .description('Rename a file in a project')
417
- // .option('--cookie <session>', 'Session cookie override')
418
- // .action(async (oldname, newname, project, options) => {
419
- // const spinner = ora('Renaming file...').start();
420
- // try {
421
- // const client = await getClient(options.cookie);
422
- // const proj = await resolveProject(client, project);
423
- // await client.renameByPath(proj.id, oldname, newname);
424
- // spinner.succeed(`Renamed: ${oldname} → ${newname}`);
425
- // setLastProject(proj.id);
426
- // } catch (error: any) {
427
- // spinner.fail(`Failed: ${error.message}`);
428
- // process.exit(1);
429
- // }
430
- // });
391
+ // ─────────────────────────────────────────────────────────────────────────────
392
+ // DELETE / RENAME COMMANDS
393
+ // ─────────────────────────────────────────────────────────────────────────────
394
+ // Use deleteByPath / renameByPath which resolve a path to an entity id via
395
+ // /project/<id>/entities, then call the documented delete/rename endpoints.
396
+ program
397
+ .command('delete <file> [project]')
398
+ .alias('rm')
399
+ .description('Delete a file or folder from a project')
400
+ .option('--cookie <session>', 'Session cookie override')
401
+ .action(async (file, project, options) => {
402
+ const spinner = ora('Deleting file...').start();
403
+ try {
404
+ const client = await getClient(options.cookie);
405
+ const proj = await resolveProject(client, project);
406
+ await client.deleteByPath(proj.id, file);
407
+ spinner.succeed(`Deleted: ${file}`);
408
+ setLastProject(proj.id);
409
+ }
410
+ catch (error) {
411
+ spinner.fail(`Failed: ${error.message}`);
412
+ process.exit(1);
413
+ }
414
+ });
415
+ program
416
+ .command('rename <oldname> <newname> [project]')
417
+ .alias('mv')
418
+ .description('Rename a file or folder in a project')
419
+ .option('--cookie <session>', 'Session cookie override')
420
+ .action(async (oldname, newname, project, options) => {
421
+ const spinner = ora('Renaming file...').start();
422
+ try {
423
+ const client = await getClient(options.cookie);
424
+ const proj = await resolveProject(client, project);
425
+ await client.renameByPath(proj.id, oldname, newname);
426
+ spinner.succeed(`Renamed: ${oldname} → ${newname}`);
427
+ setLastProject(proj.id);
428
+ }
429
+ catch (error) {
430
+ spinner.fail(`Failed: ${error.message}`);
431
+ process.exit(1);
432
+ }
433
+ });
431
434
  // ─────────────────────────────────────────────────────────────────────────────
432
435
  // COMPILE COMMAND
433
436
  // ─────────────────────────────────────────────────────────────────────────────
@@ -544,11 +547,17 @@ program
544
547
  fileCount++;
545
548
  }
546
549
  }
547
- // Save project metadata
550
+ // Save project metadata (with manifest of remote files for sync deletion tracking)
551
+ const remoteManifest = [];
552
+ for (const e of entries) {
553
+ if (!e.isDirectory)
554
+ remoteManifest.push(e.entryName);
555
+ }
548
556
  writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
549
557
  projectId,
550
558
  projectName,
551
- lastPull: new Date().toISOString()
559
+ lastPull: new Date().toISOString(),
560
+ remoteManifest
552
561
  }, null, 2));
553
562
  if (skippedCount > 0) {
554
563
  spinner.warn(`Downloaded ${fileCount} files, skipped ${skippedCount} locally modified files`);
@@ -578,6 +587,9 @@ program
578
587
  .option('--all', 'Upload all files (not just changed)')
579
588
  .option('--dry-run', 'Show what would be uploaded without uploading')
580
589
  .option('--probe-folder', 'Probe for correct folder ID (use if uploads fail with folder_not_found)')
590
+ .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
591
+ .option('--no-ignore', 'Disable all ignore filtering (escape hatch — uploads everything)')
592
+ .option('--show-ignored', 'Print files skipped by ignore rules')
581
593
  .option('--cookie <session>', 'Session cookie override')
582
594
  .action(async (dir, options) => {
583
595
  const targetDir = dir || '.';
@@ -621,23 +633,38 @@ program
621
633
  projectName = proj.name;
622
634
  }
623
635
  spinner.text = 'Scanning files...';
636
+ // Build ignore context (defaults + .olignore + .olignore.local)
637
+ const ignoreCtx = loadIgnore(targetDir, {
638
+ noDefaults: options.defaultIgnore === false,
639
+ disableAll: options.ignore === false,
640
+ });
624
641
  // Get list of files to upload
625
642
  const { readdirSync, statSync } = await import('node:fs');
626
643
  const filesToUpload = [];
644
+ const filesIgnored = [];
627
645
  function scanDir(currentDir, relativeBase = '') {
628
646
  const entries = readdirSync(currentDir, { withFileTypes: true });
647
+ // Pre-compute sibling .tex set for the PDF special rule.
648
+ const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
629
649
  for (const entry of entries) {
630
- // Skip hidden files and .olcli.json
650
+ // Skip hidden files and .olcli.json (always — predates ignore subsystem)
631
651
  if (entry.name.startsWith('.'))
632
652
  continue;
633
653
  const fullPath = join(currentDir, entry.name);
634
654
  const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
635
- if (!entry.isDirectory() && entry.name === 'output.pdf')
636
- continue;
637
655
  if (entry.isDirectory()) {
656
+ // Test directory ignore (gitignore semantics: trailing slash matches dir)
657
+ if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
658
+ filesIgnored.push(`${relativePath}/`);
659
+ continue;
660
+ }
638
661
  scanDir(fullPath, relativePath);
639
662
  }
640
663
  else {
664
+ if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
665
+ filesIgnored.push(relativePath);
666
+ continue;
667
+ }
641
668
  // Check if file is newer than last pull (unless --all)
642
669
  if (options.all || !lastPull) {
643
670
  filesToUpload.push({ path: fullPath, relativePath });
@@ -652,6 +679,14 @@ program
652
679
  }
653
680
  }
654
681
  scanDir(targetDir);
682
+ if (options.showIgnored && filesIgnored.length > 0) {
683
+ spinner.stop();
684
+ console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`)));
685
+ for (const p of filesIgnored) {
686
+ console.log(chalk.dim(` ${p}`));
687
+ }
688
+ spinner.start('Scanning files...');
689
+ }
655
690
  if (filesToUpload.length === 0) {
656
691
  spinner.info('No files to upload');
657
692
  return;
@@ -735,9 +770,14 @@ program
735
770
  });
736
771
  program
737
772
  .command('sync [dir]')
738
- .description('Pull then push (bidirectional sync)')
773
+ .description('Pull then push (bidirectional sync, propagates local deletions)')
739
774
  .option('--project <name>', 'Project name or ID')
740
775
  .option('--verbose', 'Show detailed file operations')
776
+ .option('--no-delete', 'Do not propagate local deletions to the remote (safer)')
777
+ .option('--dry-run', 'Show what would change without applying')
778
+ .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
779
+ .option('--no-ignore', 'Disable all ignore filtering (escape hatch — uploads everything)')
780
+ .option('--show-ignored', 'Print files skipped by ignore rules')
741
781
  .option('--cookie <session>', 'Session cookie override')
742
782
  .action(async (dir, options) => {
743
783
  const targetDir = dir || '.';
@@ -784,22 +824,37 @@ program
784
824
  if (!existsSync(targetDir)) {
785
825
  mkdirSync(targetDir, { recursive: true });
786
826
  }
827
+ // Build ignore context (defaults + .olignore + .olignore.local)
828
+ const ignoreCtx = loadIgnore(targetDir, {
829
+ noDefaults: options.defaultIgnore === false,
830
+ disableAll: options.ignore === false,
831
+ });
787
832
  // Track local modifications
788
833
  const localFiles = new Map();
834
+ const filesIgnored = [];
789
835
  const { readdirSync, statSync } = await import('node:fs');
790
836
  function scanLocalFiles(currentDir, relativeBase = '') {
791
837
  if (!existsSync(currentDir))
792
838
  return;
793
839
  const entries = readdirSync(currentDir, { withFileTypes: true });
840
+ const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
794
841
  for (const entry of entries) {
795
842
  if (entry.name.startsWith('.'))
796
843
  continue;
797
844
  const fullPath = join(currentDir, entry.name);
798
845
  const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
799
846
  if (entry.isDirectory()) {
847
+ if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
848
+ filesIgnored.push(`${relativePath}/`);
849
+ continue;
850
+ }
800
851
  scanLocalFiles(fullPath, relativePath);
801
852
  }
802
853
  else {
854
+ if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
855
+ filesIgnored.push(relativePath);
856
+ continue;
857
+ }
803
858
  const stats = statSync(fullPath);
804
859
  localFiles.set(relativePath, {
805
860
  mtime: stats.mtime,
@@ -812,6 +867,14 @@ program
812
867
  if (existsSync(metaPath)) {
813
868
  scanLocalFiles(targetDir);
814
869
  }
870
+ if (options.showIgnored && filesIgnored.length > 0) {
871
+ spinner.stop();
872
+ console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} local file(s)/dir(s):`)));
873
+ for (const p of filesIgnored) {
874
+ console.log(chalk.dim(` ${p}`));
875
+ }
876
+ spinner.start();
877
+ }
815
878
  // Extract remote files
816
879
  const remoteFiles = new Map();
817
880
  for (const entry of zip.getEntries()) {
@@ -821,14 +884,56 @@ program
821
884
  }
822
885
  // Merge: local changes take precedence for files modified after last pull
823
886
  let lastPull;
887
+ let previousManifest = [];
824
888
  if (existsSync(metaPath)) {
825
889
  const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
826
890
  lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
891
+ if (Array.isArray(meta.remoteManifest)) {
892
+ previousManifest = meta.remoteManifest;
893
+ }
827
894
  }
828
895
  const filesToUpload = [];
829
896
  const filesUpdatedLocally = [];
830
897
  const filesKeptLocal = [];
831
898
  const filesNewLocal = [];
899
+ const filesDeletedRemote = [];
900
+ const filesDeleteSkipped = [];
901
+ // Detect locally-deleted files: present in previous manifest, missing locally,
902
+ // still present on the remote. Propagate the deletion to the remote BEFORE
903
+ // we write remote contents back over the working tree (otherwise the file
904
+ // would be silently restored — the bug reported in #7).
905
+ // Conflict policy: if the project has no previous manifest yet (first sync),
906
+ // we cannot distinguish "never existed locally" from "deleted locally", so
907
+ // skip deletion propagation on the very first sync.
908
+ if (options.delete !== false && previousManifest.length > 0 && existsSync(metaPath)) {
909
+ const locallyDeleted = [];
910
+ for (const path of previousManifest) {
911
+ if (path === 'output.pdf' || path.endsWith('/output.pdf'))
912
+ continue;
913
+ if (!localFiles.has(path) && remoteFiles.has(path)) {
914
+ locallyDeleted.push(path);
915
+ }
916
+ }
917
+ if (locallyDeleted.length > 0) {
918
+ spinner.text = `Propagating ${locallyDeleted.length} local deletion(s) to remote...`;
919
+ for (const path of locallyDeleted) {
920
+ if (options.dryRun) {
921
+ filesDeletedRemote.push(path);
922
+ remoteFiles.delete(path);
923
+ continue;
924
+ }
925
+ try {
926
+ await client.deleteByPath(projectId, path);
927
+ filesDeletedRemote.push(path);
928
+ // Drop from remoteFiles so we don't re-extract it below
929
+ remoteFiles.delete(path);
930
+ }
931
+ catch (err) {
932
+ filesDeleteSkipped.push({ path, reason: err.message || String(err) });
933
+ }
934
+ }
935
+ }
936
+ }
832
937
  spinner.text = 'Comparing files...';
833
938
  // Write remote files, but preserve local modifications
834
939
  for (const [path, remoteContent] of remoteFiles) {
@@ -863,24 +968,56 @@ program
863
968
  }
864
969
  }
865
970
  // Upload local changes
866
- if (filesToUpload.length > 0) {
971
+ if (filesToUpload.length > 0 && !options.dryRun) {
867
972
  spinner.text = `Uploading ${filesToUpload.length} local change(s)...`;
868
973
  for (const file of filesToUpload) {
869
974
  await client.uploadFile(projectId, null, file.path, file.content);
870
975
  }
871
976
  }
977
+ // Refresh manifest of remote files post-sync (deletions out, new uploads in)
978
+ const newManifest = new Set(remoteFiles.keys());
979
+ for (const f of filesToUpload)
980
+ newManifest.add(f.path);
981
+ for (const p of filesDeletedRemote)
982
+ newManifest.delete(p);
872
983
  // Update metadata
873
- writeFileSync(metaPath, JSON.stringify({
874
- projectId,
875
- projectName,
876
- lastPull: new Date().toISOString(),
877
- lastSync: new Date().toISOString()
878
- }, null, 2));
879
- spinner.succeed(`Synced "${projectName}"`);
984
+ if (!options.dryRun) {
985
+ writeFileSync(metaPath, JSON.stringify({
986
+ projectId,
987
+ projectName,
988
+ lastPull: new Date().toISOString(),
989
+ lastSync: new Date().toISOString(),
990
+ remoteManifest: Array.from(newManifest).sort()
991
+ }, null, 2));
992
+ }
993
+ if (options.dryRun) {
994
+ spinner.succeed(`Dry-run sync "${projectName}" (no changes applied)`);
995
+ }
996
+ else {
997
+ spinner.succeed(`Synced "${projectName}"`);
998
+ }
880
999
  // Summary
881
1000
  console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`));
882
1001
  console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`));
1002
+ if (filesDeletedRemote.length > 0) {
1003
+ console.log(chalk.dim(` ✖ ${filesDeletedRemote.length} deleted on remote`));
1004
+ }
1005
+ if (filesDeleteSkipped.length > 0) {
1006
+ console.log(chalk.yellow(` ⚠ ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`));
1007
+ }
883
1008
  if (options.verbose) {
1009
+ if (filesDeletedRemote.length > 0) {
1010
+ console.log(chalk.red('\n Deleted on remote (matched local deletion):'));
1011
+ for (const f of filesDeletedRemote) {
1012
+ console.log(chalk.dim(` ${f}`));
1013
+ }
1014
+ }
1015
+ if (filesDeleteSkipped.length > 0) {
1016
+ console.log(chalk.yellow('\n Deletion skipped (will retry on next sync):'));
1017
+ for (const { path, reason } of filesDeleteSkipped) {
1018
+ console.log(chalk.dim(` ${path} — ${reason}`));
1019
+ }
1020
+ }
884
1021
  if (filesKeptLocal.length > 0) {
885
1022
  console.log(chalk.yellow('\n Local changes pushed (local was newer):'));
886
1023
  for (const f of filesKeptLocal) {
@@ -933,6 +1070,42 @@ configCmd
933
1070
  .action(() => {
934
1071
  console.log(getSessionCookieName());
935
1072
  });
1073
+ program
1074
+ .command('ignored [dir]')
1075
+ .description('Show ignore patterns currently in effect for a project directory')
1076
+ .option('--no-default-ignore', 'Exclude built-in defaults from the listing')
1077
+ .option('--no-ignore', 'Show what --no-ignore would do (lists nothing)')
1078
+ .action((dir, options) => {
1079
+ const targetDir = dir || '.';
1080
+ const ctx = loadIgnore(targetDir, {
1081
+ noDefaults: options.defaultIgnore === false,
1082
+ disableAll: options.ignore === false,
1083
+ });
1084
+ if (!ctx.enabled) {
1085
+ console.log(chalk.yellow('Ignore filtering is disabled (--no-ignore).'));
1086
+ console.log(chalk.dim('Every local file would be uploaded.'));
1087
+ return;
1088
+ }
1089
+ if (ctx.sources.length === 0) {
1090
+ console.log(chalk.yellow('No ignore patterns active.'));
1091
+ console.log(chalk.dim('Built-in defaults are disabled and no .olignore file was found.'));
1092
+ return;
1093
+ }
1094
+ console.log(chalk.bold(`Ignore patterns in effect for ${targetDir}:`));
1095
+ console.log(chalk.dim('(later sources override earlier ones; ! prefix negates)'));
1096
+ for (const src of ctx.sources) {
1097
+ console.log();
1098
+ console.log(chalk.cyan(`── ${src.label} (${src.patterns.length}) ──`));
1099
+ for (const p of src.patterns) {
1100
+ console.log(` ${p}`);
1101
+ }
1102
+ }
1103
+ console.log();
1104
+ console.log(chalk.dim(`Total: ${ctx.patterns.length} pattern(s)`));
1105
+ if (ctx.defaultsEnabled) {
1106
+ console.log(chalk.dim('Note: *.pdf is also ignored when a same-named *.tex/.ltx exists in the same folder.'));
1107
+ }
1108
+ });
936
1109
  program
937
1110
  .command('check')
938
1111
  .description('Show credential sources and config path')