@aloth/olcli 0.9.0 → 0.10.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
@@ -20,6 +20,7 @@ Work with Overleaf projects directly from your command line. Edit locally with y
20
20
  - ⬇️ **Pull** project files to local directory for offline editing
21
21
  - ⬆️ **Push** local changes back to Overleaf
22
22
  - 🔄 **Sync** bidirectionally with smart conflict detection
23
+ - 🔍 **Diff** local files against the live remote before pushing
23
24
  - 🔀 **Git remote** — use Overleaf as a native git remote ([docs](docs/GIT-REMOTE.md))
24
25
  - ✌️ **Two-way deletions** — files removed locally are deleted on Overleaf on next sync
25
26
  - 🗑️ **Delete** and ✏️ **rename** remote files by path
@@ -132,6 +133,7 @@ All commands auto-detect the project when run from a synced directory (contains
132
133
  | `olcli pull [project] [dir]` | Download project files to local directory |
133
134
  | `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
134
135
  | `olcli sync [dir]` | Bidirectional sync (pull + push) |
136
+ | `olcli diff [project] [dir]` | Show content-level changes between local files and the remote |
135
137
  | `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
136
138
  | `olcli download <file> [project]` | Download a single file |
137
139
  | `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
@@ -150,8 +152,11 @@ All commands auto-detect the project when run from a synced directory (contains
150
152
  | `olcli comments delete <id>` | Delete a comment thread |
151
153
  | `olcli ignored [dir]` | List ignore patterns in effect |
152
154
  | `olcli config set-url <url>` | Set self-hosted base URL |
155
+ | `olcli config get-url` | Show the configured base URL |
153
156
  | `olcli config set-cookie-name <name>` | Set session cookie name |
157
+ | `olcli config get-cookie-name` | Show the configured session cookie name |
154
158
  | `olcli config set-timeout <ms>` | Set default HTTP timeout |
159
+ | `olcli config get-timeout` | Show the configured HTTP timeout |
155
160
  | `olcli check` | Show config paths and credential sources |
156
161
 
157
162
  ### Compile Options
@@ -192,6 +197,39 @@ Useful in multi-doc projects: each `-r` run compiles the file as if it were the
192
197
  - **Propagates local deletions** — use `--no-delete` to opt out
193
198
  - Use `--dry-run` to preview without applying
194
199
 
200
+ ### Diff
201
+
202
+ `olcli diff` compares the bytes of your local files against the project's
203
+ current contents and prints a unified diff.
204
+
205
+ ```bash
206
+ olcli diff # every changed file, as patches
207
+ olcli diff --name-only # just the changed paths
208
+ olcli diff --file main.tex # one file
209
+ olcli diff -U 8 # wider context
210
+ ```
211
+
212
+ **The remote side is fetched fresh on every run.** The diff describes the
213
+ project as it is at that moment — which is what a subsequent `push` would
214
+ overwrite — not a comparison against your last `pull`. `.olcli.json` records
215
+ remote *paths*, never remote *contents*, so there is no stored snapshot to
216
+ compare against; and the whole project arrives in a single request, the same
217
+ one `pull` makes, so fetching fresh costs one round trip rather than one per
218
+ file. A collaborator editing between `diff` and `push` can still change the
219
+ outcome, which is why the fetch time is printed.
220
+
221
+ In the output, `a/` is the remote and `b/` is local: a `+` line is content
222
+ `push` would upload, a `-` line is content it would overwrite. Files that
223
+ differ only in bytes that are not text (PDFs, images) are reported as
224
+ `Binary files ... differ`. Both sides pass through the same ignore layers, so
225
+ build artifacts sitting on Overleaf are not reported as locally deleted.
226
+
227
+ `diff --name-only` and `push --dry-run` answer different questions and will
228
+ disagree. `push --dry-run` lists files whose **modification time** is newer
229
+ than the last pull, because that is what `push` uploads; `diff` lists files
230
+ whose **contents** actually differ. A file you touched without editing appears
231
+ in the first and not the second.
232
+
195
233
  #### How deletion propagation works
196
234
 
197
235
  `olcli` records a manifest of remote files in `.olcli.json`. On next sync:
package/dist/cli.js CHANGED
@@ -12,9 +12,11 @@ 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, normalizeRemotePath } from './paths.js';
16
16
  import { planProjectRenames } from './rename-plan.js';
17
- import { loadIgnore, shouldIgnore, buildTexSiblingSet, } from './ignore.js';
17
+ import { scanLocalFiles } from './scan.js';
18
+ import { compareTrees, filterRemoteTree, renderFileDiff, statusLetter } from './diff.js';
19
+ import { loadIgnore } from './ignore.js';
18
20
  // Read version from package.json
19
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
22
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -87,7 +89,7 @@ async function resolveProject(client, projectArg, dir = '.') {
87
89
  return { id: projectArg, name: projectArg };
88
90
  }
89
91
  // Otherwise, look up by name
90
- let proj = await client.getProject(projectArg);
92
+ const proj = await client.getProject(projectArg);
91
93
  if (!proj) {
92
94
  throw new Error(`Project not found: ${projectArg}`);
93
95
  }
@@ -276,21 +278,6 @@ program
276
278
  process.exit(1);
277
279
  }
278
280
  });
279
- function printFolder(folder, indent) {
280
- // Print subfolders
281
- for (const f of folder.folders || []) {
282
- console.log(`${indent}📁 ${chalk.blue(f.name)}/`);
283
- printFolder(f, indent + ' ');
284
- }
285
- // Print docs
286
- for (const d of folder.docs || []) {
287
- console.log(`${indent}📄 ${d.name}`);
288
- }
289
- // Print files
290
- for (const f of folder.fileRefs || []) {
291
- console.log(`${indent}📎 ${f.name}`);
292
- }
293
- }
294
281
  const commentsCmd = program
295
282
  .command('comments')
296
283
  .description('View and manage Overleaf comments');
@@ -934,9 +921,15 @@ program
934
921
  let fileCount = 0;
935
922
  let skippedCount = 0;
936
923
  const skippedFiles = [];
924
+ const unsafeEntries = [];
937
925
  for (const entry of entries) {
938
926
  if (!entry.isDirectory) {
939
- const filePath = join(targetDir, entry.entryName);
927
+ const filePath = resolveWithin(targetDir, entry.entryName);
928
+ if (!filePath) {
929
+ // Entry would escape the target directory (zip-slip) - never extract
930
+ unsafeEntries.push(entry.entryName);
931
+ continue;
932
+ }
940
933
  const fileDir = dirname(filePath);
941
934
  // Check if local file exists and is newer than last pull
942
935
  if (!options.force && existsSync(filePath) && lastPull) {
@@ -949,7 +942,7 @@ program
949
942
  continue;
950
943
  }
951
944
  }
952
- catch (e) {
945
+ catch {
953
946
  // File doesn't exist or can't stat, proceed with download
954
947
  }
955
948
  }
@@ -960,11 +953,18 @@ program
960
953
  fileCount++;
961
954
  }
962
955
  }
956
+ if (unsafeEntries.length > 0) {
957
+ console.log(chalk.yellow(` Skipped ${unsafeEntries.length} unsafe archive entr${unsafeEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory):`));
958
+ for (const name of unsafeEntries.slice(0, 5)) {
959
+ console.log(chalk.dim(` ${name}`));
960
+ }
961
+ }
963
962
  // Save project metadata (with manifest of remote files for sync deletion tracking)
964
963
  const remoteManifest = [];
965
964
  for (const e of entries) {
966
- if (!e.isDirectory)
965
+ if (!e.isDirectory && resolveWithin(targetDir, e.entryName)) {
967
966
  remoteManifest.push(e.entryName);
967
+ }
968
968
  }
969
969
  writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
970
970
  projectId,
@@ -1062,51 +1062,19 @@ program
1062
1062
  disableAll: options.ignore === false,
1063
1063
  });
1064
1064
  // Get list of files to upload
1065
- const { readdirSync, statSync } = await import('node:fs');
1065
+ const { files: localFileList, ignored: filesIgnored } = scanLocalFiles(targetDir, ignoreCtx);
1066
1066
  const filesToUpload = [];
1067
- const filesIgnored = [];
1068
1067
  // Every local file that survives ignore filtering, regardless of mtime.
1069
1068
  // filesToUpload is mtime-filtered and therefore useless as a deletion
1070
1069
  // baseline: an unchanged file would look "absent" and get deleted.
1071
1070
  const allLocalPaths = new Set();
1072
- function scanDir(currentDir, relativeBase = '') {
1073
- const entries = readdirSync(currentDir, { withFileTypes: true });
1074
- // Pre-compute sibling .tex set for the PDF special rule.
1075
- const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
1076
- for (const entry of entries) {
1077
- // Skip hidden files and .olcli.json (always — predates ignore subsystem)
1078
- if (entry.name.startsWith('.'))
1079
- continue;
1080
- const fullPath = join(currentDir, entry.name);
1081
- const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
1082
- if (entry.isDirectory()) {
1083
- // Test directory ignore (gitignore semantics: trailing slash matches dir)
1084
- if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
1085
- filesIgnored.push(`${relativePath}/`);
1086
- continue;
1087
- }
1088
- scanDir(fullPath, relativePath);
1089
- }
1090
- else {
1091
- if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
1092
- filesIgnored.push(relativePath);
1093
- continue;
1094
- }
1095
- allLocalPaths.add(relativePath);
1096
- // Check if file is newer than last pull (unless --all)
1097
- if (options.all || !lastPull) {
1098
- filesToUpload.push({ path: fullPath, relativePath });
1099
- }
1100
- else {
1101
- const stats = statSync(fullPath);
1102
- if (stats.mtime > lastPull) {
1103
- filesToUpload.push({ path: fullPath, relativePath });
1104
- }
1105
- }
1106
- }
1071
+ for (const file of localFileList) {
1072
+ allLocalPaths.add(file.relativePath);
1073
+ // Check if file is newer than last pull (unless --all)
1074
+ if (options.all || !lastPull || file.mtime > lastPull) {
1075
+ filesToUpload.push({ path: file.path, relativePath: file.relativePath });
1107
1076
  }
1108
1077
  }
1109
- scanDir(targetDir);
1110
1078
  if (options.showIgnored && filesIgnored.length > 0) {
1111
1079
  spinner.stop();
1112
1080
  console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`)));
@@ -1156,6 +1124,12 @@ program
1156
1124
  if (noBaseline) {
1157
1125
  console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
1158
1126
  }
1127
+ // This list is mtime-based: a file touched but not edited is in it, and
1128
+ // a file whose bytes already match the remote is too. `olcli diff`
1129
+ // compares content instead.
1130
+ if (filesToUpload.length > 0) {
1131
+ console.log(chalk.dim(' selected by modification time — run `olcli diff` to see content changes'));
1132
+ }
1159
1133
  return;
1160
1134
  }
1161
1135
  // If --probe-folder is set, or if we don't have a cached rootFolderId, try probing
@@ -1321,41 +1295,17 @@ program
1321
1295
  });
1322
1296
  // Track local modifications
1323
1297
  const localFiles = new Map();
1324
- const filesIgnored = [];
1325
- const { readdirSync, statSync } = await import('node:fs');
1326
- function scanLocalFiles(currentDir, relativeBase = '') {
1327
- if (!existsSync(currentDir))
1328
- return;
1329
- const entries = readdirSync(currentDir, { withFileTypes: true });
1330
- const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
1331
- for (const entry of entries) {
1332
- if (entry.name.startsWith('.'))
1333
- continue;
1334
- const fullPath = join(currentDir, entry.name);
1335
- const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
1336
- if (entry.isDirectory()) {
1337
- if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
1338
- filesIgnored.push(`${relativePath}/`);
1339
- continue;
1340
- }
1341
- scanLocalFiles(fullPath, relativePath);
1342
- }
1343
- else {
1344
- if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
1345
- filesIgnored.push(relativePath);
1346
- continue;
1347
- }
1348
- const stats = statSync(fullPath);
1349
- localFiles.set(relativePath, {
1350
- mtime: stats.mtime,
1351
- content: readFileSync(fullPath)
1352
- });
1353
- }
1354
- }
1355
- }
1298
+ let filesIgnored = [];
1356
1299
  // Read local files before overwriting
1357
- if (existsSync(metaPath)) {
1358
- scanLocalFiles(targetDir);
1300
+ if (existsSync(targetDir) && existsSync(metaPath)) {
1301
+ const scan = scanLocalFiles(targetDir, ignoreCtx);
1302
+ filesIgnored = scan.ignored;
1303
+ for (const file of scan.files) {
1304
+ localFiles.set(file.relativePath, {
1305
+ mtime: file.mtime,
1306
+ content: readFileSync(file.path)
1307
+ });
1308
+ }
1359
1309
  }
1360
1310
  if (options.showIgnored && filesIgnored.length > 0) {
1361
1311
  spinner.stop();
@@ -1365,13 +1315,23 @@ program
1365
1315
  }
1366
1316
  spinner.start();
1367
1317
  }
1368
- // Extract remote files
1318
+ // Extract remote files (skipping entries that would escape the target
1319
+ // directory - zip-slip protection)
1369
1320
  const remoteFiles = new Map();
1321
+ const unsafeRemoteEntries = [];
1370
1322
  for (const entry of zip.getEntries()) {
1371
1323
  if (!entry.isDirectory) {
1324
+ if (!resolveWithin(targetDir, entry.entryName)) {
1325
+ unsafeRemoteEntries.push(entry.entryName);
1326
+ continue;
1327
+ }
1372
1328
  remoteFiles.set(entry.entryName, entry.getData());
1373
1329
  }
1374
1330
  }
1331
+ if (unsafeRemoteEntries.length > 0) {
1332
+ spinner.warn(`Skipped ${unsafeRemoteEntries.length} unsafe archive entr${unsafeRemoteEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory)`);
1333
+ spinner.start();
1334
+ }
1375
1335
  // Merge: local changes take precedence for files modified after last pull
1376
1336
  let lastPull;
1377
1337
  let previousManifest = [];
@@ -1427,7 +1387,9 @@ program
1427
1387
  spinner.text = 'Comparing files...';
1428
1388
  // Write remote files, but preserve local modifications
1429
1389
  for (const [path, remoteContent] of remoteFiles) {
1430
- const filePath = join(targetDir, path);
1390
+ const filePath = resolveWithin(targetDir, path);
1391
+ if (!filePath)
1392
+ continue; // already filtered above; defense in depth
1431
1393
  const fileDir = dirname(filePath);
1432
1394
  if (!existsSync(fileDir)) {
1433
1395
  mkdirSync(fileDir, { recursive: true });
@@ -1528,6 +1490,142 @@ program
1528
1490
  process.exit(1);
1529
1491
  }
1530
1492
  });
1493
+ program
1494
+ .command('diff [project] [dir]')
1495
+ .description('Show content-level differences between local files and the remote project')
1496
+ .option('--name-only', 'List changed paths instead of printing patches')
1497
+ .option('--file <path>', 'Diff a single file')
1498
+ .option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
1499
+ .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
1500
+ .option('--no-ignore', 'Disable all ignore filtering')
1501
+ .option('--cookie <session>', 'Session cookie override')
1502
+ .addHelpText('after', `
1503
+ The remote side is fetched fresh on every run, so the diff describes the
1504
+ project as it is right now - which is what a subsequent push would overwrite.
1505
+ It is not a comparison against the last pull. A collaborator editing between
1506
+ diff and push can still change the outcome; the fetch time is printed for that
1507
+ reason.`)
1508
+ .action(async (project, dir, options) => {
1509
+ const targetDir = dir || '.';
1510
+ if (!existsSync(targetDir)) {
1511
+ console.error(chalk.red(`Directory not found: ${targetDir}`));
1512
+ process.exit(1);
1513
+ }
1514
+ const spinner = ora('Connecting...').start();
1515
+ try {
1516
+ const client = await getClient(options.cookie);
1517
+ let resolved;
1518
+ try {
1519
+ resolved = await resolveProject(client, project, targetDir);
1520
+ }
1521
+ catch (error) {
1522
+ spinner.fail(error.message);
1523
+ console.error('Either run from a directory with .olcli.json or pass a project name/ID');
1524
+ process.exit(1);
1525
+ }
1526
+ const { id: projectId, name: projectName } = resolved;
1527
+ // The whole project arrives as one zip in a single request - the same
1528
+ // call pull and sync already make. Fetching per-file would mean one
1529
+ // request per file and could not tell us which files differ without
1530
+ // downloading them anyway.
1531
+ spinner.text = 'Fetching remote project...';
1532
+ const zipBuffer = await client.downloadProject(projectId);
1533
+ const fetchedAt = new Date();
1534
+ const AdmZip = (await import('adm-zip')).default;
1535
+ const zip = new AdmZip(zipBuffer);
1536
+ const ignoreCtx = loadIgnore(targetDir, {
1537
+ noDefaults: options.defaultIgnore === false,
1538
+ disableAll: options.ignore === false,
1539
+ });
1540
+ // Both sides go through the same filters; see filterRemoteTree.
1541
+ const remoteFiles = filterRemoteTree(zip.getEntries()
1542
+ .filter((e) => !e.isDirectory)
1543
+ .map((e) => ({ path: e.entryName, data: e.getData() })), ignoreCtx, (path) => resolveWithin(targetDir, path) !== null);
1544
+ const scan = scanLocalFiles(targetDir, ignoreCtx);
1545
+ const localFiles = new Map();
1546
+ for (const file of scan.files) {
1547
+ localFiles.set(file.relativePath, readFileSync(file.path));
1548
+ }
1549
+ let entries = compareTrees(localFiles, remoteFiles).filter((e) => e.status !== 'unchanged');
1550
+ if (options.file) {
1551
+ const wanted = normalizeRemotePath(options.file);
1552
+ entries = entries.filter((e) => e.path === wanted);
1553
+ if (entries.length === 0) {
1554
+ spinner.info(`No differences in ${wanted}`);
1555
+ if (!localFiles.has(wanted) && !remoteFiles.has(wanted)) {
1556
+ console.log(chalk.dim(' (file is on neither side, or is filtered by an ignore rule)'));
1557
+ }
1558
+ return;
1559
+ }
1560
+ }
1561
+ spinner.stop();
1562
+ if (entries.length === 0) {
1563
+ console.log(chalk.green(`No differences — local files match "${projectName}"`));
1564
+ console.log(chalk.dim(` remote fetched ${fetchedAt.toISOString()}`));
1565
+ return;
1566
+ }
1567
+ if (options.nameOnly) {
1568
+ for (const e of entries) {
1569
+ const colour = e.status === 'added' ? chalk.green
1570
+ : e.status === 'deleted' ? chalk.red
1571
+ : chalk.yellow;
1572
+ console.log(`${colour(statusLetter(e.status))} ${e.path}`);
1573
+ }
1574
+ }
1575
+ else {
1576
+ for (const e of entries) {
1577
+ const patch = renderFileDiff(e, localFiles.get(e.path), remoteFiles.get(e.path), { context: options.unified });
1578
+ patch.replace(/\n$/, '').split('\n').forEach((line, index) => {
1579
+ console.log(colourizeDiffLine(line, index, e.binary));
1580
+ });
1581
+ }
1582
+ }
1583
+ console.log();
1584
+ // With --file the summary would count only the one file asked for, which
1585
+ // reads as "this is all that differs". Report totals only for a full run.
1586
+ if (!options.file) {
1587
+ const counts = {
1588
+ added: entries.filter((e) => e.status === 'added').length,
1589
+ modified: entries.filter((e) => e.status === 'modified').length,
1590
+ deleted: entries.filter((e) => e.status === 'deleted').length,
1591
+ };
1592
+ console.log(chalk.bold(`${entries.length} file(s) differ from "${projectName}": ` +
1593
+ `${counts.added} added, ${counts.modified} modified, ${counts.deleted} remote-only`));
1594
+ if (counts.deleted > 0) {
1595
+ console.log(chalk.dim(' remote-only files are left alone by push; use push --delete to remove them'));
1596
+ }
1597
+ }
1598
+ console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
1599
+ setLastProject(projectId);
1600
+ }
1601
+ catch (error) {
1602
+ spinner.fail(`Failed: ${error.message}`);
1603
+ process.exit(1);
1604
+ }
1605
+ });
1606
+ /**
1607
+ * Colourize one line of a rendered patch. chalk already no-ops when stdout is
1608
+ * not a TTY, so this needs no flag of its own.
1609
+ *
1610
+ * The file headers are identified by position, not by prefix: a removed line
1611
+ * whose own content starts with `--` renders as `--- something` and would
1612
+ * otherwise be mistaken for the `---` header and shown as unchanged.
1613
+ */
1614
+ function colourizeDiffLine(line, index, binary) {
1615
+ if (index === 0)
1616
+ return chalk.bold(line); // our `diff --olcli` header
1617
+ if (binary)
1618
+ return chalk.magenta(line); // the single summary line
1619
+ if (index <= 2)
1620
+ return chalk.bold(line); // `---` / `+++`
1621
+ if (line.startsWith('@@'))
1622
+ return chalk.cyan(line);
1623
+ if (line.startsWith('+'))
1624
+ return chalk.green(line);
1625
+ if (line.startsWith('-'))
1626
+ return chalk.red(line);
1627
+ return line;
1628
+ }
1531
1629
  // ─────────────────────────────────────────────────────────────────────────────
1532
1630
  // HELP
1533
1631
  // ─────────────────────────────────────────────────────────────────────────────