From c6ecc1f91a1257d22a1bd12ce251384657d634fe Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Fri, 31 Jul 2026 16:03:50 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(login):=20use=20session=20logi?= =?UTF-8?q?n=20by=20default,=20move=20OAuth=20behind=20--oauth=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session login (email + password) is now the default authentication flow, with browser-based OAuth moved behind the --oauth flag. This prioritises the simpler, more reliable credential-based authentication for typical use. --- README.md | 2 +- docs/authentication.mdx | 90 +++++++++++++++++++++++++++++++---------- docs/index.mdx | 46 ++++++++++++--------- src/cli.ts | 48 +++++++++++++++++----- src/login.test.ts | 66 ++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 src/login.test.ts diff --git a/README.md b/README.md index d53a4ca..a572cd4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Features -- **OAuth login** — authenticate once via browser; credentials are stored securely and reused across sessions. +- **Two ways to log in** — email and password by default, needing no setup and reaching the whole API; or `--oauth` for a browser flow that never handles your password. Credentials are stored securely and reused across sessions. - **File & folder management** — list, stat, copy, move, rename, and delete files and folders from the terminal. - **Revision history** — inspect every saved revision of a file and revert to any earlier version in one command. - **Trash recovery** — list deleted files and restore them by file ID. diff --git a/docs/authentication.mdx b/docs/authentication.mdx index 94d59fa..945a8ee 100644 --- a/docs/authentication.mdx +++ b/docs/authentication.mdx @@ -1,60 +1,106 @@ --- title: 🔐 Authentication -description: One-time OAuth 2.0 setup — log in through the browser and store a token locally. +description: Log in with email and password for a session token, or through the browser with OAuth 2.0. --- -Every command needs an authenticated pCloud session. `pcloud login` runs a -browser-based OAuth 2.0 flow once and stores the resulting token locally; after -that, commands just work. +Every command needs an authenticated pCloud session. There are two ways to get +one, and they are not equivalent — pick deliberately. -## 🛠️ Create an OAuth application +| | `pcloud login` (session) | `pcloud login --oauth` | +| -------------------------------- | ------------------------- | ----------------------------------- | +| Setup | none | register an OAuth application first | +| You type your password | yes, once | never | +| Revisions, trash, zip, downloads | ✅ | ❌ not reachable | +| Expiry | 30 days, or 7 days unused | no fixed expiry | -You need a pCloud OAuth application to obtain a client ID and secret. Create one -from the [pCloud OAuth 2.0 documentation](https://docs.pcloud.com/methods/oauth_2.0/), -then expose the credentials in your shell or a `.env` file: +Session login is the default because it needs no setup and reaches the whole API. +OAuth exists for when you would rather the CLI never handled your password. + +## 🔑 Log in + +```bash +pcloud login +``` + +You are asked for your email and password, and for a two-factor code if your +account uses one. The password is sent to pCloud over HTTPS and is never written +to disk — only the returned token is stored, at `~/.config/pcloud/tokens.json` +(mode `0600`). + +Confirm it worked: + +```bash +pcloud whoami +``` + +This prints the account email, plan, and quota usage. + +## 🌐 Log in through the browser instead + +OAuth 2.0 keeps your password out of the CLI entirely, at the cost of a one-time +setup and a narrower API surface. + +First create a pCloud OAuth application from the +[pCloud OAuth 2.0 documentation](https://docs.pcloud.com/methods/oauth_2.0/) and +expose its credentials: ```bash export PCLOUD_CLIENT_ID=your_client_id export PCLOUD_CLIENT_SECRET=your_client_secret ``` -## 🔑 Log in +Then: ```bash -pcloud login +pcloud login --oauth ``` This opens your browser to the pCloud authorisation page. After you approve access, pCloud redirects back to a local callback server and the token is saved -to `~/.config/pcloud/tokens.json` (mode `0600`). The CLI never sees your pCloud -password. +to the same place. -Once logged in, confirm the session: +> **OAuth cannot reach everything.** pCloud's access tokens do not grant access to +> `trash_list` / `trash_restore`, and the same applies to revisions, zip and +> downloads. This is a limitation of the pCloud API, not of this CLI — there is no +> workaround. If you need those commands, use session login. + +If both are stored, the session token wins: it is the strictly more capable tier. + +## 🚪 Log out ```bash -pcloud whoami +pcloud logout ``` -This prints the account email, plan, and quota usage. - -## 🚪 Log out +This revokes the session token with pCloud before removing the local file, so the +token is dead on their side too — deleting the file alone would leave it live +until it expired. -Remove the stored credentials at any time: +## 🩺 Check what your credential can reach ```bash -pcloud logout +pcloud doctor ``` +Probes every command against your current credential and reports which are +reachable, which need a session token, and which call endpoints pCloud does not +expose. It also reports local sync-daemon health — see +[Local sync inspection](/projects/pcloud-cli/docs/sync). + ## 🤖 Bypassing the credential store -For CI or scripted contexts, set `PCLOUD_ACCESS_TOKEN` directly. When present, no -stored credentials are read or written and `login` / `logout` are unnecessary: +For CI or scripted contexts, set a token directly in the environment. When +present, no stored credentials are read or written and `login` / `logout` are +unnecessary: ```bash -export PCLOUD_ACCESS_TOKEN=your_access_token +export PCLOUD_ACCESS_TOKEN=your_access_token # OAuth tier +export PCLOUD_AUTH=your_session_token # session tier pcloud ls / ``` +`PCLOUD_AUTH` takes precedence, matching the stored-credential rule above. + ## 🌍 Choosing a region The CLI targets the EU API (`eapi.pcloud.com`) by default. If your account lives diff --git a/docs/index.mdx b/docs/index.mdx index 6136dc3..235a1dc 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -7,9 +7,8 @@ description: A terminal-first CLI for pCloud — list folders, restore deleted f manage files, recover what you deleted from the trash, and roll a file back to a previous version — all without leaving the command line. -It authenticates with pCloud over OAuth 2.0, storing a token locally so you only -log in once. A single `PCLOUD_ACCESS_TOKEN` environment variable lets you bypass -stored credentials entirely for CI and scripted contexts. +It authenticates once and stores the token locally. A single environment variable +lets you bypass stored credentials entirely for CI and scripted contexts. ## ✨ Features @@ -18,11 +17,15 @@ stored credentials entirely for CI and scripted contexts. - **Trash recovery** — list deleted files and restore them by file ID. - **Rewind & revisions** — browse a file's version history and restore an earlier version to any destination. -- **OAuth 2.0 authentication** — browser-based login; the token is stored locally - at `~/.config/pcloud/tokens.json` (mode `0600`). The CLI never sees your - password. -- **CI-friendly** — set `PCLOUD_ACCESS_TOKEN` to skip the credential store - entirely. +- **Local sync inspection** — read the pCloud Drive daemon's own database to find + broken sync pairs, and prune orphaned ones the desktop app reports only as a + bogus permissions error. +- **Two ways to log in** — email and password by default (no setup, full API + access), or browser-based OAuth 2.0 if you would rather the CLI never handled + your password. Tokens are stored at `~/.config/pcloud/tokens.json` (mode + `0600`). +- **CI-friendly** — set `PCLOUD_AUTH` or `PCLOUD_ACCESS_TOKEN` to skip the + credential store entirely. - **Regional endpoints** — targets the EU API by default; switch with `PCLOUD_REGION=us`. @@ -32,7 +35,7 @@ stored credentials entirely for CI and scripted contexts. npm install -g @kud/pcloud-cli ``` -Requires Node.js 20 or newer. The binary is exposed as `pcloud`. +Requires Node.js 24 or newer (the sync commands use the built-in `node:sqlite`). The binary is exposed as `pcloud`. ## 🚀 Quick start @@ -44,17 +47,22 @@ pcloud ls / pcloud list-rewind /Documents/report.pdf ``` -See [Authentication](/projects/pcloud-cli/docs/authentication) for the one-time -OAuth setup, then [Trash & restore](/projects/pcloud-cli/docs/trash) and -[Rewind & versions](/projects/pcloud-cli/docs/rewind) for recovery workflows. +See [Authentication](/projects/pcloud-cli/docs/authentication) for both login +methods and when to choose each, [Trash & restore](/projects/pcloud-cli/docs/trash) +and [Rewind & versions](/projects/pcloud-cli/docs/rewind) for recovery workflows, +and [Local sync inspection](/projects/pcloud-cli/docs/sync) for diagnosing the +desktop app's sync pairs. ## ⚙️ Configuration -| Variable | Required | Description | -| ---------------------- | ---------------- | ------------------------------------------------- | -| `PCLOUD_CLIENT_ID` | For `login` only | OAuth application client ID | -| `PCLOUD_CLIENT_SECRET` | For `login` only | OAuth application client secret | -| `PCLOUD_ACCESS_TOKEN` | Optional | Bypasses `~/.config/pcloud/tokens.json` entirely | -| `PCLOUD_REGION` | Optional | `eu` (default) or `us` — selects the API endpoint | +| Variable | Required | Description | +| ---------------------- | ------------------- | ------------------------------------------------- | +| `PCLOUD_CLIENT_ID` | For `login --oauth` | OAuth application client ID | +| `PCLOUD_CLIENT_SECRET` | For `login --oauth` | OAuth application client secret | +| `PCLOUD_AUTH` | Optional | Session token — bypasses the credential store | +| `PCLOUD_ACCESS_TOKEN` | Optional | OAuth token — bypasses the credential store | +| `PCLOUD_REGION` | Optional | `eu` (default) or `us` — selects the API endpoint | -When `PCLOUD_ACCESS_TOKEN` is set, no stored credentials are read or written. +Neither client variable is needed for the default `pcloud login`. When either +token variable is set, no stored credentials are read or written; `PCLOUD_AUTH` +takes precedence, since a session token reaches more of the API. diff --git a/src/cli.ts b/src/cli.ts index a009ceb..0c7ffde 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -187,6 +187,8 @@ const runSessionLogin = async (): Promise => { ) console.log("and downloads. Your password is sent to pCloud over HTTPS and") console.log("is never written to disk — only the returned token is stored.\n") + console.log("Prefer not to type it here? `pcloud login --oauth` uses the") + console.log("browser instead, if you have an OAuth application registered.\n") const username = await ask("Email: ") const password = await askHidden("Password: ") @@ -219,11 +221,24 @@ program .command("login") .description("Set up authentication with pCloud") .option( - "--session", - "Log in with email and password for a session token (unlocks revisions, trash, zip, downloads)", + "--oauth", + "Log in through the browser instead (requires PCLOUD_CLIENT_ID and PCLOUD_CLIENT_SECRET)", ) + .option("--session", "Accepted and ignored — session login is the default") .action(async (options) => { - if (options.session) { + if (options.oauth && options.session) { + console.error( + "Error: --oauth and --session ask for different flows. Pick one.\n", + ) + process.exit(1) + } + + // Session is the default because it is both the cheaper and the more capable + // tier: it needs no registered OAuth application, and it is the only one that + // reaches revisions, trash, zip and downloads. OAuth's advantage — never + // handling the password — is real but narrow, so it earns a flag rather than + // the default. + if (!options.oauth) { try { await runSessionLogin() return @@ -237,22 +252,35 @@ program } try { - console.log("\n🔐 Welcome to pCloud CLI Setup!\n") - console.log("You will be redirected to pCloud in your browser to log in.") - console.log( - "After logging in, you'll be redirected back automatically.\n", - ) - + // Checked before the welcome banner rather than after it. Announcing "you + // will be redirected to your browser" and only then discovering there is + // nothing to redirect with leaves the user reading an instruction the next + // line contradicts. const clientId = process.env.PCLOUD_CLIENT_ID const clientSecret = process.env.PCLOUD_CLIENT_SECRET if (!clientId || !clientSecret) { console.error( - "\n❌ Missing credentials. Export PCLOUD_CLIENT_ID and PCLOUD_CLIENT_SECRET in your shell or .env file.\n", + "\n❌ OAuth needs PCLOUD_CLIENT_ID and PCLOUD_CLIENT_SECRET.\n", + ) + console.error("Register an application at") + console.error(" https://docs.pcloud.com/methods/oauth_2.0/") + console.error("then export both in your shell or a .env file.\n") + console.error( + "Or just run `pcloud login` — it needs no setup at all, and", + ) + console.error( + "reaches revisions, trash, zip and downloads that OAuth cannot.\n", ) process.exit(1) } + console.log("\n🔐 pCloud OAuth login\n") + console.log("You will be redirected to pCloud in your browser to log in.") + console.log( + "After logging in, you'll be redirected back automatically.\n", + ) + const oauth = new OAuthFlow(clientId, clientSecret, authBaseUrl) const tokens = await oauth.authenticate() diff --git a/src/login.test.ts b/src/login.test.ts new file mode 100644 index 0000000..98cc1d0 --- /dev/null +++ b/src/login.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url)) +const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)) + +// The old failure printed "You will be redirected to pCloud in your browser" +// and only then discovered it had no credentials to redirect with. Asserting the +// promise is absent is the regression test; asserting the error is present is not, +// since the error was always there — just below a line that contradicted it. +const PROMISED_A_BROWSER = /redirected/i + +// --oauth reads only environment variables, so unlike the browse tests this needs +// no HOME isolation: there is no credential store on the path being exercised. +const runLogin = (args: string[]) => { + const env = { ...process.env } as NodeJS.ProcessEnv + delete env.PCLOUD_CLIENT_ID + delete env.PCLOUD_CLIENT_SECRET + + return spawnSync(TSX, [CLI, "login", ...args], { + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }) +} + +describe("login --oauth without an OAuth application", () => { + it("exits non-zero", () => { + expect(runLogin(["--oauth"]).status).toBe(1) + }) + + it("names both variables it needs", () => { + const { stderr } = runLogin(["--oauth"]) + expect(stderr).toContain("PCLOUD_CLIENT_ID") + expect(stderr).toContain("PCLOUD_CLIENT_SECRET") + }) + + it("offers the setup-free alternative", () => { + expect(runLogin(["--oauth"]).stderr).toContain("pcloud login") + }) + + it("does not promise a browser it cannot open", () => { + const { stdout, stderr } = runLogin(["--oauth"]) + expect(stdout + stderr).not.toMatch(PROMISED_A_BROWSER) + }) +}) + +describe("login flag conflicts", () => { + it("refuses --oauth and --session together rather than silently picking one", () => { + const { status, stderr } = runLogin(["--oauth", "--session"]) + expect(status).toBe(1) + expect(stderr).toMatch(/Pick one/) + }) +}) + +describe("login default", () => { + // Killed by the timeout at the password prompt — reaching the prompt at all is + // the assertion, since it can only be reached through the session branch. + it("goes to session login, not OAuth", () => { + const { stdout } = runLogin([]) + expect(stdout).toContain("pCloud session login") + expect(stdout).not.toMatch(PROMISED_A_BROWSER) + }) +})