Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed
- **`olcli logout` left `.olauth` behind and reported success anyway** ([#50](https://github.com/aloth/olcli/issues/50)) - it cleared the global config and printed `Credentials cleared`, while the `.olauth` file in the current directory survived. That file is consulted *ahead* of the global config, so the user stayed authenticated in that directory - and in `olcli-mcp`, which reads it too. `logout` now clears both and lists what it actually removed
- Environment variables cannot be unset by a child process, so `OVERLEAF_SESSION` and `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are now reported instead of ignored. They outrank everything on disk, and a logout that stays silent about them repeats the original mistake in a different place
- **`olcli auth` claimed `Password login saved.` even under `--no-save-password`** - the same class of bug: a message stating an outcome that did not happen. It now reports what was actually stored

### Changed
- **The account password is no longer persisted by default** ([#50](https://github.com/aloth/olcli/issues/50)) - it is written only when you ask for it with `--save-password`. The session cookie is stored either way and is what every later command uses; the password only bought an automatic re-login after that cookie expired. A cookie is scoped to olcli and rotates, a password is reusable everywhere and cannot be revoked without changing it
- `--no-save-password` still parses and still means "do not save", so existing scripts keep working - it is simply the default now
- **Behaviour change for self-hosted users:** an expired session no longer re-logs in silently. Re-run `olcli auth`, pass `--save-password` to keep the old behaviour, or set `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD`
- **`olcli auth --password` is now optional and prompts instead** ([#50](https://github.com/aloth/olcli/issues/50)) - passing it puts the password in shell history, so `olcli auth --email you@example.com` now reads it from the terminal without echoing. The flag still works and warns; with no terminal available, the error names `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD`, which every command already reads
- Keystroke handling is a pure reducer so it can be tested without a pty. Driving the real prompt over one is what surfaced the bug it now guards: filtering only the ESC of an arrow key left the printable `[` and `A` behind and silently appended them to the password
- **`olcli check` now reports whether a password is stored** and whether a `.olauth` file is present, never the values. Answering "is my password on disk?" previously meant opening the config file

## [0.11.0] - 2026-09-04

### Added
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,16 @@ olcli auth --cookie "your_session_cookie_value"
**Email/password** (self-hosted without reCAPTCHA):

```bash
olcli auth --email "you@example.com" --password "your_password"
olcli auth --email "you@example.com"
# prompts for the password, so it stays out of your shell history
```

The password is **not stored** unless you pass `--save-password`. A session
cookie is saved either way and is what later commands use; the password only
buys an automatic re-login once that cookie expires. For scripts, set
`OVERLEAF_EMAIL` and `OVERLEAF_PASSWORD` — every command reads them, so a
scripted run never needs `olcli auth` at all.

### 2. List Projects

```bash
Expand Down Expand Up @@ -128,7 +135,7 @@ All commands auto-detect the project when run from a synced directory (contains
|---------|-------------|
| `olcli auth` | Set session cookie or login with email/password |
| `olcli whoami` | Check authentication status |
| `olcli logout` | Clear stored credentials |
| `olcli logout` | Clear the global config and the local `.olauth`, reporting each |
| `olcli list` | List all projects |
| `olcli info [project]` | Show project details and file list |
| `olcli pull [project] [dir]` | Download project files to local directory |
Expand Down Expand Up @@ -284,6 +291,23 @@ hardcoded here: on macOS it lands under `~/Library/Preferences/`, on Linux under
which is usually your LaTeX project. Add it to that project's `.gitignore`
before committing.

### What is stored, and how to clear it

Everything is stored in plaintext, so it is worth knowing what is on disk:

| Credential | Stored by default | Where |
|---|---|---|
| Session cookie | yes | global config, or `.olauth` with `--save-local` |
| Email + password | **no** — only with `--save-password` | global config |

`olcli check` reports what exists without printing any secret.

`olcli logout` clears the global config **and** the `.olauth` file in the
current directory, then lists what it removed. It cannot unset environment
variables, so if `OVERLEAF_SESSION` or `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are
set, it says so instead of implying you are logged out — those take precedence
over anything on disk.

### Self-hosted Overleaf

```bash
Expand Down
13 changes: 11 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ Clear stored credentials:
olcli logout
```

Clears the global config and the `.olauth` file in the current directory, and
reports each. Environment variables cannot be unset by a child process, so
`OVERLEAF_SESSION` and `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are reported rather
than silently ignored — they outrank anything on disk.

For unattended use, prefer `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` over
`olcli auth --password`: every command reads them, and nothing is written to
disk or to shell history.

### Self-hosted Overleaf

```bash
Expand Down Expand Up @@ -255,9 +264,9 @@ zip arxiv.zip *.tex main.bbl figures/*.pdf
| Command | Description |
|---------|-------------|
| `olcli auth --cookie <value>` | Authenticate with session cookie |
| `olcli auth --email <e> --password <p>` | Authenticate with password (self-hosted) |
| `olcli auth --email <e>` | Authenticate with password, prompted (self-hosted) |
| `olcli whoami` | Check authentication status |
| `olcli logout` | Clear stored credentials |
| `olcli logout` | Clear the global config and the local `.olauth` |
| `olcli check` | Show config paths and credential sources |
| `olcli list` | List all projects |
| `olcli project create <name>` | Create a blank or example project |
Expand Down
126 changes: 116 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
setTimeout,
getPasswordCredentials,
setPasswordCredentials,
clearOlAuth,
inspectStoredCredentials,
type PasswordCredentials
} from './config.js';
import { promptHidden, PromptCancelled, NotATerminal } from './prompt.js';

const program = new Command();

Expand Down Expand Up @@ -158,9 +161,18 @@
.description('Authenticate with Overleaf using a session cookie or email/password')
.option('--cookie <session>', 'Session cookie (overleaf_session2 value)')
.option('--email <email>', 'Account email for password login')
.option('--password <password>', 'Account password for password login')
.option('--no-save-password', 'Do not persist email/password credentials')
.option('--password <password>', 'Account password (omit to be prompted; see warning below)')
.option('--save-password', 'Persist the password in the config file, in plaintext')
.option('--no-save-password', 'Do not persist the password (the default; kept for existing scripts)')
.option('--save-local', 'Save to .olauth in current directory')
.addHelpText('after', `
The password is not stored unless you ask for it with --save-password. A
session cookie is stored either way, and that is what later commands use; the
password only buys an automatic re-login once the cookie expires.

Passing --password puts the password in your shell history. Omit it to be
prompted instead, or set OVERLEAF_EMAIL and OVERLEAF_PASSWORD, which every
command reads without needing 'auth' at all.`)
.action(async (options) => {
if (!options.cookie && !options.email && !options.password) {
console.log(chalk.yellow('To authenticate, provide a session cookie:'));
Expand All @@ -173,7 +185,8 @@
console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"'));
console.log();
console.log('Or log in with email/password:');
console.log(chalk.cyan(' olcli auth --email "you@example.com" --password "your_password"'));
console.log(chalk.cyan(' olcli auth --email "you@example.com"'));
console.log(chalk.dim(' (prompts for the password, so it stays out of your shell history)'));
console.log();
console.log('Or set OVERLEAF_SESSION environment variable');
return;
Expand All @@ -184,11 +197,41 @@
process.exit(1);
}

if (!options.cookie && (!options.email || !options.password)) {
console.error(chalk.red('Both --email and --password are required for password login.'));
if (!options.cookie && !options.email) {
console.error(chalk.red('--email is required for password login.'));
process.exit(1);
}

// Resolve the password before the spinner starts: a prompt and a spinner
// both own the terminal, and ora would redraw over the prompt line.
let password: string | undefined = options.password;
if (!options.cookie) {
if (password) {
console.log(chalk.yellow('⚠ --password is now in your shell history.'));
console.log(chalk.dim(' Omit it to be prompted, or set OVERLEAF_EMAIL/OVERLEAF_PASSWORD.'));
} else {
try {
password = await promptHidden(`Password for ${options.email}: `);
} catch (error: any) {

Check warning on line 215 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 215 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
if (error instanceof NotATerminal) {
console.error(chalk.red('No terminal available to prompt for a password.'));
console.error('Set OVERLEAF_EMAIL and OVERLEAF_PASSWORD instead — every command reads them,');
console.error("so a scripted run does not need 'olcli auth' at all.");
process.exit(1);
}
if (error instanceof PromptCancelled) {
console.error(chalk.red('Cancelled.'));
process.exit(1);
}
throw error;
}
if (!password) {
console.error(chalk.red('Password must not be empty.'));
process.exit(1);
}
}
}

const spinner = ora('Verifying session...').start();
try {
const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl();
Expand All @@ -208,19 +251,34 @@
}
} else {
spinner.text = 'Logging in with email/password...';
const client = await OverleafClient.fromPasswordLogin(options.email, options.password, baseUrl);
const client = await OverleafClient.fromPasswordLogin(options.email, password!, baseUrl);
const projects = await client.listProjects();
persistClientSession(client, cookieName);
setBaseUrl(baseUrl);
if (options.savePassword !== false) {
setPasswordCredentials(options.email, options.password);

// Opt-in, not opt-out. The session cookie persisted just above is what
// later commands actually use; the password only buys an automatic
// re-login after that cookie expires, and it is stored in plaintext.
// A cookie is scoped to olcli and rotates; a password is reusable
// everywhere and cannot be revoked without changing it. See issue #50.
const savePassword = options.savePassword === true;
if (savePassword) {
setPasswordCredentials(options.email, password!);
}

spinner.succeed(`Authenticated! Found ${projects.length} projects. Password login saved.`);
// The old message said "Password login saved." unconditionally - even
// under --no-save-password, which had just prevented exactly that.
spinner.succeed(`Authenticated! Found ${projects.length} projects.`);
if (savePassword) {
console.log(chalk.yellow('Password stored in plaintext in the config file.'));
} else {
console.log(chalk.dim('Session cookie stored. The password was not saved; re-run'));
console.log(chalk.dim('olcli auth when the session expires, or use --save-password.'));
}
}

console.log(chalk.dim(`Config saved to: ${getConfigPath()}`));
} catch (error: any) {

Check warning on line 281 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 281 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Authentication failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -243,7 +301,7 @@
const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName);
const projects = await client.listProjects();
spinner.succeed(`Authenticated with access to ${projects.length} projects`);
} catch (error: any) {

Check warning on line 304 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 304 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Session invalid: ${error.message}`);
}
});
Expand All @@ -251,9 +309,41 @@
program
.command('logout')
.description('Clear stored credentials')
.addHelpText('after', `
Clears the global config and the .olauth file in the current directory, and
reports each one separately. Environment variables cannot be cleared by a
child process, so OVERLEAF_SESSION and OVERLEAF_EMAIL/OVERLEAF_PASSWORD are
reported instead of silently ignored - both take precedence over anything on
disk.`)
.action(() => {
// Read before clearing: afterwards there is nothing left to report on.
const before = inspectStoredCredentials();

clearConfig();
console.log(chalk.green('Credentials cleared'));
const removedOlAuth = clearOlAuth();

const cleared: string[] = [];
if (before.sessionCookie) cleared.push('session cookie (global config)');
if (before.password) cleared.push('saved password (global config)');
if (removedOlAuth) cleared.push(removedOlAuth);

if (cleared.length === 0) {
console.log('Nothing stored to clear.');
} else {
console.log(chalk.green('Cleared:'));
for (const item of cleared) console.log(` ${item}`);
}

// The reason this command was wrong before: it announced success while a
// higher-precedence source kept the user authenticated. Anything olcli
// cannot clear has to be said out loud, or the message is a lie again.
if (before.envSession || before.envPassword) {
console.log();
console.log(chalk.yellow('Still authenticated in this shell:'));
if (before.envSession) console.log(' OVERLEAF_SESSION is set');
if (before.envPassword) console.log(' OVERLEAF_EMAIL and OVERLEAF_PASSWORD are set');
console.log(chalk.dim(' These outrank anything on disk. Unset them to finish logging out.'));
}
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -295,7 +385,7 @@
console.log(` ${chalk.cyan(p.id)} - ${chalk.bold(p.name)}`);
console.log(` ${chalk.dim(`Last updated: ${date}`)}`);
}
} catch (error: any) {

Check warning on line 388 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 388 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand Down Expand Up @@ -337,7 +427,7 @@
}

setLastProject(proj.id);
} catch (error: any) {

Check warning on line 430 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 430 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -347,7 +437,7 @@
.command('comments')
.description('View and manage Overleaf comments');

function printCommentContext(comment: any): void {

Check warning on line 440 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 440 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
if (!comment.context) return;

const ctx = comment.context;
Expand Down Expand Up @@ -415,7 +505,7 @@
}

setLastProject(proj.id);
} catch (error: any) {

Check warning on line 508 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 508 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -439,7 +529,7 @@
}
spinner.succeed(`Replied to ${threadId}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 532 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 532 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -463,7 +553,7 @@
}
spinner.succeed(`Resolved ${threadId} at ${comment.path}:${comment.line}:${comment.column}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 556 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 556 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand All @@ -487,7 +577,7 @@
}
spinner.succeed(`Reopened ${threadId} at ${comment.path}:${comment.line}:${comment.column}`);
setLastProject(proj.id);
} catch (error: any) {

Check warning on line 580 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (20.18.1)

Unexpected any. Specify a different type

Check warning on line 580 in src/cli.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
spinner.fail(`Failed: ${error.message}`);
process.exit(1);
}
Expand Down Expand Up @@ -1961,6 +2051,22 @@
} else {
console.log(chalk.yellow('✗ No session cookie found'));
}

// A stored password is a credential source this command used to omit,
// which made it impossible to answer "is my password on disk?" without
// opening the config file. Never print the value - only whether it exists
// and which source it came from.
const stored = inspectStoredCredentials();
if (stored.envPassword) {
console.log(chalk.yellow('⚠ Password set via OVERLEAF_EMAIL/OVERLEAF_PASSWORD'));
} else if (stored.password) {
console.log(chalk.yellow('⚠ Password stored in plaintext in the config file'));
console.log(chalk.dim(" Remove it with 'olcli logout', then re-auth without --save-password."));
}

if (stored.olAuthPath) {
console.log(chalk.dim(` .olauth present: ${stored.olAuthPath}`));
}
});

program.parse(process.argv);
61 changes: 59 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import Conf from 'conf';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

interface OlcliConfig {
Expand Down Expand Up @@ -135,10 +135,67 @@ export function getConfigPath(): string {
return config.path;
}

/**
* Where a `.olauth` file would live for a given directory.
*
* Defaults to the current working directory, which is what `saveOlAuth` and
* `getSessionCookie` both use.
*/
export function getOlAuthPath(dir?: string): string {
return join(dir || process.cwd(), '.olauth');
}

/**
* Save session cookie in .olauth format for compatibility
*/
export function saveOlAuth(cookie: string, path?: string): void {
const authPath = path || join(process.cwd(), '.olauth');
const authPath = path || getOlAuthPath();
writeFileSync(authPath, `${getSessionCookieName()}=${cookie}`, 'utf-8');
}

/**
* Delete the `.olauth` file for a directory, if there is one.
*
* Returns the path that was removed, or null when there was nothing to
* remove. `logout` needs the distinction to report what it actually did.
*/
export function clearOlAuth(dir?: string): string | null {
const authPath = getOlAuthPath(dir);
if (!existsSync(authPath)) return null;
rmSync(authPath);
return authPath;
}

/**
* What credentials exist right now, and where.
*
* Deliberately reports every source `getSessionCookie` and
* `getPasswordCredentials` consult, including the two that no command can
* clear. `logout` used to clear the global config and announce success while
* a `.olauth` file - which takes precedence over it - stayed on disk and kept
* the user authenticated. Reporting per source is what stops that message
* from being wrong again. See issue #50.
*/
export interface StoredCredentials {
/** Session cookie in the global config file. */
sessionCookie: boolean;
/** Email/password pair in the global config file. */
password: boolean;
/** Path of the `.olauth` file, when one exists. Takes precedence over the config. */
olAuthPath: string | null;
/** `OVERLEAF_SESSION` is set. Takes precedence over everything, and logout cannot unset it. */
envSession: boolean;
/** `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are set. Same caveat. */
envPassword: boolean;
}

export function inspectStoredCredentials(dir?: string): StoredCredentials {
const authPath = getOlAuthPath(dir);
return {
sessionCookie: Boolean(config.get('sessionCookie')),
password: Boolean(config.get('loginEmail') || config.get('loginPassword')),
olAuthPath: existsSync(authPath) ? authPath : null,
envSession: Boolean(process.env.OVERLEAF_SESSION),
envPassword: Boolean(process.env.OVERLEAF_EMAIL && process.env.OVERLEAF_PASSWORD)
};
}
Loading
Loading