@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 +61 -5
- package/dist/cli.js +228 -55
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +6 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +113 -3
- package/dist/client.js.map +1 -1
- package/dist/ignore.d.ts +85 -0
- package/dist/ignore.d.ts.map +1 -0
- package/dist/ignore.js +172 -0
- package/dist/ignore.js.map +1 -0
- package/package.json +2 -1
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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')
|