@impeccable/reviews 0.1.8 → 0.1.10

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,17 +10,18 @@ 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, sentTo, startedIn } from './host-questions.js';
13
+ import { APPS_QUESTION, 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';
17
17
  import { passCloudflareAccess } from './cloudflare-access.js';
18
18
  import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
19
19
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
20
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, 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';
23
23
  import { redact } from './log.js';
24
+ import { scan } from './scan.js';
24
25
  import { gitEnvironment } from './sandbox.js';
25
26
  import { openBrowser } from './login.js';
26
27
  import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
@@ -107,6 +108,21 @@ export function resolveWrite(instructions, setupApi, repo, path) {
107
108
  }
108
109
  return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
109
110
  }
111
+ /** 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
+ export function appsSummary(apps) {
113
+ const picked = apps.filter((a) => a.state !== 'unpicked');
114
+ const ready = picked.filter((a) => a.state === 'ready');
115
+ const rest = picked.filter((a) => a.state !== 'ready');
116
+ 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
+ }
120
+ /** 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
+ const LOCKS = {
122
+ 'vercel-protection': { lock: "Vercel's login", tool: 'impeccable_vercel_bypass', left: 'Save the Vercel bypass secret on the setup page' },
123
+ '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' },
124
+ 'cloudflare-access': { lock: 'Cloudflare Access', tool: 'impeccable_cloudflare_access', left: 'Save the Cloudflare service token on the setup page' },
125
+ };
110
126
  const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
111
127
  /** 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. */
112
128
  function stageError(status, tasks) {
@@ -155,6 +171,11 @@ const REPORT = {
155
171
  description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
156
172
  items: { type: 'string', minLength: 1 },
157
173
  },
174
+ apps: {
175
+ type: 'array',
176
+ description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". Names only, never an address or a key.',
177
+ items: { type: 'object', additionalProperties: false, required: ['appKey', 'reason'], properties: { appKey: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } } },
178
+ },
158
179
  },
159
180
  };
160
181
  const WRITE = {
@@ -803,18 +824,34 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
803
824
  * What the pushed `head` changes against `base`, as their remote-tracking refs in this checkout show GitHub: each file's
804
825
  * `git diff --name-status` letter and its path as it is, never quoted; a rename as a removal and an addition. Null when either ref is missing or git fails.
805
826
  */
806
- export async function pushedChanges(cwd, base, head) {
827
+ export async function pushedChanges(cwd, base, head, lessSetup = false) {
807
828
  try {
829
+ const git = (args, timeout = 10_000) => execFileAsync('git', args, { cwd, timeout, env: gitEnvironment() });
808
830
  // The two branches as the remote has them, fetched so the diff can be read here: refs/remotes/origin/* sit in the checkout's git folder, where a command in the sandbox can move them.
809
831
  const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
810
- const { stdout: heads } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
832
+ const { stdout: heads } = await git(['ls-remote', '--heads', 'origin', ...refs], 60_000);
811
833
  const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
812
834
  if (!refs.every((ref) => sha.has(ref)))
813
835
  return null;
814
- await execFileAsync('git', ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
815
- const { stdout } = await execFileAsync('git', ['diff', '-z', '--name-status', '--no-renames', `${sha.get(refs[0])}...${sha.get(refs[1])}`], { cwd, timeout: 10_000, env: gitEnvironment() });
816
- const fields = stdout.split('\0');
817
- return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
836
+ await git(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], 60_000);
837
+ // From where the two met (three dots), so a base that moved on since the branch left it adds nothing of its own.
838
+ const changed = async (from, to) => {
839
+ const { stdout } = await git(['diff', '-z', '--name-status', '--no-renames', `${from}...${to}`]);
840
+ const fields = stdout.split('\0');
841
+ return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
842
+ };
843
+ const pushed = sha.get(refs[1]);
844
+ if (!lessSetup)
845
+ return changed(sha.get(refs[0]), pushed);
846
+ // The test drive branches from the setup commit, so a diff from the base carries the setup branch's files too (the workflow, a build fix). Read from the local setup branch under the pushed head instead (impeccable/setup, or its -N; of several, the nearest), the changes are the test drive's own: a file the setup branch changed and the test drive changed again is among them, one it left as the setup branch had it is not.
847
+ const { stdout: setups } = await git(['for-each-ref', '--format=%(refname)', 'refs/heads/impeccable/setup', 'refs/heads/impeccable/setup-*']);
848
+ const isAncestor = (of, to) => git(['merge-base', '--is-ancestor', of, to]).then(() => true, () => false);
849
+ let setup;
850
+ for (const ref of setups.split('\n').filter(Boolean)) {
851
+ if ((await isAncestor(ref, pushed)) && (setup === undefined || (await isAncestor(setup, ref))))
852
+ setup = ref;
853
+ }
854
+ return changed(setup ?? sha.get(refs[0]), pushed);
818
855
  }
819
856
  catch {
820
857
  return null;
@@ -1648,8 +1685,25 @@ export function createTools(ctx) {
1648
1685
  /** 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. */
1649
1686
  const liveApps = new Map();
1650
1687
  const provenApps = new Set();
1688
+ /** 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. */
1689
+ const appMethods = new Map();
1690
+ const lockMet = new Map();
1691
+ const lockPending = new Map();
1692
+ /** 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). */
1693
+ const vercelDeployed = new Map();
1651
1694
  /** 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. */
1652
1695
  const vercelProductions = new Map();
1696
+ /** 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. */
1697
+ let vercelTeamList;
1698
+ /** A page of a Vercel project's dashboard, under the team the project's own ids name (the link file's or Vercel's list's); the team this run chose where the CLI cannot list the teams, and Vercel's picker last. */
1699
+ const vercelPageFor = async (vercel, link, page, project) => {
1700
+ let scope;
1701
+ if (link && project) {
1702
+ vercelTeamList ??= vercelTeams(vercel);
1703
+ scope = await vercelDashboardScope(vercel, link, await vercelTeamList);
1704
+ }
1705
+ return vercelProjectPage(page, scope ?? chosenHostTeam(ctx.log, 'vercel'), project);
1706
+ };
1653
1707
  /** The bypass secret a proof sends to `origin`: the one proved there, or, for a live address, the one of the project whose production it is. */
1654
1708
  const bypassFor = (origin, live) => {
1655
1709
  const store = bypassOrigins.get(origin);
@@ -1676,6 +1730,7 @@ export function createTools(ctx) {
1676
1730
  // The CLI's current scope is the team; one team is the team; several with none current are the person's pick.
1677
1731
  team: async () => {
1678
1732
  const teams = await vercelTeams(cli);
1733
+ vercelTeamList = Promise.resolve(teams);
1679
1734
  const one = teams.find((t) => t.current) ?? (teams.length === 1 ? teams[0] : undefined);
1680
1735
  return one ? { team: one.value, choices: [] } : { choices: teams.map(({ label, value }) => ({ label, value })) };
1681
1736
  },
@@ -1826,6 +1881,8 @@ export function createTools(ctx) {
1826
1881
  if (appsRepo) {
1827
1882
  const folders = new Map(await Promise.all([...(pickedApps ?? [])].map(async (key) => [await appFolder(pi, key), key])));
1828
1883
  connectedApp = project.rootDirectory !== undefined ? folders.get(project.rootDirectory) : undefined;
1884
+ if (project.name)
1885
+ noteApp(connectedApp, { host_project: project.name });
1829
1886
  if (connectedApp === undefined) {
1830
1887
  // A project not connected to this repository builds no push of it, so the push goes; one connected already would build it, so it stays held.
1831
1888
  if (project.repository !== repository.toLowerCase())
@@ -1854,7 +1911,7 @@ export function createTools(ctx) {
1854
1911
  vercelProject = first.project ?? vercelProject;
1855
1912
  // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
1856
1913
  if (first.production === 'none' && !first.locked) {
1857
- const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
1914
+ const page = await vercelPageFor(vercel, link, 'settings/deployment-protection', vercelProject);
1858
1915
  first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, repository, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
1859
1916
  waiting: "Waiting for Vercel's login on the project",
1860
1917
  skipped: 'The person skipped locking the Vercel project.',
@@ -1918,8 +1975,10 @@ export function createTools(ctx) {
1918
1975
  const app = appKey !== undefined ? { appKey } : {};
1919
1976
  if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
1920
1977
  return { stored: false, failed: `The setup API did not store the ${what}.` };
1978
+ noteApp(appKey, { key_placed: true });
1921
1979
  if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
1922
1980
  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.` };
1981
+ noteApp(appKey, { method_saved: true });
1923
1982
  return { stored: true, failed: null };
1924
1983
  };
1925
1984
  /** The instructions the writes are checked against: the first state may have come before the scan proposed the apps, so a later read that shows them brings the apps write in. */
@@ -1936,8 +1995,41 @@ export function createTools(ctx) {
1936
1995
  let pickedApps;
1937
1996
  /** This run confirmed the scan's proposal, so nothing was answered before: the question opens with every app picked. */
1938
1997
  let confirmedApps = false;
1998
+ /** What the run did for each picked app, as its own tool calls found it, sent with the session whenever it changes (#2307). */
1999
+ const appSummaries = new Map();
2000
+ const sendApps = () => ctx.sync.update({ apps: [...appSummaries.values()].slice(0, 50) });
2001
+ const pick = (keys) => {
2002
+ pickedApps = keys;
2003
+ const before = new Map(appSummaries);
2004
+ appSummaries.clear();
2005
+ for (const key of keys)
2006
+ appSummaries.set(key, before.get(key) ?? { app_key: key, host_project: null, preview_proven: false, preview_host: null, key_placed: false, method_saved: false });
2007
+ sendApps();
2008
+ };
2009
+ const noteApp = (key, patch) => {
2010
+ const was = key !== undefined ? appSummaries.get(key) : undefined;
2011
+ if (!was || Object.entries(patch).every(([field, value]) => was[field] === value))
2012
+ return;
2013
+ appSummaries.set(key, { ...was, ...patch });
2014
+ sendApps();
2015
+ };
2016
+ /** The command saw the app's preview open at `url`, whether or not a key for it was saved after: only its hostname is kept. */
2017
+ const sawApp = (key, url, proof) => {
2018
+ if (proof.proven)
2019
+ noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
2020
+ return proof;
2021
+ };
2022
+ /** A proof opened the app's preview at `url`, so its method may be saved. */
2023
+ const proveApp = (key, url) => {
2024
+ provenApps.add(key);
2025
+ noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
2026
+ };
1939
2027
  /** Each app's folder, by key, as the state names it: what its host project or site must build. */
1940
2028
  const appFolders = new Map();
2029
+ /** The apps as the state last listed them. */
2030
+ let knownApps = null;
2031
+ /** An app's name as the state gives it, or its key. */
2032
+ const appName = (key) => knownApps?.find((a) => a.key === key)?.name ?? key;
1941
2033
  /**
1942
2034
  * A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
1943
2035
  * there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
@@ -1971,6 +2063,7 @@ export function createTools(ctx) {
1971
2063
  });
1972
2064
  for (const app of apps)
1973
2065
  appFolders.set(app.key, app.folder);
2066
+ knownApps = apps;
1974
2067
  return apps;
1975
2068
  };
1976
2069
  /** An app's folder, read from the state the first time it is asked for. */
@@ -1988,6 +2081,48 @@ export function createTools(ctx) {
1988
2081
  return `Refused: give "appKey", the key of an app this run sets up (${[...pickedApps].join(', ')}). Nothing was ${what}.`;
1989
2082
  return null;
1990
2083
  };
2084
+ /**
2085
+ * On a repository with several apps, why a step for another app than `appKey` may not run yet: an app's proof met a
2086
+ * host's lock, and the tool that gets a key past it was not called for that app since, so its key would never be put
2087
+ * in place (#286). Null where it may: no lock met, or every one answered by its tool.
2088
+ */
2089
+ const lockRefusal = (appKey, what) => {
2090
+ for (const [key, gate] of lockPending) {
2091
+ if (key === appKey)
2092
+ continue;
2093
+ const { lock, tool } = LOCKS[gate];
2094
+ return `Refused: ${appName(key)}'s preview is behind ${lock}, and ${tool} was not called for it: call it first, as preview-protection says, so each app gets its own key. Nothing was ${what}.`;
2095
+ }
2096
+ return null;
2097
+ };
2098
+ /** How this run left each app the state lists (#286), for the report and the last screen. */
2099
+ const appOutcomes = async (pi) => {
2100
+ const apps = knownApps ?? (await stateApps(pi)) ?? [];
2101
+ return apps.map(({ key, name, folder }) => {
2102
+ const method = appMethods.get(key);
2103
+ const gate = lockMet.get(key);
2104
+ const proven = provenApps.has(key);
2105
+ if (!pickedApps?.has(key))
2106
+ return { key, name, folder, state: 'unpicked' };
2107
+ if (method !== undefined && (method !== 'preview-deploy' || proven))
2108
+ return { key, name, folder, state: 'ready' };
2109
+ if (gate && !proven)
2110
+ return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
2111
+ return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
2112
+ });
2113
+ };
2114
+ /** The report's section on the apps, one line each, from what this run saved and proved rather than from the model's words. */
2115
+ const appsSection = (apps) => `\n\n## Each app, as this run left it\n\n${apps.map((a) => {
2116
+ const where = `${a.name} (${a.folder || '/'})`;
2117
+ const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
2118
+ if (a.state === 'ready')
2119
+ return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.`;
2120
+ if (a.state === 'locked')
2121
+ return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2122
+ if (a.state === 'unset')
2123
+ return `- ${where}: not set up. No preview method was saved for it.`;
2124
+ return `- ${where}: not set up this run.`;
2125
+ }).join('\n')}`;
1991
2126
  /** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
1992
2127
  const vercelApps = new Map();
1993
2128
  /** The app's Vercel project as this repository's, read now for its deployments: the one that builds the app's folder, or why none can be told. */
@@ -2016,21 +2151,23 @@ export function createTools(ctx) {
2016
2151
  const apps = await stateApps(pi);
2017
2152
  if (!apps || apps.length < 2)
2018
2153
  return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
2019
- if (ctx.app !== undefined && apps.some((a) => a.key === ctx.app)) {
2020
- pickedApps = new Set([ctx.app]);
2021
- return JSON.stringify({ values: [ctx.app], named: true });
2154
+ // Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Skip opens the list for someone who wants another app (#286).
2155
+ const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
2156
+ if (named && !(await ask(namedAppQuestion(named))).skipped) {
2157
+ pick(new Set([named.key]));
2158
+ return JSON.stringify({ values: [named.key], named: true });
2022
2159
  }
2023
2160
  // Started below the top: the app whose folder holds that folder (the deepest, never the root's) is ticked too, and the hint says so.
2024
2161
  const start = ctx.start ?? '';
2025
2162
  const here = start ? apps.filter((a) => a.folder && (start === a.folder || start.startsWith(`${a.folder}/`))).sort((a, b) => b.folder.length - a.folder.length)[0] : undefined;
2026
2163
  const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
2027
- const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here })) });
2164
+ const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) });
2028
2165
  if (answer.skipped) {
2029
- pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
2166
+ pick(new Set(apps.filter((a) => a.enabled).map((a) => a.key)));
2030
2167
  return JSON.stringify({ skipped: true, on: [...pickedApps] });
2031
2168
  }
2032
2169
  const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
2033
- pickedApps = new Set(values);
2170
+ pick(new Set(values));
2034
2171
  return JSON.stringify({ values });
2035
2172
  }
2036
2173
  return [
@@ -2114,7 +2251,7 @@ export function createTools(ctx) {
2114
2251
  // On a repository with several apps, nothing but the apps write goes before the apps question, and an app's setting or check names a picked app.
2115
2252
  const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
2116
2253
  if (!target.url.endsWith('/apps')) {
2117
- const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp);
2254
+ const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
2118
2255
  if (refused)
2119
2256
  return refused;
2120
2257
  }
@@ -2167,6 +2304,10 @@ export function createTools(ctx) {
2167
2304
  savedHost = provenHost;
2168
2305
  if (saved?.appKey !== undefined)
2169
2306
  perApp = true;
2307
+ if (saved && 'intendedMethod' in saved && typeof saved.appKey === 'string')
2308
+ noteApp(saved.appKey, { method_saved: true });
2309
+ if (saved?.appKey !== undefined && typeof saved.intendedMethod === 'string')
2310
+ appMethods.set(String(saved.appKey), saved.intendedMethod);
2170
2311
  if (saved && 'intendedMethod' in saved)
2171
2312
  hostMethod = saved.intendedMethod === 'preview-deploy';
2172
2313
  for (const key of Object.keys(object(p.body) ?? {}))
@@ -2191,12 +2332,18 @@ export function createTools(ctx) {
2191
2332
  // A test account never comes back to the model, even if the API echoed it.
2192
2333
  return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
2193
2334
  }),
2194
- tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app, or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2335
+ tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there), or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2195
2336
  // A skip never stops the run, so every question offers one.
2196
2337
  const asked = p;
2197
2338
  if (asked.id === APPS_QUESTION.id && appsRepo)
2198
2339
  return appsQuestion(pi);
2199
2340
  const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
2341
+ // The Vercel setup is for a repository Vercel has never deployed: one it deploys already (every app this run sets up, on a repository with several), as impeccable_hosted_previews read it, gets its previews from the first pull request on (#286). An app with no project of its own still gets the screen, and a setup under way (asked again once the CLI was installed) is not stopped here.
2342
+ const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
2343
+ if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
2344
+ const projects = [...vercelDeployed.values()].filter(Boolean);
2345
+ 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), and save the method.`;
2346
+ }
2200
2347
  if (host?.[2] === 'setup')
2201
2348
  return JSON.stringify(await hostSignIn(host[1], pi, signal));
2202
2349
  if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
@@ -2265,11 +2412,18 @@ export function createTools(ctx) {
2265
2412
  return `Refused: ${found.problem}.`;
2266
2413
  drive.url = found.url;
2267
2414
  // The page that links to the test drive is an existing file the branch changes; files under dot folders (.github, .netlify) and dotfiles never link to it (#241).
2268
- const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
2415
+ const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2269
2416
  const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
2270
2417
  if (files.length && files.every(({ status }) => status === 'A')) {
2271
2418
  return `Refused: ${found.branch} only adds ${files.map(({ path }) => path).join(', ')} and changes no existing page, so nothing links to the test drive. Commit the page that links to it and push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2272
2419
  }
2420
+ // On a repository with several apps, the test drive's page goes into a picked app's own folder, and no other app is touched (#286): a file it adds anywhere else is refused, whatever its name. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
2421
+ if (appsRepo && pickedApps) {
2422
+ const folders = (await Promise.all([...pickedApps].map((key) => appFolder(pi, key)))).filter((folder) => folder !== undefined);
2423
+ const outside = files.filter(({ status, path }) => status === 'A' && !folders.some((folder) => !folder || path.startsWith(`${folder}/`))).map(({ path }) => path);
2424
+ if (outside.length)
2425
+ return `Refused: ${found.branch} adds ${outside.join(', ')}, outside the folders of the apps this run sets up (${folders.map((folder) => folder || '/').join(', ')}). The test drive's page goes into each picked app's own folder, as the monorepo case says, and no other app is touched: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2426
+ }
2273
2427
  let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
2274
2428
  if (setup) {
2275
2429
  // The setup pull request carries the preview attempts, so its binding cannot change.
@@ -2299,6 +2453,9 @@ export function createTools(ctx) {
2299
2453
  const control = left.find((line) => plainText(line) !== line);
2300
2454
  if (control)
2301
2455
  return `Refused: "${control}" holds a control character or an escape sequence. Each line of "left" is plain text. Nothing was saved: call impeccable_report again.`;
2456
+ const pending = lockRefusal(undefined, 'saved');
2457
+ if (pending)
2458
+ return `${pending} Call impeccable_report again once it answered.`;
2302
2459
  // The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
2303
2460
  const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
2304
2461
  if (open.length)
@@ -2312,6 +2469,10 @@ export function createTools(ctx) {
2312
2469
  const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
2313
2470
  markdown += `\n\n## What the setup pull request changes\n\n${changes ? changes.map(({ status, path }) => `- ${shown(path)} (${verbs[status] ?? status})`).join('\n') || 'Nothing: it carries no file.' : 'This checkout could not list its files: read them on the pull request before merging.'}`;
2314
2471
  }
2472
+ // On a repository with several apps, how this run left each app, from what it saved and proved (#286).
2473
+ const apps = appsRepo && pickedApps ? await appOutcomes(pi) : undefined;
2474
+ if (apps)
2475
+ markdown += appsSection(apps);
2315
2476
  if (previews.active && retry.pr) {
2316
2477
  await observe(signal);
2317
2478
  const lines = attempts().map((a, i) => `${i + 1}. [Attempt ${i + 1}](${a.url}) (run attempt ${a.attempt}): ${a.status}. ${a.diagnostics.map((d) => `${d.appKey}: step ${d.stage ?? 'unverified'}. ${d.reason}`).join(' ')}${a.changes.length ? ` Changed settings: ${a.changes.join(', ')}.` : ''}`);
@@ -2326,23 +2487,37 @@ export function createTools(ctx) {
2326
2487
  const covered = test_pr && recordedBranches?.setup
2327
2488
  ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
2328
2489
  const drive = covered === false ? undefined : test_pr;
2490
+ // Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
2491
+ const appsBefore = new Map(appSummaries);
2492
+ for (const { appKey, reason } of p.apps ?? []) {
2493
+ const plain = reason.trim();
2494
+ const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
2495
+ if (plain.length <= 200 && plainText(plain) === plain && !address && !scan('output', plain).length)
2496
+ noteApp(appKey, { reason: plain });
2497
+ }
2329
2498
  // The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
2330
2499
  ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
2331
2500
  const res = await ctx.sync.flush();
2332
2501
  if (res && res.status >= 400 && res.status < 500) {
2333
2502
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
2334
2503
  ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
2504
+ // Its reasons go with it: the report sent again gives its own.
2505
+ if (appsBefore.size) {
2506
+ for (const [key, was] of appsBefore)
2507
+ appSummaries.set(key, was);
2508
+ sendApps();
2509
+ }
2335
2510
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
2336
2511
  }
2337
2512
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2338
- ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}) });
2513
+ ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
2339
2514
  pi.shutdown();
2340
2515
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2341
2516
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
2342
2517
  }),
2343
2518
  tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
2344
2519
  const branch = p.branch;
2345
- const refused = appRefusal(p.appKey, 'read');
2520
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2346
2521
  if (refused)
2347
2522
  return refused;
2348
2523
  // The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
@@ -2386,7 +2561,9 @@ export function createTools(ctx) {
2386
2561
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2387
2562
  // A head's build is still building until it has a final status.
2388
2563
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
2389
- const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), app?.name ?? vercelProject), signal) : await read();
2564
+ // The project's Git settings, for the screen a build that never comes opens: the app's project's own page, else the linked project's.
2565
+ const gitPage = branch !== undefined ? await vercelPageFor(vercel, app ? app.link : await readLink(pi.cwd), 'settings/git', app?.name ?? vercelProject) : '';
2566
+ const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
2390
2567
  /** The project the evidence is of: the app's, else the one this folder is linked to. */
2391
2568
  const link = async () => (app ? app.link : readLink(pi.cwd));
2392
2569
  /** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
@@ -2412,6 +2589,13 @@ export function createTools(ctx) {
2412
2589
  if (app)
2413
2590
  listedApps.set(url.origin, { appKey: appKey, link: app.link, name: app.name });
2414
2591
  }
2592
+ // Vercel deploys the repository (this app's project did) when it made a deployment of it, production included: then its previews start with the first pull request, and no Vercel setup is asked (#286).
2593
+ if (branch === undefined) {
2594
+ if (evidence.deployments.some((d) => d.vercel && d.state === 'success'))
2595
+ vercelDeployed.set(appKey ?? '', app?.name ?? '');
2596
+ else
2597
+ vercelDeployed.delete(appKey ?? '');
2598
+ }
2415
2599
  // What vercel.json and the linked project (the app's, with its folder's vercel.json) say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
2416
2600
  if (branch === undefined) {
2417
2601
  const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
@@ -2431,11 +2615,13 @@ export function createTools(ctx) {
2431
2615
  vercelProductions.set(new URL(production).origin, linked.projectId);
2432
2616
  noteProduction(production, 'vercel', undefined, appKey);
2433
2617
  }
2618
+ if (app?.name)
2619
+ noteApp(appKey, { host_project: app.name });
2434
2620
  return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
2435
2621
  }),
2436
2622
  tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2437
2623
  const pull = p.pull;
2438
- const refused = appRefusal(p.appKey, 'read');
2624
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2439
2625
  if (refused)
2440
2626
  return refused;
2441
2627
  // On a repository with several apps, only the site that builds the app's folder (#281).
@@ -2466,6 +2652,9 @@ export function createTools(ctx) {
2466
2652
  if (evidence.pullRequestPreviews)
2467
2653
  previewFacts.set('netlify', evidence.pullRequestPreviews);
2468
2654
  noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
2655
+ const site = evidence.unmatched ? undefined : liveSite(evidence);
2656
+ if (site)
2657
+ noteApp(appKey, { host_project: site });
2469
2658
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2470
2659
  }),
2471
2660
  tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
@@ -2473,7 +2662,7 @@ export function createTools(ctx) {
2473
2662
  const branch = p.branch;
2474
2663
  if (pull !== undefined && branch !== undefined)
2475
2664
  return 'Invalid input: give pull or branch, not both.';
2476
- const refused = appRefusal(p.appKey, 'read');
2665
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2477
2666
  if (refused)
2478
2667
  return refused;
2479
2668
  // On a repository with several apps, only the Pages project that builds the app's folder (#281): told by the plain read, which a wait for a build keeps to.
@@ -2501,8 +2690,10 @@ export function createTools(ctx) {
2501
2690
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2502
2691
  if (!subject)
2503
2692
  cloudflareEvidence = evidence;
2504
- if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project)
2693
+ if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
2505
2694
  cloudflareApps.set(appKey, { project: evidence.project, label: (evidence.production && pagesProject(evidence.production)) || evidence.project });
2695
+ noteApp(appKey, { host_project: evidence.project });
2696
+ }
2506
2697
  for (const origin of cloudflareListed)
2507
2698
  listedApps.delete(origin);
2508
2699
  cloudflareListed.clear();
@@ -2555,7 +2746,15 @@ export function createTools(ctx) {
2555
2746
  if (proof.proven)
2556
2747
  provenHost = previewHostName(url) ?? provenHost;
2557
2748
  if (proof.proven && app)
2558
- provenApps.add(app.appKey);
2749
+ proveApp(app.appKey, url);
2750
+ if (proof.proven && app)
2751
+ lockPending.delete(app.appKey);
2752
+ // The lock in front of an app's preview is that app's own: its key tool is the next call for it, and every other app's steps wait for that (#286). An earlier proof of the app (another preview, or one the key opened before) says nothing about this lock: the app is proven again only once something opens it.
2753
+ if (app && proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
2754
+ lockMet.set(app.appKey, proof.gate);
2755
+ lockPending.set(app.appKey, proof.gate);
2756
+ provenApps.delete(app.appKey);
2757
+ }
2559
2758
  if (proof.proven && proof.pattern)
2560
2759
  provenPattern = proof.pattern;
2561
2760
  if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
@@ -2694,6 +2893,8 @@ export function createTools(ctx) {
2694
2893
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2695
2894
  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.';
2696
2895
  const at = gated;
2896
+ if (at.app)
2897
+ lockPending.delete(at.app.appKey);
2697
2898
  const { origin, host } = new URL(at.url);
2698
2899
  // Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
2699
2900
  const store = at.app?.appKey ?? '';
@@ -2727,7 +2928,7 @@ export function createTools(ctx) {
2727
2928
  hostedPreviewProven = true;
2728
2929
  provenHost = 'Vercel';
2729
2930
  if (at.app)
2730
- provenApps.add(at.app.appKey);
2931
+ proveApp(at.app.appKey, at.url);
2731
2932
  };
2732
2933
  // A secret in hand already for the same store and project: another address of the project opens with it.
2733
2934
  const held = bypassHeld.get(store);
@@ -2743,13 +2944,15 @@ export function createTools(ctx) {
2743
2944
  // A project the folder is not linked to is named on the CLI's reads and makes; the reads are held to its id all the same.
2744
2945
  ...(at.app?.name ? { project: at.app.name } : {}),
2745
2946
  consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
2746
- paste: async () => {
2747
- // The project's own settings page, in its team where the run knows it; else Vercel's picker.
2748
- (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
2749
- const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
2947
+ paste: async (stopped) => {
2948
+ // 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.
2949
+ (ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
2950
+ const answer = await ask(sentTo(vercelBypassPaste(stopped), at.url));
2750
2951
  return answer.skipped ? undefined : answer.value?.trim();
2751
2952
  },
2752
- prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
2953
+ ...(ctx.hostWait?.bypass ? { wait: ctx.hostWait.bypass } : {}),
2954
+ ...(signal ? { signal } : {}),
2955
+ prove: async (secret) => sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret })),
2753
2956
  save: async (secret) => {
2754
2957
  const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
2755
2958
  stored = saved.stored;
@@ -2781,10 +2984,12 @@ export function createTools(ctx) {
2781
2984
  if (!gated || !site)
2782
2985
  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.';
2783
2986
  const at = gated;
2987
+ if (at.app)
2988
+ lockPending.delete(at.app.appKey);
2784
2989
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2785
2990
  let pattern;
2786
2991
  const prove = async (password, form = false) => {
2787
- const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
2992
+ const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form }));
2788
2993
  if (proof.proven && proof.pattern)
2789
2994
  pattern = proof.pattern;
2790
2995
  return proof;
@@ -2795,7 +3000,7 @@ export function createTools(ctx) {
2795
3000
  if ((await prove(undefined)).proven) {
2796
3001
  provenPattern = pattern ?? provenPattern;
2797
3002
  if (at.app)
2798
- provenApps.add(at.app.appKey);
3003
+ proveApp(at.app.appKey, at.url);
2799
3004
  return JSON.stringify(stands);
2800
3005
  }
2801
3006
  passwordStands.delete(site);
@@ -2806,7 +3011,7 @@ export function createTools(ctx) {
2806
3011
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2807
3012
  provenPattern = pattern ?? provenPattern;
2808
3013
  if (at.app)
2809
- provenApps.add(at.app.appKey);
3014
+ proveApp(at.app.appKey, at.url);
2810
3015
  return JSON.stringify(passwordSaved);
2811
3016
  }
2812
3017
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
@@ -2854,7 +3059,7 @@ export function createTools(ctx) {
2854
3059
  hostedPreviewProven = true;
2855
3060
  provenHost = 'Netlify';
2856
3061
  if (at.app)
2857
- provenApps.add(at.app.appKey);
3062
+ proveApp(at.app.appKey, at.url);
2858
3063
  provenPattern = pattern ?? provenPattern;
2859
3064
  }
2860
3065
  if (value) {
@@ -2865,7 +3070,7 @@ export function createTools(ctx) {
2865
3070
  hostedPreviewProven = true;
2866
3071
  provenHost = 'Netlify';
2867
3072
  if (at.app)
2868
- provenApps.add(at.app.appKey);
3073
+ proveApp(at.app.appKey, at.url);
2869
3074
  provenPattern = pattern ?? provenPattern;
2870
3075
  passwordSaved = passwordOutcome;
2871
3076
  }
@@ -2880,16 +3085,20 @@ export function createTools(ctx) {
2880
3085
  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) => {
2881
3086
  if (!accessGated)
2882
3087
  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.';
3088
+ const at = accessGated;
3089
+ // The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
3090
+ if (at.app)
3091
+ lockPending.delete(at.app.appKey);
2883
3092
  if (accessOutcome?.status === 'skipped')
2884
3093
  return JSON.stringify(accessOutcome);
2885
- const at = accessGated;
2886
3094
  let proof;
2887
- const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
3095
+ const prove = async (token) => (proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token })));
2888
3096
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
2889
3097
  const save = async (token) => {
2890
3098
  const app = at.app ? { appKey: at.app.appKey } : {};
2891
3099
  if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret, ...app } })).ok)
2892
3100
  return 'The setup API did not store the service token.';
3101
+ noteApp(at.app?.appKey, { key_placed: true });
2893
3102
  // An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
2894
3103
  const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
2895
3104
  const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
@@ -2897,6 +3106,7 @@ export function createTools(ctx) {
2897
3106
  return null;
2898
3107
  if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access', ...app } })).ok)
2899
3108
  return 'The setup API stored the service token, but not the setting that sends it: it is turned on under Preview protection on the setup page.';
3109
+ noteApp(at.app?.appKey, { method_saved: true });
2900
3110
  return null;
2901
3111
  };
2902
3112
  let answer;
@@ -2947,7 +3157,7 @@ export function createTools(ctx) {
2947
3157
  hostedPreviewProven = true;
2948
3158
  provenHost = 'Cloudflare Pages';
2949
3159
  if (at.app)
2950
- provenApps.add(at.app.appKey);
3160
+ proveApp(at.app.appKey, at.url);
2951
3161
  if (pattern)
2952
3162
  provenPattern = pattern;
2953
3163
  }