@impeccable/reviews 0.1.0 → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Renaissance Geek, Inc. All rights reserved.
2
+
3
+ This software is proprietary. You may install and run it only to set up and
4
+ use Impeccable (https://impeccable.reviews), subject to Impeccable's Terms of
5
+ Service at https://impeccable.reviews/terms. You may not copy, modify,
6
+ distribute, sublicense, or reverse engineer it, except as those terms or
7
+ applicable law allow.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
11
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL RENAISSANCE
12
+ GEEK, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
13
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
14
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -29,13 +29,14 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
29
29
  | `--yes` | off | Runs the allowlisted commands without asking, which they already do; anything off the list is still refused. |
30
30
  | `--debug` | off | Writes a debug log and a transcript of the run (`transcript-<run>.md`, with its raw events in `transcript-<run>.jsonl`) to the temp folder, tokens removed, and prints where they are. |
31
31
  | `--transcript <path>` | the temp folder | Writes the transcript there instead: a folder, or a `.md` file with the `.jsonl` beside it. |
32
+ | `--scan-report` | off | Writes what the fence's content checks found (`scan-report-<run>.json`: each match's check, where it was, the tool and what it did, never the text, with counts per check) beside the transcript, and prints where it is. |
32
33
  | `--frames <dir>` | off | Writes each screen into the folder as it changes (`NNN-<state>.ansi` with its styling, `NNN-<state>.txt`, and `index.json`), for the eval. |
33
34
 
34
35
  ## What happens
35
36
 
36
37
  1. **Sign in.** The consent page (pick the organization and this repository) 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. On a remote shell, press P and paste the address the browser ends on.
37
38
  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.
38
- 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 or Netlify 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), 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, to sign in to the host, and only when its CLI on this machine is signed out: one screen, 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). Then it does the rest itself. On Vercel it creates the project with `vercel link` in the team the CLI's current scope names (asking which, only when you have several and none is current), connects the repository, pushes the test drive and waits up to five minutes for Vercel's build of it (the task in progress carries the timer, "1:05 of 5:00"), 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, 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.
39
+ 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 wrangler 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, to sign in to the host, and only when its CLI on this machine is signed out: one screen, 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). Then it does the rest itself. On Vercel it creates the project with `vercel link` in the team the CLI's current scope names (asking which, only when you have several and none is current), connects the repository, 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 carries the timer, "1:05 of 5:00"), 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, 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.
39
40
  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:
40
41
  - 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;
41
42
  - 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.
@@ -45,13 +46,13 @@ The sign-in's setup token lasts an hour. A run that goes longer asks you to sign
45
46
 
46
47
  ## What the agent may run
47
48
 
48
- The fence keeps the setup to its own steps and out of `.env` files; it is not a sandbox: the repository's scripts and builds run as you, with your files and your `gh` and git sign-ins, as they would for a developer, but without the tokens, keys and passwords in your environment.
49
+ The fence keeps the setup to its own steps and out of `.env` files; it is not a sandbox: the repository's scripts and builds run as you, with your files and your `gh` and git sign-ins, as they would for a developer, but without the tokens, keys and passwords in your environment. It also reads what goes past it: text shaped like a secret (a cloud or GitHub key, a private key, a token) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, 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.
49
50
 
50
51
  Without asking: `git status|diff|log|show|add|commit|checkout -b|push -u origin <branch>`, `gh pr create|view` 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.
51
52
 
52
- `vercel whoami` and `vercel --version`. Once you are signed in to Vercel for the setup: `vercel link --yes`, `vercel project add|ls`, `vercel git connect` 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`, and a global install of the Vercel CLI itself when it is missing (`npm install -g vercel`, or your package manager's global add). The sign-in (`vercel login`) is the run's own tool, never the agent's command. 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), or a token on the command line.
53
+ `vercel whoami` and `vercel --version`. Once you are signed in to Vercel for the setup: `vercel link --yes`, `vercel project add|ls`, `vercel git connect` 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`, and a global install of the Vercel CLI itself when it is missing (`npm install -g vercel`, or your package manager's global add). The sign-in (`vercel login`) is the run's own tool, never the agent's command. 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.
53
54
 
54
- `netlify --version` and `netlify status`. Once you are signed in to Netlify for the setup: `netlify link`, `netlify sites:create` and `netlify deploy` for a deploy preview (`--build --context deploy-preview`), in your own current Netlify team, or with `--account-slug` for the one team you picked when the run asked, and a global install of the Netlify CLI itself when it is missing (`npm install -g netlify-cli`, or your package manager's global add). The sign-in (`netlify login`) and the repository's connection (the site's settings, through Netlify's API) are the run's own tools, never the agent's commands; `netlify init` never runs. Never a production deploy, a site's removal, its environment variables, or a token on the command line.
55
+ `netlify --version` and `netlify status`. Once you are signed in to Netlify for the setup: `netlify link`, `netlify sites:create` and `netlify deploy` for a deploy preview (`--build --context deploy-preview`), in your own current Netlify team, or with `--account-slug` for the one team you picked when the run asked, and a global install of the Netlify CLI itself when it is missing (`npm install -g netlify-cli`, or your package manager's global add). The sign-in (`netlify login`), the repository's connection (the site's settings, through Netlify's API) and the site's password (`netlify api` prints a site's settings, the password among them) are the run's own tools, never the agent's commands; `netlify init` never runs. Never a production deploy, a site's removal, its environment variables, or a token on the command line.
55
56
 
56
57
  Scratch output, such as the log of a server the agent starts, goes to a folder in the OS temp directory, never into the checkout; a `>` redirect into that run's folder is the one write outside the checkout that runs, and the agent reads what it wrote there back (the path with its symlinks resolved).
57
58
 
@@ -65,4 +66,6 @@ A test account's password, typed hidden on the account's screen (after a screen
65
66
 
66
67
  Vercel's bypass secret, which lets reviews open previews behind Vercel's login, is handled the same way. When Vercel's login stands in front of a preview, the command asks you once, on one screen, whether to create a bypass secret for Impeccable, and on your yes creates it as you through the Vercel CLI, labelled "Impeccable reviews" (`vercel api` for Vercel's protection-bypass route; an older CLI's `vercel project protection enable --protection-bypass`), then reads it back from the project's settings. A project that already holds a secret with that label gives it without a question. When the CLI is signed out, this folder is not linked, or your role cannot make one, a screen names the settings page and takes the secret you type, hidden. It opens the preview with the secret first and saves it, with the setting that sends it in the `x-vercel-protection-bypass` header, only once the page shows your app. The agent never holds it: the secret goes from the command's own process to Vercel and to Impeccable's setting, and the debug log and the transcript remove it. Vercel documents this as Protection Bypass for Automation, which every plan includes; you revoke it in the project's Settings, Deployment Protection.
67
68
 
68
- Working on the CLI itself (a local build, the smoke test, the evals): [docs/development.md](docs/development.md).
69
+ Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you. For a password Netlify gives back, it asks you once, on one screen, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back, the CLI is signed out or the site is in none of your teams, a screen takes the password you type, hidden (the run never sets one itself: the Netlify CLI takes a new password only on its command line, where any process could read it). It opens the preview with the password first, as basic credentials in the `Authorization` header (Netlify's password page asks for a password alone, so the credentials carry no username), and saves it, with the setting that sends that header, only once the page shows your app; a password that opens the preview on Netlify's own form but not in the header is reported, not saved, since reviews can send only a header. A site behind Netlify's team login (a private site, or team login protection) has no way in for tools: the run reports it and takes no method. The agent never holds the password: it goes from the command's own process to Netlify and to Impeccable's setting, and the debug log and the transcript remove it.
70
+
71
+ A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen, whether to create a service token for Impeccable, and on your yes asks for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), since wrangler's sign-in has no scope for Access. With it the command creates the token as you, named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list, or a policy of its own where it has its own); the API token is used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen takes a client id and secret you paste, the secret hidden. It opens the preview with the token in the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers first and saves it, with the setting that sends them, only once the page shows your app; a token it made that opens nothing is deleted, with its policy. The agent never holds either, and the debug log and the transcript remove both. You revoke it in Zero Trust, Access controls, Service credentials, Service Tokens.
package/dist/bin.js CHANGED
File without changes
@@ -0,0 +1,213 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ /**
3
+ * Getting past Cloudflare Access the way Cloudflare documents it for tools: a service token, a client id and a client
4
+ * secret sent in two headers, which a Service Auth policy of the Access application in front of the previews allows.
5
+ * wrangler's sign-in cannot reach Access (its OAuth scopes hold none for Zero Trust), so the person gives the run a
6
+ * Cloudflare API token with two permissions, used in the command's own process for this run and kept nowhere. The person
7
+ * consents once, on the tool's screen, before anything is made; a token opens the locked address before anything saves
8
+ * it; neither it nor the API token goes back to the model.
9
+ */
10
+ /** The headers Access reads a service token from. */
11
+ export const ACCESS_ID_HEADER = 'CF-Access-Client-Id';
12
+ export const ACCESS_SECRET_HEADER = 'CF-Access-Client-Secret';
13
+ /** The name the token and its policy carry in Zero Trust, so the person knows whose they are and can revoke them. */
14
+ export const TOKEN_NAME = 'Impeccable reviews';
15
+ const API = 'https://api.cloudflare.com/client/v4';
16
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
17
+ const list = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
18
+ /** An id Cloudflare gave, safe in a path: an account's 32 hex digits, or a UUID. */
19
+ const uid = (value) => typeof value === 'string' && /^[0-9a-f-]{8,64}$/i.test(value);
20
+ const PERMISSIONS = 'The Cloudflare API token cannot do this: it needs Access: Apps and Policies → Edit and Access: Service Tokens → Edit, on the account that holds the Access application.';
21
+ /** Why a call failed, in fixed words: the token's permissions for a 401 or a 403, else the status. Cloudflare's own message never goes on. */
22
+ const refusal = (reply, none) => (reply.ok ? none : reply.status === 401 || reply.status === 403 ? PERMISSIONS : reply.status === null ? "Cloudflare's API did not answer." : `Cloudflare's API answered ${reply.status}.`);
23
+ /** Where an application sends Access's login: its domain, its self-hosted domains and its public destinations, each a host with an optional path. */
24
+ function destinations(app) {
25
+ const uris = list(app.destinations).filter((d) => d.type === undefined || d.type === 'public').map((d) => d.uri);
26
+ return [app.domain, ...(Array.isArray(app.self_hosted_domains) ? app.self_hosted_domains : []), ...uris].filter((d) => typeof d === 'string' && d.length > 0);
27
+ }
28
+ /** Whether a destination covers the address: its host, where `*` stands for any part of a name, and its path, when it has one, the address's path or a folder above it. */
29
+ function covers(destination, url) {
30
+ const [host = '', ...rest] = destination.trim().toLowerCase().replace(/^https?:\/\//, '').split('/');
31
+ const path = rest.join('/').replace(/\/+$/, '');
32
+ const pattern = (text, any) => text.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join(any);
33
+ return new RegExp(`^${pattern(host, '[a-z0-9.-]*')}$`).test(url.hostname) && (!path || new RegExp(`^/${pattern(path, '.*')}(?:/|$)`).test(url.pathname));
34
+ }
35
+ /** An application's settings as its PUT takes them back: without what Cloudflare sets itself, its policies (sent as links), and the older name for its destinations. */
36
+ function settingsOf(app) {
37
+ const own = ['id', 'uid', 'aud', 'created_at', 'updated_at', 'policies', ...('destinations' in app ? ['self_hosted_domains'] : [])];
38
+ return Object.fromEntries(Object.entries(app).filter(([key]) => !own.includes(key)));
39
+ }
40
+ /** What undoing answers when Cloudflare refused a step of it: the person removes the rest. */
41
+ const LEFT_BEHIND = ` Some of what the run made could not be taken back: delete the service token and the policy named "${TOKEN_NAME}" in Zero Trust.`;
42
+ /**
43
+ * With the person's API token: the one self-hosted Access application whose destination covers the address, in the
44
+ * accounts the token reaches, then the service token, then a Service Auth policy that allows it on that application. An
45
+ * application on reusable policies (every one the dashboard makes now) gets a reusable policy added to its list; one on
46
+ * policies of its own gets one of those, since the two never mix on one application. What this made is undone, last
47
+ * first, when a later step fails, or on `undo`, even once the run is stopped.
48
+ */
49
+ async function create(key, deps) {
50
+ /** One call; `undoing` runs on after the run is stopped, since what it takes back would otherwise stay. */
51
+ const api = async (method, path, body, undoing = false) => {
52
+ try {
53
+ const res = await (deps.fetch ?? fetch)(`${API}${path}`, {
54
+ method,
55
+ headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
56
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
57
+ signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(deps.signal && !undoing ? [deps.signal] : [])]),
58
+ });
59
+ const data = record(await res.json().catch(() => undefined));
60
+ return res.ok && data?.success === true ? { ok: true, result: data.result } : { ok: false, status: res.status };
61
+ }
62
+ catch {
63
+ return { ok: false, status: null };
64
+ }
65
+ };
66
+ /** Every page of a list, as many as it takes: a page shorter than a full one is the last. */
67
+ const every = async (path, per) => {
68
+ const items = [];
69
+ for (let page = 1; page <= 20; page++) {
70
+ const reply = await api('GET', `${path}?per_page=${per}&page=${page}`);
71
+ if (!reply.ok)
72
+ return reply;
73
+ const batch = Array.isArray(reply.result) ? reply.result : [];
74
+ items.push(...batch);
75
+ if (batch.length < per)
76
+ break;
77
+ }
78
+ return { ok: true, result: items };
79
+ };
80
+ const url = new URL(deps.url);
81
+ const accounts = await every('/accounts', 50);
82
+ if (!accounts.ok)
83
+ return { reason: refusal(accounts, '') };
84
+ // Every account's applications, so two that cover the address, in one account or two, are never settled by the order they came in.
85
+ const found = [];
86
+ let unread;
87
+ for (const account of list(accounts.result)) {
88
+ if (!uid(account.id))
89
+ continue;
90
+ const apps = await every(`/accounts/${account.id}/access/apps`, 1000);
91
+ if (!apps.ok)
92
+ unread = apps;
93
+ else
94
+ for (const app of list(apps.result))
95
+ if (app.type === 'self_hosted' && uid(app.id) && destinations(app).some((d) => covers(d, url)))
96
+ found.push({ account: account.id, app });
97
+ }
98
+ if (found.length > 1)
99
+ return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
100
+ if (!found.length)
101
+ return { reason: unread ? refusal(unread, '') : `No Access application the API token reaches covers ${url.host}.` };
102
+ const { account, app } = found[0];
103
+ const base = `/accounts/${account}/access`;
104
+ const links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
105
+ let reusable = true;
106
+ if (links.length) {
107
+ const policies = await every(`${base}/policies`, 1000);
108
+ if (!policies.ok)
109
+ return { reason: refusal(policies, '') };
110
+ const ids = new Set(list(policies.result).map((p) => p.id));
111
+ reusable = links.every((link) => ids.has(link.id));
112
+ }
113
+ const undo = [];
114
+ const rollback = async () => {
115
+ let clean = true;
116
+ for (const step of undo.splice(0).reverse())
117
+ clean = (await step()).ok && clean;
118
+ return clean;
119
+ };
120
+ const fail = async (reply, none) => ({ reason: `${refusal(reply, none)}${(await rollback()) ? '' : LEFT_BEHIND}` });
121
+ const made = await api('POST', `${base}/service_tokens`, { name: TOKEN_NAME });
122
+ const token = record(made.ok ? made.result : undefined);
123
+ // The secret is shown this once: the log and the transcript learn it before anything else happens.
124
+ for (const part of [token?.client_secret, token?.client_id])
125
+ if (typeof part === 'string' && part)
126
+ deps.remember(part);
127
+ if (!uid(token?.id) || typeof token.client_id !== 'string' || typeof token.client_secret !== 'string')
128
+ return fail(made, 'Cloudflare answered no service token.');
129
+ undo.push(() => api('DELETE', `${base}/service_tokens/${token.id}`, undefined, true));
130
+ const rule = { name: TOKEN_NAME, decision: 'non_identity', include: [{ service_token: { token_id: token.id } }] };
131
+ const precedence = Math.max(0, ...links.map((link) => link.precedence)) + 1;
132
+ const policy = await api('POST', reusable ? `${base}/policies` : `${base}/apps/${app.id}/policies`, reusable ? rule : { ...rule, precedence });
133
+ const policyId = record(policy.ok ? policy.result : undefined)?.id;
134
+ if (!uid(policyId))
135
+ return fail(policy, 'Cloudflare answered no policy.');
136
+ if (reusable) {
137
+ undo.push(() => api('DELETE', `${base}/policies/${policyId}`, undefined, true));
138
+ const settings = settingsOf(app);
139
+ const linked = await api('PUT', `${base}/apps/${app.id}`, { ...settings, policies: [...links, { id: policyId, precedence }] });
140
+ if (!linked.ok)
141
+ return fail(linked, '');
142
+ undo.push(() => api('PUT', `${base}/apps/${app.id}`, { ...settings, policies: links }, true));
143
+ }
144
+ else
145
+ undo.push(() => api('DELETE', `${base}/apps/${app.id}/policies/${policyId}`, undefined, true));
146
+ return {
147
+ token: { id: token.client_id, secret: token.client_secret },
148
+ application: typeof app.name === 'string' && app.name ? app.name : url.host,
149
+ ...(typeof token.expires_at === 'string' ? { expires: token.expires_at } : {}),
150
+ undo: rollback,
151
+ };
152
+ }
153
+ /** The proof with the token, again while Access's login still stands in front, until Access applies a new policy or the wait runs out. */
154
+ async function admitted(token, deps) {
155
+ const wait = deps.wait ?? { totalMs: 15_000, everyMs: 3_000 };
156
+ let proof = await deps.prove(token);
157
+ for (const end = Date.now() + wait.totalMs; !proof.proven && proof.gate === 'cloudflare-access' && Date.now() < end; proof = await deps.prove(token)) {
158
+ await sleep(wait.everyMs, undefined, { signal: deps.signal });
159
+ }
160
+ return proof;
161
+ }
162
+ /**
163
+ * Gets a service token in place for the address Cloudflare Access stands in front of, and saves only one that opens it.
164
+ * On the person's yes and with their API token, the run creates it and allows it in the application's policy; on a skip,
165
+ * or when that cannot be done or the token does not open the address (and what was made is undone), the person may paste
166
+ * one they made.
167
+ */
168
+ export async function passCloudflareAccess(deps) {
169
+ const shut = (proof) => proof.reason ?? 'The address did not open with the service token.';
170
+ /** A token that opened the address, saved for reviews. */
171
+ const keep = async (token, how, made = {}) => {
172
+ const failed = await deps.save(token);
173
+ return { status: failed ? 'save-failed' : 'saved', token: how, ...made, ...(failed ? { reason: failed } : {}), value: token };
174
+ };
175
+ /** Why the run's own way ended without a token that opens the address, for the paste screen's skip. */
176
+ let stopped;
177
+ if (!(await deps.consent()))
178
+ stopped = 'The person skipped creating a service token.';
179
+ else {
180
+ const key = (await deps.apiToken())?.trim();
181
+ if (!key)
182
+ stopped = 'The person skipped the Cloudflare API token that creating the service token takes.';
183
+ else {
184
+ deps.remember(key);
185
+ const made = await create(key, deps);
186
+ if ('reason' in made)
187
+ stopped = made.reason;
188
+ else {
189
+ // A token that opens nothing is no use to anyone, nor is one a stopped run leaves behind: it and its policy go.
190
+ const proof = await admitted(made.token, deps).catch(async (err) => {
191
+ await made.undo();
192
+ throw err;
193
+ });
194
+ if (proof.proven)
195
+ return keep(made.token, 'created', { application: made.application, ...(made.expires ? { expires: made.expires } : {}) });
196
+ stopped = `${shut(proof)}${(await made.undo()) ? '' : LEFT_BEHIND}`;
197
+ }
198
+ }
199
+ }
200
+ // A stopped run asks nothing more.
201
+ deps.signal?.throwIfAborted();
202
+ const typed = await deps.paste();
203
+ if (!typed)
204
+ return { status: 'skipped', reason: stopped };
205
+ const token = { id: typed.id?.trim() ?? '', secret: typed.secret?.trim() ?? '' };
206
+ for (const part of [token.secret, token.id])
207
+ if (part)
208
+ deps.remember(part);
209
+ if (![token.id, token.secret].every((part) => /^[\x21-\x7e]{8,512}$/.test(part)))
210
+ return { status: 'refused', reason: 'What was typed is not a service token: a client id and a client secret, each one line.' };
211
+ const proof = await admitted(token, deps);
212
+ return proof.proven ? keep(token, 'typed') : { status: 'refused', reason: shut(proof) };
213
+ }
@@ -0,0 +1,249 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { commandRunner } from './netlify.js';
4
+ /**
5
+ * Cloudflare Pages: the preview deployments this repository's pull requests
6
+ * get, from GitHub's record and from wrangler on this machine when it is
7
+ * signed in (read only), and whether wrangler is signed in. Cloudflare's
8
+ * GitHub app records no deployment and no commit status: its record is a
9
+ * check run on each commit it built and a comment on the pull request, both
10
+ * naming the deployment's address (https://<id>.<project>.pages.dev) and the
11
+ * branch's alias (https://<branch>.<project>.pages.dev), which follows the
12
+ * branch's latest build. Nothing here reads a token, and nothing comes back
13
+ * beyond the fields below.
14
+ */
15
+ /** How many pull requests, the most recently updated, the evidence reads. */
16
+ const NEWEST = 10;
17
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
18
+ /** A Pages deployment's address, or a branch's alias: one label, then the project, under pages.dev. */
19
+ export const PAGES_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.pages\\.dev$`);
20
+ const LINKS = new RegExp(`https://${LABEL}\\.${LABEL}\\.pages\\.dev`, 'g');
21
+ /** 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. */
22
+ const APPS = new Set(['cloudflare-workers-and-pages', 'cloudflare-pages']);
23
+ const isApp = (login) => typeof login === 'string' && APPS.has(login.replace(/\[bot\]$/, ''));
24
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
25
+ const positive = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
26
+ const list = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
27
+ const sha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
28
+ /** A branch name GitHub can be asked about: the characters git allows, no `..`, and neither a slash nor a dot at the end. */
29
+ const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
30
+ /** A branch, as GitHub names it in a check run's summary or a wrangler row: git's characters, and not too long to be one. */
31
+ const branchOf = (value) => (typeof value === 'string' && branchName(value) ? value : undefined);
32
+ /** The project a Pages address belongs to; null for any other address. */
33
+ export function pagesProject(url) {
34
+ return PAGES_ADDRESS.exec(url)?.[2] ?? null;
35
+ }
36
+ /** The deployment's address and the branch's alias, as the app's table names them, else the first two Pages links in order; a page mentions each once. */
37
+ function addresses(html) {
38
+ const row = (label) => new RegExp(`${label}:</strong>[\\s\\S]{0,200}?href='(https://[^']+)'`).exec(html)?.[1];
39
+ const fits = (value) => (value && PAGES_ADDRESS.test(value) ? value : undefined);
40
+ const links = [...new Set(html.match(LINKS) ?? [])];
41
+ return { url: fits(row('Preview URL')) ?? links[0], alias: fits(row('Branch Preview URL')) ?? (links[1] !== links[0] ? links[1] : undefined) };
42
+ }
43
+ /** What a check run's conclusion, or the words the app's table says, tell of the build. */
44
+ 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');
45
+ /**
46
+ * GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
47
+ * pushed branch's head alone, before its pull request exists), and the app's comment on each pull request when it
48
+ * describes the head. `failed` when a read did not answer.
49
+ */
50
+ async function fromGitHub(owner, repo, gh, only, branch) {
51
+ const base = `/repos/${owner}/${repo}`;
52
+ const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
53
+ let failed = false;
54
+ const read = async (what, none) => what.catch(() => ((failed = true), none));
55
+ const when = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0);
56
+ const heads = [];
57
+ if (branch !== undefined) {
58
+ const ref = record(await read(get(`${base}/branches/${encodeURIComponent(branch)}`), null));
59
+ const head = record(ref?.commit)?.sha;
60
+ if (ref?.name === branch && sha(head))
61
+ heads.push({ branch, sha: head, at: 0 });
62
+ }
63
+ else {
64
+ const pulls = await read(only ? get(`${base}/pulls/${only}`).then((one) => [one]) : get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${NEWEST}`), []);
65
+ for (const pull of list(pulls)) {
66
+ const head = record(pull.head);
67
+ if (positive(pull.number) && (!only || pull.number === only) && sha(head?.sha))
68
+ heads.push({ number: pull.number, branch: branchOf(head?.ref), sha: head.sha, at: when(pull.updated_at) });
69
+ }
70
+ }
71
+ const found = [];
72
+ // Cloudflare posts its check run once the build ended, so a head with none is still building, or was never built.
73
+ const runs = await Promise.all(heads.map((head) => read(get(`${base}/commits/${head.sha}/check-runs?per_page=100`), null)));
74
+ runs.forEach((result, i) => {
75
+ const head = heads[i];
76
+ for (const run of list(record(result)?.check_runs)) {
77
+ if (!isApp(record(run.app)?.slug))
78
+ continue;
79
+ const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
80
+ if (!url)
81
+ continue;
82
+ found.push({ ...(head.number ? { pullRequest: head.number } : {}), ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, url, ...(alias ? { alias } : {}), state: run.status === 'completed' ? stateOf(run.conclusion) : 'building', from: 'check run', at: when(run.completed_at) || head.at });
83
+ break;
84
+ }
85
+ });
86
+ if (branch === undefined) {
87
+ const comments = await read(get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`), []);
88
+ for (const comment of list(comments)) {
89
+ const issue = typeof comment.issue_url === 'string' ? Number(/\/issues\/([1-9]\d*)$/.exec(comment.issue_url)?.[1]) : NaN;
90
+ const head = heads.find((h) => h.number === issue);
91
+ if (!head || !isApp(record(comment.user)?.login) || typeof comment.body !== 'string')
92
+ continue;
93
+ // 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.
94
+ const commit = /Latest commit:<\/strong>[\s\S]{0,80}?<code>([0-9a-f]{7,40})<\/code>/.exec(comment.body)?.[1];
95
+ if (!commit || !head.sha.startsWith(commit))
96
+ continue;
97
+ const { url, alias } = addresses(comment.body);
98
+ if (!url || found.some((p) => p.pullRequest === issue && p.url === url))
99
+ continue;
100
+ found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, url, ...(alias ? { alias } : {}), state: stateOf(/Status:<\/strong>[\s\S]{0,120}?<\/td>/.exec(comment.body)?.[0]), from: 'bot comment', at: when(comment.updated_at) || head.at });
101
+ }
102
+ }
103
+ return { previews: found, heads, failed };
104
+ }
105
+ /** The Pages project the checkout's wrangler config names: only a config with a Pages output directory is a Pages project's, since a Worker's config names a Worker. */
106
+ export async function configuredProject(cwd) {
107
+ const name = (value) => (typeof value === 'string' && new RegExp(`^${LABEL}$`).test(value) ? value : undefined);
108
+ try {
109
+ const toml = await readFile(join(cwd, 'wrangler.toml'), 'utf8');
110
+ if (/^\s*pages_build_output_dir\s*=/m.test(toml))
111
+ return name(/^\s*name\s*=\s*"([^"\n]+)"/m.exec(toml)?.[1]);
112
+ }
113
+ catch {
114
+ // No TOML config: the JSON ones next.
115
+ }
116
+ for (const file of ['wrangler.json', 'wrangler.jsonc']) {
117
+ try {
118
+ // A jsonc config may carry comments, on their own lines or after a value, and trailing commas.
119
+ const config = record(JSON.parse(plainJson(await readFile(join(cwd, file), 'utf8'))));
120
+ if (typeof config?.pages_build_output_dir === 'string')
121
+ return name(config.name);
122
+ }
123
+ catch {
124
+ // Not there, or not readable as JSON.
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+ /** JSON with comments as JSON: line and block comments outside strings go, and so does a comma before a closing bracket. */
130
+ export function plainJson(text) {
131
+ let out = '';
132
+ for (let i = 0; i < text.length; i++) {
133
+ const c = text[i];
134
+ if (c === '"') {
135
+ // A string, its escapes kept, so a // inside it stays.
136
+ let j = i + 1;
137
+ for (; j < text.length && text[j] !== '"'; j++)
138
+ if (text[j] === '\\')
139
+ j++;
140
+ out += text.slice(i, j + 1);
141
+ i = j;
142
+ }
143
+ else if (c === '/' && text[i + 1] === '/') {
144
+ const end = text.indexOf('\n', i);
145
+ i = end < 0 ? text.length : end - 1;
146
+ }
147
+ else if (c === '/' && text[i + 1] === '*') {
148
+ const end = text.indexOf('*/', i + 2);
149
+ i = end < 0 ? text.length : end + 1;
150
+ }
151
+ else
152
+ out += c;
153
+ }
154
+ return out.replace(/,(\s*[}\]])/g, '$1');
155
+ }
156
+ /** What `wrangler whoami --json` printed, as JSON: the same object signed out, with `loggedIn` false, whatever it exited with. */
157
+ const whoamiOf = (printed) => {
158
+ const text = String(printed ?? '');
159
+ return record(JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)));
160
+ };
161
+ /** `wrangler whoami --json`: whether wrangler is signed in. */
162
+ export async function wranglerStatus(npx) {
163
+ let status;
164
+ try {
165
+ status = whoamiOf(await npx(['--yes', 'wrangler', 'whoami', '--json']));
166
+ }
167
+ catch (err) {
168
+ if (err.missing)
169
+ return 'missing';
170
+ try {
171
+ status = whoamiOf(err.stdout);
172
+ }
173
+ catch {
174
+ return 'unknown';
175
+ }
176
+ }
177
+ return status?.loggedIn === true ? 'signed-in' : status?.loggedIn === false ? 'signed-out' : 'unknown';
178
+ }
179
+ /**
180
+ * What wrangler lists of the project's preview deployments when it is signed in: `pages deployment list --json` prints
181
+ * one row a deployment, with the branch, the commit's first seven characters, the address and the status (a date once
182
+ * the build succeeded, else the stage's word).
183
+ */
184
+ async function fromWrangler(project, npx) {
185
+ const cli = await wranglerStatus(npx);
186
+ if (cli !== 'signed-in' || !project)
187
+ return { cli, rows: [], failed: cli === 'unknown' };
188
+ try {
189
+ const printed = await npx(['--yes', 'wrangler', 'pages', 'deployment', 'list', '--project-name', project, '--environment', 'preview', '--json']);
190
+ const rows = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1))).flatMap((row, at) => {
191
+ const url = typeof row.Deployment === 'string' && PAGES_ADDRESS.test(row.Deployment) ? row.Deployment : undefined;
192
+ if (!url || !/^preview$/i.test(String(row.Environment)))
193
+ return [];
194
+ const status = String(row.Status ?? '');
195
+ const state = /^(idle|active|queued|building)$/i.test(status) ? 'building' : /^fail/i.test(status) ? 'failure' : /^cancel/i.test(status) ? 'canceled' : 'success';
196
+ const commit = typeof row.Source === 'string' && /^[0-9a-f]{7,40}$/.test(row.Source) ? row.Source : undefined;
197
+ return [{ ...(branchOf(row.Branch) ? { branch: row.Branch } : {}), ...(commit ? { sha: commit } : {}), url, state, from: 'wrangler', at: -at }];
198
+ });
199
+ return { cli, rows, failed: false };
200
+ }
201
+ catch {
202
+ return { cli, rows: [], failed: true };
203
+ }
204
+ }
205
+ /**
206
+ * Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and wrangler have them;
207
+ * with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
208
+ */
209
+ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
210
+ const unread = () => ({ previews: [], cli: 'missing', error: "Cloudflare's previews could not be read.", unread: ['GitHub'] });
211
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..')
212
+ return unread();
213
+ if (deps.branch !== undefined && !branchName(deps.branch))
214
+ return unread();
215
+ const github = await fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull, deps.branch);
216
+ const project = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (await configuredProject(cwd));
217
+ // wrangler installs itself through npx on a machine without it, which takes a while the first time.
218
+ const wrangler = await fromWrangler(project, deps.npx ?? commandRunner('npx', cwd, { signal: deps.signal, timeoutMs: 120_000 }));
219
+ // A row is the head's when its commit is the head's; the pull request's heads say which. A wait for a pushed branch takes its head's rows alone.
220
+ const head = (row) => github.heads.find((h) => row.sha && h.sha.startsWith(row.sha) && (deps.branch === undefined || h.branch === deps.branch));
221
+ const rows = wrangler.rows.flatMap((row) => {
222
+ const of = head(row);
223
+ if (deps.branch !== undefined && !of)
224
+ return [];
225
+ if (deps.pull !== undefined && of?.number !== deps.pull)
226
+ return [];
227
+ return [of ? { ...row, ...(of.number ? { pullRequest: of.number } : {}), branch: of.branch ?? row.branch, sha: of.sha, at: of.at } : row];
228
+ });
229
+ // Of the same build, the check run outranks wrangler's row, and both the app's comment; each address once.
230
+ const rank = (p) => (p.from === 'check run' ? 0 : p.from === 'wrangler' ? 1 : 2);
231
+ const previews = [];
232
+ for (const p of [...github.previews, ...rows].sort((a, b) => rank(a) - rank(b))) {
233
+ const twin = previews.find((q) => q.url === p.url && (q.pullRequest ?? q.branch) === (p.pullRequest ?? p.branch));
234
+ if (!twin)
235
+ previews.push(p);
236
+ else if (!twin.alias && p.alias)
237
+ twin.alias = p.alias;
238
+ }
239
+ // The most recently updated pull request's first; a branch with no pull request among them last, as wrangler lists it.
240
+ previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
241
+ return {
242
+ ...(project ? { project } : {}),
243
+ previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
244
+ cli: wrangler.cli,
245
+ ...(github.failed || wrangler.failed
246
+ ? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(wrangler.failed ? ['wrangler'] : [])] }
247
+ : {}),
248
+ };
249
+ }