Skip to content
Merged
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
26 changes: 21 additions & 5 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
92 changes: 80 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -229,25 +236,86 @@ async function runLogin(args: ParsedArgs): Promise<number> {
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
// a rotation, and an assistant relaying this has to pass the warning on.
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;
Expand Down
Loading
Loading