@aloth/olcli 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * olcli - Overleaf Command Line Interface
4
+ *
5
+ * Command-line access to Overleaf projects using session cookies
6
+ * for authentication. Download, upload, sync, and compile LaTeX projects.
7
+ */
8
+ import { Command } from 'commander';
9
+ import chalk from 'chalk';
10
+ import ora from 'ora';
11
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
12
+ import { join, dirname, basename } from 'node:path';
13
+ import { OverleafClient } from './client.js';
14
+ import { getSessionCookie, setSessionCookie, setLastProject, getConfigPath, saveOlAuth, clearConfig } from './config.js';
15
+ const program = new Command();
16
+ program
17
+ .name('olcli')
18
+ .description('Overleaf CLI - interact with Overleaf projects from the command line')
19
+ .version('0.1.0');
20
+ /**
21
+ * Helper to get authenticated client
22
+ */
23
+ async function getClient(cookieOpt) {
24
+ const cookie = cookieOpt || getSessionCookie();
25
+ if (!cookie) {
26
+ console.error(chalk.red('No session cookie found.'));
27
+ console.error('Set one with: olcli auth --cookie <session_cookie>');
28
+ console.error('Or set OVERLEAF_SESSION environment variable');
29
+ console.error('Or create .olauth file in current directory');
30
+ process.exit(1);
31
+ }
32
+ return OverleafClient.fromSessionCookie(cookie);
33
+ }
34
+ async function resolveProject(client, projectArg, dir = '.') {
35
+ // If project argument provided, use it
36
+ if (projectArg) {
37
+ // If it looks like a valid MongoDB ObjectId (24 hex chars), trust it directly
38
+ if (/^[a-f0-9]{24}$/i.test(projectArg)) {
39
+ // Trust the ID, use a placeholder name (will be overwritten on next list)
40
+ return { id: projectArg, name: projectArg };
41
+ }
42
+ // Otherwise, look up by name
43
+ let proj = await client.getProject(projectArg);
44
+ if (!proj) {
45
+ throw new Error(`Project not found: ${projectArg}`);
46
+ }
47
+ return { id: proj.id, name: proj.name };
48
+ }
49
+ // Otherwise, check for .olcli.json
50
+ const metaPath = join(dir, '.olcli.json');
51
+ if (existsSync(metaPath)) {
52
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
53
+ if (meta.projectId && meta.projectName) {
54
+ return { id: meta.projectId, name: meta.projectName };
55
+ }
56
+ }
57
+ // No project found
58
+ throw new Error('No project specified. Provide a project name/ID or run from a synced directory.');
59
+ }
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // AUTH COMMANDS
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ program
64
+ .command('auth')
65
+ .description('Authenticate with Overleaf using session cookie')
66
+ .option('--cookie <session>', 'Session cookie (overleaf_session2 value)')
67
+ .option('--save-local', 'Save to .olauth in current directory')
68
+ .action(async (options) => {
69
+ if (!options.cookie) {
70
+ console.log(chalk.yellow('To authenticate, provide your session cookie:'));
71
+ console.log();
72
+ console.log('1. Log into overleaf.com in your browser');
73
+ console.log('2. Open Developer Tools (F12) → Application → Cookies');
74
+ console.log('3. Find the cookie named "overleaf_session2"');
75
+ console.log('4. Copy its value and run:');
76
+ console.log();
77
+ console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"'));
78
+ console.log();
79
+ console.log('Or set OVERLEAF_SESSION environment variable');
80
+ return;
81
+ }
82
+ const spinner = ora('Verifying session...').start();
83
+ try {
84
+ const client = await OverleafClient.fromSessionCookie(options.cookie);
85
+ const projects = await client.listProjects();
86
+ setSessionCookie(options.cookie);
87
+ if (options.saveLocal) {
88
+ saveOlAuth(options.cookie);
89
+ spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`);
90
+ }
91
+ else {
92
+ spinner.succeed(`Authenticated! Found ${projects.length} projects.`);
93
+ }
94
+ console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
95
+ }
96
+ catch (error) {
97
+ spinner.fail(`Authentication failed: ${error.message}`);
98
+ process.exit(1);
99
+ }
100
+ });
101
+ program
102
+ .command('whoami')
103
+ .description('Show current authentication status')
104
+ .action(async () => {
105
+ const cookie = getSessionCookie();
106
+ if (!cookie) {
107
+ console.log(chalk.yellow('Not authenticated'));
108
+ return;
109
+ }
110
+ const spinner = ora('Checking session...').start();
111
+ try {
112
+ const client = await OverleafClient.fromSessionCookie(cookie);
113
+ const projects = await client.listProjects();
114
+ spinner.succeed(`Authenticated with access to ${projects.length} projects`);
115
+ }
116
+ catch (error) {
117
+ spinner.fail(`Session invalid: ${error.message}`);
118
+ }
119
+ });
120
+ program
121
+ .command('logout')
122
+ .description('Clear stored credentials')
123
+ .action(() => {
124
+ clearConfig();
125
+ console.log(chalk.green('Credentials cleared'));
126
+ });
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // PROJECT COMMANDS
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ program
131
+ .command('list')
132
+ .alias('ls')
133
+ .description('List all projects')
134
+ .option('--json', 'Output as JSON')
135
+ .option('-n, --limit <n>', 'Limit number of results', parseInt)
136
+ .option('--cookie <session>', 'Session cookie override')
137
+ .action(async (options) => {
138
+ const spinner = ora('Fetching projects...').start();
139
+ try {
140
+ const client = await getClient(options.cookie);
141
+ let projects = await client.listProjects();
142
+ if (options.limit) {
143
+ projects = projects.slice(0, options.limit);
144
+ }
145
+ spinner.stop();
146
+ if (options.json) {
147
+ console.log(JSON.stringify(projects, null, 2));
148
+ return;
149
+ }
150
+ if (projects.length === 0) {
151
+ console.log(chalk.yellow('No projects found'));
152
+ return;
153
+ }
154
+ console.log(chalk.bold(`Found ${projects.length} project(s):\n`));
155
+ for (const p of projects) {
156
+ const date = new Date(p.lastUpdated).toLocaleDateString();
157
+ console.log(` ${chalk.cyan(p.id)} - ${chalk.bold(p.name)}`);
158
+ console.log(` ${chalk.dim(`Last updated: ${date}`)}`);
159
+ }
160
+ }
161
+ catch (error) {
162
+ spinner.fail(`Failed: ${error.message}`);
163
+ process.exit(1);
164
+ }
165
+ });
166
+ program
167
+ .command('info [project]')
168
+ .description('Show project details (by name or ID)')
169
+ .option('--json', 'Output as JSON')
170
+ .option('--cookie <session>', 'Session cookie override')
171
+ .action(async (project, options) => {
172
+ const spinner = ora('Fetching project info...').start();
173
+ try {
174
+ const client = await getClient(options.cookie);
175
+ const proj = await resolveProject(client, project);
176
+ // Get entities (works without parsing HTML)
177
+ const entities = await client.getEntities(proj.id);
178
+ spinner.stop();
179
+ if (options.json) {
180
+ console.log(JSON.stringify({ project: proj, entities }, null, 2));
181
+ return;
182
+ }
183
+ console.log(chalk.bold(`Project: ${proj.name}`));
184
+ console.log(` ID: ${chalk.cyan(proj.id)}`);
185
+ console.log();
186
+ // Print file list grouped by folder
187
+ console.log(chalk.bold('Files:'));
188
+ // Sort entities by path for nice display
189
+ const sorted = entities.sort((a, b) => a.path.localeCompare(b.path));
190
+ for (const entity of sorted) {
191
+ const icon = entity.type === 'doc' ? '📄' : '📎';
192
+ console.log(` ${icon} ${entity.path}`);
193
+ }
194
+ setLastProject(proj.id);
195
+ }
196
+ catch (error) {
197
+ spinner.fail(`Failed: ${error.message}`);
198
+ process.exit(1);
199
+ }
200
+ });
201
+ function printFolder(folder, indent) {
202
+ // Print subfolders
203
+ for (const f of folder.folders || []) {
204
+ console.log(`${indent}📁 ${chalk.blue(f.name)}/`);
205
+ printFolder(f, indent + ' ');
206
+ }
207
+ // Print docs
208
+ for (const d of folder.docs || []) {
209
+ console.log(`${indent}📄 ${d.name}`);
210
+ }
211
+ // Print files
212
+ for (const f of folder.fileRefs || []) {
213
+ console.log(`${indent}📎 ${f.name}`);
214
+ }
215
+ }
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+ // DOWNLOAD COMMANDS
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ program
220
+ .command('download <file> [project]')
221
+ .description('Download a single file from project')
222
+ .option('-o, --output <path>', 'Output path (default: same as file name)')
223
+ .option('--cookie <session>', 'Session cookie override')
224
+ .action(async (file, project, options) => {
225
+ const spinner = ora('Downloading file...').start();
226
+ try {
227
+ const client = await getClient(options.cookie);
228
+ const proj = await resolveProject(client, project);
229
+ const content = await client.downloadByPath(proj.id, file);
230
+ const outputPath = options.output || basename(file);
231
+ writeFileSync(outputPath, content);
232
+ spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`);
233
+ setLastProject(proj.id);
234
+ }
235
+ catch (error) {
236
+ spinner.fail(`Failed: ${error.message}`);
237
+ process.exit(1);
238
+ }
239
+ });
240
+ program
241
+ .command('zip [project]')
242
+ .description('Download project as zip archive')
243
+ .option('-o, --output <path>', 'Output path (default: <project-name>.zip)')
244
+ .option('--cookie <session>', 'Session cookie override')
245
+ .action(async (project, options) => {
246
+ const spinner = ora('Downloading project...').start();
247
+ try {
248
+ const client = await getClient(options.cookie);
249
+ const proj = await resolveProject(client, project);
250
+ const zip = await client.downloadProject(proj.id);
251
+ const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.zip`;
252
+ writeFileSync(outputPath, zip);
253
+ spinner.succeed(`Downloaded: ${outputPath} (${(zip.length / 1024).toFixed(1)} KB)`);
254
+ setLastProject(proj.id);
255
+ }
256
+ catch (error) {
257
+ spinner.fail(`Failed: ${error.message}`);
258
+ process.exit(1);
259
+ }
260
+ });
261
+ program
262
+ .command('pdf [project]')
263
+ .description('Compile and download PDF')
264
+ .option('-o, --output <path>', 'Output path (default: <project-name>.pdf)')
265
+ .option('--cookie <session>', 'Session cookie override')
266
+ .action(async (project, options) => {
267
+ const spinner = ora('Compiling project...').start();
268
+ try {
269
+ const client = await getClient(options.cookie);
270
+ const proj = await resolveProject(client, project);
271
+ spinner.text = 'Compiling...';
272
+ const pdf = await client.downloadPdf(proj.id);
273
+ const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.pdf`;
274
+ writeFileSync(outputPath, pdf);
275
+ spinner.succeed(`Downloaded PDF: ${outputPath} (${(pdf.length / 1024).toFixed(1)} KB)`);
276
+ setLastProject(proj.id);
277
+ }
278
+ catch (error) {
279
+ spinner.fail(`Failed: ${error.message}`);
280
+ process.exit(1);
281
+ }
282
+ });
283
+ program
284
+ .command('output [type]')
285
+ .description('Download compile output files (bbl, log, aux, etc.)')
286
+ .option('-o, --output <path>', 'Output path')
287
+ .option('--list', 'List available output files')
288
+ .option('--project <name>', 'Project name or ID')
289
+ .option('--cookie <session>', 'Session cookie override')
290
+ .action(async (type, options) => {
291
+ const spinner = ora('Compiling project...').start();
292
+ try {
293
+ const client = await getClient(options.cookie);
294
+ // If type looks like a project name (contains spaces or is in project list), treat it as project
295
+ let actualType = type;
296
+ let projectArg = options.project;
297
+ if (type && !projectArg && !['bbl', 'log', 'aux', 'blg', 'pdf', 'out', 'fls', 'fdb_latexmk', 'stderr', 'pdfxref', 'chktex'].includes(type)) {
298
+ // Type might actually be a project name
299
+ const projects = await client.listProjects();
300
+ const matchedProject = projects.find(p => p.name === type || p.id === type);
301
+ if (matchedProject) {
302
+ projectArg = type;
303
+ actualType = undefined;
304
+ }
305
+ }
306
+ const proj = await resolveProject(client, projectArg);
307
+ const result = await client.compileWithOutputs(proj.id);
308
+ if (result.status !== 'success') {
309
+ spinner.warn(`Compilation ${result.status}, but output files may still be available`);
310
+ }
311
+ if (options.list || !actualType) {
312
+ spinner.stop();
313
+ console.log(chalk.bold('Available output files:'));
314
+ for (const file of result.outputFiles) {
315
+ console.log(` ${chalk.cyan(file.type.padEnd(12))} ${file.path}`);
316
+ }
317
+ console.log();
318
+ console.log(chalk.dim('Usage: olcli output <type>'));
319
+ console.log(chalk.dim('Example: olcli output bbl'));
320
+ return;
321
+ }
322
+ // Find matching output file
323
+ const outputFile = result.outputFiles.find(f => f.type === actualType || f.path.endsWith(`.${actualType}`));
324
+ if (!outputFile) {
325
+ spinner.fail(`Output file not found: ${actualType}`);
326
+ console.log(chalk.dim('Use --list to see available files'));
327
+ process.exit(1);
328
+ }
329
+ spinner.text = `Downloading ${outputFile.path}...`;
330
+ const content = await client.downloadOutputFile(outputFile.url);
331
+ const outputPath = options.output || outputFile.path.replace('output.', '');
332
+ writeFileSync(outputPath, content);
333
+ spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`);
334
+ setLastProject(proj.id);
335
+ }
336
+ catch (error) {
337
+ spinner.fail(`Failed: ${error.message}`);
338
+ process.exit(1);
339
+ }
340
+ });
341
+ // ─────────────────────────────────────────────────────────────────────────────
342
+ // UPLOAD COMMANDS
343
+ // ─────────────────────────────────────────────────────────────────────────────
344
+ program
345
+ .command('upload <file> [project]')
346
+ .description('Upload a file to a project')
347
+ .option('--folder <id>', 'Target folder ID (default: root)')
348
+ .option('--cookie <session>', 'Session cookie override')
349
+ .action(async (file, project, options) => {
350
+ const spinner = ora('Uploading file...').start();
351
+ try {
352
+ const client = await getClient(options.cookie);
353
+ const proj = await resolveProject(client, project);
354
+ if (!existsSync(file)) {
355
+ spinner.fail(`File not found: ${file}`);
356
+ process.exit(1);
357
+ }
358
+ const content = readFileSync(file);
359
+ const fileName = basename(file);
360
+ // Pass folder ID or null for root folder (client will compute it)
361
+ const folderId = options.folder || null;
362
+ const result = await client.uploadFile(proj.id, folderId, fileName, content);
363
+ if (result.success) {
364
+ spinner.succeed(`Uploaded: ${fileName} → "${proj.name}"`);
365
+ }
366
+ else {
367
+ spinner.fail(`Upload failed for: ${fileName}`);
368
+ process.exit(1);
369
+ }
370
+ setLastProject(proj.id);
371
+ }
372
+ catch (error) {
373
+ spinner.fail(`Failed: ${error.message}`);
374
+ process.exit(1);
375
+ }
376
+ });
377
+ // NOTE: delete and rename commands are disabled - they require entity IDs
378
+ // which are not exposed via the current Overleaf API without Socket.IO.
379
+ // Use the Overleaf web UI for these operations.
380
+ //
381
+ // program
382
+ // .command('delete <file> [project]')
383
+ // .alias('rm')
384
+ // .description('Delete a file from a project')
385
+ // .option('--cookie <session>', 'Session cookie override')
386
+ // .action(async (file, project, options) => {
387
+ // const spinner = ora('Deleting file...').start();
388
+ // try {
389
+ // const client = await getClient(options.cookie);
390
+ // const proj = await resolveProject(client, project);
391
+ // await client.deleteByPath(proj.id, file);
392
+ // spinner.succeed(`Deleted: ${file}`);
393
+ // setLastProject(proj.id);
394
+ // } catch (error: any) {
395
+ // spinner.fail(`Failed: ${error.message}`);
396
+ // process.exit(1);
397
+ // }
398
+ // });
399
+ //
400
+ // program
401
+ // .command('rename <oldname> <newname> [project]')
402
+ // .alias('mv')
403
+ // .description('Rename a file in a project')
404
+ // .option('--cookie <session>', 'Session cookie override')
405
+ // .action(async (oldname, newname, project, options) => {
406
+ // const spinner = ora('Renaming file...').start();
407
+ // try {
408
+ // const client = await getClient(options.cookie);
409
+ // const proj = await resolveProject(client, project);
410
+ // await client.renameByPath(proj.id, oldname, newname);
411
+ // spinner.succeed(`Renamed: ${oldname} → ${newname}`);
412
+ // setLastProject(proj.id);
413
+ // } catch (error: any) {
414
+ // spinner.fail(`Failed: ${error.message}`);
415
+ // process.exit(1);
416
+ // }
417
+ // });
418
+ // ─────────────────────────────────────────────────────────────────────────────
419
+ // COMPILE COMMAND
420
+ // ─────────────────────────────────────────────────────────────────────────────
421
+ program
422
+ .command('compile [project]')
423
+ .description('Compile a project (trigger PDF generation)')
424
+ .option('--cookie <session>', 'Session cookie override')
425
+ .action(async (project, options) => {
426
+ const spinner = ora('Compiling...').start();
427
+ try {
428
+ const client = await getClient(options.cookie);
429
+ const proj = await resolveProject(client, project);
430
+ const result = await client.compileProject(proj.id);
431
+ spinner.succeed(`Compiled "${proj.name}"`);
432
+ console.log(chalk.dim(`PDF URL: ${result.pdfUrl}`));
433
+ setLastProject(proj.id);
434
+ }
435
+ catch (error) {
436
+ spinner.fail(`Compilation failed: ${error.message}`);
437
+ process.exit(1);
438
+ }
439
+ });
440
+ // ─────────────────────────────────────────────────────────────────────────────
441
+ // SYNC COMMANDS
442
+ // ─────────────────────────────────────────────────────────────────────────────
443
+ program
444
+ .command('pull [project] [dir]')
445
+ .description('Download project files to local directory')
446
+ .option('--force', 'Overwrite local files even if newer')
447
+ .option('--cookie <session>', 'Session cookie override')
448
+ .action(async (project, dir, options) => {
449
+ let targetDir = dir || '.';
450
+ let projectId;
451
+ let projectName;
452
+ // Check for existing .olcli.json if no project specified
453
+ const metaPath = join(targetDir, '.olcli.json');
454
+ if (!project && existsSync(metaPath)) {
455
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
456
+ projectId = meta.projectId;
457
+ projectName = meta.projectName;
458
+ }
459
+ else if (!project) {
460
+ console.error(chalk.red('No project specified.'));
461
+ console.error('Usage: olcli pull <project> [dir]');
462
+ console.error('Or run from a directory with .olcli.json');
463
+ process.exit(1);
464
+ }
465
+ const spinner = ora('Fetching project...').start();
466
+ try {
467
+ const client = await getClient(options.cookie);
468
+ // Resolve project if needed
469
+ if (!projectId) {
470
+ let proj = await client.getProjectById(project);
471
+ if (!proj) {
472
+ proj = await client.getProject(project);
473
+ }
474
+ if (!proj) {
475
+ spinner.fail(`Project not found: ${project}`);
476
+ process.exit(1);
477
+ }
478
+ projectId = proj.id;
479
+ projectName = proj.name;
480
+ // Default directory is project name (sanitized) if not specified
481
+ if (!dir) {
482
+ targetDir = proj.name.replace(/[^a-zA-Z0-9-_]/g, '_');
483
+ }
484
+ }
485
+ spinner.text = 'Downloading project...';
486
+ const zipBuffer = await client.downloadProject(projectId);
487
+ // Extract zip
488
+ spinner.text = 'Extracting files...';
489
+ const AdmZip = (await import('adm-zip')).default;
490
+ const zip = new AdmZip(zipBuffer);
491
+ // Create target directory
492
+ if (!existsSync(targetDir)) {
493
+ mkdirSync(targetDir, { recursive: true });
494
+ }
495
+ // Get local file modification times for safety check
496
+ const { statSync } = await import('node:fs');
497
+ const localMetaPath = join(targetDir, '.olcli.json');
498
+ let lastPull;
499
+ if (existsSync(localMetaPath)) {
500
+ const meta = JSON.parse(readFileSync(localMetaPath, 'utf-8'));
501
+ lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
502
+ }
503
+ // Extract files with safety check
504
+ const entries = zip.getEntries();
505
+ let fileCount = 0;
506
+ let skippedCount = 0;
507
+ const skippedFiles = [];
508
+ for (const entry of entries) {
509
+ if (!entry.isDirectory) {
510
+ const filePath = join(targetDir, entry.entryName);
511
+ const fileDir = dirname(filePath);
512
+ // Check if local file exists and is newer than last pull
513
+ if (!options.force && existsSync(filePath) && lastPull) {
514
+ try {
515
+ const stats = statSync(filePath);
516
+ if (stats.mtime > lastPull) {
517
+ // Local file is newer - skip unless --force
518
+ skippedCount++;
519
+ skippedFiles.push(entry.entryName);
520
+ continue;
521
+ }
522
+ }
523
+ catch (e) {
524
+ // File doesn't exist or can't stat, proceed with download
525
+ }
526
+ }
527
+ if (!existsSync(fileDir)) {
528
+ mkdirSync(fileDir, { recursive: true });
529
+ }
530
+ writeFileSync(filePath, entry.getData());
531
+ fileCount++;
532
+ }
533
+ }
534
+ // Save project metadata
535
+ writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
536
+ projectId,
537
+ projectName,
538
+ lastPull: new Date().toISOString()
539
+ }, null, 2));
540
+ if (skippedCount > 0) {
541
+ spinner.warn(`Downloaded ${fileCount} files, skipped ${skippedCount} locally modified files`);
542
+ console.log(chalk.yellow(' Skipped (local is newer):'));
543
+ for (const f of skippedFiles.slice(0, 5)) {
544
+ console.log(chalk.dim(` ${f}`));
545
+ }
546
+ if (skippedFiles.length > 5) {
547
+ console.log(chalk.dim(` ... and ${skippedFiles.length - 5} more`));
548
+ }
549
+ console.log(chalk.dim(' Use --force to overwrite'));
550
+ }
551
+ else {
552
+ spinner.succeed(`Downloaded ${fileCount} files to ${targetDir}/`);
553
+ }
554
+ setLastProject(projectId);
555
+ }
556
+ catch (error) {
557
+ spinner.fail(`Failed: ${error.message}`);
558
+ process.exit(1);
559
+ }
560
+ });
561
+ program
562
+ .command('push [dir]')
563
+ .description('Upload local changes to Overleaf project')
564
+ .option('--project <name>', 'Project name or ID (overrides .olcli.json)')
565
+ .option('--all', 'Upload all files (not just changed)')
566
+ .option('--dry-run', 'Show what would be uploaded without uploading')
567
+ .option('--cookie <session>', 'Session cookie override')
568
+ .action(async (dir, options) => {
569
+ const targetDir = dir || '.';
570
+ const metaPath = join(targetDir, '.olcli.json');
571
+ // Check for project metadata
572
+ let projectId;
573
+ let projectName;
574
+ let lastPull;
575
+ if (existsSync(metaPath)) {
576
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
577
+ projectId = meta.projectId;
578
+ projectName = meta.projectName;
579
+ lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
580
+ }
581
+ if (options.project) {
582
+ // Override with command line option
583
+ projectId = undefined;
584
+ projectName = options.project;
585
+ }
586
+ if (!projectId && !projectName) {
587
+ console.error(chalk.red('No project specified.'));
588
+ console.error('Either run from a directory with .olcli.json or use --project');
589
+ process.exit(1);
590
+ }
591
+ const spinner = ora('Connecting...').start();
592
+ try {
593
+ const client = await getClient(options.cookie);
594
+ // Resolve project if needed
595
+ if (!projectId) {
596
+ let proj = await client.getProjectById(projectName);
597
+ if (!proj) {
598
+ proj = await client.getProject(projectName);
599
+ }
600
+ if (!proj) {
601
+ spinner.fail(`Project not found: ${projectName}`);
602
+ process.exit(1);
603
+ }
604
+ projectId = proj.id;
605
+ projectName = proj.name;
606
+ }
607
+ spinner.text = 'Scanning files...';
608
+ // Get list of files to upload
609
+ const { readdirSync, statSync } = await import('node:fs');
610
+ const filesToUpload = [];
611
+ function scanDir(currentDir, relativeBase = '') {
612
+ const entries = readdirSync(currentDir, { withFileTypes: true });
613
+ for (const entry of entries) {
614
+ // Skip hidden files and .olcli.json
615
+ if (entry.name.startsWith('.'))
616
+ continue;
617
+ const fullPath = join(currentDir, entry.name);
618
+ const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
619
+ if (entry.isDirectory()) {
620
+ scanDir(fullPath, relativePath);
621
+ }
622
+ else {
623
+ // Check if file is newer than last pull (unless --all)
624
+ if (options.all || !lastPull) {
625
+ filesToUpload.push({ path: fullPath, relativePath });
626
+ }
627
+ else {
628
+ const stats = statSync(fullPath);
629
+ if (stats.mtime > lastPull) {
630
+ filesToUpload.push({ path: fullPath, relativePath });
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ scanDir(targetDir);
637
+ if (filesToUpload.length === 0) {
638
+ spinner.info('No files to upload');
639
+ return;
640
+ }
641
+ if (options.dryRun) {
642
+ spinner.stop();
643
+ console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
644
+ for (const f of filesToUpload) {
645
+ console.log(` ${chalk.cyan(f.relativePath)}`);
646
+ }
647
+ return;
648
+ }
649
+ spinner.text = `Uploading ${filesToUpload.length} file(s)...`;
650
+ let uploaded = 0;
651
+ let failed = 0;
652
+ for (const file of filesToUpload) {
653
+ try {
654
+ const content = readFileSync(file.path);
655
+ await client.uploadFile(projectId, null, file.relativePath, content);
656
+ uploaded++;
657
+ spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`;
658
+ }
659
+ catch (error) {
660
+ console.error(chalk.yellow(`\n Warning: Failed to upload ${file.relativePath}: ${error.message}`));
661
+ failed++;
662
+ }
663
+ }
664
+ // Update last pull time
665
+ if (existsSync(metaPath)) {
666
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
667
+ meta.lastPush = new Date().toISOString();
668
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2));
669
+ }
670
+ if (failed > 0) {
671
+ spinner.warn(`Uploaded ${uploaded} files, ${failed} failed`);
672
+ }
673
+ else {
674
+ spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
675
+ }
676
+ setLastProject(projectId);
677
+ }
678
+ catch (error) {
679
+ spinner.fail(`Failed: ${error.message}`);
680
+ process.exit(1);
681
+ }
682
+ });
683
+ program
684
+ .command('sync [dir]')
685
+ .description('Pull then push (bidirectional sync)')
686
+ .option('--project <name>', 'Project name or ID')
687
+ .option('--verbose', 'Show detailed file operations')
688
+ .option('--cookie <session>', 'Session cookie override')
689
+ .action(async (dir, options) => {
690
+ const targetDir = dir || '.';
691
+ // Check if this is an existing project directory
692
+ const metaPath = join(targetDir, '.olcli.json');
693
+ let projectId;
694
+ let projectName;
695
+ if (existsSync(metaPath)) {
696
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
697
+ projectId = meta.projectId;
698
+ projectName = meta.projectName;
699
+ }
700
+ if (options.project) {
701
+ projectName = options.project;
702
+ projectId = undefined;
703
+ }
704
+ if (!projectId && !projectName) {
705
+ console.error(chalk.red('No project specified.'));
706
+ console.error('Either run from a directory with .olcli.json or use --project');
707
+ process.exit(1);
708
+ }
709
+ const spinner = ora('Connecting...').start();
710
+ try {
711
+ const client = await getClient(options.cookie);
712
+ // Resolve project
713
+ if (!projectId) {
714
+ let proj = await client.getProjectById(projectName);
715
+ if (!proj) {
716
+ proj = await client.getProject(projectName);
717
+ }
718
+ if (!proj) {
719
+ spinner.fail(`Project not found: ${projectName}`);
720
+ process.exit(1);
721
+ }
722
+ projectId = proj.id;
723
+ projectName = proj.name;
724
+ }
725
+ // Step 1: Download current state
726
+ spinner.text = 'Downloading project...';
727
+ const zipBuffer = await client.downloadProject(projectId);
728
+ const AdmZip = (await import('adm-zip')).default;
729
+ const zip = new AdmZip(zipBuffer);
730
+ // Create target directory
731
+ if (!existsSync(targetDir)) {
732
+ mkdirSync(targetDir, { recursive: true });
733
+ }
734
+ // Track local modifications
735
+ const localFiles = new Map();
736
+ const { readdirSync, statSync } = await import('node:fs');
737
+ function scanLocalFiles(currentDir, relativeBase = '') {
738
+ if (!existsSync(currentDir))
739
+ return;
740
+ const entries = readdirSync(currentDir, { withFileTypes: true });
741
+ for (const entry of entries) {
742
+ if (entry.name.startsWith('.'))
743
+ continue;
744
+ const fullPath = join(currentDir, entry.name);
745
+ const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
746
+ if (entry.isDirectory()) {
747
+ scanLocalFiles(fullPath, relativePath);
748
+ }
749
+ else {
750
+ const stats = statSync(fullPath);
751
+ localFiles.set(relativePath, {
752
+ mtime: stats.mtime,
753
+ content: readFileSync(fullPath)
754
+ });
755
+ }
756
+ }
757
+ }
758
+ // Read local files before overwriting
759
+ if (existsSync(metaPath)) {
760
+ scanLocalFiles(targetDir);
761
+ }
762
+ // Extract remote files
763
+ const remoteFiles = new Map();
764
+ for (const entry of zip.getEntries()) {
765
+ if (!entry.isDirectory) {
766
+ remoteFiles.set(entry.entryName, entry.getData());
767
+ }
768
+ }
769
+ // Merge: local changes take precedence for files modified after last pull
770
+ let lastPull;
771
+ if (existsSync(metaPath)) {
772
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
773
+ lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
774
+ }
775
+ const filesToUpload = [];
776
+ const filesUpdatedLocally = [];
777
+ const filesKeptLocal = [];
778
+ const filesNewLocal = [];
779
+ spinner.text = 'Comparing files...';
780
+ // Write remote files, but preserve local modifications
781
+ for (const [path, remoteContent] of remoteFiles) {
782
+ const filePath = join(targetDir, path);
783
+ const fileDir = dirname(filePath);
784
+ if (!existsSync(fileDir)) {
785
+ mkdirSync(fileDir, { recursive: true });
786
+ }
787
+ const localFile = localFiles.get(path);
788
+ if (localFile && lastPull && localFile.mtime > lastPull) {
789
+ // Local file was modified after last pull - keep local, queue for upload if different
790
+ if (!localFile.content.equals(remoteContent)) {
791
+ filesToUpload.push({ path, content: localFile.content });
792
+ filesKeptLocal.push(path);
793
+ }
794
+ // Don't overwrite local file
795
+ }
796
+ else {
797
+ // Write remote version
798
+ writeFileSync(filePath, remoteContent);
799
+ filesUpdatedLocally.push(path);
800
+ }
801
+ }
802
+ // Check for new local files (not in remote)
803
+ for (const [path, localFile] of localFiles) {
804
+ if (!remoteFiles.has(path)) {
805
+ filesToUpload.push({ path, content: localFile.content });
806
+ filesNewLocal.push(path);
807
+ }
808
+ }
809
+ // Upload local changes
810
+ if (filesToUpload.length > 0) {
811
+ spinner.text = `Uploading ${filesToUpload.length} local change(s)...`;
812
+ for (const file of filesToUpload) {
813
+ await client.uploadFile(projectId, null, file.path, file.content);
814
+ }
815
+ }
816
+ // Update metadata
817
+ writeFileSync(metaPath, JSON.stringify({
818
+ projectId,
819
+ projectName,
820
+ lastPull: new Date().toISOString(),
821
+ lastSync: new Date().toISOString()
822
+ }, null, 2));
823
+ spinner.succeed(`Synced "${projectName}"`);
824
+ // Summary
825
+ console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`));
826
+ console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`));
827
+ if (options.verbose) {
828
+ if (filesKeptLocal.length > 0) {
829
+ console.log(chalk.yellow('\n Local changes pushed (local was newer):'));
830
+ for (const f of filesKeptLocal) {
831
+ console.log(chalk.dim(` ${f}`));
832
+ }
833
+ }
834
+ if (filesNewLocal.length > 0) {
835
+ console.log(chalk.green('\n New local files pushed:'));
836
+ for (const f of filesNewLocal) {
837
+ console.log(chalk.dim(` ${f}`));
838
+ }
839
+ }
840
+ }
841
+ setLastProject(projectId);
842
+ }
843
+ catch (error) {
844
+ spinner.fail(`Failed: ${error.message}`);
845
+ process.exit(1);
846
+ }
847
+ });
848
+ // ─────────────────────────────────────────────────────────────────────────────
849
+ // HELP
850
+ // ─────────────────────────────────────────────────────────────────────────────
851
+ program
852
+ .command('check')
853
+ .description('Show credential sources and config path')
854
+ .action(() => {
855
+ console.log(chalk.bold('Configuration:'));
856
+ console.log(` Config file: ${getConfigPath()}`);
857
+ console.log();
858
+ console.log(chalk.bold('Credential sources (in order):'));
859
+ console.log(' 1. OVERLEAF_SESSION environment variable');
860
+ console.log(' 2. .olauth file in current directory');
861
+ console.log(' 3. Global config file');
862
+ console.log();
863
+ const cookie = getSessionCookie();
864
+ if (cookie) {
865
+ console.log(chalk.green('✓ Session cookie found'));
866
+ console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`));
867
+ }
868
+ else {
869
+ console.log(chalk.yellow('✗ No session cookie found'));
870
+ }
871
+ });
872
+ program.parse();
873
+ //# sourceMappingURL=cli.js.map