@aloth/olcli 0.11.0 → 0.13.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 CHANGED
@@ -8,20 +8,23 @@
8
8
  import { Command } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import ora from 'ora';
11
- import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
11
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
12
12
  import { join, dirname, basename } from 'node:path';
13
+ import { tmpdir } from 'node:os';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { OverleafClient } from './client.js';
15
16
  import { resolveRemotePath, resolveWithin, normalizeRemotePath } from './paths.js';
16
17
  import { planProjectRenames } from './rename-plan.js';
17
18
  import { scanLocalFiles } from './scan.js';
18
- import { compareTrees, filterRemoteTree, renderFileDiff, statusLetter } from './diff.js';
19
+ import { compareTrees, DIFF_EXIT_FAILURE, differencesExitCode, filterRemoteTree, renderFileDiff, statusLetter, } from './diff.js';
20
+ import { DIFF_OUTPUT_DIR, LatexdiffError, RootDocumentError, buildLatexdiffArgs, diffOutputPath, isTexPath, latexdiffInstallHint, materializeTree, remoteScratchPath, resolveRootDocument, runLatexdiff, } from './latexdiff.js';
19
21
  import { loadIgnore } from './ignore.js';
20
22
  // Read version from package.json
21
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
24
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
23
25
  const VERSION = pkg.version;
24
- import { getSessionCookie, setSessionCookie, setLastProject, getConfigPath, saveOlAuth, clearConfig, getBaseUrl, setBaseUrl, getSessionCookieName, setSessionCookieName, getTimeout, setTimeout, getPasswordCredentials, setPasswordCredentials } from './config.js';
26
+ import { getSessionCookie, setSessionCookie, setLastProject, getConfigPath, saveOlAuth, clearConfig, getBaseUrl, setBaseUrl, getSessionCookieName, setSessionCookieName, getTimeout, setTimeout, getPasswordCredentials, setPasswordCredentials, clearOlAuth, inspectStoredCredentials } from './config.js';
27
+ import { promptHidden, PromptCancelled, NotATerminal } from './prompt.js';
25
28
  const program = new Command();
26
29
  program
27
30
  .name('olcli')
@@ -114,9 +117,18 @@ program
114
117
  .description('Authenticate with Overleaf using a session cookie or email/password')
115
118
  .option('--cookie <session>', 'Session cookie (overleaf_session2 value)')
116
119
  .option('--email <email>', 'Account email for password login')
117
- .option('--password <password>', 'Account password for password login')
118
- .option('--no-save-password', 'Do not persist email/password credentials')
120
+ .option('--password <password>', 'Account password (omit to be prompted; see warning below)')
121
+ .option('--save-password', 'Persist the password in the config file, in plaintext')
122
+ .option('--no-save-password', 'Do not persist the password (the default; kept for existing scripts)')
119
123
  .option('--save-local', 'Save to .olauth in current directory')
124
+ .addHelpText('after', `
125
+ The password is not stored unless you ask for it with --save-password. A
126
+ session cookie is stored either way, and that is what later commands use; the
127
+ password only buys an automatic re-login once the cookie expires.
128
+
129
+ Passing --password puts the password in your shell history. Omit it to be
130
+ prompted instead, or set OVERLEAF_EMAIL and OVERLEAF_PASSWORD, which every
131
+ command reads without needing 'auth' at all.`)
120
132
  .action(async (options) => {
121
133
  if (!options.cookie && !options.email && !options.password) {
122
134
  console.log(chalk.yellow('To authenticate, provide a session cookie:'));
@@ -129,7 +141,8 @@ program
129
141
  console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"'));
130
142
  console.log();
131
143
  console.log('Or log in with email/password:');
132
- console.log(chalk.cyan(' olcli auth --email "you@example.com" --password "your_password"'));
144
+ console.log(chalk.cyan(' olcli auth --email "you@example.com"'));
145
+ console.log(chalk.dim(' (prompts for the password, so it stays out of your shell history)'));
133
146
  console.log();
134
147
  console.log('Or set OVERLEAF_SESSION environment variable');
135
148
  return;
@@ -138,10 +151,41 @@ program
138
151
  console.error(chalk.red('Use either --cookie or --email/--password, not both.'));
139
152
  process.exit(1);
140
153
  }
141
- if (!options.cookie && (!options.email || !options.password)) {
142
- console.error(chalk.red('Both --email and --password are required for password login.'));
154
+ if (!options.cookie && !options.email) {
155
+ console.error(chalk.red('--email is required for password login.'));
143
156
  process.exit(1);
144
157
  }
158
+ // Resolve the password before the spinner starts: a prompt and a spinner
159
+ // both own the terminal, and ora would redraw over the prompt line.
160
+ let password = options.password;
161
+ if (!options.cookie) {
162
+ if (password) {
163
+ console.log(chalk.yellow('⚠ --password is now in your shell history.'));
164
+ console.log(chalk.dim(' Omit it to be prompted, or set OVERLEAF_EMAIL/OVERLEAF_PASSWORD.'));
165
+ }
166
+ else {
167
+ try {
168
+ password = await promptHidden(`Password for ${options.email}: `);
169
+ }
170
+ catch (error) {
171
+ if (error instanceof NotATerminal) {
172
+ console.error(chalk.red('No terminal available to prompt for a password.'));
173
+ console.error('Set OVERLEAF_EMAIL and OVERLEAF_PASSWORD instead — every command reads them,');
174
+ console.error("so a scripted run does not need 'olcli auth' at all.");
175
+ process.exit(1);
176
+ }
177
+ if (error instanceof PromptCancelled) {
178
+ console.error(chalk.red('Cancelled.'));
179
+ process.exit(1);
180
+ }
181
+ throw error;
182
+ }
183
+ if (!password) {
184
+ console.error(chalk.red('Password must not be empty.'));
185
+ process.exit(1);
186
+ }
187
+ }
188
+ }
145
189
  const spinner = ora('Verifying session...').start();
146
190
  try {
147
191
  const baseUrl = program.opts().baseUrl || getBaseUrl();
@@ -160,14 +204,29 @@ program
160
204
  }
161
205
  else {
162
206
  spinner.text = 'Logging in with email/password...';
163
- const client = await OverleafClient.fromPasswordLogin(options.email, options.password, baseUrl);
207
+ const client = await OverleafClient.fromPasswordLogin(options.email, password, baseUrl);
164
208
  const projects = await client.listProjects();
165
209
  persistClientSession(client, cookieName);
166
210
  setBaseUrl(baseUrl);
167
- if (options.savePassword !== false) {
168
- setPasswordCredentials(options.email, options.password);
211
+ // Opt-in, not opt-out. The session cookie persisted just above is what
212
+ // later commands actually use; the password only buys an automatic
213
+ // re-login after that cookie expires, and it is stored in plaintext.
214
+ // A cookie is scoped to olcli and rotates; a password is reusable
215
+ // everywhere and cannot be revoked without changing it. See issue #50.
216
+ const savePassword = options.savePassword === true;
217
+ if (savePassword) {
218
+ setPasswordCredentials(options.email, password);
219
+ }
220
+ // The old message said "Password login saved." unconditionally - even
221
+ // under --no-save-password, which had just prevented exactly that.
222
+ spinner.succeed(`Authenticated! Found ${projects.length} projects.`);
223
+ if (savePassword) {
224
+ console.log(chalk.yellow('Password stored in plaintext in the config file.'));
225
+ }
226
+ else {
227
+ console.log(chalk.dim('Session cookie stored. The password was not saved; re-run'));
228
+ console.log(chalk.dim('olcli auth when the session expires, or use --save-password.'));
169
229
  }
170
- spinner.succeed(`Authenticated! Found ${projects.length} projects. Password login saved.`);
171
230
  }
172
231
  console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
173
232
  }
@@ -200,9 +259,44 @@ program
200
259
  program
201
260
  .command('logout')
202
261
  .description('Clear stored credentials')
262
+ .addHelpText('after', `
263
+ Clears the global config and the .olauth file in the current directory, and
264
+ reports each one separately. Environment variables cannot be cleared by a
265
+ child process, so OVERLEAF_SESSION and OVERLEAF_EMAIL/OVERLEAF_PASSWORD are
266
+ reported instead of silently ignored - both take precedence over anything on
267
+ disk.`)
203
268
  .action(() => {
269
+ // Read before clearing: afterwards there is nothing left to report on.
270
+ const before = inspectStoredCredentials();
204
271
  clearConfig();
205
- console.log(chalk.green('Credentials cleared'));
272
+ const removedOlAuth = clearOlAuth();
273
+ const cleared = [];
274
+ if (before.sessionCookie)
275
+ cleared.push('session cookie (global config)');
276
+ if (before.password)
277
+ cleared.push('saved password (global config)');
278
+ if (removedOlAuth)
279
+ cleared.push(removedOlAuth);
280
+ if (cleared.length === 0) {
281
+ console.log('Nothing stored to clear.');
282
+ }
283
+ else {
284
+ console.log(chalk.green('Cleared:'));
285
+ for (const item of cleared)
286
+ console.log(` ${item}`);
287
+ }
288
+ // The reason this command was wrong before: it announced success while a
289
+ // higher-precedence source kept the user authenticated. Anything olcli
290
+ // cannot clear has to be said out loud, or the message is a lie again.
291
+ if (before.envSession || before.envPassword) {
292
+ console.log();
293
+ console.log(chalk.yellow('Still authenticated in this shell:'));
294
+ if (before.envSession)
295
+ console.log(' OVERLEAF_SESSION is set');
296
+ if (before.envPassword)
297
+ console.log(' OVERLEAF_EMAIL and OVERLEAF_PASSWORD are set');
298
+ console.log(chalk.dim(' These outrank anything on disk. Unset them to finish logging out.'));
299
+ }
206
300
  });
207
301
  // ─────────────────────────────────────────────────────────────────────────────
208
302
  // PROJECT COMMANDS
@@ -1528,6 +1622,13 @@ program
1528
1622
  .option('--name-only', 'List changed paths instead of printing patches')
1529
1623
  .option('--file <path>', 'Diff a single file')
1530
1624
  .option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
1625
+ .option('--exit-code', 'Exit 1 if anything differs, 0 if nothing does, 2 on failure (for CI)')
1626
+ .option('--latexdiff', 'Mark the revision up inside the document with latexdiff (requires latexdiff on PATH)')
1627
+ .option('--pdf', 'Compile the marked-up document on Overleaf and download the PDF (implies --latexdiff)')
1628
+ .option('--main <path>', 'Root .tex document to mark up (default: the only file declaring \\documentclass)')
1629
+ .option('-o, --output <path>', `Where to write the marked-up .tex (default: ${DIFF_OUTPUT_DIR}/<root>-diff.tex)`)
1630
+ .option('--no-flatten', 'Leave \\input/\\include in place instead of inlining them')
1631
+ .option('--latexdiff-opt <opt>', 'Pass an option straight through to latexdiff (repeatable)', (value, previous = []) => [...previous, value])
1531
1632
  .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
1532
1633
  .option('--no-ignore', 'Disable all ignore filtering')
1533
1634
  .option('--cookie <session>', 'Session cookie override')
@@ -1536,12 +1637,56 @@ The remote side is fetched fresh on every run, so the diff describes the
1536
1637
  project as it is right now - which is what a subsequent push would overwrite.
1537
1638
  It is not a comparison against the last pull. A collaborator editing between
1538
1639
  diff and push can still change the outcome; the fetch time is printed for that
1539
- reason.`)
1640
+ reason.
1641
+
1642
+ --latexdiff hands those same two sides to latexdiff and marks the revision up
1643
+ inside the document instead: struck through is what a push would overwrite,
1644
+ underlined is what it would upload. --pdf additionally uploads the marked-up
1645
+ document to the project for one compile, downloads the PDF, and removes it
1646
+ again - so a reviewable PDF needs no local TeX installation.
1647
+
1648
+ --exit-code makes the command a CI gate, with diff(1)'s statuses: 0 when
1649
+ nothing differs, 1 when something does, and 2 when the run itself failed. The
1650
+ last one matters - without it a pipeline cannot tell a changed file from an
1651
+ expired session cookie. It applies to whatever was compared, so --file narrows
1652
+ the gate to one file, and it reports on the project even under --latexdiff,
1653
+ where the markup only covers the root document.`)
1540
1654
  .action(async (project, dir, options) => {
1541
1655
  const targetDir = dir || '.';
1656
+ // Every way this command can fail, as opposed to finding differences.
1657
+ // Under --exit-code that has to be distinguishable from status 1, or a
1658
+ // pipeline reads "could not reach Overleaf" as "the paper changed"; with
1659
+ // the flag absent it stays 1, which is what every other command exits.
1660
+ const failureCode = options.exitCode ? DIFF_EXIT_FAILURE : 1;
1542
1661
  if (!existsSync(targetDir)) {
1543
1662
  console.error(chalk.red(`Directory not found: ${targetDir}`));
1544
- process.exit(1);
1663
+ process.exit(failureCode);
1664
+ }
1665
+ // Checked before connecting: an unusable combination of flags should not
1666
+ // cost a login and a full project download first.
1667
+ const latexdiffMode = Boolean(options.latexdiff || options.pdf);
1668
+ const conflicting = [
1669
+ options.nameOnly ? '--name-only' : null,
1670
+ options.file ? '--file' : null,
1671
+ options.unified !== undefined ? '-U/--unified' : null,
1672
+ ].filter(Boolean);
1673
+ if (latexdiffMode && conflicting.length > 0) {
1674
+ console.error(chalk.red(`--latexdiff cannot be combined with ${conflicting.join(', ')}`));
1675
+ console.error('It marks up one root document; those options select and shape unified patch output.');
1676
+ process.exit(failureCode);
1677
+ }
1678
+ if (!latexdiffMode) {
1679
+ // Accepting these silently would produce a normal patch and no markup,
1680
+ // with nothing in the output saying the flag was dropped.
1681
+ const latexdiffOnly = [
1682
+ options.main ? '--main' : null,
1683
+ options.output ? '--output' : null,
1684
+ options.latexdiffOpt?.length ? '--latexdiff-opt' : null,
1685
+ ].filter(Boolean);
1686
+ if (latexdiffOnly.length > 0) {
1687
+ console.error(chalk.red(`${latexdiffOnly.join(', ')} only applies with --latexdiff`));
1688
+ process.exit(failureCode);
1689
+ }
1545
1690
  }
1546
1691
  const spinner = ora('Connecting...').start();
1547
1692
  try {
@@ -1553,7 +1698,7 @@ reason.`)
1553
1698
  catch (error) {
1554
1699
  spinner.fail(error.message);
1555
1700
  console.error('Either run from a directory with .olcli.json or pass a project name/ID');
1556
- process.exit(1);
1701
+ process.exit(failureCode);
1557
1702
  }
1558
1703
  const { id: projectId, name: projectName } = resolved;
1559
1704
  // The whole project arrives as one zip in a single request - the same
@@ -1579,6 +1724,28 @@ reason.`)
1579
1724
  localFiles.set(file.relativePath, readFileSync(file.path));
1580
1725
  }
1581
1726
  let entries = compareTrees(localFiles, remoteFiles).filter((e) => e.status !== 'unchanged');
1727
+ if (latexdiffMode) {
1728
+ await runLatexdiffMode({
1729
+ client,
1730
+ projectId,
1731
+ projectName,
1732
+ targetDir,
1733
+ localFiles,
1734
+ remoteFiles,
1735
+ entries,
1736
+ fetchedAt,
1737
+ failureCode,
1738
+ options,
1739
+ spinner,
1740
+ });
1741
+ setLastProject(projectId);
1742
+ // Reports on the comparison, not on the markup: a changed figure is a
1743
+ // difference in the project even though a marked-up root document
1744
+ // cannot show it. The `No .tex file differs` line above says as much.
1745
+ if (options.exitCode)
1746
+ process.exitCode = differencesExitCode(entries);
1747
+ return;
1748
+ }
1582
1749
  if (options.file) {
1583
1750
  const wanted = normalizeRemotePath(options.file);
1584
1751
  entries = entries.filter((e) => e.path === wanted);
@@ -1629,12 +1796,211 @@ reason.`)
1629
1796
  }
1630
1797
  console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
1631
1798
  setLastProject(projectId);
1799
+ // Set rather than exited: `process.exit` drops whatever is still
1800
+ // buffered on a non-TTY stdout, and `olcli diff --exit-code > patch.txt`
1801
+ // is precisely a large patch going into a pipe. Returning lets node
1802
+ // flush and then exit with this status on its own.
1803
+ if (options.exitCode)
1804
+ process.exitCode = differencesExitCode(entries);
1632
1805
  }
1633
1806
  catch (error) {
1634
1807
  spinner.fail(`Failed: ${error.message}`);
1635
- process.exit(1);
1808
+ process.exit(failureCode);
1636
1809
  }
1637
1810
  });
1811
+ /**
1812
+ * `--latexdiff` / `--pdf`: mark the revision up inside the document.
1813
+ *
1814
+ * Runs on the two sides `diff` has already fetched, so the semantics are the
1815
+ * ones documented for the command - old is the remote as of this run, new is
1816
+ * the working directory - and no extra request is made to produce the markup.
1817
+ *
1818
+ * The remote side has to reach the filesystem before `latexdiff` can read it,
1819
+ * and the whole tree is written rather than the root document alone, because
1820
+ * `--flatten` resolves each `\input` relative to its own side.
1821
+ */
1822
+ async function runLatexdiffMode(params) {
1823
+ const { client, projectId, projectName, targetDir, localFiles, remoteFiles, entries, fetchedAt, failureCode, options, spinner, } = params;
1824
+ let root = '';
1825
+ try {
1826
+ root = resolveRootDocument(localFiles, options.main ? normalizeRemotePath(options.main) : undefined);
1827
+ }
1828
+ catch (error) {
1829
+ if (!(error instanceof RootDocumentError))
1830
+ throw error;
1831
+ spinner.stop();
1832
+ console.error(chalk.red(error.message));
1833
+ for (const candidate of error.candidates) {
1834
+ console.error(chalk.dim(` ${candidate}`));
1835
+ }
1836
+ process.exit(failureCode);
1837
+ }
1838
+ // latexdiff needs two versions of the same document. A root document that
1839
+ // only exists locally has one, and diffing it against an empty file would
1840
+ // mark up the entire paper as an addition.
1841
+ if (!remoteFiles.has(root)) {
1842
+ spinner.stop();
1843
+ console.error(chalk.red(`${root} is not in "${projectName}" yet, so there is no earlier version to mark up.`));
1844
+ console.error(chalk.dim(' Push it first, or use --main to name a document that exists on both sides.'));
1845
+ process.exit(failureCode);
1846
+ }
1847
+ if (!entries.some((e) => isTexPath(e.path))) {
1848
+ spinner.info(`No .tex file differs from "${projectName}" - the markup will show no changes`);
1849
+ }
1850
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'olcli-latexdiff-'));
1851
+ const cleanupTmp = () => rmSync(tmpRoot, { recursive: true, force: true });
1852
+ try {
1853
+ spinner.start('Writing the remote side to a temporary directory...');
1854
+ materializeTree(tmpRoot, remoteFiles);
1855
+ spinner.text = `Running latexdiff on ${root}...`;
1856
+ let markup = '';
1857
+ let warnings = '';
1858
+ try {
1859
+ const result = await runLatexdiff(buildLatexdiffArgs(join(tmpRoot, root), join(targetDir, root), { flatten: options.flatten !== false, extra: options.latexdiffOpt }));
1860
+ markup = result.markup;
1861
+ warnings = result.stderr.trim();
1862
+ }
1863
+ catch (error) {
1864
+ if (!(error instanceof LatexdiffError))
1865
+ throw error;
1866
+ spinner.fail(error.message);
1867
+ if (error.missing) {
1868
+ console.error(chalk.dim(` ${latexdiffInstallHint()}`));
1869
+ console.error(chalk.dim(' Everything else in olcli diff needs no external tools.'));
1870
+ }
1871
+ else if (error.stderr) {
1872
+ console.error(error.stderr);
1873
+ }
1874
+ cleanupTmp();
1875
+ process.exit(failureCode);
1876
+ }
1877
+ // An explicit --output is a path the user chose, so it is taken relative to
1878
+ // the current directory; the default belongs to the project directory being
1879
+ // diffed. Either way the PDF sits next to the source it was built from.
1880
+ const texPath = options.output || join(targetDir, diffOutputPath(root, 'tex'));
1881
+ const pdfPath = texPath.replace(/\.(tex|ltx)$/i, '') + '.pdf';
1882
+ mkdirSync(dirname(texPath), { recursive: true });
1883
+ writeFileSync(texPath, markup, 'utf-8');
1884
+ spinner.succeed(`Marked up ${root} (${(Buffer.byteLength(markup) / 1024).toFixed(1)} KB)`);
1885
+ if (warnings) {
1886
+ for (const line of warnings.split('\n')) {
1887
+ console.log(chalk.yellow(` latexdiff: ${line}`));
1888
+ }
1889
+ }
1890
+ if (options.pdf) {
1891
+ await compileMarkupOnOverleaf({ client, projectId, projectName, root, markup, texPath, pdfPath, failureCode, spinner });
1892
+ }
1893
+ console.log();
1894
+ console.log(chalk.bold(`Revision of ${root} against "${projectName}"`));
1895
+ console.log(` ${texPath}`);
1896
+ if (options.pdf)
1897
+ console.log(` ${pdfPath}`);
1898
+ console.log(chalk.dim(` struck through = remote as of ${fetchedAt.toISOString()}, underlined = local`));
1899
+ if (options.flatten !== false) {
1900
+ console.log(chalk.dim(' \\input/\\include were inlined; pass --no-flatten to keep them'));
1901
+ }
1902
+ }
1903
+ finally {
1904
+ cleanupTmp();
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Compile a marked-up document with Overleaf's compiler and download the PDF.
1909
+ *
1910
+ * The compile endpoint takes a `rootResourcePath` that has to already exist in
1911
+ * the project - there is no way to compile a document that is not in it. So
1912
+ * the markup is uploaded, compiled, and removed again, which is a real
1913
+ * mutation of someone's project for the duration of one compile and is
1914
+ * announced before it happens.
1915
+ *
1916
+ * Three things follow from that and are not incidental:
1917
+ *
1918
+ * - The upload refuses to overwrite. If the scratch path is taken, that file
1919
+ * belongs to the user, and clobbering it to produce a diff would be a worse
1920
+ * outcome than not producing one.
1921
+ * - The delete runs from a `finally`, and nothing between the upload and it
1922
+ * calls `process.exit` - that would terminate before the cleanup and leave
1923
+ * the file on the project. Failures are collected and reported afterwards.
1924
+ * - An interrupt cannot be cleaned up after reliably, so it prints the exact
1925
+ * command that removes the file rather than leaving it to be discovered.
1926
+ */
1927
+ async function compileMarkupOnOverleaf(params) {
1928
+ const { client, projectId, projectName, root, markup, texPath, pdfPath, failureCode, spinner } = params;
1929
+ const scratch = remoteScratchPath(root);
1930
+ spinner.start(`Checking ${scratch} is free...`);
1931
+ if (await client.findEntityByPath(projectId, scratch)) {
1932
+ spinner.fail(`"${projectName}" already has a file named ${scratch}`);
1933
+ console.error(chalk.dim(' --pdf uploads the marked-up document under that name for one compile and'));
1934
+ console.error(chalk.dim(' removes it again; it will not overwrite a file that is already there.'));
1935
+ console.error(chalk.dim(` The marked-up source was still written: ${texPath}`));
1936
+ process.exit(failureCode);
1937
+ }
1938
+ spinner.stop();
1939
+ console.log(chalk.dim(` uploading ${scratch} to "${projectName}" for one compile, then removing it`));
1940
+ const onInterrupt = () => {
1941
+ console.error(chalk.yellow(`\nInterrupted with ${scratch} still in "${projectName}".`));
1942
+ console.error(chalk.yellow(`Remove it with: olcli rm ${scratch}`));
1943
+ process.exit(130);
1944
+ };
1945
+ spinner.start('Uploading the marked-up document...');
1946
+ await client.uploadFile(projectId, null, scratch, Buffer.from(markup, 'utf-8'));
1947
+ process.once('SIGINT', onInterrupt);
1948
+ let failure = null;
1949
+ try {
1950
+ spinner.text = 'Compiling on Overleaf...';
1951
+ const compile = await client.compileWithOutputs(projectId, scratch);
1952
+ if (compile.status !== 'success' || !compile.pdfUrl) {
1953
+ failure = [`Overleaf reported "${compile.status}" compiling ${scratch}`];
1954
+ // The log is the only thing that explains a LaTeX failure, and it stops
1955
+ // being reachable as soon as the scratch file is removed below.
1956
+ const logFile = compile.outputFiles.find((f) => f.path === 'output.log');
1957
+ if (logFile) {
1958
+ const logPath = pdfPath.replace(/\.pdf$/, '.log');
1959
+ writeFileSync(logPath, await client.downloadOutputFile(logFile.url));
1960
+ failure.push(` compiler log: ${logPath}`);
1961
+ }
1962
+ failure.push(` marked-up source: ${texPath}`);
1963
+ // A .sty or .cls that only exists locally is the common one: Overleaf
1964
+ // compiles against the project, so anything the markup needs has to be
1965
+ // in the project. A missing *figure* does not fail - Overleaf draws a
1966
+ // placeholder box naming the file and reports success.
1967
+ failure.push(' A class, style or input file that exists only locally is the usual cause;');
1968
+ failure.push(' Overleaf compiles against the project, not against your working directory.');
1969
+ // A PDF from an earlier run would otherwise sit next to the log, dated
1970
+ // now by the directory listing and describing a different revision.
1971
+ rmSync(pdfPath, { force: true });
1972
+ }
1973
+ else {
1974
+ spinner.text = 'Downloading the PDF...';
1975
+ const pdf = await client.downloadOutputFile(compile.pdfUrl);
1976
+ writeFileSync(pdfPath, pdf);
1977
+ spinner.succeed(`Compiled ${basename(pdfPath)} (${(pdf.length / 1024).toFixed(1)} KB)`);
1978
+ }
1979
+ }
1980
+ catch (error) {
1981
+ const message = error instanceof Error ? error.message : String(error);
1982
+ failure = [`Compiling ${scratch} failed: ${message}`, ` marked-up source: ${texPath}`];
1983
+ }
1984
+ finally {
1985
+ process.off('SIGINT', onInterrupt);
1986
+ spinner.start(`Removing ${scratch} from "${projectName}"...`);
1987
+ try {
1988
+ await client.deleteByPath(projectId, scratch);
1989
+ spinner.stop();
1990
+ }
1991
+ catch (error) {
1992
+ const message = error instanceof Error ? error.message : String(error);
1993
+ spinner.warn(`${scratch} is still in "${projectName}": ${message}`);
1994
+ console.error(chalk.yellow(` remove it with: olcli rm ${scratch}`));
1995
+ }
1996
+ }
1997
+ if (failure) {
1998
+ spinner.fail(failure[0]);
1999
+ for (const line of failure.slice(1))
2000
+ console.error(chalk.dim(line));
2001
+ process.exit(failureCode);
2002
+ }
2003
+ }
1638
2004
  /**
1639
2005
  * Colourize one line of a rendered patch. chalk already no-ops when stdout is
1640
2006
  * not a TTY, so this needs no flag of its own.
@@ -1764,6 +2130,21 @@ program
1764
2130
  else {
1765
2131
  console.log(chalk.yellow('✗ No session cookie found'));
1766
2132
  }
2133
+ // A stored password is a credential source this command used to omit,
2134
+ // which made it impossible to answer "is my password on disk?" without
2135
+ // opening the config file. Never print the value - only whether it exists
2136
+ // and which source it came from.
2137
+ const stored = inspectStoredCredentials();
2138
+ if (stored.envPassword) {
2139
+ console.log(chalk.yellow('⚠ Password set via OVERLEAF_EMAIL/OVERLEAF_PASSWORD'));
2140
+ }
2141
+ else if (stored.password) {
2142
+ console.log(chalk.yellow('⚠ Password stored in plaintext in the config file'));
2143
+ console.log(chalk.dim(" Remove it with 'olcli logout', then re-auth without --save-password."));
2144
+ }
2145
+ if (stored.olAuthPath) {
2146
+ console.log(chalk.dim(` .olauth present: ${stored.olAuthPath}`));
2147
+ }
1767
2148
  });
1768
2149
  program.parse(process.argv);
1769
2150
  //# sourceMappingURL=cli.js.map