@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/README.md +109 -2
- package/dist/cli.js +398 -17
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +56 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +61 -4
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -2
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts +30 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +34 -0
- package/dist/diff.js.map +1 -1
- package/dist/latexdiff.d.ts +165 -0
- package/dist/latexdiff.d.ts.map +1 -0
- package/dist/latexdiff.js +269 -0
- package/dist/latexdiff.js.map +1 -0
- package/dist/prompt.d.ts +63 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +146 -0
- package/dist/prompt.js.map +1 -0
- package/package.json +1 -1
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
|
|
118
|
-
.option('--
|
|
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"
|
|
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 &&
|
|
142
|
-
console.error(chalk.red('
|
|
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,
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|