@impeccable/reviews 0.1.15 → 0.1.16

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
@@ -92,4 +92,4 @@ Vercel's bypass secret, which lets reviews open previews behind Vercel's login,
92
92
 
93
93
  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, and that read is what shows the site to be yours: a site the CLI cannot read as you (it is signed out, or the site is in none of your teams) gets no password, and the command refuses, saying so, since the lock at a listed address may be anyone's. For a password Netlify gives back, it asks you once, on one screen that names the preview's address, 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, a screen names the address and 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) has no way in for tools, so the command asks you once, on one screen, whether to make the site public, and on your yes makes it public as you through the Netlify CLI (`netlify api updateSite` with `sso_login` off); anyone with its address can then open it, the live site included, until you make it private again in Project configuration, General, Visitor access. Where Netlify refuses (your team's default keeps every project private, or your role cannot change it), the command opens that page in your browser and asks you to try again once you changed it there; a password you set there instead is stored as above. 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.
94
94
 
95
- 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 that names the preview's address, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), 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). The token goes only to a preview an Access application in your accounts covers, which that read shows; where none covers it, the command refuses, saying so, and asks for no token. When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. Only where that sign-in cannot create the token (it grants no Access, cf does not run here, or the application runs on policies of its own, which cf has no command for) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen names the address and takes a client id and secret you paste, the secret hidden, again only once an application of yours was read to cover the preview (on a skip, through cf as it is signed in here, with no sign-in opened). 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.
95
+ 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 that names the preview's address, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), 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). The token goes only to a preview an Access application in your accounts covers, which that read shows; where none covers it, the command refuses, saying so, and asks for no token. When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. An application whose policies are its own, which cf has no command to add to, gets one more screen, naming it, asking to make those policies reusable (each keeps allowing whom it allows, and shows under Access controls, Policies); on your yes cf makes them so and the token's policy goes beside them, and on a skip the paste screen below says which account and application to make the token in, and why. Only where that sign-in cannot create the token (it grants no Access, or cf does not run here) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen names the address and takes a client id and secret you paste, the secret hidden, again only once an application of yours was read to cover the preview (on a skip, through cf as it is signed in here, with no sign-in opened). 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.
@@ -4,9 +4,11 @@ import { cfFailure, cfStatus } from './cloudflare.js';
4
4
  * Getting past Cloudflare Access the way Cloudflare documents it for tools: a service token, a client id and a client
5
5
  * secret sent in two headers, which a Service Auth policy of the Access application in front of the previews allows.
6
6
  * The run makes both as the person through cf, Cloudflare's CLI, signed in with the scopes below, and signs cf in, in
7
- * the browser, where it lacks them. Where that sign-in cannot make them (it grants no Access, cf does not run here, or
8
- * the application runs on policies of its own, which cf has no command to add to), the person gives the run a Cloudflare
9
- * API token with two permissions instead, used in the command's own process for this run and kept nowhere. The person
7
+ * the browser, where it lacks them. An application on policies of its own, which cf has no command to add to, has them
8
+ * made reusable through cf on the person's yes (#327), or else the person makes the token by hand, told the account, the
9
+ * application and why. Where that sign-in cannot make them (it grants no Access, or cf does not run here), the person
10
+ * gives the run a Cloudflare API token with two permissions instead, used in the command's own process for this run and
11
+ * kept nowhere. The person
10
12
  * consents once, on the tool's screen, before anything is made; a token opens the locked address before anything saves
11
13
  * it; neither it nor the API token goes back to the model.
12
14
  */
@@ -54,7 +56,7 @@ function withApiToken(key, deps) {
54
56
  },
55
57
  };
56
58
  }
57
- /** The cf command for each call in an account's Access; a policy of an application's own has none. A delete asks first unless forced, and the run deletes only what it made. */
59
+ /** The cf command for each call in an account's Access; adding a policy of an application's own has none, but making one reusable has. A delete asks first unless forced, and the run deletes only what it made. */
58
60
  const CF_COMMANDS = [
59
61
  [/^GET apps$/, () => ['applications', 'list']],
60
62
  [/^GET policies$/, () => ['policies', 'list']],
@@ -63,6 +65,7 @@ const CF_COMMANDS = [
63
65
  [/^PUT apps\/([\w-]+)$/, (id) => ['applications', 'update', id]],
64
66
  [/^DELETE service_tokens\/([\w-]+)$/, (id) => ['service-tokens', 'delete', id, '--force']],
65
67
  [/^DELETE policies\/([\w-]+)$/, (id) => ['policies', 'delete', id, '--force']],
68
+ [/^PUT apps\/([\w-]+)\/policies\/([\w-]+)\/make_reusable$/, (app, policy) => ['applications', 'policies', 'make-reusable', policy, '--app-id', app]],
66
69
  ];
67
70
  /** Cloudflare's API through cf signed in as the person: each call as the cf command that makes it, in the account its path names, which is one of the sign-in's. */
68
71
  function throughCf(cf, accounts, signal) {
@@ -79,7 +82,7 @@ function throughCf(cf, accounts, signal) {
79
82
  const command = CF_COMMANDS.find(([pattern]) => pattern.test(call));
80
83
  if (!at || !command)
81
84
  return { ok: false, status: null };
82
- const args = ['zero-trust', 'access', ...command[1](command[0].exec(call)[1] ?? ''),
85
+ const args = ['zero-trust', 'access', ...command[1](...command[0].exec(call).slice(1)),
83
86
  ...(paging.has('page') ? ['--page', paging.get('page'), '--per-page', paging.get('per_page')] : []),
84
87
  ...(body === undefined ? [] : ['--body', JSON.stringify(body)])];
85
88
  try {
@@ -208,17 +211,41 @@ async function create(way, deps, found) {
208
211
  return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
209
212
  const { account, app } = found[0];
210
213
  const base = `/accounts/${account}/access`;
211
- const links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
214
+ let links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
212
215
  let reusable = true;
216
+ let ids = new Set();
213
217
  if (links.length) {
214
218
  const policies = await every(api, `${base}/policies`, 1000);
215
219
  if (!policies.ok)
216
220
  return stop(way, policies, '');
217
- const ids = new Set(list(policies.result).map((p) => p.id));
221
+ ids = new Set(list(policies.result).map((p) => p.id));
218
222
  reusable = links.every((link) => ids.has(link.id));
219
223
  }
220
- if (!reusable && way.cf)
221
- return { fallback: 'The Access application runs on policies of its own, which cf has no command to add to.' };
224
+ if (!reusable && way.cf) {
225
+ // cf adds no policy of an application's own, but makes one reusable: asked first, since the person's policies change kind (#327).
226
+ const name = typeof app.name === 'string' && app.name ? app.name : url.host;
227
+ if (!(await deps.reusable(name))) {
228
+ return {
229
+ reason: `The person kept the policies of the Access application "${name}" its own, which the Cloudflare CLI cannot add the token's policy to.`,
230
+ manual: `The Access application "${name}", in the Cloudflare account ${account}, keeps its policies to itself, and the Cloudflare CLI can't add a policy to those. Make the token by hand in that account: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, named "${TOKEN_NAME}"; then add a policy to "${name}" (Access controls → Applications → "${name}" → Policies) with the action Service Auth, including that token.`,
231
+ };
232
+ }
233
+ const converted = [];
234
+ for (const link of links) {
235
+ if (ids.has(link.id))
236
+ converted.push(link);
237
+ else {
238
+ const made = await api('PUT', `${base}/apps/${app.id}/policies/${link.id}/make_reusable`);
239
+ if (!made.ok)
240
+ return stop(way, made, '');
241
+ // The reusable policy Cloudflare answers, by its own id.
242
+ const id = record(made.result)?.id;
243
+ converted.push({ ...link, id: uid(id) ? id : link.id });
244
+ }
245
+ }
246
+ links = converted;
247
+ reusable = true;
248
+ }
222
249
  const undo = [];
223
250
  const rollback = async () => {
224
251
  let clean = true;
@@ -295,8 +322,9 @@ export async function passCloudflareAccess(deps) {
295
322
  const failed = await deps.save(token);
296
323
  return { status: failed ? 'save-failed' : 'saved', token: how, ...made, ...(failed ? { reason: failed } : {}), value: token };
297
324
  };
298
- /** Why the run's own way ended without a token that opens the address, for the paste screen's skip. */
325
+ /** Why the run's own way ended without a token that opens the address, for the paste screen's skip, and how to make one by hand there. */
299
326
  let stopped;
327
+ let manual;
300
328
  if (!(await deps.consent())) {
301
329
  stopped = 'The person skipped creating a service token.';
302
330
  // cf signed in here, with whatever scopes, says whether an application of the person's covers the address, since reading takes no write; a skip opens no sign-in for it.
@@ -319,8 +347,11 @@ export async function passCloudflareAccess(deps) {
319
347
  made = await build(withApiToken(key, deps));
320
348
  }
321
349
  }
322
- if (!('token' in made))
350
+ if (!('token' in made)) {
323
351
  stopped = 'reason' in made ? made.reason : made.fallback;
352
+ if ('manual' in made && typeof made.manual === 'string')
353
+ manual = made.manual;
354
+ }
324
355
  else {
325
356
  const token = made;
326
357
  // A token that opens nothing is no use to anyone, nor is one a stopped run leaves behind: it and its policy go.
@@ -337,7 +368,7 @@ export async function passCloudflareAccess(deps) {
337
368
  deps.signal?.throwIfAborted();
338
369
  if (!covered)
339
370
  return { status: 'refused', reason: `${stopped} A service token goes only to a preview an Access application in the person's accounts covers, and none was read for ${url.host}, so none was asked for or sent.` };
340
- const typed = await deps.paste();
371
+ const typed = await deps.paste(manual);
341
372
  if (!typed)
342
373
  return { status: 'skipped', reason: stopped };
343
374
  const token = { id: typed.id?.trim() ?? '', secret: typed.secret?.trim() ?? '' };
@@ -224,6 +224,23 @@ export function sentTo(screen, url) {
224
224
  const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
225
  return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
226
226
  }
227
+ /** The one consent to making the Access application's own policies reusable, which the run's way through cf needs to add the token's policy (#327): the application named. */
228
+ export function cloudflareAccessReusable(application) {
229
+ return {
230
+ id: 'cloudflare-access-reusable',
231
+ kind: 'confirm',
232
+ title: 'Your Access policies belong to one application',
233
+ hint: `The policies of your Access application "${application}" are its own, and the Cloudflare CLI can't add the token's policy beside those.\n\nThe run can make them reusable policies, as the dashboard makes them now: each keeps allowing exactly whom it allows today, and also shows under Access controls → Policies, where another application could use it. They stay reusable after the run.`,
234
+ prompt: 'Make the policies reusable?',
235
+ confirmLabel: 'Make reusable',
236
+ skipLabel: 'Skip',
237
+ };
238
+ }
239
+ /** The paste screen for a service token, with how to make one by hand where the run's way stopped on something the person does there (the account, the application and why). */
240
+ export function cloudflareAccessPaste(manual) {
241
+ const screen = HOST_SCREENS['cloudflare-access-key'];
242
+ return manual ? { ...screen, prompt: `Cloudflare Access keeps your previews behind a login. ${manual} Paste its client ID and secret here. It is stored encrypted and used only to open previews.` } : screen;
243
+ }
227
244
  /**
228
245
  * The paste screen for Vercel's bypass secret, saying what actually stopped the run's own way (the CLI's read, Vercel's
229
246
  * refusal in its own words, a secret made but not read back, or one that did not open the preview) rather than one fixed
package/dist/http.js CHANGED
@@ -28,7 +28,8 @@ export async function requestJson(url, init, log) {
28
28
  return { ok: true, status: res.status, data };
29
29
  const said = data && typeof data === 'object' ? data.error : undefined;
30
30
  const error = typeof said === 'string' ? said : typeof data === 'string' && data ? data.slice(0, 200) : res.statusText || `HTTP ${res.status}`;
31
- return { ok: false, status: res.status, data, error };
31
+ const retryAfter = res.status === 429 ? Number(res.headers.get('retry-after') ?? NaN) : NaN;
32
+ return { ok: false, status: res.status, data, error, ...(Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfter } : {}) };
32
33
  }
33
34
  catch (err) {
34
35
  const error = err instanceof Error ? (err.name === 'TimeoutError' ? 'the request timed out' : err.message) : String(err);
package/dist/netlify.js CHANGED
@@ -325,8 +325,9 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
325
325
  // the one site built from this repository with the app's folder as its base (#319). The checks below read it again as built from this
326
326
  // repository, so a site found here is never written to.
327
327
  const otherApp = site !== undefined && deps.directory !== undefined && ours(site) && folderOf(record(site.build_settings)?.base) !== deps.directory;
328
- if (!siteId || otherApp) {
329
- const linkedTo = otherApp ? "This folder is linked to the Netlify site of another app's folder" : 'This folder is linked to no Netlify site';
328
+ const elsewhere = site !== undefined && deps.ownSite === true && !ours(site);
329
+ if (!siteId || otherApp || elsewhere) {
330
+ const linkedTo = otherApp ? "This folder is linked to the Netlify site of another app's folder" : elsewhere ? 'This folder is linked to a Netlify site built from another repository' : 'This folder is linked to no Netlify site';
330
331
  const where = deps.directory !== undefined ? ` from ${deps.directory || "the repository's root"}` : '';
331
332
  let built;
332
333
  try {
@@ -2,6 +2,8 @@
2
2
 
3
3
  Netlify deploys previews of the pull requests: impeccable_netlify_previews lists a built preview of a pull request (`state` success or ready), with its address. Ask it only where the repository is set up for Netlify (`netlify.toml`, `.netlify/`, `netlify-cli` or an `@netlify/*` package), but its record decides, not the checkout. Netlify 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
+ It applies as well **before the first pull request**: a site built from this repository has Deploy Previews on (impeccable_netlify_previews lists none, and its `pullRequestPreviews` says `enabled: true`; on a repository with several apps, read with the app's `appKey`), so its deploy previews start with the test drive's draft, the site's first. Then there is nothing to prove in step 1 yet: impeccable_ask with `netlify-setup` is refused, since there is no sign-in, site or connection to make. Run impeccable_netlify_connect (with the app's `appKey`) first: it changes nothing on that site, and checks that Netlify's GitHub app still reads the repository, asking the person once for its access on GitHub when it does not (a connection left undone ends as netlify-setup's "A step fails before the test drive is pushed" says). Then go on to pull-requests' step 4, which opens the test drive as a draft, waits for its deploy preview (impeccable_netlify_previews with `pull`), proves the test drive's page there, past Netlify's lock as preview-protection says, and then step 2 saves the method.
6
+
5
7
  ### Steps
6
8
 
7
9
  1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest listed preview whose state is success or ready (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
@@ -14,7 +16,7 @@ Netlify deploys previews of the pull requests: impeccable_netlify_previews lists
14
16
  - **Behind a password or Netlify's login** (`gate: "netlify-protection"`): the site password, or Netlify's team login, stands in front of the previews. That is the preview-protection case's, whose steps come before step 2: on the person's yes the password is stored, or the site behind the team login made public.
15
17
  - **Deploy Previews off now** (`pullRequestPreviews` in the answer, `enabled: false`, with its `reason`): the site built previews before, and its settings build none now. The aim holds: ask once with impeccable_enable_previews (`host: "netlify"`), after step 1's proof and preview-protection's steps and before the test drive is pushed, so the person sees the lock's question and this one back to back, at most one each. `enabled`: go on. `skipped` or `refused`: push no test drive (pull-requests' "No preview, no test drive"), and say under "What was skipped, and why" that Deploy Previews are off for the site, with the `reason`, and name the `page`; `left` stays empty, since the person was asked in this run. `enabled: 'unknown'`: say so in one line under "What is not verified" and go on.
16
18
  - **Another app, or no answer from any of the three**: Netlify's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Netlify address answered.
17
- - **Nothing listed**: Netlify's previews are not on record. Where the checkout is set up for Netlify that is the netlify-setup case's; else set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
19
+ - **Nothing listed**: Netlify's previews are not on record. With a site of this repository whose Deploy Previews are on, that is the first pull request still to come, above. Else, where the checkout is set up for Netlify, that is the netlify-setup case's; else set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
18
20
  - **The pull requests**: no setup pull request; the test drive branches from the default branch. Netlify builds a deploy preview only for a pull request, so the test drive opens as a draft, its deploy preview is waited for and the test drive's page proven on it, and then the draft is marked ready: pull-requests' step 4. In place of pull-requests' step 3, build the test drive branch once with the build Netlify runs (`netlify.toml`'s `[build]` `command`, else the package's `build` script), so a page that does not build is never pushed.
19
21
  - The evidence is read only: link, deploy and change nothing on Netlify, beyond what impeccable_netlify_password and impeccable_enable_previews do on the person's yes. Netlify does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern: save the pattern the proof answered, and no other.
20
22
  - The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Netlify builds after the run, goes under "What is not verified".
@@ -1,6 +1,6 @@
1
1
  ### When this applies
2
2
 
3
- The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys); or the person picked Netlify in server-previews. And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
3
+ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys); or the person picked Netlify in server-previews. And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). And no site built from this repository has Deploy Previews on: with `pullRequestPreviews` `enabled: true` the site previews the first pull request by itself, which is netlify-previews' (before the first pull request), not this case's. These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
4
4
 
5
5
  ### Steps
6
6
 
@@ -14,7 +14,7 @@ Every run, once the settings are saved. The person merges the setup pull request
14
14
 
15
15
  A host's wait that ran out shows its own screen, which opens the host's page where its builds are turned on and waits again on the person's yes. Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
16
16
  5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the pages its diff holds, no more, and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft, and a branch that changes no existing page. Done when impeccable_test_pr answered "Recorded".
17
- 6. **Check the test drive's preview once**, while it is the newest open pull request: impeccable_write to `latest-pull-check` with the app's `appKey`. Done when it answered; this early a 409 `no-preview-running` or a job still starting is expected, and the preview goes under "What is not verified".
17
+ 6. **Check the test drive's preview once**, while it is the newest open pull request: impeccable_write to `latest-pull-check` with the app's `appKey`. Done when it answered; this early a 409 `no-preview-running` or a job still starting is expected, and the preview goes under "What is not verified". The setup API checks a repository once every 30 seconds, whichever app asks: the tool waits that out itself and tries again, so make each app's call in turn, and never wait with a command. A 429 it still answered means that app's check did not run: say so under "What is not verified".
18
18
  7. **Open the setup pull request**, when there is a setup commit: `git push -u origin impeccable/setup`, then `gh pr create --repo <owner>/<repo> --head impeccable/setup --base <defaultBranch>` with the title "Set up Impeccable previews" and a body that starts "Merge this to keep Impeccable's previews working." and then says what it carries and why, one line each. Record it with impeccable_test_pr: the test drive again, and this one in `setup_pr`. Done when impeccable_test_pr answered "Recorded".
19
19
 
20
20
  ### Reference
@@ -10,7 +10,7 @@ The setup commit carries the workflow the state offers in the preview item's `fi
10
10
  2. **Sync the workflow** on the setup branch, once the settings are saved: impeccable_sync_workflow writes the state's file byte for byte and removes the retired file the state names. Done when it answers `ok: true`.
11
11
  - **`workflowStanding` is current**: nothing to write; leave the file as it is.
12
12
  - **absent, out-of-date, older-generation or retired**: the tool writes the fresh file.
13
- - **unknown**: the current workflow could not be read. Leave the file as it is and sync nothing: the step is done, and the workflow goes under "What is not verified".
13
+ - **unknown**: Impeccable has not read the current workflow on GitHub yet (`workflowPending` true), or the read failed (`workflowError` says why). The tool waits on a pending read itself, reading the state again for up to five minutes, and writes the file once the read lands. When it still refuses, leave the file as it is and sync nothing: the step is done, and the workflow goes under "What is not verified" with the refusal's reason (a read still pending comes within the hour; a failed one names its cause).
14
14
  - **Refused for local edits**: nothing changed. Keep the person's edits, ask them through impeccable_ask to resolve the conflict, then sync again.
15
15
  3. **Commit** only the setup files: the workflow, the retired file's removal, a build fix. Then call impeccable_sync_workflow again. Done when `stagedMatches` and `committedMatches` are true, and `retiredRemoval.staged` and `retiredRemoval.committed` too when it names a retired file; null means nothing was compared.
16
16
 
package/dist/playbook.js CHANGED
@@ -21,7 +21,7 @@ Set the repository up with whatever the host's CLI can do (Netlify's, Vercel's,
21
21
 
22
22
  1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\` and \`workflowStanding\`, whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
23
23
  2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
24
- 3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says when the pages need a server, and as the static-site case says when they do not; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
24
+ 3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says when the pages need a server, and as the static-site case says when they do not; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
25
25
  4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
26
26
  5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
27
27
  6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
package/dist/tools.js CHANGED
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
10
10
  import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
- import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
13
+ import { APPS_QUESTION, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
14
14
  import { enableNetlifyPreviews, folderOf } from './host-previews.js';
15
15
  import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
16
16
  import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
@@ -108,14 +108,17 @@ export function resolveWrite(instructions, setupApi, repo, path) {
108
108
  }
109
109
  return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
110
110
  }
111
+ /** How many times a preview check the setup API turned away for its rate (one a repository every 30 seconds) is tried again (#338). */
112
+ const CHECK_RETRIES = 3;
111
113
  /** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
112
114
  export function appsSummary(apps) {
113
115
  const picked = apps.filter((a) => a.state !== 'unpicked');
114
116
  const ready = picked.filter((a) => a.state === 'ready');
115
117
  const rest = picked.filter((a) => a.state !== 'ready');
116
118
  const names = (list) => list.map((a) => a.name).join(', ');
117
- const line = [...(ready.length ? [`Set up: ${names(ready)}.`] : []), ...(rest.length ? [`Not set up: ${names(rest)}.`] : [])].join(' ');
118
- return { notReady: rest.map((a) => a.name), line, todo: rest.flatMap((a) => (a.left ? [a.left] : [])) };
119
+ const unchecked = ready.filter((a) => a.unchecked);
120
+ const line = [...(ready.length ? [`Set up: ${names(ready)}.`] : []), ...(unchecked.length ? [`Not checked: ${names(unchecked)}.`] : []), ...(rest.length ? [`Not set up: ${names(rest)}.`] : [])].join(' ');
121
+ return { notReady: rest.map((a) => a.name), line, todo: [...rest, ...unchecked].flatMap((a) => (a.left ? [a.left] : [])) };
119
122
  }
120
123
  /** A host's lock in front of a preview, as impeccable_prove_preview names it: how the person reads it, the tool that gets a key past it, and the step left where none was saved (preview-protection's step 3). */
121
124
  const LOCKS = {
@@ -1287,7 +1290,11 @@ export async function refreshWorkflowBase(owner, repo, defaultBranch, root, depe
1287
1290
  return fail(dependencies.signal?.aborted ? 'cancelled' : stage);
1288
1291
  }
1289
1292
  }
1290
- export function createWorkflowSynchronizer() {
1293
+ /**
1294
+ * `pending`: how long a state whose workflow standing is unknown because Impeccable has not read GitHub yet
1295
+ * (`workflowPending`, pristine#2698) is read again before the sync gives up on it; `status` says so meanwhile.
1296
+ */
1297
+ export function createWorkflowSynchronizer(opts = {}) {
1291
1298
  const CURRENT = '.github/workflows/impeccable-preview.yml';
1292
1299
  const RETIRED = '.github/workflows/pristine-preview.yml';
1293
1300
  const MAX_BYTES = 1_000_000;
@@ -1361,6 +1368,13 @@ export function createWorkflowSynchronizer() {
1361
1368
  || (file.index !== owned.baselineIndex && !ownIndex))
1362
1369
  refuse('The workflow changed since this run wrote it. Keep those edits and resolve them before syncing.');
1363
1370
  }
1371
+ /** Whether the state's workflow standing is unknown only because Impeccable has not read GitHub yet. */
1372
+ function pendingRead(state, repoName) {
1373
+ const repos = obj(state)?.repos;
1374
+ const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
1375
+ const preview = matches.length === 1 ? obj(obj(matches[0]?.items)?.preview) : null;
1376
+ return preview?.workflowStanding === 'unknown' && preview.workflowPending === true;
1377
+ }
1364
1378
  function offeredPreview(state, repoName) {
1365
1379
  const repos = obj(state)?.repos;
1366
1380
  const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
@@ -1369,8 +1383,13 @@ export function createWorkflowSynchronizer() {
1369
1383
  if (!preview || preview.status === 'not-selected' || preview.status === 'skipped'
1370
1384
  || !standings.includes(standing))
1371
1385
  refuse('The selected repository has no verified workflow state. Nothing was changed.');
1372
- if (standing === 'unknown')
1373
- refuse('The current workflow could not be read. Its drift is unknown; nothing was changed.');
1386
+ if (standing === 'unknown') {
1387
+ // Which unknown it is (pristine#2698): a read not made yet, or one that failed, and why.
1388
+ if (preview.workflowPending === true)
1389
+ refuse("Impeccable has not read the current workflow on GitHub yet, and it was still not read after waiting: its reads wait while the GitHub account's hourly budget is spent, so it comes within the hour. Its drift is unknown; nothing was changed.");
1390
+ const error = typeof preview.workflowError === 'string' ? preview.workflowError.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
1391
+ refuse(error ? `Impeccable could not read the current workflow on GitHub: ${error.replace(/\.$/, '')}. Its drift is unknown; nothing was changed.` : 'The current workflow could not be read. Its drift is unknown; nothing was changed.');
1392
+ }
1374
1393
  return { preview, standing: standing };
1375
1394
  }
1376
1395
  function offeredBytes(preview) {
@@ -1400,7 +1419,15 @@ export function createWorkflowSynchronizer() {
1400
1419
  const topLevel = (await git(root, ['rev-parse', '--show-toplevel'], signal)).toString('utf8').trim();
1401
1420
  if (await realpath(topLevel) !== root)
1402
1421
  refuse('Run workflow sync from the selected repository root. Nothing was changed.');
1403
- const { preview, standing } = offeredPreview(await readState(), repo);
1422
+ // A workflow Impeccable has not read on GitHub yet is waited for, the state read again until the read lands (#330).
1423
+ let state = await readState();
1424
+ const wait = opts.pending ?? { totalMs: 300_000, everyMs: 30_000 };
1425
+ for (const end = Date.now() + wait.totalMs; pendingRead(state, repo) && Date.now() < end;) {
1426
+ opts.status?.('Waiting for Impeccable to read the workflow on GitHub');
1427
+ await sleep(wait.everyMs, undefined, { signal });
1428
+ state = await readState();
1429
+ }
1430
+ const { preview, standing } = offeredPreview(state, repo);
1404
1431
  const retired = preview.workflowRetiredPath;
1405
1432
  if (retired != null && retired !== RETIRED && retired !== CURRENT)
1406
1433
  refuse('State named an unsupported retired workflow path. Nothing was changed.');
@@ -1643,7 +1670,7 @@ export function createTools(ctx) {
1643
1670
  /** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
1644
1671
  let connectOutcome;
1645
1672
  // Keep ownership of files for this one run; never recreate it per call.
1646
- const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1673
+ const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer({ status: (line) => said(line) });
1647
1674
  const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
1648
1675
  const workflowReady = (access) => access.authenticated === true
1649
1676
  && access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope
@@ -1723,12 +1750,16 @@ export function createTools(ctx) {
1723
1750
  /** On a repository with several apps: the app each production address a host tool answered is the live app of, read for that app's own project or site, and the apps whose host preview a proof opened. */
1724
1751
  const liveApps = new Map();
1725
1752
  const provenApps = new Set();
1753
+ /** The apps whose preview check (connection-check or latest-pull-check) the setup API answered in this run ('' for the repository): a ready app missing here is named on the last screen (#338). */
1754
+ const checkedApps = new Set();
1726
1755
  /** On a repository with several apps (#286): the method each app's preview write saved; the host's lock a proof of each app's preview met; and the apps whose lock was met with no key tool called for them since, which holds every other app's steps until it is. */
1727
1756
  const appMethods = new Map();
1728
1757
  const lockMet = new Map();
1729
1758
  const lockPending = new Map();
1730
1759
  /** The projects whose deployments Vercel made of this repository, as impeccable_hosted_previews read them, by the app read for ('' for the repository): a repository Vercel deploys already needs no Vercel setup, since its previews start with the first pull request (#286). */
1731
1760
  const vercelDeployed = new Map();
1761
+ /** The Netlify sites built from this repository whose settings build Deploy Previews, as impeccable_netlify_previews read them, by the app read for ('' for the repository): their previews start with the first pull request, so there is no Netlify setup to ask about (#311). */
1762
+ const netlifyPreviewing = new Map();
1732
1763
  /** Each Vercel project's production address, read from Vercel, by origin, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
1733
1764
  const vercelProductions = new Map();
1734
1765
  /** The person's Vercel teams as the CLI listed them this run, once: a project's dashboard page is named by its own team, never the CLI's current scope or Vercel's picker, which open whatever project they like. */
@@ -1871,14 +1902,14 @@ export function createTools(ctx) {
1871
1902
  }, signal);
1872
1903
  }
1873
1904
  /** The connection of the linked Netlify site with this repository, through Netlify's site settings and its GitHub app, with the app's access asked when it cannot read the repository. */
1874
- async function netlifyConnect(pi, settings, signal, directory) {
1875
- // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
1876
- if (!hostSetupAgreed(ctx.log, 'netlify'))
1905
+ async function netlifyConnect(pi, settings, signal, directory, previewing = false) {
1906
+ // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI. A site built from this repository with Deploy Previews on needs no setup (#311): the connection changes nothing on it, and only checks that Netlify's GitHub app still reads the repository, asking for its access when it does not.
1907
+ if (!hostSetupAgreed(ctx.log, 'netlify') && !previewing)
1877
1908
  return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
1878
1909
  const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
1879
1910
  const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1880
1911
  const clone = ctx.hostWait?.clone;
1881
- const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}) });
1912
+ const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}), ...(previewing ? { ownSite: true } : {}) });
1882
1913
  netlifySite = result.site?.name ?? netlifySite;
1883
1914
  if (result.pullRequestPreviews)
1884
1915
  previewFacts.set('netlify', result.pullRequestPreviews);
@@ -2142,8 +2173,12 @@ export function createTools(ctx) {
2142
2173
  const proven = provenApps.has(key);
2143
2174
  if (!pickedApps?.has(key))
2144
2175
  return { key, name, folder, state: 'unpicked' };
2145
- if (method !== undefined && (method !== 'preview-deploy' || proven))
2146
- return { key, name, folder, state: 'ready' };
2176
+ if (method !== undefined && (method !== 'preview-deploy' || proven)) {
2177
+ // With a test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
2178
+ return ctx.sync.payload.test_pr && !checkedApps.has(key)
2179
+ ? { key, name, folder, state: 'ready', unchecked: true, left: `${name}: Impeccable's check of its preview did not run here; the test drive's review shows whether it reaches it.` }
2180
+ : { key, name, folder, state: 'ready' };
2181
+ }
2147
2182
  if (gate && !proven)
2148
2183
  return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
2149
2184
  return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
@@ -2154,7 +2189,7 @@ export function createTools(ctx) {
2154
2189
  const where = `${a.name} (${a.folder || '/'})`;
2155
2190
  const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
2156
2191
  if (a.state === 'ready')
2157
- return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.`;
2192
+ return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.${a.unchecked ? " Impeccable's check of its preview did not run." : ''}`;
2158
2193
  if (a.state === 'locked')
2159
2194
  return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2160
2195
  if (a.state === 'unset')
@@ -2331,7 +2366,20 @@ export function createTools(ctx) {
2331
2366
  body = { ...object(body), accounts: list.map((account) => ({ ...object(account), password: typed.get(String(object(account)?.password)) })) };
2332
2367
  }
2333
2368
  const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
2334
- const response = await call(pi, target.url, { method: target.method, body });
2369
+ let response = await call(pi, target.url, { method: target.method, body });
2370
+ // The setup API checks a repository's preview once per 30 seconds, whichever app asks: the next app's check waits its turn here, a bounded wait in place of the sleep the fence refuses (#338).
2371
+ const check = /\/(connection-check|latest-pull-check)$/.test(target.url);
2372
+ for (let tries = 0; check && response.status === 429 && tries < CHECK_RETRIES && !signal?.aborted; tries++) {
2373
+ const seconds = Math.min(Math.max(Math.ceil(response.retryAfter ?? 30), 1), 60);
2374
+ said(`Waiting ${seconds} s for the next preview check`);
2375
+ await (ctx.checkWait ?? ((ms, stop) => sleep(ms, undefined, { signal: stop })))(seconds * 1000, signal).catch(() => undefined);
2376
+ if (signal?.aborted)
2377
+ break;
2378
+ response = await call(pi, target.url, { method: target.method, body });
2379
+ }
2380
+ // A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
2381
+ if (check && (response.ok || response.status === 409))
2382
+ checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
2335
2383
  if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
2336
2384
  confirmedApps = true;
2337
2385
  if (target.url.endsWith('/preview') && response.ok) {
@@ -2384,6 +2432,11 @@ export function createTools(ctx) {
2384
2432
  const projects = [...vercelDeployed.values()].filter(Boolean);
2385
2433
  return `Refused: Vercel deploys this repository already${projects.length ? ` (${projects.join(', ')})` : ''}: it made deployments of it, production included, so its previews start with the first pull request, and there is no Vercel sign-in, project or connection to make. Go on as vercel-previews says: build and push the test drive as pull-requests' step 4 says, wait for Vercel's deployment of the pushed branch with impeccable_hosted_previews and branch, prove the test drive's page there (past Vercel's login as preview-protection says: impeccable_vercel_bypass finds the project with no vercel link, and signs the Vercel CLI in itself where it is signed out), and save the method.`;
2386
2434
  }
2435
+ // The Netlify setup is for a repository no Netlify site previews yet: a site built from it with Deploy Previews on (every app's, on a repository with several) builds the first pull request's deploy preview by itself (#311).
2436
+ const previewing = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => netlifyPreviewing.has(key)) : netlifyPreviewing.has('');
2437
+ if (asked.id === 'netlify-setup' && previewing && !hostSignIns.has('netlify')) {
2438
+ return `Refused: Netlify builds this repository already (${[...new Set(netlifyPreviewing.values())].join(', ')}), with Deploy Previews on, so its deploy previews start with the first pull request, and there is no Netlify sign-in, site or connection to make. Go on as netlify-previews says before the first pull request: impeccable_netlify_connect${appsRepo ? ' with the app\'s appKey' : ''} first, which changes nothing on the site and checks that Netlify's GitHub app still reads the repository (asking the person for its access when it does not), then build the test drive once with the build Netlify runs, push it and open it as a draft as pull-requests' step 4 says, wait for its deploy preview with impeccable_netlify_previews and pull, prove the test drive's page there, save the method, and mark the draft ready.`;
2439
+ }
2387
2440
  if (host?.[2] === 'setup')
2388
2441
  return JSON.stringify(await hostSignIn(host[1], pi, signal));
2389
2442
  if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
@@ -2698,6 +2751,13 @@ export function createTools(ctx) {
2698
2751
  }
2699
2752
  if (evidence.pullRequestPreviews)
2700
2753
  previewFacts.set('netlify', evidence.pullRequestPreviews);
2754
+ // A site built from this repository (this app's, read by its folder) with Deploy Previews on previews the next pull request, listed or not (#311).
2755
+ if (pull === undefined) {
2756
+ if (!evidence.unmatched && evidence.pullRequestPreviews?.enabled === true)
2757
+ netlifyPreviewing.set(appKey ?? '', evidence.pullRequestPreviews.project);
2758
+ else
2759
+ netlifyPreviewing.delete(appKey ?? '');
2760
+ }
2701
2761
  noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
2702
2762
  const site = evidence.unmatched ? undefined : liveSite(evidence);
2703
2763
  if (site)
@@ -2829,7 +2889,7 @@ export function createTools(ctx) {
2829
2889
  const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
2830
2890
  if (appKey !== undefined && directory === undefined)
2831
2891
  return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
2832
- const result = await netlifyConnect(pi, { command, publish }, signal, directory);
2892
+ const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''));
2833
2893
  if (result.how && result.site)
2834
2894
  noteApp(appKey, { host_project: result.site.name });
2835
2895
  return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
@@ -3200,7 +3260,7 @@ export function createTools(ctx) {
3200
3260
  }
3201
3261
  return JSON.stringify(passwordOutcome);
3202
3262
  }),
3203
- tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3263
+ tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3204
3264
  if (!accessGated)
3205
3265
  return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
3206
3266
  const at = accessGated;
@@ -3247,8 +3307,9 @@ export function createTools(ctx) {
3247
3307
  const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
3248
3308
  return typed.skipped ? undefined : typed.value;
3249
3309
  },
3250
- paste: async () => {
3251
- const typed = await ask(sentTo(HOST_SCREENS['cloudflare-access-key'], at.url));
3310
+ reusable: async (application) => !(await ask(cloudflareAccessReusable(application))).skipped,
3311
+ paste: async (manual) => {
3312
+ const typed = await ask(sentTo(cloudflareAccessPaste(manual), at.url));
3252
3313
  return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
3253
3314
  },
3254
3315
  prove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,