diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 5eaed0d..2606300 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -139,14 +139,29 @@ 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 → 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. +- **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.) + ### 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 +173,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..3b88a05 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, 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'; @@ -103,10 +103,17 @@ 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. 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 @@ -229,7 +236,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 +268,54 @@ 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) { + // 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) { + 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. When they confirm they have approved it, run'); + console.log(' this same command again (or `login --wait` to block until they do).\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..6daa7e6 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,128 @@ 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); +/** + * 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, + 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..e7582d5 100644 --- a/tests/login.test.ts +++ b/tests/login.test.ts @@ -3,7 +3,14 @@ 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, + redeemIfApproved, + startLogin, + waitForApproval, +} from '../src/login.js'; import type { Config } from '../src/types.js'; function config(overrides: Partial = {}): Config { @@ -132,3 +139,127 @@ 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(); + }); +}); + +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(); + }); +});