@aloth/olcli 0.9.1 → 0.11.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
@@ -17,9 +17,11 @@ Work with Overleaf projects directly from your command line. Edit locally with y
17
17
  ## Features
18
18
 
19
19
  - 📋 **List** all your Overleaf projects
20
+ - ✨ **Create** blank or example projects
20
21
  - ⬇️ **Pull** project files to local directory for offline editing
21
22
  - ⬆️ **Push** local changes back to Overleaf
22
23
  - 🔄 **Sync** bidirectionally with smart conflict detection
24
+ - 🔍 **Diff** local files against the live remote before pushing
23
25
  - 🔀 **Git remote** — use Overleaf as a native git remote ([docs](docs/GIT-REMOTE.md))
24
26
  - ✌️ **Two-way deletions** — files removed locally are deleted on Overleaf on next sync
25
27
  - 🗑️ **Delete** and ✏️ **rename** remote files by path
@@ -132,10 +134,12 @@ All commands auto-detect the project when run from a synced directory (contains
132
134
  | `olcli pull [project] [dir]` | Download project files to local directory |
133
135
  | `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
134
136
  | `olcli sync [dir]` | Bidirectional sync (pull + push) |
137
+ | `olcli diff [project] [dir]` | Show content-level changes between local files and the remote |
135
138
  | `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
136
139
  | `olcli download <file> [project]` | Download a single file |
137
140
  | `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
138
141
  | `olcli rename <old> <new> [project]` | Rename a remote file or folder (alias: `mv`) |
142
+ | `olcli project create <name>` | Create a blank or example project (`--template blank\|example`) |
139
143
  | `olcli project rename <new> [project]` | Rename the project itself (`--dry-run`) |
140
144
  | `olcli project rename-bulk` | Rename many projects by pattern (dry-run unless `--apply`) |
141
145
  | `olcli compile [project]` | Trigger PDF compilation |
@@ -150,8 +154,11 @@ All commands auto-detect the project when run from a synced directory (contains
150
154
  | `olcli comments delete <id>` | Delete a comment thread |
151
155
  | `olcli ignored [dir]` | List ignore patterns in effect |
152
156
  | `olcli config set-url <url>` | Set self-hosted base URL |
157
+ | `olcli config get-url` | Show the configured base URL |
153
158
  | `olcli config set-cookie-name <name>` | Set session cookie name |
159
+ | `olcli config get-cookie-name` | Show the configured session cookie name |
154
160
  | `olcli config set-timeout <ms>` | Set default HTTP timeout |
161
+ | `olcli config get-timeout` | Show the configured HTTP timeout |
155
162
  | `olcli check` | Show config paths and credential sources |
156
163
 
157
164
  ### Compile Options
@@ -192,6 +199,39 @@ Useful in multi-doc projects: each `-r` run compiles the file as if it were the
192
199
  - **Propagates local deletions** — use `--no-delete` to opt out
193
200
  - Use `--dry-run` to preview without applying
194
201
 
202
+ ### Diff
203
+
204
+ `olcli diff` compares the bytes of your local files against the project's
205
+ current contents and prints a unified diff.
206
+
207
+ ```bash
208
+ olcli diff # every changed file, as patches
209
+ olcli diff --name-only # just the changed paths
210
+ olcli diff --file main.tex # one file
211
+ olcli diff -U 8 # wider context
212
+ ```
213
+
214
+ **The remote side is fetched fresh on every run.** The diff describes the
215
+ project as it is at that moment — which is what a subsequent `push` would
216
+ overwrite — not a comparison against your last `pull`. `.olcli.json` records
217
+ remote *paths*, never remote *contents*, so there is no stored snapshot to
218
+ compare against; and the whole project arrives in a single request, the same
219
+ one `pull` makes, so fetching fresh costs one round trip rather than one per
220
+ file. A collaborator editing between `diff` and `push` can still change the
221
+ outcome, which is why the fetch time is printed.
222
+
223
+ In the output, `a/` is the remote and `b/` is local: a `+` line is content
224
+ `push` would upload, a `-` line is content it would overwrite. Files that
225
+ differ only in bytes that are not text (PDFs, images) are reported as
226
+ `Binary files ... differ`. Both sides pass through the same ignore layers, so
227
+ build artifacts sitting on Overleaf are not reported as locally deleted.
228
+
229
+ `diff --name-only` and `push --dry-run` answer different questions and will
230
+ disagree. `push --dry-run` lists files whose **modification time** is newer
231
+ than the last pull, because that is what `push` uploads; `diff` lists files
232
+ whose **contents** actually differ. A file you touched without editing appears
233
+ in the first and not the second.
234
+
195
235
  #### How deletion propagation works
196
236
 
197
237
  `olcli` records a manifest of remote files in `.olcli.json`. On next sync:
@@ -234,7 +274,15 @@ Credentials are checked in order:
234
274
 
235
275
  1. `OVERLEAF_SESSION` environment variable
236
276
  2. `.olauth` file in current directory
237
- 3. Global config: `~/.config/olcli-nodejs/config.json`
277
+ 3. Global config — run `olcli check` to see the exact path
278
+
279
+ The global config path is platform-dependent (`conf` resolves it), so it is not
280
+ hardcoded here: on macOS it lands under `~/Library/Preferences/`, on Linux under
281
+ `~/.config/`. `olcli auth` prints the path it wrote to.
282
+
283
+ ⚠️ `olcli auth --save-local` writes `.olauth` into the **current directory**,
284
+ which is usually your LaTeX project. Add it to that project's `.gitignore`
285
+ before committing.
238
286
 
239
287
  ### Self-hosted Overleaf
240
288
 
@@ -303,6 +351,7 @@ import { OverleafClient } from '@aloth/olcli';
303
351
 
304
352
  const client = await OverleafClient.fromSessionCookie(cookie);
305
353
 
354
+ const created = await client.createProject('My Paper');
306
355
  const projects = await client.listProjects();
307
356
  const info = await client.getProjectInfo(projectId);
308
357
  const zipBuf = await client.downloadProject(projectId);
@@ -319,7 +368,8 @@ const comments = await client.listComments(projectId, { status: 'open' });
319
368
  import {
320
369
  OverleafClient,
321
370
  // Types
322
- Project, ProjectInfo, FolderEntry, DocEntry, FileEntry,
371
+ Project, ProjectInfo, ProjectTemplate, CreateProjectOptions, CreatedProject,
372
+ FolderEntry, DocEntry, FileEntry,
323
373
  CommentMessage, ProjectComment, CommentContext, CommentStatus,
324
374
  ListCommentsOptions, AddCommentOptions, Credentials, SessionCookiePair,
325
375
  // Config utilities
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');
@@ -714,7 +701,39 @@ program
714
701
  // ─────────────────────────────────────────────────────────────────────────────
715
702
  const projectCmd = program
716
703
  .command('project')
717
- .description('Operate on projects themselves (rename, bulk rename)');
704
+ .description('Operate on projects themselves (create, rename, bulk rename)');
705
+ projectCmd
706
+ .command('create <name>')
707
+ .description('Create a new project')
708
+ .option('-t, --template <type>', 'Project template: blank or example', 'blank')
709
+ .option('--json', 'Output as JSON')
710
+ .option('--cookie <session>', 'Session cookie override')
711
+ .action(async (name, options) => {
712
+ const template = options.template;
713
+ if (template !== 'blank' && template !== 'example') {
714
+ console.error(chalk.red(`Unsupported project template: ${template}`));
715
+ console.error('Supported templates: blank, example');
716
+ process.exit(1);
717
+ }
718
+ const spinner = ora('Creating project...').start();
719
+ try {
720
+ const client = await getClient(options.cookie);
721
+ const created = await client.createProject(name, { template });
722
+ setLastProject(created.id);
723
+ if (options.json) {
724
+ spinner.stop();
725
+ console.log(JSON.stringify(created, null, 2));
726
+ return;
727
+ }
728
+ spinner.succeed(`Created project: ${created.name}`);
729
+ console.log(` ${chalk.cyan(created.id)}`);
730
+ console.log(` ${chalk.cyan(created.url)}`);
731
+ }
732
+ catch (error) {
733
+ spinner.fail(`Failed: ${error.message}`);
734
+ process.exit(1);
735
+ }
736
+ });
718
737
  projectCmd
719
738
  .command('rename <newname> [project]')
720
739
  .description('Rename a project')
@@ -955,7 +974,7 @@ program
955
974
  continue;
956
975
  }
957
976
  }
958
- catch (e) {
977
+ catch {
959
978
  // File doesn't exist or can't stat, proceed with download
960
979
  }
961
980
  }
@@ -1075,51 +1094,19 @@ program
1075
1094
  disableAll: options.ignore === false,
1076
1095
  });
1077
1096
  // Get list of files to upload
1078
- const { readdirSync, statSync } = await import('node:fs');
1097
+ const { files: localFileList, ignored: filesIgnored } = scanLocalFiles(targetDir, ignoreCtx);
1079
1098
  const filesToUpload = [];
1080
- const filesIgnored = [];
1081
1099
  // Every local file that survives ignore filtering, regardless of mtime.
1082
1100
  // filesToUpload is mtime-filtered and therefore useless as a deletion
1083
1101
  // baseline: an unchanged file would look "absent" and get deleted.
1084
1102
  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
- }
1103
+ for (const file of localFileList) {
1104
+ allLocalPaths.add(file.relativePath);
1105
+ // Check if file is newer than last pull (unless --all)
1106
+ if (options.all || !lastPull || file.mtime > lastPull) {
1107
+ filesToUpload.push({ path: file.path, relativePath: file.relativePath });
1120
1108
  }
1121
1109
  }
1122
- scanDir(targetDir);
1123
1110
  if (options.showIgnored && filesIgnored.length > 0) {
1124
1111
  spinner.stop();
1125
1112
  console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`)));
@@ -1169,6 +1156,12 @@ program
1169
1156
  if (noBaseline) {
1170
1157
  console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
1171
1158
  }
1159
+ // This list is mtime-based: a file touched but not edited is in it, and
1160
+ // a file whose bytes already match the remote is too. `olcli diff`
1161
+ // compares content instead.
1162
+ if (filesToUpload.length > 0) {
1163
+ console.log(chalk.dim(' selected by modification time — run `olcli diff` to see content changes'));
1164
+ }
1172
1165
  return;
1173
1166
  }
1174
1167
  // If --probe-folder is set, or if we don't have a cached rootFolderId, try probing
@@ -1334,41 +1327,17 @@ program
1334
1327
  });
1335
1328
  // Track local modifications
1336
1329
  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
- }
1330
+ let filesIgnored = [];
1369
1331
  // Read local files before overwriting
1370
- if (existsSync(metaPath)) {
1371
- scanLocalFiles(targetDir);
1332
+ if (existsSync(targetDir) && existsSync(metaPath)) {
1333
+ const scan = scanLocalFiles(targetDir, ignoreCtx);
1334
+ filesIgnored = scan.ignored;
1335
+ for (const file of scan.files) {
1336
+ localFiles.set(file.relativePath, {
1337
+ mtime: file.mtime,
1338
+ content: readFileSync(file.path)
1339
+ });
1340
+ }
1372
1341
  }
1373
1342
  if (options.showIgnored && filesIgnored.length > 0) {
1374
1343
  spinner.stop();
@@ -1553,6 +1522,142 @@ program
1553
1522
  process.exit(1);
1554
1523
  }
1555
1524
  });
1525
+ program
1526
+ .command('diff [project] [dir]')
1527
+ .description('Show content-level differences between local files and the remote project')
1528
+ .option('--name-only', 'List changed paths instead of printing patches')
1529
+ .option('--file <path>', 'Diff a single file')
1530
+ .option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
1531
+ .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
1532
+ .option('--no-ignore', 'Disable all ignore filtering')
1533
+ .option('--cookie <session>', 'Session cookie override')
1534
+ .addHelpText('after', `
1535
+ The remote side is fetched fresh on every run, so the diff describes the
1536
+ project as it is right now - which is what a subsequent push would overwrite.
1537
+ It is not a comparison against the last pull. A collaborator editing between
1538
+ diff and push can still change the outcome; the fetch time is printed for that
1539
+ reason.`)
1540
+ .action(async (project, dir, options) => {
1541
+ const targetDir = dir || '.';
1542
+ if (!existsSync(targetDir)) {
1543
+ console.error(chalk.red(`Directory not found: ${targetDir}`));
1544
+ process.exit(1);
1545
+ }
1546
+ const spinner = ora('Connecting...').start();
1547
+ try {
1548
+ const client = await getClient(options.cookie);
1549
+ let resolved;
1550
+ try {
1551
+ resolved = await resolveProject(client, project, targetDir);
1552
+ }
1553
+ catch (error) {
1554
+ spinner.fail(error.message);
1555
+ console.error('Either run from a directory with .olcli.json or pass a project name/ID');
1556
+ process.exit(1);
1557
+ }
1558
+ const { id: projectId, name: projectName } = resolved;
1559
+ // The whole project arrives as one zip in a single request - the same
1560
+ // call pull and sync already make. Fetching per-file would mean one
1561
+ // request per file and could not tell us which files differ without
1562
+ // downloading them anyway.
1563
+ spinner.text = 'Fetching remote project...';
1564
+ const zipBuffer = await client.downloadProject(projectId);
1565
+ const fetchedAt = new Date();
1566
+ const AdmZip = (await import('adm-zip')).default;
1567
+ const zip = new AdmZip(zipBuffer);
1568
+ const ignoreCtx = loadIgnore(targetDir, {
1569
+ noDefaults: options.defaultIgnore === false,
1570
+ disableAll: options.ignore === false,
1571
+ });
1572
+ // Both sides go through the same filters; see filterRemoteTree.
1573
+ const remoteFiles = filterRemoteTree(zip.getEntries()
1574
+ .filter((e) => !e.isDirectory)
1575
+ .map((e) => ({ path: e.entryName, data: e.getData() })), ignoreCtx, (path) => resolveWithin(targetDir, path) !== null);
1576
+ const scan = scanLocalFiles(targetDir, ignoreCtx);
1577
+ const localFiles = new Map();
1578
+ for (const file of scan.files) {
1579
+ localFiles.set(file.relativePath, readFileSync(file.path));
1580
+ }
1581
+ let entries = compareTrees(localFiles, remoteFiles).filter((e) => e.status !== 'unchanged');
1582
+ if (options.file) {
1583
+ const wanted = normalizeRemotePath(options.file);
1584
+ entries = entries.filter((e) => e.path === wanted);
1585
+ if (entries.length === 0) {
1586
+ spinner.info(`No differences in ${wanted}`);
1587
+ if (!localFiles.has(wanted) && !remoteFiles.has(wanted)) {
1588
+ console.log(chalk.dim(' (file is on neither side, or is filtered by an ignore rule)'));
1589
+ }
1590
+ return;
1591
+ }
1592
+ }
1593
+ spinner.stop();
1594
+ if (entries.length === 0) {
1595
+ console.log(chalk.green(`No differences — local files match "${projectName}"`));
1596
+ console.log(chalk.dim(` remote fetched ${fetchedAt.toISOString()}`));
1597
+ return;
1598
+ }
1599
+ if (options.nameOnly) {
1600
+ for (const e of entries) {
1601
+ const colour = e.status === 'added' ? chalk.green
1602
+ : e.status === 'deleted' ? chalk.red
1603
+ : chalk.yellow;
1604
+ console.log(`${colour(statusLetter(e.status))} ${e.path}`);
1605
+ }
1606
+ }
1607
+ else {
1608
+ for (const e of entries) {
1609
+ const patch = renderFileDiff(e, localFiles.get(e.path), remoteFiles.get(e.path), { context: options.unified });
1610
+ patch.replace(/\n$/, '').split('\n').forEach((line, index) => {
1611
+ console.log(colourizeDiffLine(line, index, e.binary));
1612
+ });
1613
+ }
1614
+ }
1615
+ console.log();
1616
+ // With --file the summary would count only the one file asked for, which
1617
+ // reads as "this is all that differs". Report totals only for a full run.
1618
+ if (!options.file) {
1619
+ const counts = {
1620
+ added: entries.filter((e) => e.status === 'added').length,
1621
+ modified: entries.filter((e) => e.status === 'modified').length,
1622
+ deleted: entries.filter((e) => e.status === 'deleted').length,
1623
+ };
1624
+ console.log(chalk.bold(`${entries.length} file(s) differ from "${projectName}": ` +
1625
+ `${counts.added} added, ${counts.modified} modified, ${counts.deleted} remote-only`));
1626
+ if (counts.deleted > 0) {
1627
+ console.log(chalk.dim(' remote-only files are left alone by push; use push --delete to remove them'));
1628
+ }
1629
+ }
1630
+ console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
1631
+ setLastProject(projectId);
1632
+ }
1633
+ catch (error) {
1634
+ spinner.fail(`Failed: ${error.message}`);
1635
+ process.exit(1);
1636
+ }
1637
+ });
1638
+ /**
1639
+ * Colourize one line of a rendered patch. chalk already no-ops when stdout is
1640
+ * not a TTY, so this needs no flag of its own.
1641
+ *
1642
+ * The file headers are identified by position, not by prefix: a removed line
1643
+ * whose own content starts with `--` renders as `--- something` and would
1644
+ * otherwise be mistaken for the `---` header and shown as unchanged.
1645
+ */
1646
+ function colourizeDiffLine(line, index, binary) {
1647
+ if (index === 0)
1648
+ return chalk.bold(line); // our `diff --olcli` header
1649
+ if (binary)
1650
+ return chalk.magenta(line); // the single summary line
1651
+ if (index <= 2)
1652
+ return chalk.bold(line); // `---` / `+++`
1653
+ if (line.startsWith('@@'))
1654
+ return chalk.cyan(line);
1655
+ if (line.startsWith('+'))
1656
+ return chalk.green(line);
1657
+ if (line.startsWith('-'))
1658
+ return chalk.red(line);
1659
+ return line;
1660
+ }
1556
1661
  // ─────────────────────────────────────────────────────────────────────────────
1557
1662
  // HELP
1558
1663
  // ─────────────────────────────────────────────────────────────────────────────