@aloth/olcli 0.9.1 → 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, resolveWithin } 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');
@@ -955,7 +942,7 @@ program
955
942
  continue;
956
943
  }
957
944
  }
958
- catch (e) {
945
+ catch {
959
946
  // File doesn't exist or can't stat, proceed with download
960
947
  }
961
948
  }
@@ -1075,51 +1062,19 @@ program
1075
1062
  disableAll: options.ignore === false,
1076
1063
  });
1077
1064
  // Get list of files to upload
1078
- const { readdirSync, statSync } = await import('node:fs');
1065
+ const { files: localFileList, ignored: filesIgnored } = scanLocalFiles(targetDir, ignoreCtx);
1079
1066
  const filesToUpload = [];
1080
- const filesIgnored = [];
1081
1067
  // Every local file that survives ignore filtering, regardless of mtime.
1082
1068
  // filesToUpload is mtime-filtered and therefore useless as a deletion
1083
1069
  // baseline: an unchanged file would look "absent" and get deleted.
1084
1070
  const allLocalPaths = new Set();
1085
- function scanDir(currentDir, relativeBase = '') {
1086
- const entries = readdirSync(currentDir, { withFileTypes: true });
1087
- // Pre-compute sibling .tex set for the PDF special rule.
1088
- const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
1089
- for (const entry of entries) {
1090
- // Skip hidden files and .olcli.json (always — predates ignore subsystem)
1091
- if (entry.name.startsWith('.'))
1092
- continue;
1093
- const fullPath = join(currentDir, entry.name);
1094
- const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
1095
- if (entry.isDirectory()) {
1096
- // Test directory ignore (gitignore semantics: trailing slash matches dir)
1097
- if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
1098
- filesIgnored.push(`${relativePath}/`);
1099
- continue;
1100
- }
1101
- scanDir(fullPath, relativePath);
1102
- }
1103
- else {
1104
- if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
1105
- filesIgnored.push(relativePath);
1106
- continue;
1107
- }
1108
- allLocalPaths.add(relativePath);
1109
- // Check if file is newer than last pull (unless --all)
1110
- if (options.all || !lastPull) {
1111
- filesToUpload.push({ path: fullPath, relativePath });
1112
- }
1113
- else {
1114
- const stats = statSync(fullPath);
1115
- if (stats.mtime > lastPull) {
1116
- filesToUpload.push({ path: fullPath, relativePath });
1117
- }
1118
- }
1119
- }
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 });
1120
1076
  }
1121
1077
  }
1122
- scanDir(targetDir);
1123
1078
  if (options.showIgnored && filesIgnored.length > 0) {
1124
1079
  spinner.stop();
1125
1080
  console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`)));
@@ -1169,6 +1124,12 @@ program
1169
1124
  if (noBaseline) {
1170
1125
  console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
1171
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
+ }
1172
1133
  return;
1173
1134
  }
1174
1135
  // If --probe-folder is set, or if we don't have a cached rootFolderId, try probing
@@ -1334,41 +1295,17 @@ program
1334
1295
  });
1335
1296
  // Track local modifications
1336
1297
  const localFiles = new Map();
1337
- const filesIgnored = [];
1338
- const { readdirSync, statSync } = await import('node:fs');
1339
- function scanLocalFiles(currentDir, relativeBase = '') {
1340
- if (!existsSync(currentDir))
1341
- return;
1342
- const entries = readdirSync(currentDir, { withFileTypes: true });
1343
- const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
1344
- for (const entry of entries) {
1345
- if (entry.name.startsWith('.'))
1346
- continue;
1347
- const fullPath = join(currentDir, entry.name);
1348
- const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
1349
- if (entry.isDirectory()) {
1350
- if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
1351
- filesIgnored.push(`${relativePath}/`);
1352
- continue;
1353
- }
1354
- scanLocalFiles(fullPath, relativePath);
1355
- }
1356
- else {
1357
- if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
1358
- filesIgnored.push(relativePath);
1359
- continue;
1360
- }
1361
- const stats = statSync(fullPath);
1362
- localFiles.set(relativePath, {
1363
- mtime: stats.mtime,
1364
- content: readFileSync(fullPath)
1365
- });
1366
- }
1367
- }
1368
- }
1298
+ let filesIgnored = [];
1369
1299
  // Read local files before overwriting
1370
- if (existsSync(metaPath)) {
1371
- 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
+ }
1372
1309
  }
1373
1310
  if (options.showIgnored && filesIgnored.length > 0) {
1374
1311
  spinner.stop();
@@ -1553,6 +1490,142 @@ program
1553
1490
  process.exit(1);
1554
1491
  }
1555
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
+ }
1556
1629
  // ─────────────────────────────────────────────────────────────────────────────
1557
1630
  // HELP
1558
1631
  // ─────────────────────────────────────────────────────────────────────────────