@aloth/olcli 0.15.3 → 0.16.1

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.
Files changed (49) hide show
  1. package/README.md +140 -437
  2. package/dist/browser-auth.d.ts +144 -0
  3. package/dist/browser-auth.d.ts.map +1 -0
  4. package/dist/browser-auth.js +442 -0
  5. package/dist/browser-auth.js.map +1 -0
  6. package/dist/cli.js +429 -174
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +20 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +103 -37
  11. package/dist/client.js.map +1 -1
  12. package/dist/config.d.ts +25 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +40 -7
  15. package/dist/config.js.map +1 -1
  16. package/dist/errors.d.ts +50 -0
  17. package/dist/errors.d.ts.map +1 -0
  18. package/dist/errors.js +115 -0
  19. package/dist/errors.js.map +1 -0
  20. package/dist/fast-export.d.ts +2 -0
  21. package/dist/fast-export.d.ts.map +1 -1
  22. package/dist/fast-export.js +5 -1
  23. package/dist/fast-export.js.map +1 -1
  24. package/dist/mcp-server.d.ts.map +1 -1
  25. package/dist/mcp-server.js +4 -1
  26. package/dist/mcp-server.js.map +1 -1
  27. package/dist/mcp.js +1 -1
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/paths.d.ts +18 -0
  30. package/dist/paths.d.ts.map +1 -1
  31. package/dist/paths.js +45 -0
  32. package/dist/paths.js.map +1 -1
  33. package/dist/project-match.d.ts +37 -0
  34. package/dist/project-match.d.ts.map +1 -0
  35. package/dist/project-match.js +97 -0
  36. package/dist/project-match.js.map +1 -0
  37. package/dist/proxy.d.ts +52 -0
  38. package/dist/proxy.d.ts.map +1 -0
  39. package/dist/proxy.js +189 -0
  40. package/dist/proxy.js.map +1 -0
  41. package/dist/remote-helper.d.ts +3 -2
  42. package/dist/remote-helper.d.ts.map +1 -1
  43. package/dist/remote-helper.js +130 -37
  44. package/dist/remote-helper.js.map +1 -1
  45. package/dist/term.d.ts +25 -0
  46. package/dist/term.d.ts.map +1 -0
  47. package/dist/term.js +42 -0
  48. package/dist/term.js.map +1 -0
  49. package/package.json +9 -4
package/dist/cli.js CHANGED
@@ -7,14 +7,15 @@
7
7
  */
8
8
  import { Command } from 'commander';
9
9
  import chalk from 'chalk';
10
- import ora from 'ora';
10
+ import { colorDisabled, createSpinner as ora, icon } from './term.js';
11
+ import { formatError } from './errors.js';
11
12
  import { writeFileSync, readFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
12
13
  import { join, dirname, basename } from 'node:path';
13
14
  import { tmpdir } from 'node:os';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { OverleafClient, resolveCommentConcurrency } from './client.js';
16
17
  import { formatAuthor } from './tracked-changes.js';
17
- import { resolveRemotePath, resolveWithin, normalizeRemotePath } from './paths.js';
18
+ import { resolveRemotePath, resolveWithin, normalizeRemotePath, safeLocalName, legacyLocalName } from './paths.js';
18
19
  import { planProjectRenames } from './rename-plan.js';
19
20
  import { scanLocalFiles } from './scan.js';
20
21
  import { compareTrees, DIFF_EXIT_FAILURE, differencesExitCode, filterRemoteTree, renderFileDiff, statusLetter, } from './diff.js';
@@ -24,17 +25,92 @@ import { loadIgnore } from './ignore.js';
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
26
27
  const VERSION = pkg.version;
27
- import { getSessionCookie, setSessionCookie, setLastProject, getConfigPath, saveOlAuth, clearConfig, getBaseUrl, setBaseUrl, getSessionCookieName, setSessionCookieName, getTimeout, setTimeout, getPasswordCredentials, setPasswordCredentials, clearOlAuth, inspectStoredCredentials } from './config.js';
28
+ import { getSessionCookie, setSessionCookie, setLastProject, getConfigPath, saveOlAuth, clearConfig, getBaseUrl, setBaseUrl, getSessionCookieName, setSessionCookieName, getTimeout, setTimeout, getPasswordCredentials, setPasswordCredentials, clearOlAuth, inspectStoredCredentials, parseCookieInput, resolveSessionCookie, maskSecret } from './config.js';
28
29
  import { promptHidden, PromptCancelled, NotATerminal } from './prompt.js';
30
+ import { BrowserSignInAborted, browserSignIn, chromiumLauncher, findBrowser, requireWebSocket } from './browser-auth.js';
31
+ // NO_COLOR (no-color.org) and --no-color. chalk handles --no-color and a
32
+ // non-TTY stdout by itself, but not NO_COLOR; ora and log-symbols share this
33
+ // chalk instance, so one assignment covers them too.
34
+ if (colorDisabled())
35
+ chalk.level = 0;
29
36
  const program = new Command();
30
37
  program
31
38
  .name('olcli')
32
39
  .description('Overleaf CLI - interact with Overleaf projects from the command line')
33
40
  .version(VERSION)
34
- .option('--base-url <url>', 'Overleaf instance base URL (overrides OVERLEAF_BASE_URL and config)')
35
- .option('--cookie-name <name>', 'Session cookie name (default: overleaf_session2, use overleaf.sid for older instances)')
41
+ .option('--base-url <url>', 'Overleaf instance URL (overrides OVERLEAF_BASE_URL and config)')
42
+ .option('--cookie-name <name>', 'Session cookie name (default: overleaf_session2; or overleaf.sid)')
36
43
  .option('--timeout <ms>', 'HTTP request timeout in milliseconds', parseInt)
37
- .option('--verbose', 'Print every HTTP request, status, and error response body to stderr');
44
+ .option('--verbose', 'Print every HTTP request, status and error body to stderr')
45
+ .option('--no-color', 'Turn off color (same as setting NO_COLOR)')
46
+ .addHelpText('after', `
47
+ Environment:
48
+ OVERLEAF_SESSION session cookie (instead of olcli auth)
49
+ OVERLEAF_EMAIL, OVERLEAF_PASSWORD
50
+ password login when no session cookie works
51
+ OVERLEAF_BASE_URL instance URL, like --base-url
52
+ OVERLEAF_COOKIE_NAME session cookie name, like --cookie-name
53
+ OVERLEAF_TIMEOUT HTTP timeout in milliseconds, like --timeout
54
+ OLCLI_CONCURRENCY documents fetched in parallel by comments list
55
+ HTTPS_PROXY proxy for https:// instances (CONNECT tunnel)
56
+ HTTP_PROXY proxy for http:// instances
57
+ NO_PROXY comma-separated hosts that bypass the proxy
58
+ (localhost and 127.0.0.1 are always direct)
59
+ NO_COLOR turn off color`);
60
+ /**
61
+ * The one way a command reports a failure: the cause (and the command that
62
+ * fixes it) on the spinner line, details such as the HTTP status on stderr
63
+ * after it. Exits with `code`, 1 unless the command reserves 1 for something
64
+ * else (`diff --exit-code`).
65
+ */
66
+ function failWith(spinner, error, code = 1) {
67
+ const verbose = Boolean(program.opts().verbose);
68
+ const [first, ...rest] = formatError(error, { verbose });
69
+ spinner.fail(first);
70
+ for (const line of rest)
71
+ console.error(chalk.dim(` ${line}`));
72
+ process.exit(code);
73
+ }
74
+ /** Everything on stdin, as text. Used by `auth --cookie -`. */
75
+ async function readStdin() {
76
+ const chunks = [];
77
+ for await (const chunk of process.stdin)
78
+ chunks.push(Buffer.from(chunk));
79
+ return Buffer.concat(chunks).toString('utf-8');
80
+ }
81
+ /**
82
+ * What to run after a successful `auth`. Names a real project when there is
83
+ * one whose name can be quoted safely, the placeholder otherwise.
84
+ */
85
+ function nextStepCommands(projects) {
86
+ const first = projects.find((p) => p.name && !/["$`\\]/.test(p.name));
87
+ return ['olcli list', `olcli pull "${first ? first.name : '<name>'}"`];
88
+ }
89
+ /**
90
+ * Where `pull <project>` puts a project when no directory is given.
91
+ *
92
+ * Normally `safeLocalName(name)`. Up to 0.15 every non-ASCII letter became
93
+ * "_", so a directory pulled back then has a different name; when that one
94
+ * exists and its .olcli.json points at the same project, and the new name is
95
+ * not taken yet, it is reused rather than downloading a second copy.
96
+ */
97
+ function defaultProjectDir(projectId, name, cwd = '.') {
98
+ const current = safeLocalName(name);
99
+ const legacy = legacyLocalName(name);
100
+ if (legacy === current || existsSync(join(cwd, current)))
101
+ return current;
102
+ const legacyMeta = join(cwd, legacy, '.olcli.json');
103
+ if (existsSync(legacyMeta)) {
104
+ try {
105
+ if (JSON.parse(readFileSync(legacyMeta, 'utf-8')).projectId === projectId)
106
+ return legacy;
107
+ }
108
+ catch {
109
+ // Unreadable metadata: not provably ours, use the new name.
110
+ }
111
+ }
112
+ return current;
113
+ }
38
114
  /**
39
115
  * Helper to get authenticated client
40
116
  *
@@ -65,10 +141,8 @@ async function getClient(cookieOpt, baseUrlOpt, failureCode = 1) {
65
141
  return loginWithSavedPassword(passwordCredentials, baseUrl, cookieName);
66
142
  }
67
143
  console.error(chalk.red('No session cookie or password credentials found.'));
68
- console.error('Set one with: olcli auth --cookie <session_cookie>');
69
- console.error('Or use: olcli auth --email <email> --password <password>');
70
- console.error('Or set OVERLEAF_SESSION environment variable');
71
- console.error('Or create .olauth file in current directory');
144
+ console.error('Sign in with: olcli auth --cookie (prompts for the cookie; see olcli auth --help)');
145
+ console.error('Or set the OVERLEAF_SESSION environment variable.');
72
146
  process.exit(failureCode);
73
147
  }
74
148
  async function loginWithSavedPassword(credentials, baseUrl, cookieName) {
@@ -97,10 +171,7 @@ async function resolveProject(client, projectArg, dir = '.') {
97
171
  return { id: projectArg, name: projectArg };
98
172
  }
99
173
  // Otherwise, look up by name
100
- const proj = await client.getProject(projectArg);
101
- if (!proj) {
102
- throw new Error(`Project not found: ${projectArg}`);
103
- }
174
+ const proj = await client.findProject(projectArg);
104
175
  return { id: proj.id, name: proj.name };
105
176
  }
106
177
  // Otherwise, check for .olcli.json
@@ -117,16 +188,33 @@ async function resolveProject(client, projectArg, dir = '.') {
117
188
  // ─────────────────────────────────────────────────────────────────────────────
118
189
  // AUTH COMMANDS
119
190
  // ─────────────────────────────────────────────────────────────────────────────
120
- program
191
+ const authCmd = program
121
192
  .command('auth')
122
193
  .description('Authenticate with Overleaf using a session cookie or email/password')
123
- .option('--cookie <session>', 'Session cookie (overleaf_session2 value)')
194
+ .option('--cookie [session]', 'Session cookie; omit the value to be prompted, "-" reads stdin')
124
195
  .option('--email <email>', 'Account email for password login')
125
196
  .option('--password <password>', 'Account password (omit to be prompted; see warning below)')
126
197
  .option('--save-password', 'Persist the password in the config file, in plaintext')
127
198
  .option('--no-save-password', 'Do not persist the password (the default; kept for existing scripts)')
128
199
  .option('--save-local', 'Save to .olauth in current directory')
200
+ .option('--browser', 'Sign in in a browser window; only the session cookie is kept')
129
201
  .addHelpText('after', `
202
+ Easiest: olcli auth --browser opens the login page of the instance in Chrome,
203
+ Edge, Chromium or Brave, with a new temporary profile (not your own), and
204
+ waits up to 5 minutes until you have signed in. Works with SSO, Google, ORCID
205
+ and two-factor logins. olcli keeps only the session cookie; it never sees your
206
+ password. The browser and the profile are removed afterwards. Set
207
+ OLCLI_BROWSER to the browser (an .app bundle or the executable) when it is
208
+ not found. Needs Node 22 or newer.
209
+
210
+ Get your session cookie: sign in to Overleaf in a browser, open the developer
211
+ tools (F12), then Application (Chrome, Edge) or Storage (Firefox), Cookies,
212
+ and copy the value of overleaf_session2 (overleaf.sid on older self-hosted
213
+ instances). Then either:
214
+ olcli auth --cookie prompts, input stays hidden
215
+ pbpaste | olcli auth --cookie - reads the cookie from stdin
216
+ A value given on the command line works too, but lands in your shell history.
217
+
130
218
  The password is not stored unless you ask for it with --save-password. A
131
219
  session cookie is stored either way, and that is what later commands use; the
132
220
  password only buys an automatic re-login once the cookie expires.
@@ -135,15 +223,28 @@ Passing --password puts the password in your shell history. Omit it to be
135
223
  prompted instead, or set OVERLEAF_EMAIL and OVERLEAF_PASSWORD, which every
136
224
  command reads without needing 'auth' at all.`)
137
225
  .action(async (options) => {
226
+ if (options.browser) {
227
+ if (options.cookie || options.email || options.password) {
228
+ console.error(chalk.red('Use either --browser or --cookie or --email/--password, not several.'));
229
+ process.exit(1);
230
+ }
231
+ await authWithBrowser(options);
232
+ return;
233
+ }
138
234
  if (!options.cookie && !options.email && !options.password) {
139
- console.log(chalk.yellow('To authenticate, provide a session cookie:'));
235
+ console.log(chalk.yellow('To authenticate, sign in in a browser window:'));
236
+ console.log();
237
+ console.log(chalk.cyan(' olcli auth --browser'));
238
+ console.log();
239
+ console.log(chalk.yellow('Or provide a session cookie:'));
140
240
  console.log();
141
- console.log('1. Log into overleaf.com in your browser');
142
- console.log('2. Open Developer Tools (F12) → Application → Cookies');
143
- console.log('3. Find the cookie named "overleaf_session2"');
144
- console.log('4. Copy its value and run:');
241
+ console.log('1. Sign in to Overleaf in your browser');
242
+ console.log('2. Open the developer tools (F12), then Application (Chrome, Edge) or Storage (Firefox), then Cookies');
243
+ console.log('3. Copy the value of the cookie named "overleaf_session2"');
244
+ console.log('4. Run this and paste it when asked (input stays hidden):');
145
245
  console.log();
146
- console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"'));
246
+ console.log(chalk.cyan(' olcli auth --cookie'));
247
+ console.log(chalk.dim(' (or pipe it: pbpaste | olcli auth --cookie -)'));
147
248
  console.log();
148
249
  console.log('Or log in with email/password:');
149
250
  console.log(chalk.cyan(' olcli auth --email "you@example.com"'));
@@ -160,12 +261,50 @@ command reads without needing 'auth' at all.`)
160
261
  console.error(chalk.red('--email is required for password login.'));
161
262
  process.exit(1);
162
263
  }
264
+ // The cookie is a secret, so it does not have to travel through argv:
265
+ // no value prompts with hidden input, "-" reads stdin. Resolved before
266
+ // the spinner for the same reason as the password below.
267
+ const promptCookieName = program.opts().cookieName || getSessionCookieName();
268
+ let cookie;
269
+ if (options.cookie === true) {
270
+ try {
271
+ cookie = parseCookieInput(await promptHidden(`Paste the ${promptCookieName} cookie (input hidden): `), promptCookieName);
272
+ }
273
+ catch (error) {
274
+ if (error instanceof NotATerminal) {
275
+ console.error(chalk.red('No terminal available to prompt for the cookie.'));
276
+ console.error('Pipe it instead: olcli auth --cookie - < cookie.txt');
277
+ console.error('Or set OVERLEAF_SESSION, which every command reads.');
278
+ process.exit(1);
279
+ }
280
+ if (error instanceof PromptCancelled) {
281
+ console.error(chalk.red('Cancelled.'));
282
+ process.exit(1);
283
+ }
284
+ throw error;
285
+ }
286
+ }
287
+ else if (options.cookie === '-') {
288
+ if (process.stdin.isTTY) {
289
+ console.error(chalk.dim('Reading the cookie from stdin; paste it, then press Ctrl+D.'));
290
+ }
291
+ cookie = parseCookieInput(await readStdin(), promptCookieName);
292
+ }
293
+ else if (typeof options.cookie === 'string') {
294
+ cookie = parseCookieInput(options.cookie, promptCookieName);
295
+ console.error(chalk.yellow('Warning: the cookie is now in your shell history.'));
296
+ console.error(chalk.dim(' Next time run olcli auth --cookie to be prompted, or pipe it to olcli auth --cookie -'));
297
+ }
298
+ if (options.cookie && !cookie) {
299
+ console.error(chalk.red('The session cookie must not be empty.'));
300
+ process.exit(1);
301
+ }
163
302
  // Resolve the password before the spinner starts: a prompt and a spinner
164
303
  // both own the terminal, and ora would redraw over the prompt line.
165
304
  let password = options.password;
166
305
  if (!options.cookie) {
167
306
  if (password) {
168
- console.log(chalk.yellow('⚠ --password is now in your shell history.'));
307
+ console.log(chalk.yellow(icon('⚠ ', '') + '--password is now in your shell history.'));
169
308
  console.log(chalk.dim(' Omit it to be prompted, or set OVERLEAF_EMAIL/OVERLEAF_PASSWORD.'));
170
309
  }
171
310
  else {
@@ -195,12 +334,13 @@ command reads without needing 'auth' at all.`)
195
334
  try {
196
335
  const baseUrl = program.opts().baseUrl || getBaseUrl();
197
336
  const cookieName = program.opts().cookieName || getSessionCookieName();
198
- if (options.cookie) {
199
- const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName);
200
- const projects = await client.listProjects();
201
- setSessionCookie(options.cookie);
337
+ let projects;
338
+ if (cookie) {
339
+ const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName);
340
+ projects = await client.listProjects();
341
+ setSessionCookie(cookie);
202
342
  if (options.saveLocal) {
203
- saveOlAuth(options.cookie);
343
+ saveOlAuth(cookie);
204
344
  spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`);
205
345
  }
206
346
  else {
@@ -210,7 +350,7 @@ command reads without needing 'auth' at all.`)
210
350
  else {
211
351
  spinner.text = 'Logging in with email/password...';
212
352
  const client = await OverleafClient.fromPasswordLogin(options.email, password, baseUrl);
213
- const projects = await client.listProjects();
353
+ projects = await client.listProjects();
214
354
  persistClientSession(client, cookieName);
215
355
  setBaseUrl(baseUrl);
216
356
  // Opt-in, not opt-out. The session cookie persisted just above is what
@@ -233,38 +373,201 @@ command reads without needing 'auth' at all.`)
233
373
  console.log(chalk.dim('olcli auth when the session expires, or use --save-password.'));
234
374
  }
235
375
  }
236
- console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
376
+ printNextSteps(projects);
237
377
  }
238
378
  catch (error) {
239
- spinner.fail(`Authentication failed: ${error.message}`);
240
- process.exit(1);
379
+ failWith(spinner, error);
241
380
  }
242
381
  });
243
- program
244
- .command('whoami')
245
- .description('Show current authentication status')
246
- .action(async () => {
247
- // Status 1 on both failure paths, so `olcli whoami || olcli auth` works.
248
- // exitCode rather than exit(): the message still has to reach a pipe.
249
- const cookie = getSessionCookie();
250
- if (!cookie) {
251
- console.log(chalk.yellow('Not authenticated'));
252
- process.exitCode = 1;
253
- return;
382
+ /** Print the same lines as every successful `auth`. */
383
+ function printNextSteps(projects) {
384
+ console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
385
+ console.log();
386
+ console.log('Next steps:');
387
+ for (const line of nextStepCommands(projects))
388
+ console.log(` ${chalk.cyan(line)}`);
389
+ }
390
+ /**
391
+ * `auth --browser`. The cookie found in the browser is validated with the
392
+ * same client call as `auth --cookie` (the project list) before it is
393
+ * stored, and only its name and value are stored. Ctrl-C and SIGTERM abort
394
+ * the wait; browserSignIn closes the browser and deletes the profile.
395
+ */
396
+ async function authWithBrowser(options) {
397
+ const baseUrl = program.opts().baseUrl || getBaseUrl();
398
+ const cookieName = program.opts().cookieName || getSessionCookieName();
399
+ const timeout = program.opts().timeout || getTimeout();
400
+ let browser;
401
+ try {
402
+ requireWebSocket();
403
+ browser = findBrowser();
404
+ }
405
+ catch (error) {
406
+ console.error(chalk.red(error.message));
407
+ process.exit(1);
254
408
  }
255
- const spinner = ora('Checking session...').start();
409
+ const abort = new AbortController();
410
+ const onSignal = () => abort.abort();
411
+ process.on('SIGINT', onSignal);
412
+ process.on('SIGTERM', onSignal);
413
+ console.error(`Opening ${browser.name} at ${baseUrl}/login with a temporary profile.`);
414
+ console.error(chalk.dim('Sign in there. olcli waits up to 5 minutes; Ctrl-C cancels.'));
415
+ const spinner = ora('Waiting for the sign-in...').start();
416
+ let projects = [];
256
417
  try {
257
- const baseUrl = program.opts().baseUrl || getBaseUrl();
258
- const cookieName = program.opts().cookieName || getSessionCookieName();
259
- const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName);
260
- const projects = await client.listProjects();
261
- spinner.succeed(`Authenticated with access to ${projects.length} projects`);
418
+ const session = await browserSignIn({
419
+ baseUrl,
420
+ cookieName,
421
+ startBrowser: chromiumLauncher(browser.path),
422
+ signal: abort.signal,
423
+ validate: async (candidate) => {
424
+ const client = await OverleafClient.fromSessionCookie(candidate.value, baseUrl, candidate.name);
425
+ client.setGlobalTimeout(timeout);
426
+ projects = await client.listProjects();
427
+ return true;
428
+ },
429
+ });
430
+ if (session.name !== cookieName)
431
+ setSessionCookieName(session.name);
432
+ setSessionCookie(session.value);
433
+ if (options.saveLocal) {
434
+ saveOlAuth(session.value);
435
+ spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`);
436
+ }
437
+ else {
438
+ spinner.succeed(`Authenticated! Found ${projects.length} projects.`);
439
+ }
440
+ printNextSteps(projects);
262
441
  }
263
442
  catch (error) {
264
- spinner.fail(`Session invalid: ${error.message}`);
443
+ if (error instanceof BrowserSignInAborted) {
444
+ spinner.fail('Cancelled. The browser was closed and its temporary profile deleted.');
445
+ process.exit(130);
446
+ }
447
+ failWith(spinner, error);
448
+ }
449
+ finally {
450
+ process.off('SIGINT', onSignal);
451
+ process.off('SIGTERM', onSignal);
452
+ }
453
+ }
454
+ /**
455
+ * `auth status`: one answer to "am I set up". Replaces `whoami` (is the
456
+ * session valid) and `check` (where do credentials come from), which both
457
+ * stay as hidden aliases.
458
+ *
459
+ * Exit status 1 when no credential is found or the session is rejected, so
460
+ * `olcli auth status || olcli auth` works. exitCode rather than exit(): the
461
+ * report still has to reach a pipe.
462
+ */
463
+ async function authStatus(options) {
464
+ const baseUrl = program.opts().baseUrl || getBaseUrl();
465
+ const cookieName = program.opts().cookieName || getSessionCookieName();
466
+ const resolved = resolveSessionCookie();
467
+ const stored = inspectStoredCredentials();
468
+ const password = resolved ? undefined : getPasswordCredentials();
469
+ const sourceLabel = !resolved
470
+ ? (password ? (stored.envPassword ? 'OVERLEAF_EMAIL/OVERLEAF_PASSWORD' : 'saved password (config file)') : 'none')
471
+ : resolved.source === 'env' ? 'OVERLEAF_SESSION environment variable'
472
+ : resolved.source === 'olauth' ? `.olauth (${resolved.path})`
473
+ : `config file (${resolved.path})`;
474
+ const report = {
475
+ instance: baseUrl,
476
+ account: null,
477
+ credentialSource: sourceLabel,
478
+ cookieName,
479
+ cookie: resolved ? maskSecret(resolved.value) : null,
480
+ configFile: getConfigPath(),
481
+ passwordStored: stored.password,
482
+ signedIn: false,
483
+ };
484
+ const print = () => {
485
+ if (options.json) {
486
+ console.log(JSON.stringify(report, null, 2));
487
+ return;
488
+ }
489
+ const row = (label, value) => console.log(` ${label.padEnd(12)} ${value}`);
490
+ row('Instance', report.instance);
491
+ row('Account', report.account ?? chalk.dim(report.signedIn ? 'unknown' : '-'));
492
+ row('Credential', report.credentialSource);
493
+ row('Cookie', report.cookie ? `${report.cookieName} = ${report.cookie}` : chalk.dim('-'));
494
+ row('Config file', report.configFile);
495
+ if (report.passwordStored) {
496
+ row('Password', chalk.yellow('stored in plaintext in the config file (olcli logout removes it)'));
497
+ }
498
+ if (report.signedIn) {
499
+ row('Session', chalk.green(`valid, ${report.projects} project(s)`));
500
+ }
501
+ };
502
+ let failure;
503
+ if (!resolved && !password) {
504
+ report.error = 'Not signed in';
505
+ }
506
+ else {
507
+ const spinner = options.json ? null : ora('Checking session...').start();
508
+ try {
509
+ const client = resolved
510
+ ? await OverleafClient.fromSessionCookie(resolved.value, baseUrl, cookieName)
511
+ : await OverleafClient.fromPasswordLogin(password.email, password.password, baseUrl);
512
+ const timeout = program.opts().timeout || getTimeout();
513
+ client.setGlobalTimeout(timeout);
514
+ const projects = await client.listProjects();
515
+ report.signedIn = true;
516
+ report.projects = projects.length;
517
+ report.account = client.accountEmail ?? (password ? password.email : null);
518
+ spinner?.stop();
519
+ }
520
+ catch (error) {
521
+ spinner?.stop();
522
+ failure = error;
523
+ report.error = formatError(error, { verbose: true })[0];
524
+ }
525
+ }
526
+ print();
527
+ if (!report.signedIn) {
265
528
  process.exitCode = 1;
529
+ if (options.json)
530
+ return;
531
+ console.log();
532
+ if (!resolved && !password) {
533
+ console.log(chalk.yellow('Not signed in.'));
534
+ console.log(`Run: ${chalk.cyan('olcli auth')}`);
535
+ }
536
+ else {
537
+ const [first, ...rest] = formatError(failure, { verbose: Boolean(program.opts().verbose) });
538
+ console.error(chalk.red(first));
539
+ for (const line of rest)
540
+ console.error(chalk.dim(` ${line}`));
541
+ }
266
542
  }
267
- });
543
+ }
544
+ /** Hidden alias for a command folded into `auth status`. */
545
+ function deprecatedAlias(name, description) {
546
+ program
547
+ .command(name, { hidden: true })
548
+ .description(description)
549
+ .option('--json', 'Output as JSON')
550
+ .action(async (options) => {
551
+ console.error(chalk.dim(`'olcli ${name}' is now 'olcli auth status'.`));
552
+ await authStatus(options);
553
+ });
554
+ }
555
+ authCmd
556
+ .command('status')
557
+ .description('Show instance, account, credential source and a masked cookie')
558
+ .option('--json', 'Output as JSON')
559
+ .addHelpText('after', `
560
+ Exits 1 when no credential is found or Overleaf rejects the session, so
561
+ 'olcli auth status || olcli auth' signs in only when needed. The cookie is
562
+ shown masked: its last four characters and its length.
563
+
564
+ Credential sources, first match wins:
565
+ 1. OVERLEAF_SESSION environment variable
566
+ 2. .olauth file in the current directory
567
+ 3. Global config file
568
+ 4. OVERLEAF_EMAIL/OVERLEAF_PASSWORD, or a password saved with --save-password`)
569
+ .action(authStatus);
570
+ deprecatedAlias('whoami', 'Alias of: auth status');
268
571
  program
269
572
  .command('logout')
270
573
  .description('Clear stored credentials')
@@ -342,8 +645,7 @@ program
342
645
  }
343
646
  }
344
647
  catch (error) {
345
- spinner.fail(`Failed: ${error.message}`);
346
- process.exit(1);
648
+ failWith(spinner, error);
347
649
  }
348
650
  });
349
651
  program
@@ -371,14 +673,13 @@ program
371
673
  // Sort entities by path for nice display
372
674
  const sorted = entities.sort((a, b) => a.path.localeCompare(b.path));
373
675
  for (const entity of sorted) {
374
- const icon = entity.type === 'doc' ? '📄' : '📎';
375
- console.log(` ${icon} ${entity.path}`);
676
+ const marker = entity.type === 'doc' ? icon('📄', 'doc ') : icon('📎', 'file');
677
+ console.log(` ${marker} ${entity.path}`);
376
678
  }
377
679
  setLastProject(proj.id);
378
680
  }
379
681
  catch (error) {
380
- spinner.fail(`Failed: ${error.message}`);
381
- process.exit(1);
682
+ failWith(spinner, error);
382
683
  }
383
684
  });
384
685
  const commentsCmd = program
@@ -478,8 +779,7 @@ commentsCmd
478
779
  setLastProject(proj.id);
479
780
  }
480
781
  catch (error) {
481
- spinner.fail(`Failed: ${error.message}`);
482
- process.exit(1);
782
+ failWith(spinner, error);
483
783
  }
484
784
  });
485
785
  commentsCmd
@@ -502,8 +802,7 @@ commentsCmd
502
802
  setLastProject(proj.id);
503
803
  }
504
804
  catch (error) {
505
- spinner.fail(`Failed: ${error.message}`);
506
- process.exit(1);
805
+ failWith(spinner, error);
507
806
  }
508
807
  });
509
808
  commentsCmd
@@ -526,8 +825,7 @@ commentsCmd
526
825
  setLastProject(proj.id);
527
826
  }
528
827
  catch (error) {
529
- spinner.fail(`Failed: ${error.message}`);
530
- process.exit(1);
828
+ failWith(spinner, error);
531
829
  }
532
830
  });
533
831
  commentsCmd
@@ -550,8 +848,7 @@ commentsCmd
550
848
  setLastProject(proj.id);
551
849
  }
552
850
  catch (error) {
553
- spinner.fail(`Failed: ${error.message}`);
554
- process.exit(1);
851
+ failWith(spinner, error);
555
852
  }
556
853
  });
557
854
  commentsCmd
@@ -574,8 +871,7 @@ commentsCmd
574
871
  setLastProject(proj.id);
575
872
  }
576
873
  catch (error) {
577
- spinner.fail(`Failed: ${error.message}`);
578
- process.exit(1);
874
+ failWith(spinner, error);
579
875
  }
580
876
  });
581
877
  commentsCmd
@@ -613,8 +909,7 @@ commentsCmd
613
909
  setLastProject(proj.id);
614
910
  }
615
911
  catch (error) {
616
- spinner.fail(`Failed: ${error.message}`);
617
- process.exit(1);
912
+ failWith(spinner, error);
618
913
  }
619
914
  });
620
915
  // ─────────────────────────────────────────────────────────────────────────────
@@ -622,7 +917,10 @@ commentsCmd
622
917
  // ─────────────────────────────────────────────────────────────────────────────
623
918
  const changesCmd = program
624
919
  .command('changes')
625
- .description('Review Overleaf tracked changes (suggested edits). Requires a project whose owner has an Overleaf premium plan; collaborators on any plan can then use it');
920
+ .description('Accept or reject tracked changes (needs a premium project owner)')
921
+ .addHelpText('after', `
922
+ Tracked changes need a project whose owner has an Overleaf premium plan;
923
+ collaborators on any plan can then accept and reject them.`);
626
924
  changesCmd
627
925
  .command('accept <ids...>')
628
926
  .description('Accept tracked changes by id (ids from: olcli comments list --changes)')
@@ -648,8 +946,7 @@ changesCmd
648
946
  setLastProject(proj.id);
649
947
  }
650
948
  catch (error) {
651
- spinner.fail(`Failed: ${error.message}`);
652
- process.exit(1);
949
+ failWith(spinner, error);
653
950
  }
654
951
  });
655
952
  changesCmd
@@ -688,8 +985,7 @@ changesCmd
688
985
  setLastProject(proj.id);
689
986
  }
690
987
  catch (error) {
691
- spinner.fail(`Failed: ${error.message}`);
692
- process.exit(1);
988
+ failWith(spinner, error);
693
989
  }
694
990
  });
695
991
  program
@@ -709,8 +1005,7 @@ program
709
1005
  setLastProject(proj.id);
710
1006
  }
711
1007
  catch (error) {
712
- spinner.fail(`Failed: ${error.message}`);
713
- process.exit(1);
1008
+ failWith(spinner, error);
714
1009
  }
715
1010
  });
716
1011
  program
@@ -724,14 +1019,13 @@ program
724
1019
  const client = await getClient(options.cookie);
725
1020
  const proj = await resolveProject(client, project);
726
1021
  const zip = await client.downloadProject(proj.id);
727
- const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.zip`;
1022
+ const outputPath = options.output || `${safeLocalName(proj.name)}.zip`;
728
1023
  writeFileSync(outputPath, zip);
729
1024
  spinner.succeed(`Downloaded: ${outputPath} (${(zip.length / 1024).toFixed(1)} KB)`);
730
1025
  setLastProject(proj.id);
731
1026
  }
732
1027
  catch (error) {
733
- spinner.fail(`Failed: ${error.message}`);
734
- process.exit(1);
1028
+ failWith(spinner, error);
735
1029
  }
736
1030
  });
737
1031
  program
@@ -747,14 +1041,13 @@ program
747
1041
  const proj = await resolveProject(client, project);
748
1042
  spinner.text = 'Compiling...';
749
1043
  const pdf = await client.downloadPdf(proj.id, undefined, options.resource);
750
- const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.pdf`;
1044
+ const outputPath = options.output || `${safeLocalName(proj.name)}.pdf`;
751
1045
  writeFileSync(outputPath, pdf);
752
1046
  spinner.succeed(`Downloaded PDF: ${outputPath} (${(pdf.length / 1024).toFixed(1)} KB)`);
753
1047
  setLastProject(proj.id);
754
1048
  }
755
1049
  catch (error) {
756
- spinner.fail(`Failed: ${error.message}`);
757
- process.exit(1);
1050
+ failWith(spinner, error);
758
1051
  }
759
1052
  });
760
1053
  program
@@ -812,8 +1105,7 @@ program
812
1105
  setLastProject(proj.id);
813
1106
  }
814
1107
  catch (error) {
815
- spinner.fail(`Failed: ${error.message}`);
816
- process.exit(1);
1108
+ failWith(spinner, error);
817
1109
  }
818
1110
  });
819
1111
  // ─────────────────────────────────────────────────────────────────────────────
@@ -853,8 +1145,7 @@ program
853
1145
  setLastProject(proj.id);
854
1146
  }
855
1147
  catch (error) {
856
- spinner.fail(`Failed: ${error.message}`);
857
- process.exit(1);
1148
+ failWith(spinner, error);
858
1149
  }
859
1150
  });
860
1151
  // ─────────────────────────────────────────────────────────────────────────────
@@ -877,8 +1168,7 @@ program
877
1168
  setLastProject(proj.id);
878
1169
  }
879
1170
  catch (error) {
880
- spinner.fail(`Failed: ${error.message}`);
881
- process.exit(1);
1171
+ failWith(spinner, error);
882
1172
  }
883
1173
  });
884
1174
  program
@@ -919,8 +1209,7 @@ Examples:
919
1209
  setLastProject(proj.id);
920
1210
  }
921
1211
  catch (error) {
922
- spinner.fail(`Failed: ${error.message}`);
923
- process.exit(1);
1212
+ failWith(spinner, error);
924
1213
  }
925
1214
  });
926
1215
  // ─────────────────────────────────────────────────────────────────────────────
@@ -957,8 +1246,7 @@ projectCmd
957
1246
  console.log(` ${chalk.cyan(created.url)}`);
958
1247
  }
959
1248
  catch (error) {
960
- spinner.fail(`Failed: ${error.message}`);
961
- process.exit(1);
1249
+ failWith(spinner, error);
962
1250
  }
963
1251
  });
964
1252
  projectCmd
@@ -986,8 +1274,7 @@ projectCmd
986
1274
  setLastProject(proj.id);
987
1275
  }
988
1276
  catch (error) {
989
- spinner.fail(`Failed: ${error.message}`);
990
- process.exit(1);
1277
+ failWith(spinner, error);
991
1278
  }
992
1279
  });
993
1280
  projectCmd
@@ -1085,8 +1372,7 @@ projectCmd
1085
1372
  }
1086
1373
  }
1087
1374
  catch (error) {
1088
- spinner.fail(`Failed: ${error.message}`);
1089
- process.exit(1);
1375
+ failWith(spinner, error);
1090
1376
  }
1091
1377
  });
1092
1378
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1108,8 +1394,7 @@ program
1108
1394
  setLastProject(proj.id);
1109
1395
  }
1110
1396
  catch (error) {
1111
- spinner.fail(`Compilation failed: ${error.message}`);
1112
- process.exit(1);
1397
+ failWith(spinner, error);
1113
1398
  }
1114
1399
  });
1115
1400
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1142,19 +1427,16 @@ program
1142
1427
  const client = await getClient(options.cookie);
1143
1428
  // Resolve project if needed
1144
1429
  if (!projectId) {
1145
- let proj = await client.getProjectById(project);
1146
- if (!proj) {
1147
- proj = await client.getProject(project);
1148
- }
1149
- if (!proj) {
1150
- spinner.fail(`Project not found: ${project}`);
1151
- process.exit(1);
1152
- }
1430
+ const proj = await client.findProject(project);
1153
1431
  projectId = proj.id;
1154
1432
  projectName = proj.name;
1155
- // Default directory is project name (sanitized) if not specified
1433
+ // Default directory is the project name, made safe for a path
1156
1434
  if (!dir) {
1157
- targetDir = proj.name.replace(/[^a-zA-Z0-9-_]/g, '_');
1435
+ targetDir = defaultProjectDir(proj.id, proj.name);
1436
+ if (targetDir === legacyLocalName(proj.name) && targetDir !== safeLocalName(proj.name)) {
1437
+ spinner.info(`Using existing directory ${targetDir}/ (created by an earlier olcli)`);
1438
+ spinner.start('Downloading project...');
1439
+ }
1158
1440
  }
1159
1441
  }
1160
1442
  spinner.text = 'Downloading project...';
@@ -1248,8 +1530,7 @@ program
1248
1530
  setLastProject(projectId);
1249
1531
  }
1250
1532
  catch (error) {
1251
- spinner.fail(`Failed: ${error.message}`);
1252
- process.exit(1);
1533
+ failWith(spinner, error);
1253
1534
  }
1254
1535
  });
1255
1536
  program
@@ -1303,14 +1584,7 @@ program
1303
1584
  const client = await getClient(options.cookie);
1304
1585
  // Resolve project if needed
1305
1586
  if (!projectId) {
1306
- let proj = await client.getProjectById(projectName);
1307
- if (!proj) {
1308
- proj = await client.getProject(projectName);
1309
- }
1310
- if (!proj) {
1311
- spinner.fail(`Project not found: ${projectName}`);
1312
- process.exit(1);
1313
- }
1587
+ const proj = await client.findProject(projectName);
1314
1588
  projectId = proj.id;
1315
1589
  projectName = proj.name;
1316
1590
  }
@@ -1473,7 +1747,7 @@ program
1473
1747
  spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
1474
1748
  }
1475
1749
  if (deleted > 0) {
1476
- console.log(chalk.dim(` ✖ ${deleted} deleted on remote`));
1750
+ console.log(chalk.dim(` ${icon('✖', '-')} ${deleted} deleted on remote`));
1477
1751
  }
1478
1752
  if (deleteSkipped.length > 0) {
1479
1753
  console.log(chalk.yellow(` ${deleteSkipped.length} deletion(s) skipped:`));
@@ -1487,8 +1761,7 @@ program
1487
1761
  setLastProject(projectId);
1488
1762
  }
1489
1763
  catch (error) {
1490
- spinner.fail(`Failed: ${error.message}`);
1491
- process.exit(1);
1764
+ failWith(spinner, error);
1492
1765
  }
1493
1766
  });
1494
1767
  program
@@ -1530,14 +1803,7 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1530
1803
  const client = await getClient(options.cookie);
1531
1804
  // Resolve project
1532
1805
  if (!projectId) {
1533
- let proj = await client.getProjectById(projectName);
1534
- if (!proj) {
1535
- proj = await client.getProject(projectName);
1536
- }
1537
- if (!proj) {
1538
- spinner.fail(`Project not found: ${projectName}`);
1539
- process.exit(1);
1540
- }
1806
+ const proj = await client.findProject(projectName);
1541
1807
  projectId = proj.id;
1542
1808
  projectName = proj.name;
1543
1809
  }
@@ -1711,13 +1977,13 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1711
1977
  spinner.succeed(`Synced "${projectName}"`);
1712
1978
  }
1713
1979
  // Summary
1714
- console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`));
1715
- console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`));
1980
+ console.log(chalk.dim(` ${icon('↓', '<')} ${filesUpdatedLocally.length} pulled from remote`));
1981
+ console.log(chalk.dim(` ${icon('↑', '>')} ${filesToUpload.length} pushed to remote`));
1716
1982
  if (filesDeletedRemote.length > 0) {
1717
- console.log(chalk.dim(` ✖ ${filesDeletedRemote.length} deleted on remote`));
1983
+ console.log(chalk.dim(` ${icon('✖', '-')} ${filesDeletedRemote.length} deleted on remote`));
1718
1984
  }
1719
1985
  if (filesDeleteSkipped.length > 0) {
1720
- console.log(chalk.yellow(` ⚠ ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`));
1986
+ console.log(chalk.yellow(` ${icon('⚠', '!')} ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`));
1721
1987
  }
1722
1988
  // The program-level --verbose, not a sync option: commander assigns a
1723
1989
  // flag declared on both levels to the program, so a local --verbose here
@@ -1751,13 +2017,12 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1751
2017
  setLastProject(projectId);
1752
2018
  }
1753
2019
  catch (error) {
1754
- spinner.fail(`Failed: ${error.message}`);
1755
- process.exit(1);
2020
+ failWith(spinner, error);
1756
2021
  }
1757
2022
  });
1758
2023
  program
1759
2024
  .command('diff [project] [dir]')
1760
- .description('Show content-level differences between local files and the remote project')
2025
+ .description('Show content differences between local files and the remote project')
1761
2026
  .option('--name-only', 'List changed paths instead of printing patches')
1762
2027
  .option('--file <path>', 'Diff a single file')
1763
2028
  .option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
@@ -1952,8 +2217,7 @@ where the markup only covers the root document.`)
1952
2217
  process.exitCode = differencesExitCode(entries);
1953
2218
  }
1954
2219
  catch (error) {
1955
- spinner.fail(`Failed: ${error.message}`);
1956
- process.exit(failureCode);
2220
+ failWith(spinner, error, failureCode);
1957
2221
  }
1958
2222
  });
1959
2223
  /**
@@ -2258,41 +2522,32 @@ program
2258
2522
  console.log(chalk.dim('Note: *.pdf is also ignored when a same-named *.tex/.ltx exists in the same folder.'));
2259
2523
  }
2260
2524
  });
2261
- program
2262
- .command('check')
2263
- .description('Show credential sources and config path')
2264
- .action(() => {
2265
- console.log(chalk.bold('Configuration:'));
2266
- console.log(` Config file: ${getConfigPath()}`);
2267
- console.log();
2268
- console.log(chalk.bold('Credential sources (in order):'));
2269
- console.log(' 1. OVERLEAF_SESSION environment variable');
2270
- console.log(' 2. .olauth file in current directory');
2271
- console.log(' 3. Global config file');
2272
- console.log();
2273
- const cookie = getSessionCookie();
2274
- if (cookie) {
2275
- console.log(chalk.green('✓ Session cookie found'));
2276
- console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`));
2277
- }
2278
- else {
2279
- console.log(chalk.yellow('✗ No session cookie found'));
2280
- }
2281
- // A stored password is a credential source this command used to omit,
2282
- // which made it impossible to answer "is my password on disk?" without
2283
- // opening the config file. Never print the value - only whether it exists
2284
- // and which source it came from.
2285
- const stored = inspectStoredCredentials();
2286
- if (stored.envPassword) {
2287
- console.log(chalk.yellow('⚠ Password set via OVERLEAF_EMAIL/OVERLEAF_PASSWORD'));
2288
- }
2289
- else if (stored.password) {
2290
- console.log(chalk.yellow('⚠ Password stored in plaintext in the config file'));
2291
- console.log(chalk.dim(" Remove it with 'olcli logout', then re-auth without --save-password."));
2292
- }
2293
- if (stored.olAuthPath) {
2294
- console.log(chalk.dim(` .olauth present: ${stored.olAuthPath}`));
2295
- }
2296
- });
2525
+ deprecatedAlias('check', 'Alias of: auth status');
2526
+ // ─────────────────────────────────────────────────────────────────────────────
2527
+ // ROOT HELP GROUPS
2528
+ // ─────────────────────────────────────────────────────────────────────────────
2529
+ // Workflow order: sign in, find a project, sync it, touch single files, build,
2530
+ // review, settings. A visible top-level command without a group fails a test.
2531
+ const HELP_GROUPS = [
2532
+ ['Account:', ['auth', 'logout']],
2533
+ ['Projects:', ['list', 'info', 'project']],
2534
+ ['Sync:', ['pull', 'push', 'sync', 'diff']],
2535
+ ['Files:', ['download', 'upload', 'delete', 'rename', 'zip']],
2536
+ ['Build:', ['compile', 'pdf', 'output']],
2537
+ ['Review:', ['comments', 'changes']],
2538
+ ['Settings:', ['config', 'ignored']],
2539
+ ];
2540
+ {
2541
+ const order = new Map();
2542
+ HELP_GROUPS.forEach(([heading, names], group) => {
2543
+ names.forEach((name, position) => {
2544
+ program.commands.find((c) => c.name() === name)?.helpGroup(heading);
2545
+ order.set(name, group * 100 + position);
2546
+ });
2547
+ });
2548
+ // Commander lists groups in the order their first command appears.
2549
+ program.commands.sort((a, b) => (order.get(a.name()) ?? 9999) - (order.get(b.name()) ?? 9999));
2550
+ program.commandsGroup('Help:').helpCommand(true);
2551
+ }
2297
2552
  program.parse(process.argv);
2298
2553
  //# sourceMappingURL=cli.js.map