@aloth/olcli 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ Work with Overleaf projects directly from your command line. Edit locally with y
23
23
  - ⬆️ **Push** local changes back to Overleaf
24
24
  - 🔄 **Sync** bidirectionally with smart conflict detection
25
25
  - 🔍 **Diff** local files against the live remote before pushing
26
+ - 📝 **Marked-up revisions** — `diff --latexdiff` produces the struck-through/underlined PDF advisors and journals ask for
26
27
  - 🔀 **Git remote** — use Overleaf as a native git remote ([docs](docs/GIT-REMOTE.md))
27
28
  - ✌️ **Two-way deletions** — files removed locally are deleted on Overleaf on next sync
28
29
  - 🗑️ **Delete** and ✏️ **rename** remote files by path
@@ -217,6 +218,7 @@ olcli diff # every changed file, as patches
217
218
  olcli diff --name-only # just the changed paths
218
219
  olcli diff --file main.tex # one file
219
220
  olcli diff -U 8 # wider context
221
+ olcli diff --exit-code # exit 1 if anything differs, for CI
220
222
  ```
221
223
 
222
224
  **The remote side is fetched fresh on every run.** The diff describes the
@@ -240,6 +242,80 @@ than the last pull, because that is what `push` uploads; `diff` lists files
240
242
  whose **contents** actually differ. A file you touched without editing appears
241
243
  in the first and not the second.
242
244
 
245
+ #### Marked-up revisions with latexdiff
246
+
247
+ A unified diff is the right artifact for a developer and the wrong one for a
248
+ thesis advisor. `--latexdiff` marks the same revision up inside the document
249
+ instead — deletions struck through, additions underlined — which is what
250
+ advisors and journals ask for.
251
+
252
+ ```bash
253
+ olcli diff --latexdiff # write .olcli-diff/main-diff.tex
254
+ olcli diff --latexdiff --pdf # ...and compile it, download .olcli-diff/main-diff.pdf
255
+ ```
256
+
257
+ Requires `latexdiff` on your PATH. It ships with TeX Live and MacTeX; nothing
258
+ else in `olcli diff` needs an external tool.
259
+
260
+ **`--pdf` compiles on Overleaf, so you do not need a local TeX installation.**
261
+ The compile endpoint can only build a file that is in the project, so the
262
+ marked-up document is uploaded as `olcli-latexdiff.tex` next to your root
263
+ document, compiled, downloaded, and then removed. The command says so before it
264
+ does it, refuses to overwrite a file of that name if one already exists, and
265
+ prints the exact `olcli rm` command if the cleanup itself fails.
266
+
267
+ `\input` and `\include` are inlined before comparing (`--no-flatten` to opt
268
+ out). Without that, a remote compile would resolve those against the files
269
+ sitting in the project — the old content — and quietly produce a PDF showing
270
+ every input file as unchanged.
271
+
272
+ Output goes to `.olcli-diff/`, which is dotted so that `push` and `sync` never
273
+ pick it up; `-o <path>` puts it elsewhere. The root document is the `.tex` file
274
+ declaring `\documentclass`; if several do, `--main <path>` picks one rather
275
+ than the command guessing. `--latexdiff-opt` passes anything else straight
276
+ through, e.g. `--latexdiff-opt --math-markup=0`.
277
+
278
+ One limit worth knowing: `--pdf` compiles against the project, not against your
279
+ working directory, so anything the markup needs must already be on Overleaf. A
280
+ `.sty` or `.cls` you added locally fails the compile — the compiler log is
281
+ written next to the marked-up source when that happens. A *figure* you added
282
+ locally does not fail it; Overleaf draws a placeholder box naming the missing
283
+ file and the rest of the PDF is fine.
284
+
285
+ #### Using `diff` as a CI gate
286
+
287
+ `--exit-code` turns the command into a check, with the statuses `diff(1)` uses:
288
+
289
+ | Status | Meaning |
290
+ |--------|---------|
291
+ | `0` | Nothing differs |
292
+ | `1` | Something differs |
293
+ | `2` | The run failed — bad flags, no session, project unreachable |
294
+
295
+ ```yaml
296
+ - name: Fail if the paper on Overleaf has drifted from the repo
297
+ run: olcli diff --exit-code --name-only
298
+ env:
299
+ OVERLEAF_SESSION: ${{ secrets.OVERLEAF_SESSION }}
300
+ ```
301
+
302
+ **The `1` versus `2` split is the point of the flag.** Without it a pipeline
303
+ cannot tell a changed file from an expired session cookie, and a job that goes
304
+ red for the second reason sends whoever reads the log hunting for a diff that
305
+ was never computed. Every other olcli command reports failure as `1`, and
306
+ `olcli diff` still does when `--exit-code` is absent, so adding the flag does
307
+ not change what existing scripts see.
308
+
309
+ The gate covers whatever was compared: `--file main.tex` narrows it to one
310
+ file, the way a `git diff --exit-code` pathspec does, and a `--file` that
311
+ matches nothing is `0` rather than an error. Under `--latexdiff` it still
312
+ reports on the project as a whole — a changed figure is a real difference even
313
+ though a marked-up root document cannot show it.
314
+
315
+ Note that this compares against Overleaf **now**, not against your last pull,
316
+ so the check is "has anyone drifted from what is committed here", which is what
317
+ makes it worth running on a schedule as well as on a push.
318
+
243
319
  #### How deletion propagation works
244
320
 
245
321
  `olcli` records a manifest of remote files in `.olcli.json`. On next sync:
package/dist/cli.js CHANGED
@@ -8,14 +8,16 @@
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));
@@ -1620,6 +1622,13 @@ program
1620
1622
  .option('--name-only', 'List changed paths instead of printing patches')
1621
1623
  .option('--file <path>', 'Diff a single file')
1622
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])
1623
1632
  .option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
1624
1633
  .option('--no-ignore', 'Disable all ignore filtering')
1625
1634
  .option('--cookie <session>', 'Session cookie override')
@@ -1628,12 +1637,56 @@ The remote side is fetched fresh on every run, so the diff describes the
1628
1637
  project as it is right now - which is what a subsequent push would overwrite.
1629
1638
  It is not a comparison against the last pull. A collaborator editing between
1630
1639
  diff and push can still change the outcome; the fetch time is printed for that
1631
- 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.`)
1632
1654
  .action(async (project, dir, options) => {
1633
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;
1634
1661
  if (!existsSync(targetDir)) {
1635
1662
  console.error(chalk.red(`Directory not found: ${targetDir}`));
1636
- 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
+ }
1637
1690
  }
1638
1691
  const spinner = ora('Connecting...').start();
1639
1692
  try {
@@ -1645,7 +1698,7 @@ reason.`)
1645
1698
  catch (error) {
1646
1699
  spinner.fail(error.message);
1647
1700
  console.error('Either run from a directory with .olcli.json or pass a project name/ID');
1648
- process.exit(1);
1701
+ process.exit(failureCode);
1649
1702
  }
1650
1703
  const { id: projectId, name: projectName } = resolved;
1651
1704
  // The whole project arrives as one zip in a single request - the same
@@ -1671,6 +1724,28 @@ reason.`)
1671
1724
  localFiles.set(file.relativePath, readFileSync(file.path));
1672
1725
  }
1673
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
+ }
1674
1749
  if (options.file) {
1675
1750
  const wanted = normalizeRemotePath(options.file);
1676
1751
  entries = entries.filter((e) => e.path === wanted);
@@ -1721,12 +1796,211 @@ reason.`)
1721
1796
  }
1722
1797
  console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
1723
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);
1724
1805
  }
1725
1806
  catch (error) {
1726
1807
  spinner.fail(`Failed: ${error.message}`);
1727
- process.exit(1);
1808
+ process.exit(failureCode);
1728
1809
  }
1729
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
+ }
1730
2004
  /**
1731
2005
  * Colourize one line of a rendered patch. chalk already no-ops when stdout is
1732
2006
  * not a TTY, so this needs no flag of its own.