@impeccable/reviews 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,11 +36,15 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
36
36
  | `--no-sandbox` | off | Runs the setup's commands without a sandbox, with all your access, where the command would otherwise stop: on Linux without `bubblewrap`, `socat` and `ripgrep`, and on other systems. The first screen says so once, and the local preview check does not run. |
37
37
  | `--no-telemetry` | off | Reports none of the command's steps to Impeccable (see What it reports). `DO_NOT_TRACK=1` does the same. |
38
38
 
39
+ ## The screenshots skill
40
+
41
+ For a repository Impeccable reviews from screenshots, `npx @impeccable/reviews skill` writes the `impeccable-screenshots` skill into the checkout (`.claude/skills/` and `.agents/skills/`), for Claude Code, Codex, Cursor and any agent that reads `SKILL.md`. Commit it. After each push to a pull request that changes the UI, the agent picks the pages and states the diff changes, shoots them with the browser it is already connected to (signed in as you), uploads them to GitHub with `gh` or GitHub's web uploader, and replaces the pull request's Screenshots section, stamped with the commit. It runs nothing of this package.
42
+
39
43
  ## What happens
40
44
 
41
45
  1. **Sign in.** The consent page names this repository, read from the checkout's `origin` remote; you approve, and it answers a small server on the first free port of 8239, 8238, 8240, 8237, 8236 and 8235, with PKCE and a state check, then sends the browser on to the run's web page. The server holds its port on both loopback addresses (`127.0.0.1` and `::1`), since a browser takes `localhost` for either: a port another program holds on one of them counts as taken. On a remote shell, press P and paste the address the browser ends on. The sign-in trusts the programs running on this machine: the link, with its state, is in the command that opens your browser, and on the clipboard once you press C, so a program that reads it can answer the server first and spoil the sign-in, which you then run again; the server takes only that first answer, and the command uses only a token for this checkout's repository whose addresses are on `--host`.
42
46
  2. **A web front end?** Impeccable reviews web front ends, mobile web included. A checkout with none (a React Native, Expo or Flutter app without a web target, or a service with no page, template, stylesheet or web framework) ends here: the terminal and the web page say "Impeccable reviews web front ends, and this repository does not have one. Nothing was set up.", and it exits 2. When the checkout cannot tell, Impeccable's own scan decides. A checkout with a web front end that the scan skipped stops the same way, with the setup page, where the person chooses Add anyway before running it again.
43
- 3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through Cloudflare's CLI, cf, when you are signed in to it here, read only), and once that page is your app it takes the host's previews as they are, with no question and no setup pull request. When the repository is set up for Vercel or Netlify but that host has never previewed it, it asks you for one thing, on one screen: to sign in to the host, Sign in or Skip, the login in your browser on Enter, and a retry when it did not complete (two failed retries count as a skip); when the host's CLI on this machine is signed in already, the same screen asks whether to set the host up with that sign-in, Set up or Skip, and runs no login. Nothing changes on the host before your yes there. Then it does the rest itself. On Vercel it creates the project with `vercel project add`, named after the folder, in the team the CLI's current scope names (asking which, only when you have several and none is current), links the checkout to it with `vercel link`, connects the repository (only to a project the run created that way, or one connected to the repository already: `vercel link` links an existing project of the folder's name, so one of yours that the run did not create is left as it is, and the setup ends there; a `.vercel/project.json` that is not what the run's own `vercel link` wrote, or names a project Vercel no longer has, also ends it and keeps the test drive's push held, by design, since releasing it on a state the run cannot verify could make the real project's first deployment public: remove or relink the file), puts Vercel's login in front of every address of the new project before pushing anything (Vercel makes a new project's first deployment production, whatever branch it builds, and would serve the test drive at the project's own address otherwise; the report says how to open production once your default branch is live), pushes the test drive and waits up to five minutes for Vercel's build of it (the task in progress reads "waiting for Vercel's preview · 1:05", counting up), proves that preview, takes Vercel's previews as they are and only then opens the test drive's pull request, so its review renders; a new project starts with Vercel Authentication on, which the run gets past with a bypass secret on your yes. On Netlify it creates the site in your Netlify team (asking which, only when you have several), connects the repository through Netlify's site settings and Netlify's GitHub app, so pull requests get deploy previews (only the site the run created, or one built from the repository already: a site of yours built from another repository, or one the run did not create, is never pointed at it), pushes the test drive and opens it as a draft, which Impeccable leaves unreviewed, waits for its deploy preview, proves it, takes Netlify's previews as they are and marks the draft ready. When the host's GitHub app cannot reach the repository, one more screen opens the app's page on GitHub for you to give it access, and the run waits. On skip it builds Impeccable's own previews as usual.
47
+ 3. **Set up.** On a run with its own capped model token (model calls go through Impeccable's gateway, so this machine holds no model key), the agent reads the checkout, runs the build, saves settings through the setup API, and asks you in a dialog only what it cannot find out. It never asks you to run a command: what it cannot run goes into the report, with the command. When Vercel, Netlify or Cloudflare Pages already deploys previews of your pull requests, it opens the newest one (Netlify's it finds on GitHub, and through the Netlify CLI when you are signed in to it on this machine, read only; Cloudflare's in the check run and the comment Cloudflare's GitHub app posts, and through Cloudflare's CLI, cf, when you are signed in to it here, read only), and once that page is your app it takes the host's previews as they are, with no question and no setup pull request. When the repository is set up for Vercel or Netlify but that host has never previewed it, it asks you for one thing, on one screen: to sign in to the host, Sign in or Skip, the login in your browser on Enter, and a retry when it did not complete (two failed retries count as a skip); when the host's CLI on this machine is signed in already, the same screen asks whether to set the host up with that sign-in, Set up or Skip, and runs no login. Nothing changes on the host before your yes there. Then it does the rest itself. On Vercel it creates the project with `vercel project add`, named after the folder, in the team the CLI's current scope names (asking which, only when you have several and none is current), links the checkout to it with `vercel link`, connects the repository (only to a project the run created that way, or one connected to the repository already: `vercel link` links an existing project of the folder's name, so one of yours that the run did not create is left as it is, and the setup ends there; a `.vercel/project.json` that is not what the run's own `vercel link` wrote, or names a project Vercel no longer has, also ends it and keeps the test drive's push held, by design, since releasing it on a state the run cannot verify could make the real project's first deployment public: remove or relink the file), puts Vercel's login in front of every address of the new project before pushing anything (Vercel makes a new project's first deployment production, whatever branch it builds, and would serve the test drive at the project's own address otherwise; the report says how to open production once your default branch is live), pushes the test drive and waits up to five minutes for Vercel's build of it (the task in progress reads "waiting for Vercel's preview · 1:05", counting up), proves that preview, takes Vercel's previews as they are and only then opens the test drive's pull request, so its review renders; a new project starts with Vercel Authentication on, which the run gets past with a bypass secret on your yes. On Netlify it creates the site in your Netlify team (asking which, only when you have several), connects the repository through Netlify's site settings and Netlify's GitHub app, so pull requests get deploy previews (only the site the run created, or one built from the repository already: a site of yours built from another repository, or one the run did not create, is never pointed at it), pushes the test drive and opens it as a draft, which Impeccable leaves unreviewed, waits for its deploy preview, proves it, takes Netlify's previews as they are and marks the draft ready. When Render deploys the app, it finds the Render site from the repository (Render's deployments on GitHub, an onrender.com address in the checkout, and the render CLI when you are signed in to it here, which the run's own tools use), shows one screen naming Render with the site's address to check, and never asks you to type an address; where the service's pull request previews are off, that screen is also your yes to turning them on with the render CLI. When the host's GitHub app cannot reach the repository, one more screen opens the app's page on GitHub for you to give it access, and the run waits. On skip it builds Impeccable's own previews as usual.
44
48
  4. **Two pull requests,** opened as you with `gh`, even when something is left for a person, which goes into the report, both into the default branch and in this order:
45
49
  - the test drive, safe to close (`impeccable/test-drive`, "Impeccable test drive (safe to close)"), always: a few real style or layout changes on a page the preview renders, on top of the setup commit when there is one, so it carries the workflow, whose job builds the preview on the pull request while Impeccable's review waits for it. Its pull request opens only once its preview is proven: the host's build of the pushed branch, or the local proof of Impeccable's own preview. No preview, no test drive: the run then says why in the report and what you can do, and still opens the setup pull request when the repository needs one;
46
50
  - the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed. The report lists the files it changes, so one the setup never meant to change stands out.
@@ -62,7 +66,7 @@ The command stops when the sandbox does not start: on macOS inside another tool'
62
66
 
63
67
  The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it, whether their names say so or their values are shaped like a secret (a GitHub token, a private key, a service account's key, a password in a URL), and what runs as you (`git commit` and `push`, `gh`, the host CLIs) gets it without `GH_REPO` and `GH_HOST`, so `gh` works on this checkout's repository on github.com, whatever repository or host they name. The agent's own file tools (read, write, edit, grep, find and ls) open files in the command's process, as you, so for them the fence judges the path pi opens, not the string the agent typed: spelled the way pi spells it (a curly apostrophe for a straight one, among others) and with its symlinks followed, and a path that starts with `@` or `~` or a URL scheme, which pi would open elsewhere, is refused outright; read, write, edit, grep and ls judge each file once more as they open it (ls each entry too, so a symlink out of the checkout is not listed), find judges the folder it searches before fd walks it and every path fd reports after, and a symlinked folder out of the checkout is neither followed nor listed; the names of `.env` files are listed, as `ls -a` shows them, and only a read of one is refused; a grep glob that names `.env` files is refused, and no line of a `.env` file is in what grep reports, whatever glob it was given. It also reads what goes past it: text shaped like a secret (a cloud, GitHub, Google or SendGrid key, a private key, a token, a Slack webhook's address, a password in an address or on a curl command line) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, read as written and as the screen would show it once an escape sequence is taken out, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match. A commit message, a template and a pull request body come inline (`-m`, `--body`), never from a file: the file the fence read is not surely the file git or gh, outside the sandbox, read later, since a command still running in the sandbox can swap it in between. For the same reason the messages of the commits a push carries are read again as the push runs, all of them (past what the remote has: its branch of that name, the default branch, and one the pushed branch is stacked on, such as an earlier run's setup branch under a new test drive), and so is every line they add to a `.env` file's example, which a command may have written, with the same check as a file the agent writes; a push whose commits cannot be read whole is refused, as is one whose remote cannot be reached, since what it carries cannot be checked then (the push can be tried again once the network is back; the remote is asked with no prompt for a password or a passphrase, which would hold it), and one in the same command as the `git commit` or `git checkout -b` that makes what it carries, which is not there yet when it is read: the push runs as a command of its own, after the commit. A commit carries only files the agent wrote, or named in a `git add`: a file a script staged on its own is refused by name, and `git restore --staged` takes it out; a push whose new commits carry such a file (a script can commit on its own, past the index) is refused the same way. Once `impeccable_sync_workflow` wrote the preview workflow, or removed the retired one, a commit carries that file only as the tool left it: the setup state's bytes, or no file. A status line the agent starts a message with shows in the terminal and on the web page only when its progress tool would take it: in the product voice, short, and holding nothing shaped like a secret.
64
68
 
65
- Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
69
+ Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`), `gh pr list` for this repository's pull requests, named with `--repo` (with `--state`, `--limit`, at most 100, and `--json`, never `--web` or a search) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
66
70
 
67
71
  `vercel whoami` and `vercel --version`. Once you said yes to the Vercel setup: `vercel link --yes` in the checkout's root alone (never in a folder of it, whose name would pick one of your projects), `vercel project add` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, never `vercel project ls`, which names your projects, and a global install of the Vercel CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g vercel@<version>`; another package manager, version or tag is refused). The sign-in (`vercel login`) and the repository's connection (`vercel git connect`, for a project the run created or one connected to the repository already) are the run's own tools, never the agent's commands. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
68
72
 
@@ -76,7 +80,7 @@ Refused, never asked, with the reason going back to the agent, which finds anoth
76
80
 
77
81
  So that a setup that stops in the terminal shows where it stopped, the command tells Impeccable about the steps only it sees: that it started, a check that stopped it (Node too old, no git, not a checkout, no `origin` remote, a remote not on GitHub, the sandbox not starting), a sign-in that did not finish (cancelled, timed out, every callback port taken, a different repository approved), and being stopped (Ctrl+C, a signal, a crash, the last update to the web page lost). A stopped run's last update also says why it stopped.
78
82
 
79
- Each report carries the step, how it ended, when, the command's version, Node's major version, your operating system and an id made fresh for the run. Never your code, file names, what you were asked or answered, or any token's value. It never names you: once you sign in, the reports go with the sign-in token, and Impeccable puts them on the account that approved, so steps from before sign-in wait and go with it. A run that never signs in sends its steps with the run's id alone. A report that cannot be sent is dropped and never holds the command up for more than two seconds. `--no-telemetry` or `DO_NOT_TRACK=1` turns all of it off.
83
+ Each report carries the step, how it ended, when, the command's version, Node's major version, your operating system, an id made fresh for the run, and `IMPECCABLE_REVIEWS_ENV` when it says `development` or `staging`. Never your code, file names, what you were asked or answered, or any token's value. It never names you: once you sign in, the reports go with the sign-in token, and Impeccable puts them on the account that approved, so steps from before sign-in wait and go with it. A run that never signs in sends its steps with the run's id alone. A report that cannot be sent is dropped and never holds the command up for more than two seconds. `--no-telemetry` or `DO_NOT_TRACK=1` turns all of it off.
80
84
 
81
85
  ## Tokens
82
86
 
package/dist/bin.js CHANGED
@@ -38,7 +38,9 @@ async function reportOldNode(nodeMajor) {
38
38
  catch {
39
39
  // The report goes without it.
40
40
  }
41
- const body = { launch_id: globalThis.crypto.randomUUID(), version, node_major: nodeMajor, os: process.platform, events: [{ event: 'setup_command_check_failed', outcome: 'node_too_old', at: new Date().toISOString() }] };
41
+ // Labelled as report.ts labels the others, so a release check on an old Node is left out too (pristine REN-933).
42
+ const environment = process.env.IMPECCABLE_REVIEWS_ENV?.trim();
43
+ const body = { launch_id: globalThis.crypto.randomUUID(), version, node_major: nodeMajor, os: process.platform, ...(environment === 'development' || environment === 'staging' ? { environment } : {}), events: [{ event: 'setup_command_check_failed', outcome: 'node_too_old', at: new Date().toISOString() }] };
42
44
  await Promise.race([
43
45
  fetch(`${host.origin}/app/cli/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(() => undefined),
44
46
  new Promise((resolve) => setTimeout(resolve, 2000).unref()),
@@ -14,8 +14,11 @@ import { ownFolder } from './sandbox.js';
14
14
  * check run on each commit it built and a comment on the pull request, both
15
15
  * naming the deployment's address (https://<id>.<project>.pages.dev) and the
16
16
  * branch's alias (https://<branch>.<project>.pages.dev), which follows the
17
- * branch's latest build. Nothing here reads a token, and nothing comes back
18
- * beyond the fields below.
17
+ * branch's latest build. A Worker that Workers Builds builds from the repository
18
+ * gets previews of its branches where its branch builds make them, on workers.dev
19
+ * (https://<version or branch>-<worker>.<subdomain>.workers.dev): its check run
20
+ * names both addresses, or, for a Worker Preview, its comment names the preview's.
21
+ * Nothing here reads a token, and nothing comes back beyond the fields below.
19
22
  */
20
23
  /** How many pull requests, the most recently updated, the evidence reads. */
21
24
  const NEWEST = 10;
@@ -23,6 +26,8 @@ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
23
26
  /** A Pages deployment's address, or a branch's alias: one label, then the project, under pages.dev. */
24
27
  export const PAGES_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.pages\\.dev$`);
25
28
  const LINKS = new RegExp(`https://${LABEL}\\.${LABEL}\\.pages\\.dev`, 'g');
29
+ /** A Worker's preview address: one label, the version's id or the branch's alias joined to the Worker's name, then the account's subdomain, under workers.dev. */
30
+ export const WORKERS_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.workers\\.dev$`);
26
31
  /** Cloudflare's GitHub app, by the slug its check runs carry and the login its comments carry; the earlier Pages-only app posted the same. */
27
32
  const APPS = new Set(['cloudflare-workers-and-pages', 'cloudflare-pages']);
28
33
  const isApp = (login) => typeof login === 'string' && APPS.has(login.replace(/\[bot\]$/, ''));
@@ -47,14 +52,27 @@ function addresses(html) {
47
52
  const links = [...new Set(html.match(LINKS) ?? [])];
48
53
  return { url: fits(row('Preview URL')) ?? links[0], alias: fits(row('Branch Preview URL')) ?? (links[1] !== links[0] ? links[1] : undefined) };
49
54
  }
55
+ /**
56
+ * A preview of the Worker `script` by its alias on workers.dev, `<name>-<script>.<subdomain>` (`name` the preview's where
57
+ * Cloudflare names it, else any branch's), with the pattern that alias stands for, and the version's address,
58
+ * `<version id>-<script>.<subdomain>`, where it is the same Worker's on the same subdomain; none for an alias of another Worker, or none.
59
+ */
60
+ function workerPreview(script, url, alias, name) {
61
+ const [, label, subdomain] = (alias && WORKERS_ADDRESS.exec(alias)) || [];
62
+ if (!alias || !(name !== undefined ? label === `${name}-${script}` : label?.endsWith(`-${script}`)))
63
+ return undefined;
64
+ const [, version, on] = (url && WORKERS_ADDRESS.exec(url)) || [];
65
+ return { url: url && version?.slice(9) === script && /^[0-9a-f]{8}-/.test(version) && on === subdomain ? url : alias, alias, pattern: `https://{branch}-${script}.${subdomain}.workers.dev` };
66
+ }
50
67
  /** What a check run's conclusion, or the words the app's table says, tell of the build. */
51
68
  const stateOf = (value) => (typeof value !== 'string' ? 'building' : /succe/i.test(value) ? 'success' : /fail|timed_out|error/i.test(value) ? 'failure' : /cancel/i.test(value) ? 'canceled' : 'building');
52
69
  /**
53
70
  * GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
54
71
  * pushed branch's head alone, before its pull request exists), and the app's comment on each pull request when it
55
- * describes the head. `failed` when a read did not answer.
72
+ * describes the head. A Worker Preview's check run names no address, so its alias is made from `pattern`, the one an
73
+ * earlier read's Worker preview stood for. `failed` when a read did not answer.
56
74
  */
57
- async function fromGitHub(owner, repo, gh, only, branch) {
75
+ async function fromGitHub(owner, repo, gh, only, branch, pattern) {
58
76
  const base = `/repos/${owner}/${repo}`;
59
77
  const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
60
78
  let failed = false;
@@ -87,8 +105,16 @@ async function fromGitHub(owner, repo, gh, only, branch) {
87
105
  continue;
88
106
  const script = typeof run.name === 'string' ? /^Workers Builds: (\S+)$/.exec(run.name)?.[1] : undefined;
89
107
  if (script) {
108
+ const build = run.status === 'completed' ? stateOf(run.conclusion) : 'building';
90
109
  if (!worker || head.at > worker.at)
91
- worker = { script, build: run.status === 'completed' ? stateOf(run.conclusion) : 'building', at: head.at };
110
+ worker = { script, build, at: head.at };
111
+ // A branch build's preview: its summary names the version's address and the branch's alias; a Worker Preview's names neither, and its build page's address names the preview.
112
+ const said = Object.fromEntries([...String(record(run.output)?.summary ?? '').matchAll(/^Preview (URL|Alias URL): (\S+)$/gm)].map((m) => [m[1], m[2]]));
113
+ const name = typeof run.details_url === 'string' ? new RegExp(`/previews/(${LABEL})/builds/`).exec(run.details_url)?.[1] : undefined;
114
+ // An earlier read's pattern makes the alias only where it is this Worker's own, never another Worker's whose name ends in this one's.
115
+ const preview = workerPreview(script, said.URL, said['Alias URL'] ?? (name && pattern?.startsWith(`https://{branch}-${script}.`) ? pattern.replace('{branch}', name) : undefined));
116
+ if (preview)
117
+ found.push({ ...(head.number ? { pullRequest: head.number } : {}), ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: build, from: 'check run', at: when(run.completed_at) || head.at });
92
118
  continue;
93
119
  }
94
120
  const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
@@ -105,6 +131,15 @@ async function fromGitHub(owner, repo, gh, only, branch) {
105
131
  const head = heads.find((h) => h.number === issue);
106
132
  if (!head || !isApp(record(comment.user)?.login) || typeof comment.body !== 'string')
107
133
  continue;
134
+ // A Worker Preview's comment names the preview's address and the commit it last deployed, `### Preview URL: <address> (commit <sha>)`, and its dashboard link names the Worker.
135
+ const latest = /^### Preview URL: (\S+) \(commit ([0-9a-f]{7,40})\)$/m.exec(comment.body);
136
+ const [, script, name] = new RegExp(`/workers/services/view/(${LABEL})/production/previews/(${LABEL})`).exec(comment.body) ?? [];
137
+ if (latest && script) {
138
+ const preview = head.sha.startsWith(latest[2]) ? workerPreview(script, undefined, latest[1], name) : undefined;
139
+ if (preview)
140
+ found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: 'success', from: 'bot comment', at: when(comment.updated_at) || head.at });
141
+ continue;
142
+ }
108
143
  // The app edits its one comment as the pull request moves on: it describes the head when the commit it names is the head's.
109
144
  const commit = /Latest commit:<\/strong>[\s\S]{0,80}?<code>([0-9a-f]{7,40})<\/code>/.exec(comment.body)?.[1];
110
145
  if (!commit || !head.sha.startsWith(commit))
@@ -424,7 +459,8 @@ async function listCloudflareProjects(cf, accounts, signal) {
424
459
  }
425
460
  /**
426
461
  * Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
427
- * with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
462
+ * with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists;
463
+ * with `pattern`, the one an earlier read's Worker preview stood for, a Worker Preview's build listed too.
428
464
  */
429
465
  export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
430
466
  // cf is not asked here, so whether it is installed is unknown.
@@ -435,7 +471,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
435
471
  return unread();
436
472
  const gh = deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal });
437
473
  const plain = deps.pull === undefined && deps.branch === undefined;
438
- const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch);
474
+ const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch, deps.pattern);
439
475
  const config = await wranglerConfig(cwd);
440
476
  const readme = plain ? await readmeAddresses(cwd) : { pages: [] };
441
477
  // For one app's folder, an address or the root's config names no app's project: only the project's settings do.
@@ -458,6 +494,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
458
494
  else if (app)
459
495
  github.previews = github.previews.filter((p) => pagesProject(p.url) === (cf.subdomain ? pagesProject(`https://${cf.subdomain}`) : cf.project));
460
496
  const project = cf.project ?? named;
497
+ // Where a Pages project builds the repository, its previews stay the method, and a Worker's beside it are left out.
498
+ if (project)
499
+ github.previews = github.previews.filter((p) => !p.pattern);
461
500
  // A Worker built from the repository: its check runs, the config or the README say so, and the check runs say how its newest build went.
462
501
  const script = github.worker?.script ?? (config?.kind === 'worker' ? config.name : undefined);
463
502
  const worker = github.worker || config?.kind === 'worker' || readme.workers
@@ -487,8 +526,10 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
487
526
  }
488
527
  // The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
489
528
  previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
529
+ // No Pages project, and a Worker's previews listed: the Worker's own address, on the subdomain they name, is the live app.
530
+ const live = previews.find((p) => p.pattern)?.pattern?.replace('{branch}-', '');
490
531
  return {
491
- ...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : {}),
532
+ ...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : live ? { production: live } : {}),
492
533
  previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
493
534
  cli: cf.cli,
494
535
  ...(github.failed || cf.failed
package/dist/fence.js CHANGED
@@ -568,6 +568,12 @@ function cloudflareCli(args) {
568
568
  return never('deletes on Cloudflare');
569
569
  return deny("cf runs in the tools, as the person: impeccable_cloudflare_previews reads Cloudflare's previews, and impeccable_cloudflare_access signs in and makes the service token");
570
570
  }
571
+ /** render, Render's CLI: the tools run it in the command's own process, as the person (its sign-in and the service's settings), so none of it runs here, and a delete or a suspend never does. */
572
+ function renderCli(args) {
573
+ if (args.some((a) => a === 'delete' || a === 'suspend'))
574
+ return never('deletes or suspends on Render');
575
+ return deny("render runs in the tools, as the person: impeccable_render_previews reads Render's services and previews, and turns a service's pull request previews on after the person's yes");
576
+ }
571
577
  /** A package manager's install, script or version; with `tools`, also a tool from the list through pnpm exec or yarn. */
572
578
  function packageManager(pm, args, where, scriptsChanged, tools = true) {
573
579
  const [sub = '', ...rest] = args;
@@ -912,6 +918,9 @@ function simpleCommand(seg, where, opts) {
912
918
  return ghView(args.slice(2)) ? allow() : deny("gh pr view takes a pull request's number or this run's branch, --repo for this repository, --json and --comments");
913
919
  if (args[0] === 'pr' && args[1] === 'checks')
914
920
  return ghView(args.slice(2), '--required') ? allow() : deny("gh pr checks takes a pull request's number or this run's branch, --repo for this repository, --json and --required");
921
+ // The repository's own pull requests, listed: the earlier ones whose previews are its host's evidence (REN-1108).
922
+ if (args[0] === 'pr' && args[1] === 'list')
923
+ return ghList(args.slice(2)) ? allow() : deny('gh pr list takes --repo for this repository, --state, --limit (100 at most) and --json');
915
924
  // The test drive opens as a draft where its preview needs the pull request first, and is marked ready once that preview is proven: the test drive's branch by name, nothing else.
916
925
  if (args[0] === 'pr' && args[1] === 'ready')
917
926
  return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
@@ -945,6 +954,8 @@ function simpleCommand(seg, where, opts) {
945
954
  return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
946
955
  case 'cf':
947
956
  return cloudflareCli(args);
957
+ case 'render':
958
+ return renderCli(args);
948
959
  default: {
949
960
  if (!PACKAGE_MANAGERS.has(cmd))
950
961
  return deny(`${cmd} is not on the allowlist`);
@@ -1697,6 +1708,28 @@ function ghView(args, flag = '--comments') {
1697
1708
  const repository = seen.get('--repo');
1698
1709
  return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
1699
1710
  }
1711
+ /** `gh pr list [--repo <owner>/<name>] [--state open|closed|merged|all] [--limit <n>] [--json <fields>]`, each once and nothing else (no --web, no search): the repository named, or null. */
1712
+ function ghList(args) {
1713
+ const seen = new Map();
1714
+ for (let i = 0; i < args.length; i++) {
1715
+ const arg = args[i];
1716
+ const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
1717
+ const given = at < 0 ? arg : arg.slice(0, at);
1718
+ const name = { '-R': '--repo', '-s': '--state', '-L': '--limit' }[given] ?? given;
1719
+ if (!['--repo', '--state', '--limit', '--json'].includes(name) || seen.has(name))
1720
+ return null;
1721
+ const value = (at < 0 ? args[++i] : arg.slice(at + 1)) ?? '';
1722
+ if (!value || value.startsWith('-'))
1723
+ return null;
1724
+ if (name === '--state' && !['open', 'closed', 'merged', 'all'].includes(value))
1725
+ return null;
1726
+ if (name === '--limit' && !(/^[1-9]\d{0,2}$/.test(value) && Number(value) <= 100))
1727
+ return null;
1728
+ seen.set(name, value);
1729
+ }
1730
+ const repository = seen.get('--repo');
1731
+ return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
1732
+ }
1700
1733
  /** The branch of `push -u origin <branch>`, git's words from its command on, when `literal` takes it. */
1701
1734
  const pushBranch = (args, literal = setupBranch) => args.length === 4 && args[0] === 'push'
1702
1735
  && ['-u', '--set-upstream'].includes(args[1]) && args[2] === 'origin' && literal(args[3]) ? args[3] : undefined;
@@ -1827,8 +1860,8 @@ export function reservePreviewCommand(state, command, root, scratch) {
1827
1860
  if (!drives.get(parsed.branch)?.created)
1828
1861
  return deny('mark ready the test drive this run opened');
1829
1862
  }
1830
- if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
1831
- const parsed = ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
1863
+ if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks' || words[2] === 'list')) {
1864
+ const parsed = words[2] === 'list' ? ghList(words.slice(3)) : ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
1832
1865
  if (!parsed)
1833
1866
  return deny("view only this run's pull requests, by number or branch name");
1834
1867
  // Without --repo, gh picks a repository from the checkout's remotes, which may name another one.
@@ -134,8 +134,11 @@ export const HOST_SCREENS = {
134
134
  ...buildWait('vercel', 'Vercel', 'connect the repository in Settings → Git'),
135
135
  ...buildWait('netlify', 'Netlify', 'turn Deploy Previews on'),
136
136
  ...buildWait('cloudflare', 'Cloudflare', 'connect the repository to the Pages project'),
137
+ ...buildWait('render', 'Render', 'set Pull Request Previews to Automatic under the service\'s Previews settings'),
137
138
  ...previewsOff('netlify', 'Netlify', 'turn Deploy Previews on under Branches and deploy contexts → Configure'),
138
139
  ...previewsOff('cloudflare', 'Cloudflare', 'set Preview branch control to All non-Production branches under Builds & deployments'),
140
+ // Render's consent is its one screen (renderConfirm): only the retry is its own.
141
+ 'render-previews-retry': previewsOff('render', 'Render', "set Pull Request Previews to Automatic under the service's Previews settings")['render-previews-retry'],
139
142
  // No Pages project builds the repository's pull requests: the one consent to connecting the repository to the project the checkout names, or to creating one for it where Cloudflare builds a Worker instead (the tool names the project in the prompt), and the sign-in cf needs for either.
140
143
  'cloudflare-setup': {
141
144
  id: 'cloudflare-setup',
@@ -264,6 +267,24 @@ export function netlifyPasswordReplace(site, opts = {}) {
264
267
  skipLabel: 'Skip',
265
268
  };
266
269
  }
270
+ /**
271
+ * The one screen for an app on Render (REN-1104): the address to click, the site's own or an earlier pull request's
272
+ * preview, so the person sees it is theirs, and, where the service's pull-request previews are off, the consent to
273
+ * turning them on as the person with the render CLI. It never asks for an address.
274
+ */
275
+ export function renderConfirm(found) {
276
+ const whose = found.app ? `${found.app}'s previews` : 'Your previews';
277
+ const off = found.previewsOff ? `\n\nRender's pull-request previews are off for ${found.site}: on your yes, the run turns them on as you; what is live stays as it is.` : '';
278
+ return {
279
+ id: 'render-confirm',
280
+ kind: 'confirm',
281
+ title: `${whose} are on Render`,
282
+ hint: `Render builds a preview of each pull request as https://${found.site}-pr-<number>.onrender.com, and Impeccable reviews it there.\n\nOpen ${found.address} to check it's ${found.app ?? 'your app'}.${off}`,
283
+ prompt: found.previewsOff ? 'Is this your app? Turn on its previews?' : 'Is this your preview?',
284
+ confirmLabel: found.previewsOff ? 'Yes, turn on' : 'Yes',
285
+ skipLabel: 'Skip',
286
+ };
287
+ }
267
288
  /** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
268
289
  export function vercelBypassConsent(project, app) {
269
290
  const screen = HOST_SCREENS['vercel-bypass'];
package/dist/hosted.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { promisify } from 'node:util';
3
4
  import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
4
5
  import { gitEnvironment } from './sandbox.js';
@@ -63,10 +64,11 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
63
64
  if (!d || !positive(d.id) || typeof d.sha !== 'string' || !/^[0-9a-f]{40}$/.test(d.sha))
64
65
  return null;
65
66
  // GitHub lists a deployment's statuses newest first.
66
- const latest = record((await list(`${base}/deployments/${d.id}/statuses?per_page=1`))[0]);
67
+ const statuses = (await list(`${base}/deployments/${d.id}/statuses?per_page=${LIMIT}`)).map(record);
68
+ const latest = statuses[0];
67
69
  const state = STATES.find((s) => s === latest?.state) ?? null;
68
70
  const environment = typeof d.environment === 'string' && d.environment.length <= 100 && !/[\x00-\x1f\x7f]/.test(d.environment) ? d.environment : null;
69
- const url = state ? address(latest?.environment_url) : null;
71
+ const url = state ? (address(latest?.environment_url) ?? address(statuses.find((s) => s?.state === 'success' && address(s.environment_url))?.environment_url)) : null;
70
72
  let pullRequests = heads.filter((pull) => pull.sha === d.sha).map((pull) => pull.number);
71
73
  // A pull request whose head moved on since, or that was updated before the 20 read, still names the commit. When GitHub cannot say, this one stays unlinked and the evidence says so. A branch's head is asked for by its branch, before its pull request exists, so none is looked for.
72
74
  if (!pullRequests.length && state === 'success' && url && deps.branch === undefined)
@@ -84,31 +86,38 @@ const HOSTS = {
84
86
  vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/, lock: 'Vercel' },
85
87
  netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/, lock: 'Netlify' },
86
88
  cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/, lock: 'Cloudflare Access' },
89
+ // Render's static sites have no lock of Render's: a 401 there is the app's own.
90
+ render: { name: 'Render', gate: undefined, login: /$^/, lock: 'Render' },
87
91
  };
88
92
  const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
89
93
  const NETLIFY = new RegExp(`^(?=[^.]{1,63}\\.netlify\\.app$)deploy-preview-[1-9]\\d{0,6}--(${LABEL})\\.netlify\\.app$`);
90
94
  /** A Pages preview: one label (the deployment's id, or the branch's alias), then the project, under pages.dev. The project's own address, with no label, is its production. */
91
95
  const PAGES = new RegExp(`^${LABEL}\\.(${LABEL})\\.pages\\.dev$`);
92
- /** A live app's own address on its host: a Vercel alias (one name under vercel.app, the shape its deployments have too), a Netlify site's netlify.app address (a deploy or branch subdomain carries `--`), a Pages project's pages.dev address. */
93
- const LIVE = { vercel: new RegExp(`^${LABEL}\\.vercel\\.app$`), netlify: new RegExp(`^(?!.*--)(${LABEL})\\.netlify\\.app$`), cloudflare: new RegExp(`^(${LABEL})\\.pages\\.dev$`) };
96
+ /** A Render pull request's preview: a site of its own, the service's name and the pull request's number, under onrender.com. */
97
+ const RENDER = new RegExp(`^(${LABEL})-pr-([1-9]\\d{0,6})\\.onrender\\.com$`);
98
+ /** A Worker's preview: one label (the version's id, or the branch's alias, joined to the Worker's name), then the account's subdomain, under workers.dev. */
99
+ const WORKERS = new RegExp(`^${LABEL}\\.${LABEL}\\.workers\\.dev$`);
100
+ /** A live app's own address on its host: a Vercel alias (one name under vercel.app, the shape its deployments have too), a Netlify site's netlify.app address (a deploy or branch subdomain carries `--`), a Pages project's pages.dev address, a Render service's onrender.com address (a pull request's preview ends in `-pr-<n>`). */
101
+ const LIVE = { vercel: new RegExp(`^${LABEL}\\.vercel\\.app$`), netlify: new RegExp(`^(?!.*--)(${LABEL})\\.netlify\\.app$`), cloudflare: new RegExp(`^(${LABEL})\\.pages\\.dev$`), render: new RegExp(`^(?!.*-pr-[1-9]\\d{0,6}\\.)(${LABEL})\\.onrender\\.com$`) };
94
102
  /** https on one name, with no credentials, port, query or fragment. */
95
103
  const plainHttps = (url) => url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.search && !url.hash;
96
- /** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview. */
104
+ /** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview or a Worker's, Render's a pull request's preview site. */
97
105
  const hostOf = (url) => {
98
106
  if (!plainHttps(url))
99
107
  return null;
100
- return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : null;
108
+ return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) || WORKERS.test(url.hostname) ? 'cloudflare' : RENDER.test(url.hostname) ? 'render' : null;
101
109
  };
102
110
  /** The host a live app's address is on, by its domain, so its lock is known; `other` for any other https address, whose lock no key opens. */
103
111
  const liveHostOf = (url) => {
104
112
  if (!plainHttps(url))
105
113
  return null;
106
- return LIVE.vercel.test(url.hostname) ? 'vercel' : /(^|\.)netlify\.app$/.test(url.hostname) ? 'netlify' : /(^|\.)pages\.dev$/.test(url.hostname) ? 'cloudflare' : 'other';
114
+ return LIVE.vercel.test(url.hostname) ? 'vercel' : /(^|\.)netlify\.app$/.test(url.hostname) ? 'netlify' : /(^|\.)pages\.dev$/.test(url.hostname) ? 'cloudflare' : /(^|\.)onrender\.com$/.test(url.hostname) ? 'render' : 'other';
107
115
  };
108
116
  /**
109
117
  * Whether an address a file of the checkout names is a live app's on one of the hosts, and not a preview's: the site's
110
- * or project's own address under vercel.app, netlify.app or pages.dev (a Netlify deploy preview, a Pages deployment or
111
- * branch alias, and a Vercel branch alias, `<project>-git-<branch>-<team>`, are previews whatever file links them). Any
118
+ * or project's own address under vercel.app, netlify.app, pages.dev or onrender.com (a Netlify deploy preview, a Pages
119
+ * deployment or branch alias, a Render pull request's site and a Vercel branch alias, `<project>-git-<branch>-<team>`,
120
+ * are previews whatever file links them). Any
112
121
  * other address a file names is not opened: a file is anyone's to write, and a name there could reach a service on the
113
122
  * person's own network, which only the host tools' facts may name.
114
123
  */
@@ -123,12 +132,14 @@ export function liveShaped(url) {
123
132
  return LIVE.netlify.test(parsed.hostname);
124
133
  if (host === 'cloudflare')
125
134
  return LIVE.cloudflare.test(parsed.hostname);
135
+ if (host === 'render')
136
+ return LIVE.render.test(parsed.hostname);
126
137
  return false;
127
138
  }
128
139
  /** The host a preview address belongs to, by name; null for an address of none. */
129
140
  export function previewHostName(url) {
130
141
  const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
131
- return host ? HOSTS[host].name : null;
142
+ return host === 'cloudflare' && WORKERS.test(new URL(url).hostname) ? 'Cloudflare Workers' : host ? HOSTS[host].name : null;
132
143
  }
133
144
  /** The Netlify site a deploy preview address, or the site's own netlify.app address, belongs to, by name; null for any other address. */
134
145
  export function netlifySiteOf(url) {
@@ -155,7 +166,7 @@ export function vercelDeploymentOf(url, project) {
155
166
  .filter((split) => project === undefined || split.project === project.toLowerCase());
156
167
  return splits.length === 1 ? splits[0] : null;
157
168
  }
158
- /** The address pattern a proven preview stands for, where the host reports none to GitHub: Netlify's by pull request, Cloudflare's by the branch's alias. A live address stands for none. */
169
+ /** The address pattern a proven preview stands for, where the host reports none to GitHub that Impeccable tells apart: Netlify's by pull request, Cloudflare's by the branch's alias, Render's by pull request. A live address stands for none. */
159
170
  function patternOf(hostname) {
160
171
  const site = NETLIFY.exec(hostname)?.[1];
161
172
  if (site)
@@ -163,8 +174,22 @@ function patternOf(hostname) {
163
174
  const project = PAGES.exec(hostname)?.[1];
164
175
  if (project)
165
176
  return { pattern: `https://{branch}.${project}.pages.dev` };
177
+ const service = RENDER.exec(hostname)?.[1];
178
+ if (service)
179
+ return { pattern: `https://${service}-pr-{pr}.onrender.com` };
166
180
  return {};
167
181
  }
182
+ /** The Render site an address belongs to, with the pull request a preview's address names: `<site>-pr-<n>.onrender.com`, or the site's own `<site>.onrender.com`; null for any other address. */
183
+ export function renderSiteOf(url) {
184
+ if (!URL.canParse(url) || !plainHttps(new URL(url)))
185
+ return null;
186
+ const { hostname } = new URL(url);
187
+ const preview = RENDER.exec(hostname);
188
+ if (preview)
189
+ return { site: preview[1], pull: Number(preview[2]) };
190
+ const live = LIVE.render.exec(hostname);
191
+ return live ? { site: live[1] } : null;
192
+ }
168
193
  const ENTITIES = {
169
194
  amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
170
195
  hellip: '…', middot: '·', bull: '•', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', times: '×',
@@ -326,7 +351,7 @@ export async function provePreview(url, words, root, deps = {}) {
326
351
  const parsed = URL.canParse(url) ? new URL(url) : null;
327
352
  const host = !parsed ? null : !deps.live ? hostOf(parsed) : !plainHttps(parsed) ? null : typeof deps.live === 'string' ? deps.live : liveHostOf(parsed);
328
353
  if (!host)
329
- return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, or a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, with no query, is opened.');
354
+ return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, a Worker\'s, https://<id or branch>-<worker>.<subdomain>.workers.dev, or a Render preview, https://<site>-pr-<n>.onrender.com, with no query, is opened.');
330
355
  // Another host's live address has a lock no key opens, so none is looked for: its login is another site, and its 401 the app's own sign-in.
331
356
  const { gate, login, lock } = host === 'other' ? { gate: undefined, login: /$^/, lock: 'the app' } : HOSTS[host];
332
357
  const phrase = words.trim();
@@ -350,9 +375,17 @@ export async function provePreview(url, words, root, deps = {}) {
350
375
  // Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
351
376
  const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
352
377
  let target = new URL(url);
378
+ // Render's edge keeps a page it served for five minutes (`s-maxage=300`), and after a deploy goes on serving the old one for a while (REN-1117): each request carries a query value of its own beside any a redirect gave, which the edge caches apart, so the proof reads the build Render serves now. A request's `Cache-Control: no-cache` the edge ignores.
379
+ const fresh = (at) => {
380
+ if (host !== 'render')
381
+ return at;
382
+ const asked = new URL(at);
383
+ asked.searchParams.set('impeccable', randomUUID());
384
+ return asked;
385
+ };
353
386
  try {
354
387
  for (let hop = 0; hop < 4; hop++) {
355
- const res = await open(target, { redirect: 'manual', headers, credentials: 'omit', signal });
388
+ const res = await open(fresh(target), { redirect: 'manual', headers, credentials: 'omit', signal });
356
389
  if (res.status >= 300 && res.status < 400) {
357
390
  await res.body?.cancel().catch(() => { });
358
391
  const location = res.headers.get('location') ?? '';
package/dist/log.js CHANGED
@@ -31,7 +31,8 @@ export function createLog(debug, dir = tmpdir()) {
31
31
  const secret = (value) => void secrets.add(value);
32
32
  if (!debug)
33
33
  return { secret, write: () => { } };
34
- const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
34
+ // The process id keeps two runs started in the same millisecond out of one file.
35
+ const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}.log`);
35
36
  let tightened = false;
36
37
  return {
37
38
  path,
package/dist/main.js CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { runAgent } from './agent.js';
7
+ import { runSkillCommand } from './skill.js';
7
8
  import { fenceExtension, guardedFileOperations, hostsAgreed, placeOf, previewRetryState } from './fence.js';
8
9
  import { accountsSaved, focusInstructions, focusState, severalApps, signInItem } from './focus.js';
9
10
  import { requestJson } from './http.js';
@@ -76,6 +77,15 @@ async function nothingToSetUp(sync, reporter, why, title, result, links) {
76
77
  }
77
78
  export async function main(argv) {
78
79
  skipWorkingFolderForCommands();
80
+ // The screenshots skill's install: no sign-in, no agent, no sandbox (src/skill.ts).
81
+ const skillExit = await runSkillCommand(argv).catch((err) => {
82
+ console.error(err instanceof Error ? err.message : String(err));
83
+ return 1;
84
+ });
85
+ if (skillExit !== null) {
86
+ process.exitCode = skillExit;
87
+ return;
88
+ }
79
89
  let log = createLog(false);
80
90
  let sync;
81
91
  /** Takes the checkout back to the branch the run started on, and the run's own files no commit carries out of it, once the run has begun; the lines the person reads for what it could not. */
@@ -1,6 +1,6 @@
1
1
  ### When this applies
2
2
 
3
- Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
3
+ Cloudflare Pages deploys previews of the pull requests, or a Worker Cloudflare builds (Workers Builds) does where its branch builds make previews: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias (a Worker's, on workers.dev, with the `pattern` it stands for). Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
4
4
 
5
5
  ### Steps
6
6
 
@@ -9,13 +9,13 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
9
9
 
10
10
  ### Reference
11
11
 
12
- - **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
12
+ - **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`, or, with no Pages project and a Worker's previews listed, the Worker's own, `https://<worker>.<subdomain>.workers.dev`; live-app-address says how it becomes the before, in step 2's write. A workers.dev address the README names is not the before on its own: its shape cannot tell the Worker's own address from a preview's.
13
13
  - **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
14
14
  - **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Cloudflare address answered.
15
15
  - **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
16
16
  1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
17
17
  2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
18
- 3. **A Worker, not a Pages project** (`worker` in the answer, and no `pullRequestPreviews`): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), which gives pull requests no preview, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
18
+ 3. **A Worker, not a Pages project** (`worker` in the answer, no `pullRequestPreviews`, and no Worker preview listed): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), whose branch builds made no preview on record, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
19
19
  4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
20
20
  - **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty. `skipped`, or the GitHub app's access skipped on the wait's screen, with no method saved, is a no that ends the setup, as the playbook says: no other preview is set up.
21
21
  - **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
@@ -4,7 +4,7 @@ Every run that saves a preview method, for the state's `before` item: the addres
4
4
 
5
5
  ### Steps
6
6
 
7
- 1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
7
+ 1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`, or with no Pages project a Worker's own, `https://<worker>.<subdomain>.workers.dev`; impeccable_render_previews, the Render site's, `https://<site>.onrender.com`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
8
8
  2. **Prove it** with impeccable_prove_preview, under the status line *"Opening the live app"*: that address, or a page on it a visitor opens without signing in, and words the page shows that the checkout holds. A key the run already got in place (Vercel's bypass secret, Netlify's site password, Cloudflare's service token) goes with it where the lock is the same project's or site's. Done when it answers `proven: true`. **An address no key opens is not set**: a `gate`, the app's own sign-in on every page, or no answer leaves the item as it is, and the report says what the address answered.
9
9
  3. **Save it** with impeccable_write to `preview`: `beforeUrl` the proven address's root, `https://<host>` with no path, whatever page the proof opened (a login page is one role's, and the review signs in as another; impeccable_write saves the root either way), in the same write as the method where the proof came before it (`{"intendedMethod": "preview-deploy", "beforeUrl": "https://…"}`), else in a write of its own; `appKey` the app's on a repository of several apps, one address per app. Done when the answer carries `beforeUrl`; its `beforeCheck.access` says how Impeccable's own check opened it: `ok`; `auth-wall`, the app's sign-in, which the saved test accounts get past; anything else goes under "What is not verified".
10
10
 
@@ -8,7 +8,7 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
8
8
  2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Set up monorepo apps task in progress first. Every write and host tool for an app is refused until it is answered. It has no Skip: the person picks at least one app. Done when it answered `values` or `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there). Where no answer came (the input ended), the tool wrote nothing and the run ends there: call nothing more. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
9
9
  3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app).
10
10
  4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
11
- 5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`), and on the host question, a host's sign-in and team questions, impeccable_enable_previews and impeccable_cloudflare_connect, so a no ends the setup for that app alone: its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Where a host's setup case (vercel-setup, netlify-setup) opens and records an app's test drive here, check that app's preview right after the recording, as pull-requests' step 6 says with its `appKey`, while its test drive is still the newest open pull request, before the next app is set up. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
11
+ 5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`), and on the host question, a host's sign-in and team questions, impeccable_enable_previews and impeccable_cloudflare_connect, so a no ends the setup for that app alone: its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews and impeccable_render_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Where a host's setup case (vercel-setup, netlify-setup) opens and records an app's test drive here, check that app's preview right after the recording, as pull-requests' step 6 says with its `appKey`, while its test drive is still the newest open pull request, before the next app is set up. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
12
12
  6. **The pull requests** as pull-requests says: the setup commit once for the repository, taking the state's one workflow `file` once every chosen app's method and build settings are saved, or its preview skipped as server-previews says. A run that sets up one app opens its test drive as pull-requests says, on `impeccable/test-drive`, its page in that app alone. A run that sets up more opens **a test drive of its own for each chosen app with a preview**, one app after the other, so each app's review shows on its own pull request; an app whose host's setup case (vercel-setup, netlify-setup) already opened, recorded and checked its test drive in step 5 has it, and gets none again. An app's test drive is branch `impeccable/test-drive-<appKey>` from the setup commit (from the default branch when there is none), with its page in that app alone, in that app's own framework and linked from that app's own public page, and no change to any other app; that case's step 3 builds that app as its host's case says; its step 4 pushes it and proves its page on that app's own preview, with that app's key where its lock stands in front; its step 5 opens it with the app's name in the title (*"Impeccable test drive: Storefront (safe to close)"*) and records it with impeccable_test_pr and that app's `appKey` (which the tool needs only when the run sets up more than one app); its step 6 checks that app with its `appKey` while its test drive is the newest open pull request, before the next app's test drive opens. An app whose preview was skipped gets no test drive, and goes under "What was skipped, and why". Step 7, when there is a setup commit, opens the setup pull request once, last, and records it with the last app's test drive and its `appKey` again. Done when every chosen app with a preview has its test drive recorded and checked, or the reason it has none in the report, and pull-requests' step 7 is done.
13
13
  - **A chosen app whose preview was skipped** (server-previews), or whose setup ended on a no (the playbook's "A no that ends the setup"): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them, or which no ended its setup. When no chosen app is left with a preview, call impeccable_report: the last screen says why the setup ended.
14
14
  7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`), its method, its sign-in, what was verified; an app the question did not offer and the state shows off (`enabled` false) was turned off on the setup page, and its line says so, never that it was left off in the question; after `named`, an app still on that this run did not set up is outside this run, not turned off; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.