@aloth/olcli 0.15.2 → 0.16.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.
Files changed (47) 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 +455 -177
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +52 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +199 -42
  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/mcp-server.d.ts.map +1 -1
  21. package/dist/mcp-server.js +27 -19
  22. package/dist/mcp-server.js.map +1 -1
  23. package/dist/mcp.js +1 -1
  24. package/dist/mcp.js.map +1 -1
  25. package/dist/move-plan.d.ts +75 -0
  26. package/dist/move-plan.d.ts.map +1 -0
  27. package/dist/move-plan.js +168 -0
  28. package/dist/move-plan.js.map +1 -0
  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.js +3 -2
  42. package/dist/remote-helper.js.map +1 -1
  43. package/dist/term.d.ts +25 -0
  44. package/dist/term.d.ts.map +1 -0
  45. package/dist/term.js +42 -0
  46. package/dist/term.js.map +1 -0
  47. 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:'));
140
236
  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:');
237
+ console.log(chalk.cyan(' olcli auth --browser'));
145
238
  console.log();
146
- console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"'));
239
+ console.log(chalk.yellow('Or provide a session cookie:'));
240
+ console.log();
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):');
245
+ console.log();
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,27 +1168,48 @@ 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
885
1175
  .command('rename <oldname> <newname> [project]')
886
1176
  .alias('mv')
887
- .description('Rename a file or folder in a project')
1177
+ .description('Rename or move a file or folder in a project')
888
1178
  .option('--cookie <session>', 'Session cookie override')
1179
+ .addHelpText('after', `
1180
+ The destination decides what happens:
1181
+ intro.tex rename in place, the folder stays the same
1182
+ chapters/intro.tex move into chapters/ and rename; missing folders are created
1183
+ chapters/ move into chapters/ and keep the name
1184
+ / or /intro.tex move to the project root
1185
+
1186
+ Nothing is written when the destination already exists, the source is not
1187
+ found, or a folder would be moved into itself or one of its subfolders.
1188
+
1189
+ Examples:
1190
+ olcli mv draft.tex intro.tex
1191
+ olcli mv chapters/draft.tex chapters/intro/intro.tex
1192
+ olcli mv figures/old.png archive/
1193
+ olcli mv sub/notes.txt /`)
889
1194
  .action(async (oldname, newname, project, options) => {
890
1195
  const spinner = ora('Renaming file...').start();
891
1196
  try {
892
1197
  const client = await getClient(options.cookie);
893
1198
  const proj = await resolveProject(client, project);
894
- await client.renameByPath(proj.id, oldname, newname);
895
- spinner.succeed(`Renamed: ${oldname} → ${newname}`);
1199
+ const plan = await client.planMove(proj.id, oldname, newname);
1200
+ if (plan.noop) {
1201
+ spinner.info(`Nothing to do: ${plan.from} is already at ${plan.to}`);
1202
+ setLastProject(proj.id);
1203
+ return;
1204
+ }
1205
+ const result = await client.applyMovePlan(proj.id, plan);
1206
+ const verb = plan.move ? (plan.rename ? 'Moved and renamed' : 'Moved') : 'Renamed';
1207
+ const created = result.createdFolders.length ? ` (created ${result.createdFolders.join(', ')})` : '';
1208
+ spinner.succeed(`${verb}: ${result.from} → ${result.to || '/'}${created}`);
896
1209
  setLastProject(proj.id);
897
1210
  }
898
1211
  catch (error) {
899
- spinner.fail(`Failed: ${error.message}`);
900
- process.exit(1);
1212
+ failWith(spinner, error);
901
1213
  }
902
1214
  });
903
1215
  // ─────────────────────────────────────────────────────────────────────────────
@@ -934,8 +1246,7 @@ projectCmd
934
1246
  console.log(` ${chalk.cyan(created.url)}`);
935
1247
  }
936
1248
  catch (error) {
937
- spinner.fail(`Failed: ${error.message}`);
938
- process.exit(1);
1249
+ failWith(spinner, error);
939
1250
  }
940
1251
  });
941
1252
  projectCmd
@@ -963,8 +1274,7 @@ projectCmd
963
1274
  setLastProject(proj.id);
964
1275
  }
965
1276
  catch (error) {
966
- spinner.fail(`Failed: ${error.message}`);
967
- process.exit(1);
1277
+ failWith(spinner, error);
968
1278
  }
969
1279
  });
970
1280
  projectCmd
@@ -1062,8 +1372,7 @@ projectCmd
1062
1372
  }
1063
1373
  }
1064
1374
  catch (error) {
1065
- spinner.fail(`Failed: ${error.message}`);
1066
- process.exit(1);
1375
+ failWith(spinner, error);
1067
1376
  }
1068
1377
  });
1069
1378
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1085,8 +1394,7 @@ program
1085
1394
  setLastProject(proj.id);
1086
1395
  }
1087
1396
  catch (error) {
1088
- spinner.fail(`Compilation failed: ${error.message}`);
1089
- process.exit(1);
1397
+ failWith(spinner, error);
1090
1398
  }
1091
1399
  });
1092
1400
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1119,19 +1427,16 @@ program
1119
1427
  const client = await getClient(options.cookie);
1120
1428
  // Resolve project if needed
1121
1429
  if (!projectId) {
1122
- let proj = await client.getProjectById(project);
1123
- if (!proj) {
1124
- proj = await client.getProject(project);
1125
- }
1126
- if (!proj) {
1127
- spinner.fail(`Project not found: ${project}`);
1128
- process.exit(1);
1129
- }
1430
+ const proj = await client.findProject(project);
1130
1431
  projectId = proj.id;
1131
1432
  projectName = proj.name;
1132
- // Default directory is project name (sanitized) if not specified
1433
+ // Default directory is the project name, made safe for a path
1133
1434
  if (!dir) {
1134
- 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
+ }
1135
1440
  }
1136
1441
  }
1137
1442
  spinner.text = 'Downloading project...';
@@ -1225,8 +1530,7 @@ program
1225
1530
  setLastProject(projectId);
1226
1531
  }
1227
1532
  catch (error) {
1228
- spinner.fail(`Failed: ${error.message}`);
1229
- process.exit(1);
1533
+ failWith(spinner, error);
1230
1534
  }
1231
1535
  });
1232
1536
  program
@@ -1280,14 +1584,7 @@ program
1280
1584
  const client = await getClient(options.cookie);
1281
1585
  // Resolve project if needed
1282
1586
  if (!projectId) {
1283
- let proj = await client.getProjectById(projectName);
1284
- if (!proj) {
1285
- proj = await client.getProject(projectName);
1286
- }
1287
- if (!proj) {
1288
- spinner.fail(`Project not found: ${projectName}`);
1289
- process.exit(1);
1290
- }
1587
+ const proj = await client.findProject(projectName);
1291
1588
  projectId = proj.id;
1292
1589
  projectName = proj.name;
1293
1590
  }
@@ -1450,7 +1747,7 @@ program
1450
1747
  spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
1451
1748
  }
1452
1749
  if (deleted > 0) {
1453
- console.log(chalk.dim(` ✖ ${deleted} deleted on remote`));
1750
+ console.log(chalk.dim(` ${icon('✖', '-')} ${deleted} deleted on remote`));
1454
1751
  }
1455
1752
  if (deleteSkipped.length > 0) {
1456
1753
  console.log(chalk.yellow(` ${deleteSkipped.length} deletion(s) skipped:`));
@@ -1464,8 +1761,7 @@ program
1464
1761
  setLastProject(projectId);
1465
1762
  }
1466
1763
  catch (error) {
1467
- spinner.fail(`Failed: ${error.message}`);
1468
- process.exit(1);
1764
+ failWith(spinner, error);
1469
1765
  }
1470
1766
  });
1471
1767
  program
@@ -1507,14 +1803,7 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1507
1803
  const client = await getClient(options.cookie);
1508
1804
  // Resolve project
1509
1805
  if (!projectId) {
1510
- let proj = await client.getProjectById(projectName);
1511
- if (!proj) {
1512
- proj = await client.getProject(projectName);
1513
- }
1514
- if (!proj) {
1515
- spinner.fail(`Project not found: ${projectName}`);
1516
- process.exit(1);
1517
- }
1806
+ const proj = await client.findProject(projectName);
1518
1807
  projectId = proj.id;
1519
1808
  projectName = proj.name;
1520
1809
  }
@@ -1688,13 +1977,13 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1688
1977
  spinner.succeed(`Synced "${projectName}"`);
1689
1978
  }
1690
1979
  // Summary
1691
- console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`));
1692
- 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`));
1693
1982
  if (filesDeletedRemote.length > 0) {
1694
- console.log(chalk.dim(` ✖ ${filesDeletedRemote.length} deleted on remote`));
1983
+ console.log(chalk.dim(` ${icon('✖', '-')} ${filesDeletedRemote.length} deleted on remote`));
1695
1984
  }
1696
1985
  if (filesDeleteSkipped.length > 0) {
1697
- console.log(chalk.yellow(` ⚠ ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`));
1986
+ console.log(chalk.yellow(` ${icon('⚠', '!')} ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`));
1698
1987
  }
1699
1988
  // The program-level --verbose, not a sync option: commander assigns a
1700
1989
  // flag declared on both levels to the program, so a local --verbose here
@@ -1728,13 +2017,12 @@ skipped, or pushed. Combine it with --dry-run to preview deletions:
1728
2017
  setLastProject(projectId);
1729
2018
  }
1730
2019
  catch (error) {
1731
- spinner.fail(`Failed: ${error.message}`);
1732
- process.exit(1);
2020
+ failWith(spinner, error);
1733
2021
  }
1734
2022
  });
1735
2023
  program
1736
2024
  .command('diff [project] [dir]')
1737
- .description('Show content-level differences between local files and the remote project')
2025
+ .description('Show content differences between local files and the remote project')
1738
2026
  .option('--name-only', 'List changed paths instead of printing patches')
1739
2027
  .option('--file <path>', 'Diff a single file')
1740
2028
  .option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
@@ -1929,8 +2217,7 @@ where the markup only covers the root document.`)
1929
2217
  process.exitCode = differencesExitCode(entries);
1930
2218
  }
1931
2219
  catch (error) {
1932
- spinner.fail(`Failed: ${error.message}`);
1933
- process.exit(failureCode);
2220
+ failWith(spinner, error, failureCode);
1934
2221
  }
1935
2222
  });
1936
2223
  /**
@@ -2235,41 +2522,32 @@ program
2235
2522
  console.log(chalk.dim('Note: *.pdf is also ignored when a same-named *.tex/.ltx exists in the same folder.'));
2236
2523
  }
2237
2524
  });
2238
- program
2239
- .command('check')
2240
- .description('Show credential sources and config path')
2241
- .action(() => {
2242
- console.log(chalk.bold('Configuration:'));
2243
- console.log(` Config file: ${getConfigPath()}`);
2244
- console.log();
2245
- console.log(chalk.bold('Credential sources (in order):'));
2246
- console.log(' 1. OVERLEAF_SESSION environment variable');
2247
- console.log(' 2. .olauth file in current directory');
2248
- console.log(' 3. Global config file');
2249
- console.log();
2250
- const cookie = getSessionCookie();
2251
- if (cookie) {
2252
- console.log(chalk.green('✓ Session cookie found'));
2253
- console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`));
2254
- }
2255
- else {
2256
- console.log(chalk.yellow('✗ No session cookie found'));
2257
- }
2258
- // A stored password is a credential source this command used to omit,
2259
- // which made it impossible to answer "is my password on disk?" without
2260
- // opening the config file. Never print the value - only whether it exists
2261
- // and which source it came from.
2262
- const stored = inspectStoredCredentials();
2263
- if (stored.envPassword) {
2264
- console.log(chalk.yellow('⚠ Password set via OVERLEAF_EMAIL/OVERLEAF_PASSWORD'));
2265
- }
2266
- else if (stored.password) {
2267
- console.log(chalk.yellow('⚠ Password stored in plaintext in the config file'));
2268
- console.log(chalk.dim(" Remove it with 'olcli logout', then re-auth without --save-password."));
2269
- }
2270
- if (stored.olAuthPath) {
2271
- console.log(chalk.dim(` .olauth present: ${stored.olAuthPath}`));
2272
- }
2273
- });
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
+ }
2274
2552
  program.parse(process.argv);
2275
2553
  //# sourceMappingURL=cli.js.map