@impeccable/reviews 0.1.17 → 0.1.19

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/dist/tools.js CHANGED
@@ -10,13 +10,13 @@ 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, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
13
+ import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
14
14
  import { enableNetlifyPreviews, folderOf } from './host-previews.js';
15
- import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
+ import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
16
16
  import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
17
17
  import { passCloudflareAccess } from './cloudflare-access.js';
18
18
  import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
19
- import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
19
+ import { passNetlifyLock } from './netlify-password.js';
20
20
  import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
21
21
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
22
22
  import { fitsTask, stagesOf } from './stages.js';
@@ -123,8 +123,10 @@ export function appsSummary(apps) {
123
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). */
124
124
  const LOCKS = {
125
125
  'vercel-protection': { lock: "Vercel's login", tool: 'impeccable_vercel_bypass', left: 'Save the Vercel bypass secret on the setup page' },
126
- 'netlify-protection': { lock: "Netlify's password or team login", tool: 'impeccable_netlify_password', left: 'Save the Netlify site password on the setup page, or make the site public on its Visitor access page' },
126
+ 'netlify-protection': { lock: "Netlify's site password", tool: 'impeccable_netlify_password', left: `Type the Netlify site password in the app's Settings, under "A way past your host's lock"` },
127
127
  'cloudflare-access': { lock: 'Cloudflare Access', tool: 'impeccable_cloudflare_access', left: 'Save the Cloudflare service token on the setup page' },
128
+ // Netlify's gate where its page was the team login, which no key gets past: how the last screen words it (#375).
129
+ 'netlify-login': { lock: "Netlify's team login", tool: 'impeccable_netlify_password', left: `Make the Netlify site public, or give it a password, in ${NETLIFY_PASSWORD_WHERE}` },
128
130
  };
129
131
  const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
130
132
  /** Why the status line and the task list say different things, one task in progress at a time and the status its detail; null when they agree. */
@@ -167,6 +169,22 @@ export function ending(report) {
167
169
  }
168
170
  /** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
169
171
  export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
172
+ /** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
173
+ export function hostLines(used) {
174
+ const byHost = new Map();
175
+ for (const u of used) {
176
+ const entry = byHost.get(u.host) ?? { accounts: new Set(), projects: new Set() };
177
+ if (u.account)
178
+ entry.accounts.add(u.account);
179
+ entry.projects.add(u.project);
180
+ byHost.set(u.host, entry);
181
+ }
182
+ return [...byHost].map(([host, { accounts, projects }]) => {
183
+ const what = host === 'Netlify' ? 'site' : 'project';
184
+ const account = accounts.size ? `${host === 'Cloudflare' ? '' : 'team '}${[...accounts].join(', ')} · ` : '';
185
+ return `${host}: ${account}${projects.size > 1 ? `${what}s` : what} ${[...projects].join(', ')}`;
186
+ });
187
+ }
170
188
  const PROGRESS = {
171
189
  type: 'object',
172
190
  additionalProperties: false,
@@ -229,6 +247,14 @@ const TEST_PR = {
229
247
  const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
230
248
  /** The app a host tool reads for, on a repository with several apps: its previews are those of the project or site that builds the app's folder. */
231
249
  const APP_KEY = { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app this is for, one the apps question picked: only the previews of the host project that builds that app\'s folder are read.' };
250
+ const VERCEL_BYPASS = {
251
+ type: 'object',
252
+ additionalProperties: false,
253
+ properties: {
254
+ appKey: APP_KEY,
255
+ beforePush: { type: 'boolean', description: "True before the test drive's push on Vercel, where no preview met Vercel's login yet: reads whether the project's previews are protected and asks the consent now, before anything is pushed." },
256
+ },
257
+ };
232
258
  const HOSTED_PREVIEWS = {
233
259
  type: 'object',
234
260
  additionalProperties: false,
@@ -1584,6 +1610,29 @@ export function createTools(ctx) {
1584
1610
  /** Whether any preview write with a method was taken this run, and the host's lock the last gated proof met: the last screen names it when no preview ever opened (#321), and Preview method is completed only once a method is saved (#322). */
1585
1611
  let methodSaved = false;
1586
1612
  let lastLock;
1613
+ /** Netlify's gate, as last met, was its team login rather than the site password: the last screen names that one. */
1614
+ let lastTeamLogin = false;
1615
+ /** The host preview addresses a proof or a host's key opened, and the Cloudflare account and Netlify team of each project or site the host tools read, by host and project: the last screen names them (#360), and no tool's answer does. */
1616
+ const usedPreviews = new Set();
1617
+ const accountOf = new Map();
1618
+ /** The Vercel project the bypass tool read for each address its secret opened, and its team where that is not the CLI's current scope. */
1619
+ const vercelNamed = new Map();
1620
+ /** What the last screen says the run used on each host, from the previews it opened. */
1621
+ const hostsUsed = () => [...usedPreviews].flatMap((url) => {
1622
+ const origin = new URL(url).origin;
1623
+ const label = pagesProject(origin);
1624
+ if (label)
1625
+ return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
1626
+ const site = netlifySiteOf(url);
1627
+ if (site)
1628
+ return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
1629
+ const read = vercelNamed.get(origin);
1630
+ const named = read?.project ?? listedApps.get(origin)?.name;
1631
+ const vercel = vercelDeploymentOf(url, named);
1632
+ const team = vercel?.team ?? read?.team;
1633
+ const project = vercel?.project ?? named;
1634
+ return project ? [{ host: 'Vercel', project, ...(team ? { account: team } : {}) }] : [];
1635
+ });
1587
1636
  /**
1588
1637
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1589
1638
  * every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
@@ -1641,6 +1690,8 @@ export function createTools(ctx) {
1641
1690
  const bypassOrigins = new Map();
1642
1691
  /** What impeccable_vercel_bypass answered for a store, less the secret: a skip is asked once, so that answer stands. */
1643
1692
  const bypassOutcomes = new Map();
1693
+ /** The stores whose consent was given before the test drive's push, each with the project it named (`<store>/<projectId>`): the call once the gate is met asks no second time for that project alone. */
1694
+ const bypassConsented = new Set();
1644
1695
  /** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
1645
1696
  let sitePassword;
1646
1697
  const passwordSites = new Set();
@@ -1650,6 +1701,8 @@ export function createTools(ctx) {
1650
1701
  const passwordConsented = new Set();
1651
1702
  /** The saved answer, what an address the password opens later gets. */
1652
1703
  let passwordSaved;
1704
+ /** The sites whose last typed password did not open the preview: the paste screen, asked again, says so first (#375). */
1705
+ const passwordRefused = new Set();
1653
1706
  /** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
1654
1707
  let accessGated;
1655
1708
  /** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
@@ -1659,6 +1712,8 @@ export function createTools(ctx) {
1659
1712
  let accessStored = false;
1660
1713
  /** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
1661
1714
  let accessOutcome;
1715
+ /** The Access applications, by id, the person said yes to a token for. */
1716
+ const accessConsented = new Set();
1662
1717
  /** What each host's settings last said of its pull-request previews, as the evidence tools read them: the one fact impeccable_enable_previews acts on. */
1663
1718
  const previewFacts = new Map();
1664
1719
  /** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
@@ -2039,14 +2094,15 @@ export function createTools(ctx) {
2039
2094
  * Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
2040
2095
  * `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
2041
2096
  */
2042
- const saveLock = async (pi, what, header, value, appKey) => {
2097
+ /** Saves a host's key as the lock's own credential, then the lock that sends it: a bypass secret in its header, or Netlify's site password, with which reviews sign in on that site's form alone. */
2098
+ const saveLock = async (pi, what, lock, value, appKey) => {
2043
2099
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
2044
2100
  // On a repository with several apps, the key is that app's own: each app's previews are its own project's or site's.
2045
2101
  const app = appKey !== undefined ? { appKey } : {};
2046
2102
  if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
2047
2103
  return { stored: false, failed: `The setup API did not store the ${what}.` };
2048
2104
  noteApp(appKey, { key_placed: true });
2049
- if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
2105
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { ...lock, ...app } })).ok)
2050
2106
  return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
2051
2107
  noteApp(appKey, { method_saved: true });
2052
2108
  return { stored: true, failed: null };
@@ -2349,13 +2405,16 @@ export function createTools(ctx) {
2349
2405
  if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
2350
2406
  return "Refused: beforeUrl is the live app's address that impeccable_prove_preview opened with this app in this run (the production address a host tool answered, or the one the README names), or a page on it; an address no key opens is not set, and a saved one is never cleared from here. Nothing was saved.";
2351
2407
  }
2352
- // One app's before is its own: the production address its own project or site answered, or one a file in its own folder names.
2408
+ // One app's before is its own: the production address its own project or site answered, or one a file in its own folder names, or a line of the root README that names that app and no other (#367).
2353
2409
  if (appsRepo && target.url.endsWith('/preview') && saved && typeof saved.beforeUrl === 'string') {
2354
2410
  const origin = new URL(saved.beforeUrl).origin;
2355
2411
  const owner = liveApps.get(origin);
2356
2412
  const folder = await appFolder(pi, String(saved.appKey));
2357
- if (owner !== undefined ? owner !== saved.appKey : folder === undefined || !(await checkoutNames(pi.cwd, origin, folder))) {
2358
- return `Refused: ${origin} is not the live address of ${String(saved.appKey)}: it is another app's, or no file in that app's folder names it. Nothing was saved.`;
2413
+ const app = knownApps?.find((a) => a.key === saved.appKey);
2414
+ const named = async () => folder !== undefined && ((await checkoutNames(pi.cwd, origin, folder))
2415
+ || (app !== undefined && (await readmeNamesApp(pi.cwd, origin, app, (knownApps ?? []).filter((a) => a.key !== app.key)))));
2416
+ if (owner !== undefined ? owner !== saved.appKey : !(await named())) {
2417
+ return `Refused: ${origin} is not the live address of ${String(saved.appKey)}: it is another app's, or neither a file in that app's folder nor a line of the root README that names that app (and no other) names it. Nothing was saved.`;
2359
2418
  }
2360
2419
  }
2361
2420
  const testAccounts = target.url.endsWith('/test-accounts');
@@ -2389,8 +2448,6 @@ export function createTools(ctx) {
2389
2448
  confirmedApps = true;
2390
2449
  if (target.url.endsWith('/preview') && response.ok) {
2391
2450
  const keys = object(object(response.data)?.keys);
2392
- if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
2393
- retry.requireHistory = true;
2394
2451
  if (object(p.body)?.intendedMethod === 'preview-deploy')
2395
2452
  savedHost = provenHost;
2396
2453
  if (saved?.appKey !== undefined)
@@ -2615,7 +2672,8 @@ export function createTools(ctx) {
2615
2672
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
2616
2673
  }
2617
2674
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2618
- ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}) });
2675
+ const hosts = hostLines(hostsUsed());
2676
+ ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(hosts.length ? { hosts } : {}) });
2619
2677
  pi.shutdown();
2620
2678
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2621
2679
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
@@ -2625,6 +2683,9 @@ export function createTools(ctx) {
2625
2683
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2626
2684
  if (refused)
2627
2685
  return refused;
2686
+ // The bypass secret skipped: Vercel's login keeps the test drive's preview from the review, so there is none to wait for (#356).
2687
+ if (branch !== undefined && bypassOutcomes.get(appsRepo ? p.appKey : '')?.status === 'skipped')
2688
+ return "Refused: the person skipped the bypass secret, and Vercel's login stands in front of the previews, so the test drive's preview could not be reviewed: push and open no test drive, as preview-protection's step 3 says. Nothing was read.";
2628
2689
  // The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
2629
2690
  const reads = new Map();
2630
2691
  const vercel = (args) => {
@@ -2734,7 +2795,9 @@ export function createTools(ctx) {
2734
2795
  const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
2735
2796
  if (appKey !== undefined && directory === undefined)
2736
2797
  return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
2737
- const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, ...(directory !== undefined ? { directory } : {}) });
2798
+ // Each site's team is for the last screen alone.
2799
+ const onTeams = (teams) => Object.entries(teams).forEach(([name, team]) => accountOf.set(`netlify ${name}`, team));
2800
+ const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, onTeams, ...(directory !== undefined ? { directory } : {}) });
2738
2801
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2739
2802
  const states = ['success', 'ready', 'failure', 'error'];
2740
2803
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
@@ -2785,7 +2848,9 @@ export function createTools(ctx) {
2785
2848
  const appProject = appKey !== undefined ? cloudflareApps.get(appKey) : undefined;
2786
2849
  if (appKey !== undefined && (pull !== undefined || branch !== undefined) && !appProject)
2787
2850
  return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
2788
- const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}) });
2851
+ // The name of the account the project was found in is for the last screen alone.
2852
+ let accountName;
2853
+ const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}) });
2789
2854
  const read = async () => {
2790
2855
  const evidence = await readAll();
2791
2856
  return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
@@ -2800,6 +2865,10 @@ export function createTools(ctx) {
2800
2865
  const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false } : undefined;
2801
2866
  const waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
2802
2867
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2868
+ // By the label the project's addresses carry.
2869
+ const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
2870
+ if (accountName && label && !evidence.unmatched)
2871
+ accountOf.set(`cloudflare ${label}`, accountName);
2803
2872
  if (!subject)
2804
2873
  cloudflareEvidence = evidence;
2805
2874
  if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
@@ -2857,6 +2926,8 @@ export function createTools(ctx) {
2857
2926
  hostedPreviewProven ||= proof.proven;
2858
2927
  if (proof.proven)
2859
2928
  provenHost = previewHostName(url) ?? provenHost;
2929
+ if (proof.proven)
2930
+ usedPreviews.add(url);
2860
2931
  if (proof.proven && app)
2861
2932
  proveApp(app.appKey, url);
2862
2933
  if (proof.proven && app)
@@ -2867,8 +2938,17 @@ export function createTools(ctx) {
2867
2938
  lockPending.set(app.appKey, proof.gate);
2868
2939
  provenApps.delete(app.appKey);
2869
2940
  }
2870
- if (proof.gate && Object.hasOwn(LOCKS, proof.gate))
2941
+ if (proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
2871
2942
  lastLock = proof.gate;
2943
+ lastTeamLogin = proof.teamLogin === true;
2944
+ // Pristine learns the lock from the session, proven past or not, for a preview it may never meet itself (#365): an app's on its summary, a repository of one app's beside them.
2945
+ const met = { gate: lastLock, preview_host: new URL(url).hostname };
2946
+ // The app is proven again only once something opens it, as `provenApps` has it.
2947
+ if (app)
2948
+ noteApp(app.appKey, { ...met, preview_proven: false });
2949
+ else if (!appsRepo)
2950
+ ctx.sync.update({ preview_gate: met });
2951
+ }
2872
2952
  if (proof.proven)
2873
2953
  provePattern(proof.pattern, app?.appKey);
2874
2954
  if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
@@ -2956,7 +3036,7 @@ export function createTools(ctx) {
2956
3036
  return `Refused: the Pages project ${fact.project} is connected to the repository, and its settings turn pull-request previews off: impeccable_enable_previews turns them on.`;
2957
3037
  const create = !fact;
2958
3038
  if (create && !evidence.worker && !evidence.project)
2959
- return 'Refused: nothing says the repository is set up for Cloudflare: no Pages project is built from it, the checkout names none, and no Worker is built from it. Set the preview up as the server-previews or static-site case says.';
3039
+ return 'Refused: nothing says the repository is set up for Cloudflare: no Pages project is built from it, the checkout names none, and no Worker is built from it. Set the preview up as the server-previews case says.';
2960
3040
  const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
2961
3041
  const answer = (status, more) => {
2962
3042
  connectOutcome = { status, project, created: create, ...more };
@@ -3014,23 +3094,40 @@ export function createTools(ctx) {
3014
3094
  noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
3015
3095
  return JSON.stringify(answer);
3016
3096
  }),
3017
- tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3018
- if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
3019
- return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
3020
- const at = gated;
3021
- if (at.app)
3097
+ tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back. With beforePush (and the app\'s appKey on a repository with several apps), before the test drive\'s push, where no preview met Vercel\'s login yet (#356): it reads the project the same way, and where its settings show Deployment Protection in front of the previews and it holds no secret labelled "Impeccable reviews", shows the consent screen now, naming the project and the app, so the person answers before anything is pushed; the secret is made on the yes once the test drive\'s preview meets the login, with no second question. Answers {"protected": false} (no lock: push), {"protected": true, "consent": "given" or "not-needed"} (push, and call this again without beforePush once the proof meets the gate), {"protected": "unknown", "reason": …} (the project could not be read: push, and the consent comes when the gate is met) or {"status": "skipped", …} (push no test drive; reason says why).', VERCEL_BYPASS, async (p, pi, signal) => {
3098
+ const before = p.beforePush === true;
3099
+ if (before) {
3100
+ const refused = appRefusal(p.appKey, 'asked');
3101
+ if (refused)
3102
+ return refused;
3103
+ }
3104
+ else if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
3105
+ return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first, or call it with beforePush before the test drive\'s push.';
3106
+ const at = before ? undefined : gated;
3107
+ if (at?.app)
3022
3108
  lockPending.delete(at.app.appKey);
3023
- const { origin, host } = new URL(at.url);
3109
+ const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
3110
+ // Before the push, the app's project as impeccable_hosted_previews reads it: the one that builds the app's folder (#281).
3111
+ let app = at?.app;
3112
+ if (before && appsRepo) {
3113
+ const read = await vercelApp(pi, p.appKey, vercel);
3114
+ if ('reason' in read)
3115
+ return JSON.stringify({ appKey: p.appKey, protected: 'unknown', reason: read.reason });
3116
+ app = { appKey: p.appKey, link: read.link, name: read.name };
3117
+ }
3024
3118
  // Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
3025
- const store = at.app?.appKey ?? '';
3119
+ const store = app?.appKey ?? '';
3026
3120
  const asked = bypassOutcomes.get(store);
3027
3121
  if (asked?.status === 'skipped')
3028
3122
  return JSON.stringify(asked);
3123
+ /** What stops the tool before the project is vouched for: the address got nothing, or, before the push, the lock is not known yet and the gate's call asks. */
3124
+ const nothing = (why) => (at ? `Refused: nothing was sent to ${at.url}. ${why}` : JSON.stringify({ ...(app ? { appKey: app.appKey } : {}), protected: 'unknown', reason: why }));
3125
+ /** The consent screen, naming the project and the app once they are known (#358). */
3126
+ const consentScreen = (project) => vercelBypassConsent(project ?? app?.name, app && appName(app.appKey));
3029
3127
  // Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project has to have
3030
3128
  // made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names,
3031
3129
  // else the one Vercel lists as connected to this repository (#310).
3032
- const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
3033
- const fileLink = at.app ? null : await readLink(pi.cwd);
3130
+ const fileLink = app ? null : await readLink(pi.cwd);
3034
3131
  /** The consent screen's yes, given before Vercel's sign-in, stands for the secret: it is not asked again. */
3035
3132
  let consented = false;
3036
3133
  /**
@@ -3044,7 +3141,7 @@ export function createTools(ctx) {
3044
3141
  bypassOutcomes.set(store, outcome);
3045
3142
  return JSON.stringify(outcome);
3046
3143
  };
3047
- if ((await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped)
3144
+ if ((await ask(consentScreen())).skipped)
3048
3145
  return skip('The person skipped creating a bypass secret.');
3049
3146
  consented = true;
3050
3147
  const agreed = hostSetupAgreed(ctx.log, 'vercel');
@@ -3067,23 +3164,23 @@ export function createTools(ctx) {
3067
3164
  // A folder Vercel deploys from GitHub has no link file unless vercel link ran in it (the file is gitignored), so the project is found on Vercel by this repository,
3068
3165
  // with the CLI signed in as the person.
3069
3166
  let found;
3070
- if (!at.app && !fileLink) {
3167
+ if (!app && !fileLink) {
3071
3168
  const cli = (await vercelStatus(vercel)).cli;
3072
3169
  const stop = cli === 'signed-out' ? await signIn() : null;
3073
3170
  if (stop)
3074
3171
  return stop;
3075
3172
  const listed = cli === 'missing' ? { reason: 'The Vercel CLI is not installed here.' } : await vercelRepoProject(vercel, repository);
3076
3173
  if ('reason' in listed)
3077
- return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and Vercel named none for this repository: ${listed.reason} A bypass secret goes only to a preview the repository's project serves.`;
3174
+ return nothing(`This folder is linked to no Vercel project (.vercel/project.json), and Vercel named none for this repository: ${listed.reason} A bypass secret goes only to a preview the repository's project serves.`);
3078
3175
  found = listed;
3079
3176
  }
3080
- const link = at.app?.link ?? fileLink ?? found.link;
3177
+ const link = app?.link ?? fileLink ?? found.link;
3081
3178
  // Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
3082
3179
  // An app's project, and one found with no link file, are read from Vercel's list of this repository's projects, never from the file.
3083
3180
  const links = linkedByRun(ctx.log);
3084
3181
  const own = links.get(link.projectId);
3085
3182
  if (fileLink && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
3086
- return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
3183
+ return nothing("This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.");
3087
3184
  let project = await readOwnProject(vercel, link, repository);
3088
3185
  // A linked project, or an app's, read nothing because the CLI is signed out: the same sign-in, then the read again.
3089
3186
  if ('reason' in project && !consented && (await vercelStatus(vercel)).cli === 'signed-out') {
@@ -3092,9 +3189,27 @@ export function createTools(ctx) {
3092
3189
  return stop;
3093
3190
  project = await readOwnProject(vercel, link, repository);
3094
3191
  }
3095
- const which = at.app ? 'The Vercel project that builds this app' : found ? 'The Vercel project connected to this repository' : 'The Vercel project this folder is linked to';
3192
+ const which = app ? 'The Vercel project that builds this app' : found ? 'The Vercel project connected to this repository' : 'The Vercel project this folder is linked to';
3096
3193
  if ('reason' in project)
3097
- return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
3194
+ return nothing(`${project.reason} A bypass secret goes only to a preview the linked project serves.`);
3195
+ if (!at) {
3196
+ // Before the push: the project's settings say whether Vercel's login will stand in front of the test drive's preview, and the consent is asked now (#356).
3197
+ const of = { ...(app ? { appKey: app.appKey } : {}), ...(project.name ? { project: project.name } : {}) };
3198
+ if (!project.previewsProtected)
3199
+ return JSON.stringify({ ...of, protected: false });
3200
+ // A secret this run saved for the same store and project, or one labelled for Impeccable, opens the test drive's preview with no question; a yes given already stands.
3201
+ const held = bypassHeld.get(store);
3202
+ if (project.bypassLabelled || held?.projectId === link.projectId)
3203
+ return JSON.stringify({ ...of, protected: true, consent: 'not-needed' });
3204
+ if (consented || bypassConsented.has(`${store}/${link.projectId}`) || !(await ask(consentScreen(project.name))).skipped) {
3205
+ bypassConsented.add(`${store}/${link.projectId}`);
3206
+ return JSON.stringify({ ...of, protected: true, consent: 'given' });
3207
+ }
3208
+ const outcome = { status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` };
3209
+ bypassOutcomes.set(store, outcome);
3210
+ return JSON.stringify(outcome);
3211
+ }
3212
+ const { origin, host } = new URL(at.url);
3098
3213
  // The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
3099
3214
  if (!project.previewsProtected && lastLock === 'vercel-protection')
3100
3215
  lastLock = undefined;
@@ -3107,6 +3222,8 @@ export function createTools(ctx) {
3107
3222
  bypassOrigins.set(origin, store);
3108
3223
  hostedPreviewProven = true;
3109
3224
  provenHost = 'Vercel';
3225
+ usedPreviews.add(at.url);
3226
+ vercelNamed.set(origin, { ...(project.name ? { project: project.name } : {}), ...(found?.scope ? { team: found.scope } : {}) });
3110
3227
  if (at.app)
3111
3228
  proveApp(at.app.appKey, at.url);
3112
3229
  };
@@ -3123,7 +3240,8 @@ export function createTools(ctx) {
3123
3240
  link: async () => link,
3124
3241
  // A project the folder is not linked to is named on the CLI's reads and makes, in its own team; the reads are held to its id all the same.
3125
3242
  ...(at.app?.name ? { project: at.app.name } : found ? { project: found.name, ...(found.scope ? { scope: found.scope } : {}) } : {}),
3126
- consent: async () => consented || !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
3243
+ // A yes before the push stands for this secret: the gate's call asks no second time (#356).
3244
+ consent: async () => consented || bypassConsented.has(`${store}/${link.projectId}`) || !(await ask(consentScreen(project.name))).skipped,
3127
3245
  paste: async (stopped) => {
3128
3246
  // This project's own settings page, under its own team (the one its ids name, as the CLI lists the person's teams): never another project of the account's.
3129
3247
  (ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
@@ -3134,7 +3252,7 @@ export function createTools(ctx) {
3134
3252
  ...(signal ? { signal } : {}),
3135
3253
  prove: async (secret) => sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret })),
3136
3254
  save: async (secret) => {
3137
- const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
3255
+ const saved = await saveLock(pi, 'secret', { method: 'header', headerName: BYPASS_HEADER }, secret, at.app?.appKey);
3138
3256
  stored = saved.stored;
3139
3257
  return saved.failed;
3140
3258
  },
@@ -3161,7 +3279,7 @@ export function createTools(ctx) {
3161
3279
  }
3162
3280
  return JSON.stringify(outcome);
3163
3281
  }),
3164
- tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3282
+ tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3165
3283
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
3166
3284
  if (!gated || !site)
3167
3285
  return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
@@ -3170,8 +3288,8 @@ export function createTools(ctx) {
3170
3288
  lockPending.delete(at.app.appKey);
3171
3289
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
3172
3290
  let pattern;
3173
- const prove = async (password, form = false) => {
3174
- const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form }));
3291
+ const prove = async (password) => {
3292
+ const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password }));
3175
3293
  if (proof.proven && proof.pattern)
3176
3294
  pattern = proof.pattern;
3177
3295
  return proof;
@@ -3198,6 +3316,8 @@ export function createTools(ctx) {
3198
3316
  }
3199
3317
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
3200
3318
  let stored = false;
3319
+ /** The person typed a password on the paste screen in this call. */
3320
+ let typed = false;
3201
3321
  const { value, ...told } = await passNetlifyLock({
3202
3322
  run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
3203
3323
  site,
@@ -3222,27 +3342,37 @@ export function createTools(ctx) {
3222
3342
  return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
3223
3343
  },
3224
3344
  paste: async () => {
3225
- const answer = await ask(sentTo(HOST_SCREENS['netlify-password-key'], at.url));
3345
+ const screen = sentTo(HOST_SCREENS['netlify-password-key'], at.url);
3346
+ const answer = await ask(passwordRefused.has(site) ? { ...screen, prompt: `The password typed last didn't open the preview on Netlify's form, so nothing was saved. Check it and type it again. ${screen.prompt}` } : screen);
3347
+ typed = !answer.skipped;
3226
3348
  return answer.skipped ? undefined : answer.value?.trim();
3227
3349
  },
3228
3350
  prove,
3229
3351
  save: async (password) => {
3230
- const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password), at.app?.appKey);
3352
+ // Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
3353
+ const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
3231
3354
  stored = saved.stored;
3232
3355
  return saved.failed;
3233
3356
  },
3234
3357
  remember: noteSensitiveAnswer,
3235
3358
  });
3236
3359
  const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
3237
- // Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321).
3360
+ // Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321). Else they say which lock it is.
3238
3361
  if (told.protection === 'none' && lastLock === 'netlify-protection')
3239
3362
  lastLock = undefined;
3363
+ if (told.protection === 'password' || told.protection === 'team-login')
3364
+ lastTeamLogin = told.protection === 'team-login';
3365
+ if (told.status === 'refused' && typed)
3366
+ passwordRefused.add(site);
3367
+ else if (told.status === 'saved')
3368
+ passwordRefused.delete(site);
3240
3369
  if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
3241
3370
  passwordStands.set(site, passwordOutcome);
3242
3371
  if (told.status === 'opened') {
3243
3372
  // The site is public now: its previews open with nothing sent.
3244
3373
  hostedPreviewProven = true;
3245
3374
  provenHost = 'Netlify';
3375
+ usedPreviews.add(at.url);
3246
3376
  if (at.app)
3247
3377
  proveApp(at.app.appKey, at.url);
3248
3378
  provePattern(pattern, at.app?.appKey);
@@ -3254,6 +3384,7 @@ export function createTools(ctx) {
3254
3384
  passwordSites.add(site);
3255
3385
  hostedPreviewProven = true;
3256
3386
  provenHost = 'Netlify';
3387
+ usedPreviews.add(at.url);
3257
3388
  if (at.app)
3258
3389
  proveApp(at.app.appKey, at.url);
3259
3390
  provePattern(pattern, at.app?.appKey);
@@ -3267,7 +3398,7 @@ export function createTools(ctx) {
3267
3398
  }
3268
3399
  return JSON.stringify(passwordOutcome);
3269
3400
  }),
3270
- 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) => {
3401
+ 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, naming the Access application in front of the preview and its Cloudflare account once cf, signed in here, found it (none found: refused, and nothing asked), 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) => {
3271
3402
  if (!accessGated)
3272
3403
  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.';
3273
3404
  const at = accessGated;
@@ -3305,7 +3436,15 @@ export function createTools(ctx) {
3305
3436
  else {
3306
3437
  answer = await passCloudflareAccess({
3307
3438
  url: at.url,
3308
- consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
3439
+ // Named once the Access application was found (#357), and asked once per application: a yes stands for another app's previews it covers.
3440
+ consent: async (found) => {
3441
+ if (found && accessConsented.has(found.id))
3442
+ return true;
3443
+ const yes = !(await ask(sentTo(found ? cloudflareAccessConsent(found, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
3444
+ if (yes && found)
3445
+ accessConsented.add(found.id);
3446
+ return yes;
3447
+ },
3309
3448
  cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
3310
3449
  login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
3311
3450
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
@@ -3342,6 +3481,7 @@ export function createTools(ctx) {
3342
3481
  if (told.status === 'saved') {
3343
3482
  hostedPreviewProven = true;
3344
3483
  provenHost = 'Cloudflare Pages';
3484
+ usedPreviews.add(at.url);
3345
3485
  if (at.app)
3346
3486
  proveApp(at.app.appKey, at.url);
3347
3487
  provePattern(pattern, at.app?.appKey);
package/dist/ui/look.js CHANGED
@@ -113,6 +113,8 @@ const pullAction = (url, copied, inner, st) => [
113
113
  copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st),
114
114
  ];
115
115
  /** What waits on the person, under the supporting text: two lines at most, the rest counted. */
116
+ /** The hosts the run used, a line each under the result. */
117
+ const usedLines = (outro, inner, st) => (outro.used ?? []).flatMap((line) => said(line, inner, st));
116
118
  function leftLines(todo, inner, st) {
117
119
  const shown = todo.length > 2 ? [todo[0], `${todo.length - 1} more in the report`] : todo;
118
120
  return shown.map((line) => center(st.soft(`→ ${line}`), inner));
@@ -255,12 +257,10 @@ function runPage(scene, f, inner, st) {
255
257
  const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
256
258
  return ink(visibleWidth(waiting) <= inner ? waiting : row(scene.wait.timer));
257
259
  });
258
- const counted = scene.tasks.filter((t) => t.status !== 'skipped');
259
- const skipped = scene.tasks.length - counted.length;
260
260
  const widest = Math.max(...lines.map((line) => visibleWidth(line)));
261
+ // No count over the list: the list shows each task's state itself.
261
262
  return {
262
263
  heading: 'Setting up',
263
- text: said(`${counted.filter((t) => t.status === 'completed').length} of ${counted.length} done${skipped ? ` · ${skipped} skipped` : ''}`, inner, st),
264
264
  rule: divider(inner, st),
265
265
  action: block(lines, inner, widest),
266
266
  at: running >= 0 ? running : Math.max(0, scene.tasks.findIndex((t) => t.status === 'pending')),
@@ -273,7 +273,7 @@ function outroPage(scene, inner, st) {
273
273
  return {
274
274
  heading: outro.title,
275
275
  // A warning is read whole: wrapped, where centered on one line it would be cut.
276
- text: [...said(outro.result, inner, st), ...leftLines(outro.todo, inner, st), ...outro.warnings.flatMap((line) => said(`! ${line}`, inner, st))],
276
+ text: [...said(outro.result, inner, st), ...usedLines(outro, inner, st), ...leftLines(outro.todo, inner, st), ...outro.warnings.flatMap((line) => said(`! ${line}`, inner, st))],
277
277
  rule: divider(inner, st),
278
278
  action: pullAction(outro.link, scene.copied, inner, st),
279
279
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
@@ -285,7 +285,7 @@ function outroPage(scene, inner, st) {
285
285
  const links = outro.links.flatMap((l) => linkLines(l.label, l.url, inner, st));
286
286
  return {
287
287
  heading: outro.title,
288
- text: said(outro.result, inner, st),
288
+ text: [...said(outro.result, inner, st), ...usedLines(outro, inner, st)],
289
289
  rule: divider(inner, st),
290
290
  action: [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])],
291
291
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
@@ -83,9 +83,4 @@ export class RunState {
83
83
  return { previous: this.current, current: `Still working… ${elapsed(silence)}` };
84
84
  return { previous: this.previous, current: this.current };
85
85
  }
86
- /** Completed tasks out of all but the skipped ones. */
87
- progress() {
88
- const counted = this.tasks.filter((t) => t.status !== 'skipped');
89
- return { completed: counted.filter((t) => t.status === 'completed').length, total: counted.length };
90
- }
91
86
  }