From 02f202f63ccf40ebc99623705cc2bf6db41ae0d3 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Thu, 20 Aug 2026 09:26:46 +0100 Subject: [PATCH 1/2] [ENG-3568] Stop `login` hanging when an assistant runs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from Lovable: the command appears to hang. It does, from the caller's side. An assistant runs a command, waits for it to exit, and only then reads stdout — so a command that prints a link at second zero and then blocks for ten minutes shows nothing until the code has already expired. Splits the flow. `startLogin` returns as soon as the link exists; `waitForApproval` polls. The CLI picks by whether anyone is watching: - interactive terminal — unchanged: print the link, then wait - output captured — print the link and exit, then `login --wait` resumes The pending request is handed between the two invocations through a file in the temp directory, keyed by site. Not the project directory: the device code is a secret with a ten-minute life, and nothing that short-lived belongs somewhere it could be committed. It is never printed either, so it cannot leak through an assistant's captured output. Verified against production: piped, the command now exits in about a second instead of blocking. AGENT-INSTALL states the two-step sequence and the three ways an assistant would otherwise get it wrong — wrapping step one in a timeout, re-running it to retry (which invalidates the link the user is looking at), and running --wait before the user has approved. Co-Authored-By: Claude --- AGENT-INSTALL.md | 23 ++++-- src/cli.ts | 68 ++++++++++++++--- src/login.ts | 182 +++++++++++++++++++++++++++++++++----------- tests/login.test.ts | 76 +++++++++++++++++- 4 files changed, 287 insertions(+), 62 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 5eaed0d..7d999e6 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -139,14 +139,26 @@ npx @patchstack/connect login The command asks Patchstack for a short code, prints a link, and polls until the site's **owner approves it in the dashboard**. On approval it writes the new credential into `.patchstackrc.json` and exits. The link opens the approval page with the code already filled in, so the person only has to confirm. -### What you must do, as the agent +### What you must do, as the agent — two commands, not one -1. **Run the command and surface the link and code to the user verbatim.** They must open it themselves — approval requires their signed-in Patchstack account, which you do not have and must not ask for. -2. **Leave the command running.** It polls until approved or the code expires (10 minutes). Do not kill it and retry; each run issues a different code and invalidates the one already on screen. -3. **Report the outcome.** On success, tell them the credential was restored *and* that the previous one no longer works — see the warning below. +**The command exits immediately when you run it.** It detects that its output is being captured rather than watched by a person, prints the link, and returns. It does **not** block waiting for approval, because you would not see the link until it exited — by which time the code would have expired, and it would look like the command had hung. + +``` +1. npx @patchstack/connect login → prints the link, exits straight away +2. give the user the link, verbatim → they approve it in the browser +3. npx @patchstack/connect login --wait → run AFTER they confirm; exits when approved +``` + +- **Never wrap step 1 in a timeout or kill it** — it returns on its own. If you find yourself waiting on it, something else is wrong. +- **Do not re-run step 1** to "retry". Each run issues a new code and invalidates the link the user is already looking at. Use `--wait` to resume the request you started. +- **Only run step 3 once the user says they have approved.** It blocks until approval or expiry, so running it too early is what actually hangs you. +- **Surface the link verbatim.** Approval requires the user's signed-in Patchstack account, which you do not have and must never ask for. +- **Report the outcome.** On success, say the credential was restored *and* that the previous one no longer works — see the warning below. You cannot complete this alone. It is deliberately a human-in-the-loop step: starting the flow proves nothing about who is running it, so the only authorisation is an owner approving in the browser. +(In an interactive terminal the same command prints the link and then waits, since a person can watch it stream. You get the two-step form; a human at a shell gets the one-step form.) + ### Consequences to tell the user about **Approving rotates the credential — the old one stops working immediately.** Anywhere it was configured needs the new value: CI secrets, hosting-platform env vars, preview environments, other developers' checkouts. Say this before they approve, not after. @@ -158,7 +170,8 @@ You cannot complete this alone. It is deliberately a human-in-the-loop step: sta | Site was never claimed | `409` — no owner exists to approve | Ask the user to claim the site in the dashboard first, or, if the site is disposable, delete `.patchstackrc.json` and `scan` to provision a fresh one | | Running in CI | Refuses to start | CI takes its credential from `PATCHSTACK_PULSE_AUTH`; `login` is for a developer machine | | No `siteUuid` configured | Refuses to start | There is no site to recover — run `scan` | -| Code expired | Poll ends after 10 minutes | Run the command again for a new code | +| Code expired | `--wait` ends after 10 minutes | Start again from step 1 for a new code | +| `--wait` with nothing pending | "No login is waiting for approval" | Run step 1 first; `--wait` resumes a request, it does not start one | ## Uninstalling diff --git a/src/cli.ts b/src/cli.ts index e59bde9..14c0a2b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,7 +36,7 @@ import { installCommand, renderGuideChecklist, } from './guide.js'; -import { login } from './login.js'; +import { login, readPendingLogin, startLogin, waitForApproval } from './login.js'; import { runProtect, runVerify } from './protect/install/index.js'; import { buildInputMap } from './map/index.js'; import { isProvenFlow } from './map/coordinates.js'; @@ -103,10 +103,14 @@ Usage: what's missing, with tailored commands), then print the full setup guide. --full prints the guide even when setup is complete - patchstack-connect login [options] Recover this site's credential when + patchstack-connect login [--wait] Recover this site's credential when .patchstackrc.json has been lost. Prints a link - for the site's OWNER to approve in the dashboard, - then waits (10 min). Use this instead of deleting + for the site's OWNER to approve in the dashboard. + In a terminal it then waits. When the output is + piped or captured — an assistant running it — it + prints the link and EXITS, so the link is visible + immediately; call again with --wait once the user + has approved. Use this instead of deleting .patchstackrc.json and re-scanning, which would provision a second site. Approving ROTATES the credential: CI, deploys and other machines using @@ -229,7 +233,31 @@ async function runLogin(args: ParsedArgs): Promise { cliEndpoint: getStringFlag(args.flags, 'endpoint'), }); - const result = await login(config, (userCode, verificationUri) => { + const approved = () => { + // The value itself is never printed — only that it landed. + console.log('\n ✓ Credential restored and saved to .patchstackrc.json.'); + console.log(' The previous credential no longer works. Update it anywhere else it was set:'); + console.log(' CI secrets, hosting env vars, preview environments, other checkouts.\n'); + return 0; + }; + + // Resuming a request whose link has already been handed to the user. + if (args.flags.has('wait')) { + const pending = config.siteUuid === null ? null : readPendingLogin(config.siteUuid); + + if (pending === null) { + console.error('\n No login is waiting for approval. Run `patchstack-connect login` first.\n'); + return 1; + } + + const resumed = await waitForApproval(config, pending); + if (resumed.status === 'approved') return approved(); + + console.error(`\n ${resumed.message ?? 'Login failed.'}\n`); + return 1; + } + + const prompt = (userCode: string, verificationUri: string) => { console.log(`\n Your code: ${userCode}`); console.log(` Approve at: ${verificationUri}\n`); // Said before approval, not after: the person deciding needs to know it is @@ -237,17 +265,33 @@ async function runLogin(args: ParsedArgs): Promise { console.log(" Open that link and approve it as the site's owner. Approving issues a new"); console.log(' credential and stops the current one working — CI, deploys and any other'); console.log(' machine using it will need the new value.\n'); - console.log(' Waiting for approval (the code expires in 10 minutes)…'); - }); + }; + + // Nobody is watching this stream. Blocking here would hide the link until the + // command exits — by which time the code has expired — so hand it over and + // let the caller decide when to wait. + if (process.stdout.isTTY !== true) { + const started = await startLogin(config); + + if (started.status !== 'started' || started.pending === undefined) { + console.error(`\n ${started.message ?? 'Login failed.'}\n`); + return 1; + } + + prompt(started.pending.userCode, started.pending.verificationUri); + console.log(' Give that link to the user. Once they have approved it, run:'); + console.log(' npx @patchstack/connect login --wait\n'); - if (result.status === 'approved') { - // The value itself is never printed — only that it landed. - console.log('\n ✓ Credential restored and saved to .patchstackrc.json.'); - console.log(' The previous credential no longer works. Update it anywhere else it was set:'); - console.log(' CI secrets, hosting env vars, preview environments, other checkouts.\n'); return 0; } + const result = await login(config, (userCode, verificationUri) => { + prompt(userCode, verificationUri); + console.log(' Waiting for approval (the code expires in 10 minutes)…'); + }); + + if (result.status === 'approved') return approved(); + console.error(`\n ${result.message ?? 'Login failed.'}\n`); return 1; diff --git a/src/login.ts b/src/login.ts index c7ab682..5c4af74 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,12 +1,21 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; import { persistApiKey } from './config.js'; import type { Config } from './types.js'; /** * Device authorization flow (RFC 8628) for recovering a lost credential. * - * The device code stays in this process; the short user code is what the human - * carries to the browser. Approving rotates the site's credential, so the old - * one — wherever it leaked to — stops working. + * Split into `startLogin` and `waitForApproval` because the two callers need + * opposite things. A person at a terminal wants one command that prints a code + * and blocks. An assistant runs a command, waits for it to exit, and only then + * reads the output — so a command that blocks for ten minutes shows it nothing + * until the code has already expired, and looks like a hang. + * + * `startLogin` returns immediately with the link. `waitForApproval` polls. The + * CLI runs both for a terminal and only the first for everything else. */ export interface LoginDeps { @@ -16,33 +25,72 @@ export interface LoginDeps { now?: () => number; } -function baseFrom(manifestEndpoint: string): string { - const url = new URL(manifestEndpoint); - const path = url.pathname.replace(/\/$/, ''); - url.pathname = path.endsWith('/manifest') ? path.slice(0, -'/manifest'.length) : '/monitor/pulse'; - url.search = ''; - url.hash = ''; - return url.toString().replace(/\/$/, ''); +export interface PendingLogin { + /** Redeems the credential once approved. Never printed — it is a secret. */ + deviceCode: string; + userCode: string; + verificationUri: string; + expiresAt: number; + intervalMs: number; +} + +export interface StartResult { + status: 'started' | 'unclaimed' | 'not-found' | 'failed'; + pending?: PendingLogin; + message?: string; } export interface LoginResult { - status: 'approved' | 'denied' | 'expired' | 'unclaimed' | 'not-found' | 'failed'; + status: 'approved' | 'expired' | 'unclaimed' | 'not-found' | 'failed'; message?: string; userCode?: string; verificationUri?: string; } +function baseFrom(manifestEndpoint: string): string { + const url = new URL(manifestEndpoint); + const p = url.pathname.replace(/\/$/, ''); + url.pathname = p.endsWith('/manifest') ? p.slice(0, -'/manifest'.length) : '/monitor/pulse'; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + /** - * Start a flow and poll until the owner approves or the code expires. - * `onPrompt` is called once with the code to show the user. + * Where a pending request waits between the two commands. + * + * The temp directory rather than the project: the device code is a secret with + * a ten-minute life, and nothing that short-lived belongs in a repo where it + * could be committed. Keyed by site so two projects do not collide. */ -export async function login( - config: Config, - onPrompt: (userCode: string, verificationUri: string) => void, - deps: LoginDeps = {}, -): Promise { +function pendingPath(siteUuid: string): string { + const key = createHash('sha256').update(siteUuid).digest('hex').slice(0, 16); + return path.join(tmpdir(), `patchstack-login-${key}.json`); +} + +export function savePendingLogin(siteUuid: string, pending: PendingLogin): void { + writeFileSync(pendingPath(siteUuid), JSON.stringify(pending), { encoding: 'utf8', mode: 0o600 }); +} + +export function readPendingLogin(siteUuid: string): PendingLogin | null { + try { + return JSON.parse(readFileSync(pendingPath(siteUuid), 'utf8')) as PendingLogin; + } catch { + return null; // absent, unreadable, or corrupt — all mean "nothing pending" + } +} + +export function clearPendingLogin(siteUuid: string): void { + try { + rmSync(pendingPath(siteUuid)); + } catch { + /* already gone */ + } +} + +/** Ask for a code. Returns as soon as the link is available. */ +export async function startLogin(config: Config, deps: LoginDeps = {}): Promise { const fetchImpl = deps.fetchImpl ?? fetch; - const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const now = deps.now ?? (() => Date.now()); if (config.siteUuid === null) { @@ -60,7 +108,8 @@ export async function login( if (started.status === 409) { return { status: 'unclaimed', - message: 'This site has not been claimed yet, so there is no owner to approve the request. Claim it in the dashboard, or delete .patchstackrc.json to provision a new site.', + message: + 'This site has not been claimed yet, so there is no owner to approve the request. Claim it in the dashboard, or delete .patchstackrc.json to provision a new site.', }; } if (started.status === 404) { @@ -70,48 +119,93 @@ export async function login( return { status: 'failed', message: `Could not start the login (HTTP ${started.status}).` }; } - const { device_code: deviceCode, user_code: userCode, expires_in: expiresIn, interval } = - (await started.json()) as { - device_code: string; - user_code: string; - expires_in: number; - interval: number; - }; + const body = (await started.json()) as { + device_code: string; + user_code: string; + expires_in: number; + interval: number; + }; + + const pending: PendingLogin = { + deviceCode: body.device_code, + userCode: body.user_code, + // Points at the API, which redirects to the dashboard SPA — the CLI only + // knows the API origin, and the approval page lives on the app. + verificationUri: `${base}/device?code=${encodeURIComponent(body.user_code)}`, + expiresAt: now() + body.expires_in * 1000, + intervalMs: Math.max(1, body.interval) * 1000, + }; + + savePendingLogin(config.siteUuid, pending); + + return { status: 'started', pending }; +} - // Points at the API, which redirects to the dashboard SPA — the CLI only - // knows the API origin, and the approval page lives on the app. The code - // travels in the link so following it is a single confirmation. - const verificationUri = `${base}/device?code=${encodeURIComponent(userCode)}`; - onPrompt(userCode, verificationUri); +/** Poll until the owner approves, the code expires, or `until` passes. */ +export async function waitForApproval( + config: Config, + pending: PendingLogin, + deps: LoginDeps & { until?: number } = {}, +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? (() => Date.now()); - const deadline = now() + expiresIn * 1000; - const intervalMs = Math.max(1, interval) * 1000; + const deadline = Math.min(pending.expiresAt, deps.until ?? pending.expiresAt); + const expired = { status: 'expired' as const, message: 'The login request expired. Run the command again.' }; while (now() < deadline) { - await sleep(intervalMs); + await sleep(pending.intervalMs); - const polled = await fetchImpl(`${base}/device/token`, { + const polled = await fetchImpl(`${baseFrom(config.endpoint)}/device/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ device_code: deviceCode }), + body: JSON.stringify({ device_code: pending.deviceCode }), }); if (polled.status === 428) continue; // still waiting on the human - if (!polled.ok) return { status: 'expired', message: 'The login request expired. Run the command again.' }; + if (!polled.ok) { + if (config.siteUuid !== null) clearPendingLogin(config.siteUuid); + return expired; + } const { api_key: apiKey } = (await polled.json()) as { api_key?: string }; if (typeof apiKey !== 'string' || apiKey.length === 0) { return { status: 'failed', message: 'Patchstack approved the request but returned no credential.' }; } - // Approving rotates the site's single OAuth secret, which Pulse ingest and - // block-log reporting both authenticate with. persistApiKey also clears any - // pulseAuth an earlier version wrote, so nothing is left holding the value - // the server has just replaced. + // Approving rotates the site's OAuth secret, which Pulse ingest and + // block-log reporting both authenticate with. await persistApiKey(process.cwd(), apiKey); + if (config.siteUuid !== null) clearPendingLogin(config.siteUuid); + + return { status: 'approved', userCode: pending.userCode, verificationUri: pending.verificationUri }; + } + + return expired; +} + +/** + * Start a flow and block until it resolves. For a terminal, where someone is + * watching the output as it streams. + */ +export async function login( + config: Config, + onPrompt: (userCode: string, verificationUri: string) => void, + deps: LoginDeps = {}, +): Promise { + const started = await startLogin(config, deps); - return { status: 'approved', userCode, verificationUri }; + if (started.status !== 'started' || started.pending === undefined) { + return { status: started.status === 'started' ? 'failed' : started.status, message: started.message }; } - return { status: 'expired', message: 'The login request expired. Run the command again.' }; + onPrompt(started.pending.userCode, started.pending.verificationUri); + + return waitForApproval(config, started.pending, deps); +} + +/** Exported for tests that need a scratch temp dir. */ +export function makeTempDir(prefix = 'patchstack-'): string { + return mkdtempSync(path.join(tmpdir(), prefix)); } diff --git a/tests/login.test.ts b/tests/login.test.ts index ea1b55c..145fc3c 100644 --- a/tests/login.test.ts +++ b/tests/login.test.ts @@ -3,7 +3,13 @@ import { mkdtemp } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { login } from '../src/login.js'; +import { + clearPendingLogin, + login, + readPendingLogin, + startLogin, + waitForApproval, +} from '../src/login.js'; import type { Config } from '../src/types.js'; function config(overrides: Partial = {}): Config { @@ -132,3 +138,71 @@ describe('login', () => { expect(result.status).toBe('expired'); }); }); + +/** + * The two-step shape exists for assistants: they run a command, wait for it to + * exit, and only then read stdout. A command that blocks for ten minutes shows + * them nothing until the code has already expired. + */ +describe('start and resume', () => { + it('returns the link without waiting for approval', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(json(started)); + + const result = await startLogin(config(), { fetchImpl: fetchImpl as never, ...noSleep }); + + expect(result.status).toBe('started'); + expect(result.pending?.userCode).toBe('WDJB-MJHT'); + expect(result.pending?.verificationUri).toBe( + 'https://api.patchstack.com/monitor/pulse/device?code=WDJB-MJHT', + ); + // One call: the code endpoint. Nothing polled. + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('hands the pending request to a later invocation', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(json(started)); + const cfg = config({ siteUuid: 'resume-uuid' }); + + await startLogin(cfg, { fetchImpl: fetchImpl as never, ...noSleep }); + + const pending = readPendingLogin('resume-uuid'); + expect(pending?.deviceCode).toBe('device-code'); + + clearPendingLogin('resume-uuid'); + expect(readPendingLogin('resume-uuid')).toBeNull(); + }); + + it('resumes and persists the credential once approved', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'ps-resume-')); + const original = process.cwd(); + process.chdir(cwd); + + try { + const start = vi.fn().mockResolvedValueOnce(json(started)); + const cfg = config({ siteUuid: 'resume-2' }); + const begun = await startLogin(cfg, { fetchImpl: start as never, ...noSleep }); + + const poll = vi.fn().mockResolvedValueOnce(json({ api_key: 'rotated-987' })); + const result = await waitForApproval(cfg, begun.pending!, { fetchImpl: poll as never, ...noSleep }); + + expect(result.status).toBe('approved'); + expect(JSON.parse(readFileSync('.patchstackrc.json', 'utf8')).apiKey).toBe('rotated-987'); + // The pending request is consumed, so a stale --wait cannot re-redeem it. + expect(readPendingLogin('resume-2')).toBeNull(); + } finally { + process.chdir(original); + } + }); + + it('reports an unclaimed site without leaving anything pending', async () => { + const fetchImpl = vi.fn().mockResolvedValue(json({ error: '…' }, 409)); + + const result = await startLogin(config({ siteUuid: 'unclaimed-1' }), { + fetchImpl: fetchImpl as never, + ...noSleep, + }); + + expect(result.status).toBe('unclaimed'); + expect(readPendingLogin('unclaimed-1')).toBeNull(); + }); +}); From c77050c272628090e282aee32d40f2cc9774c3bd Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Thu, 20 Aug 2026 09:31:50 +0100 Subject: [PATCH 2/2] [ENG-3568] Make re-running `login` resume instead of restarting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting start from wait left a gap: nothing writes the credential unless someone comes back and runs the second command. An assistant that forgets leaves the user having approved for nothing. Re-running `login` now resumes the pending request rather than issuing a new one. If the owner has approved, it redeems and finishes; if not, it reports the code and the time remaining and exits. So the second step is the same command as the first, which is one less thing for an assistant to get right, and re-running can no longer invalidate the link the user is looking at — which the previous docs had to warn against. An abandoned flow stays harmless. Approving only marks the request server side; the rotation happens on redeem. Nothing changes until the CLI comes back, so a forgotten flow expires quietly and the site keeps working. Verified against production: a second run reports the same code with the countdown decremented, rather than starting a fresh one. --wait remains as the blocking variant for anyone who wants it. Co-Authored-By: Claude --- AGENT-INSTALL.md | 13 +++++++---- src/cli.ts | 34 +++++++++++++++++++++++---- src/login.ts | 35 ++++++++++++++++++++++++++++ tests/login.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 7d999e6..2606300 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -144,17 +144,20 @@ The command asks Patchstack for a short code, prints a link, and polls until the **The command exits immediately when you run it.** It detects that its output is being captured rather than watched by a person, prints the link, and returns. It does **not** block waiting for approval, because you would not see the link until it exited — by which time the code would have expired, and it would look like the command had hung. ``` -1. npx @patchstack/connect login → prints the link, exits straight away -2. give the user the link, verbatim → they approve it in the browser -3. npx @patchstack/connect login --wait → run AFTER they confirm; exits when approved +1. npx @patchstack/connect login → prints the link, exits straight away +2. give the user the link, verbatim → they approve it in the browser +3. npx @patchstack/connect login → the SAME command again, after they confirm. + It resumes the request and finishes the flow ``` - **Never wrap step 1 in a timeout or kill it** — it returns on its own. If you find yourself waiting on it, something else is wrong. -- **Do not re-run step 1** to "retry". Each run issues a new code and invalidates the link the user is already looking at. Use `--wait` to resume the request you started. -- **Only run step 3 once the user says they have approved.** It blocks until approval or expiry, so running it too early is what actually hangs you. +- **Step 3 is the same command.** While a request is still valid it resumes rather than restarting, so running `login` again never invalidates the link the user is looking at. If they have not approved yet it tells you so, with the time remaining, and exits. +- **Nothing changes until step 3 runs.** Approving only marks the request; the credential is rotated and written when the CLI redeems it. So an abandoned flow is harmless — the site keeps working — but the credential is not restored until you come back. - **Surface the link verbatim.** Approval requires the user's signed-in Patchstack account, which you do not have and must never ask for. - **Report the outcome.** On success, say the credential was restored *and* that the previous one no longer works — see the warning below. +`login --wait` is the blocking variant: it polls until approved instead of returning. Prefer the plain re-run — it keeps each command short, which is what fits a conversation. + You cannot complete this alone. It is deliberately a human-in-the-loop step: starting the flow proves nothing about who is running it, so the only authorisation is an owner approving in the browser. (In an interactive terminal the same command prints the link and then waits, since a person can watch it stream. You get the two-step form; a human at a shell gets the one-step form.) diff --git a/src/cli.ts b/src/cli.ts index 14c0a2b..3b88a05 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,7 +36,7 @@ import { installCommand, renderGuideChecklist, } from './guide.js'; -import { login, readPendingLogin, startLogin, waitForApproval } from './login.js'; +import { login, readPendingLogin, redeemIfApproved, startLogin, waitForApproval } from './login.js'; import { runProtect, runVerify } from './protect/install/index.js'; import { buildInputMap } from './map/index.js'; import { isProvenFlow } from './map/coordinates.js'; @@ -109,8 +109,11 @@ Usage: In a terminal it then waits. When the output is piped or captured — an assistant running it — it prints the link and EXITS, so the link is visible - immediately; call again with --wait once the user - has approved. Use this instead of deleting + immediately. Run it AGAIN once the user confirms + they approved: it resumes the same request rather + than starting a new one, and finishes the flow. + --wait blocks instead of returning. Use this + instead of deleting .patchstackrc.json and re-scanning, which would provision a second site. Approving ROTATES the credential: CI, deploys and other machines using @@ -271,6 +274,27 @@ async function runLogin(args: ParsedArgs): Promise { // command exits — by which time the code has expired — so hand it over and // let the caller decide when to wait. if (process.stdout.isTTY !== true) { + // Running it again resumes rather than restarts. An assistant that comes + // back after the user approves finishes the flow whether or not it + // remembered --wait, and re-running never invalidates a link the user is + // still looking at. + const existing = config.siteUuid === null ? null : readPendingLogin(config.siteUuid); + + if (existing !== null && Date.now() < existing.expiresAt) { + const outcome = await redeemIfApproved(config, existing); + + if (outcome === 'approved') return approved(); + + if (outcome === 'pending') { + const secondsLeft = Math.round((existing.expiresAt - Date.now()) / 1000); + console.log(`\n Still waiting for approval of code ${existing.userCode}.`); + console.log(` Approve at: ${existing.verificationUri}`); + console.log(` (valid for another ${secondsLeft}s — run this again once the user confirms)\n`); + return 0; + } + // 'expired' falls through and starts a fresh request below. + } + const started = await startLogin(config); if (started.status !== 'started' || started.pending === undefined) { @@ -279,8 +303,8 @@ async function runLogin(args: ParsedArgs): Promise { } prompt(started.pending.userCode, started.pending.verificationUri); - console.log(' Give that link to the user. Once they have approved it, run:'); - console.log(' npx @patchstack/connect login --wait\n'); + console.log(' Give that link to the user. When they confirm they have approved it, run'); + console.log(' this same command again (or `login --wait` to block until they do).\n'); return 0; } diff --git a/src/login.ts b/src/login.ts index 5c4af74..6daa7e6 100644 --- a/src/login.ts +++ b/src/login.ts @@ -141,6 +141,41 @@ export async function startLogin(config: Config, deps: LoginDeps = {}): Promise< return { status: 'started', pending }; } +/** + * Redeem a pending request if the owner has already approved it, without + * waiting. Lets a second `login` finish a flow the first one started, so an + * assistant that comes back after the user approves does the right thing + * whether or not it remembers the `--wait` flag. + */ +export async function redeemIfApproved( + config: Config, + pending: PendingLogin, + deps: LoginDeps = {}, +): Promise<'approved' | 'pending' | 'expired'> { + const fetchImpl = deps.fetchImpl ?? fetch; + + const polled = await fetchImpl(`${baseFrom(config.endpoint)}/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ device_code: pending.deviceCode }), + }); + + if (polled.status === 428) return 'pending'; + + if (!polled.ok) { + if (config.siteUuid !== null) clearPendingLogin(config.siteUuid); + return 'expired'; + } + + const { api_key: apiKey } = (await polled.json()) as { api_key?: string }; + if (typeof apiKey !== 'string' || apiKey.length === 0) return 'expired'; + + await persistApiKey(process.cwd(), apiKey); + if (config.siteUuid !== null) clearPendingLogin(config.siteUuid); + + return 'approved'; +} + /** Poll until the owner approves, the code expires, or `until` passes. */ export async function waitForApproval( config: Config, diff --git a/tests/login.test.ts b/tests/login.test.ts index 145fc3c..e7582d5 100644 --- a/tests/login.test.ts +++ b/tests/login.test.ts @@ -7,6 +7,7 @@ import { clearPendingLogin, login, readPendingLogin, + redeemIfApproved, startLogin, waitForApproval, } from '../src/login.js'; @@ -206,3 +207,59 @@ describe('start and resume', () => { expect(readPendingLogin('unclaimed-1')).toBeNull(); }); }); + +describe('redeemIfApproved', () => { + it('finishes the flow when the owner has approved', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'ps-redeem-')); + const original = process.cwd(); + process.chdir(cwd); + + try { + const cfg = config({ siteUuid: 'redeem-1' }); + const begun = await startLogin(cfg, { + fetchImpl: vi.fn().mockResolvedValueOnce(json(started)) as never, + ...noSleep, + }); + + const outcome = await redeemIfApproved(cfg, begun.pending!, { + fetchImpl: vi.fn().mockResolvedValueOnce(json({ api_key: 'restored-42' })) as never, + }); + + expect(outcome).toBe('approved'); + expect(JSON.parse(readFileSync('.patchstackrc.json', 'utf8')).apiKey).toBe('restored-42'); + } finally { + process.chdir(original); + } + }); + + it('reports pending without consuming the request, so the link stays valid', async () => { + const cfg = config({ siteUuid: 'redeem-2' }); + const begun = await startLogin(cfg, { + fetchImpl: vi.fn().mockResolvedValueOnce(json(started)) as never, + ...noSleep, + }); + + const outcome = await redeemIfApproved(cfg, begun.pending!, { + fetchImpl: vi.fn().mockResolvedValueOnce(json({ error: 'authorization_pending' }, 428)) as never, + }); + + expect(outcome).toBe('pending'); + expect(readPendingLogin('redeem-2')).not.toBeNull(); + clearPendingLogin('redeem-2'); + }); + + it('clears an expired request so the next run starts a fresh one', async () => { + const cfg = config({ siteUuid: 'redeem-3' }); + const begun = await startLogin(cfg, { + fetchImpl: vi.fn().mockResolvedValueOnce(json(started)) as never, + ...noSleep, + }); + + const outcome = await redeemIfApproved(cfg, begun.pending!, { + fetchImpl: vi.fn().mockResolvedValueOnce(json({ error: 'expired_token' }, 400)) as never, + }); + + expect(outcome).toBe('expired'); + expect(readPendingLogin('redeem-3')).toBeNull(); + }); +});