@impeccable/reviews 0.1.7 → 0.1.9

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,14 +10,14 @@ 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 { HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo } from './host-questions.js';
14
- import { enableNetlifyPreviews } from './host-previews.js';
13
+ import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn } from './host-questions.js';
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, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, 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';
@@ -107,6 +107,21 @@ export function resolveWrite(instructions, setupApi, repo, path) {
107
107
  }
108
108
  return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
109
109
  }
110
+ /** 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. */
111
+ export function appsSummary(apps) {
112
+ const picked = apps.filter((a) => a.state !== 'unpicked');
113
+ const ready = picked.filter((a) => a.state === 'ready');
114
+ const rest = picked.filter((a) => a.state !== 'ready');
115
+ const names = (list) => list.map((a) => a.name).join(', ');
116
+ const line = [...(ready.length ? [`Set up: ${names(ready)}.`] : []), ...(rest.length ? [`Not set up: ${names(rest)}.`] : [])].join(' ');
117
+ return { notReady: rest.map((a) => a.name), line, todo: rest.flatMap((a) => (a.left ? [a.left] : [])) };
118
+ }
119
+ /** 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). */
120
+ const LOCKS = {
121
+ 'vercel-protection': { lock: "Vercel's login", tool: 'impeccable_vercel_bypass', left: 'Save the Vercel bypass secret on the setup page' },
122
+ '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' },
123
+ 'cloudflare-access': { lock: 'Cloudflare Access', tool: 'impeccable_cloudflare_access', left: 'Save the Cloudflare service token on the setup page' },
124
+ };
110
125
  const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
111
126
  /** 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
127
  function stageError(status, tasks) {
@@ -179,10 +194,15 @@ const TEST_PR = {
179
194
  ...PULL_REQUEST,
180
195
  properties: { ...PULL_REQUEST.properties, setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' } },
181
196
  };
197
+ /** An app's key as the setup state gives it. */
198
+ const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
199
+ /** 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. */
200
+ 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.' };
182
201
  const HOSTED_PREVIEWS = {
183
202
  type: 'object',
184
203
  additionalProperties: false,
185
204
  properties: {
205
+ appKey: APP_KEY,
186
206
  branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the deployments of its head only, and waits up to five minutes for one to finish building.' },
187
207
  },
188
208
  };
@@ -190,6 +210,7 @@ const NETLIFY_PREVIEWS = {
190
210
  type: 'object',
191
211
  additionalProperties: false,
192
212
  properties: {
213
+ appKey: APP_KEY,
193
214
  pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
194
215
  },
195
216
  };
@@ -197,6 +218,7 @@ const CLOUDFLARE_PREVIEWS = {
197
218
  type: 'object',
198
219
  additionalProperties: false,
199
220
  properties: {
221
+ appKey: APP_KEY,
200
222
  pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
201
223
  branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the build of its head only, and waits up to five minutes for it to finish.' },
202
224
  },
@@ -796,18 +818,34 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
796
818
  * What the pushed `head` changes against `base`, as their remote-tracking refs in this checkout show GitHub: each file's
797
819
  * `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.
798
820
  */
799
- export async function pushedChanges(cwd, base, head) {
821
+ export async function pushedChanges(cwd, base, head, lessSetup = false) {
800
822
  try {
823
+ const git = (args, timeout = 10_000) => execFileAsync('git', args, { cwd, timeout, env: gitEnvironment() });
801
824
  // 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.
802
825
  const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
803
- const { stdout: heads } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
826
+ const { stdout: heads } = await git(['ls-remote', '--heads', 'origin', ...refs], 60_000);
804
827
  const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
805
828
  if (!refs.every((ref) => sha.has(ref)))
806
829
  return null;
807
- await execFileAsync('git', ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
808
- 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() });
809
- const fields = stdout.split('\0');
810
- return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
830
+ await git(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], 60_000);
831
+ // From where the two met (three dots), so a base that moved on since the branch left it adds nothing of its own.
832
+ const changed = async (from, to) => {
833
+ const { stdout } = await git(['diff', '-z', '--name-status', '--no-renames', `${from}...${to}`]);
834
+ const fields = stdout.split('\0');
835
+ return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
836
+ };
837
+ const pushed = sha.get(refs[1]);
838
+ if (!lessSetup)
839
+ return changed(sha.get(refs[0]), pushed);
840
+ // 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.
841
+ const { stdout: setups } = await git(['for-each-ref', '--format=%(refname)', 'refs/heads/impeccable/setup', 'refs/heads/impeccable/setup-*']);
842
+ const isAncestor = (of, to) => git(['merge-base', '--is-ancestor', of, to]).then(() => true, () => false);
843
+ let setup;
844
+ for (const ref of setups.split('\n').filter(Boolean)) {
845
+ if ((await isAncestor(ref, pushed)) && (setup === undefined || (await isAncestor(setup, ref))))
846
+ setup = ref;
847
+ }
848
+ return changed(setup ?? sha.get(refs[0]), pushed);
811
849
  }
812
850
  catch {
813
851
  return null;
@@ -1519,14 +1557,19 @@ export function createTools(ctx) {
1519
1557
  };
1520
1558
  /** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
1521
1559
  const vercelMade = new Set();
1522
- /** The address impeccable_prove_preview met a host's lock at, the words it looked for there, and whose lock it was. */
1560
+ /** On a repository with several apps, the app each listed origin was read for, with its own host project (#281): a key for that address goes to that project and is saved for that app. */
1561
+ const listedApps = new Map();
1562
+ /** The address impeccable_prove_preview met a host's lock at, the words it looked for there, whose lock it was, and the app it was listed for. */
1523
1563
  let gated;
1524
- /** The bypass secret saved in this run, for the proof of a Vercel address to send; never for the model. It is one project's, so it goes only to the origins impeccable_vercel_bypass proved it on, never to another project's deployment of this repository. */
1525
- let bypassSecret;
1526
- const bypassOrigins = new Set();
1527
- /** What impeccable_vercel_bypass answered, less the secret: a skip is asked once, so that answer stands; the saved one is what an address the secret opens later gets. */
1528
- let bypassOutcome;
1529
- let bypassSaved;
1564
+ /**
1565
+ * The bypass secret saved in this run, by where the setup API keeps it (the app's key, or '' for the repository's), with
1566
+ * the project it is of and what the tool answered; never for the model. A secret is one project's, so it goes only to
1567
+ * the origins impeccable_vercel_bypass proved it on (each by that same store), never to another project's deployment.
1568
+ */
1569
+ const bypassHeld = new Map();
1570
+ const bypassOrigins = new Map();
1571
+ /** What impeccable_vercel_bypass answered for a store, less the secret: a skip is asked once, so that answer stands. */
1572
+ const bypassOutcomes = new Map();
1530
1573
  /** 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. */
1531
1574
  let sitePassword;
1532
1575
  const passwordSites = new Set();
@@ -1551,6 +1594,8 @@ export function createTools(ctx) {
1551
1594
  const enableOutcomes = new Map();
1552
1595
  /** Cloudflare's plain evidence as last read: the project, the Worker and the GitHub app facts impeccable_cloudflare_connect and the wait for a build act on. */
1553
1596
  let cloudflareEvidence;
1597
+ /** Each app's Pages project, by key, as a plain read with its appKey told it: its name, and the label its addresses carry (its own host's, which a taken name suffixes). */
1598
+ const cloudflareApps = new Map();
1554
1599
  /** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
1555
1600
  let connectOutcome;
1556
1601
  // Keep ownership of files for this one run; never recreate it per call.
@@ -1631,14 +1676,32 @@ export function createTools(ctx) {
1631
1676
  /** The live app's addresses, by origin: those the host tools answered as `production`, each with its host and, on Netlify, its site, so a custom domain's lock is known and the site's key goes with it; and those a proof opened with this app, the only ones impeccable_write saves as the before. */
1632
1677
  const productionListed = new Map();
1633
1678
  const provenLive = new Set();
1634
- /** The linked Vercel project's production address, read from Vercel, 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. */
1635
- let vercelProduction;
1636
- /** The project whose bypass secret is in hand, by the link it was read or made on. */
1637
- let bypassProject;
1679
+ /** 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. */
1680
+ const liveApps = new Map();
1681
+ const provenApps = new Set();
1682
+ /** 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. */
1683
+ const appMethods = new Map();
1684
+ const lockMet = new Map();
1685
+ const lockPending = new Map();
1686
+ /** 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). */
1687
+ const vercelDeployed = new Map();
1688
+ /** 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. */
1689
+ const vercelProductions = new Map();
1690
+ /** 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. */
1691
+ const bypassFor = (origin, live) => {
1692
+ const store = bypassOrigins.get(origin);
1693
+ if (store !== undefined)
1694
+ return bypassHeld.get(store)?.secret;
1695
+ const project = live ? vercelProductions.get(origin) : undefined;
1696
+ return project ? [...bypassHeld.values()].find((held) => held.projectId === project)?.secret : undefined;
1697
+ };
1638
1698
  /** A host tool answered the live app's address: the proof may open it as the before, with the host's key where the lock is the same site's or project's. */
1639
- const noteProduction = (production, host, site) => {
1640
- if (production && URL.canParse(production))
1641
- productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
1699
+ const noteProduction = (production, host, site, appKey) => {
1700
+ if (!production || !URL.canParse(production))
1701
+ return;
1702
+ productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
1703
+ if (appKey !== undefined)
1704
+ liveApps.set(new URL(production).origin, appKey);
1642
1705
  };
1643
1706
  const hostCli = (host, pi, signal) => {
1644
1707
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
@@ -1776,6 +1839,8 @@ export function createTools(ctx) {
1776
1839
  * has no production deployment, before anything is pushed: Vercel makes a new project's first deployment Production
1777
1840
  * whatever branch it builds, and serves it at the project's own address.
1778
1841
  */
1842
+ /** On a repository with several apps, the picked app whose folder the project impeccable_vercel_connect connects builds. */
1843
+ let connectedApp;
1779
1844
  async function vercelConnect(pi, signal) {
1780
1845
  if (!hostSetupAgreed(ctx.log, 'vercel'))
1781
1846
  return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
@@ -1794,6 +1859,17 @@ export function createTools(ctx) {
1794
1859
  return { connected: false, reason: 'The project this folder is linked to no longer exists on Vercel, as far as the CLI can tell (.vercel/project.json is stale): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder\'s name again; else the Vercel setup ends here, and the person removes or relinks the file.' };
1795
1860
  if (!project)
1796
1861
  return { connected: false, reason: 'The Vercel CLI could not read the linked project (vercel api), so whether this run created it is unknown: nothing was connected, and the push stays held.' };
1862
+ // On a repository with several apps, a project builds one app's folder: one that builds no picked app's is connected and locked for none (#281).
1863
+ if (appsRepo) {
1864
+ const folders = new Map(await Promise.all([...(pickedApps ?? [])].map(async (key) => [await appFolder(pi, key), key])));
1865
+ connectedApp = project.rootDirectory !== undefined ? folders.get(project.rootDirectory) : undefined;
1866
+ if (connectedApp === undefined) {
1867
+ // 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.
1868
+ if (project.repository !== repository.toLowerCase())
1869
+ settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
1870
+ return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} builds ${project.rootDirectory ? `the folder ${project.rootDirectory}` : "the repository's root"}, which is no app this run sets up: it stays as it is, and nothing was connected. The Vercel setup ends here.` };
1871
+ }
1872
+ }
1797
1873
  // A project connected to this repository already is this repository's: the connection changes nothing on it, and the lock below is what it gets as the run's own project would.
1798
1874
  if (project.repository !== repository.toLowerCase()) {
1799
1875
  const foreign = !(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`)) ? `this run's vercel project add did not make it in ${own.team ? `the team ${own.team}` : 'the personal scope'}, so vercel link linked a project of that name that existed already` : null;
@@ -1873,11 +1949,13 @@ export function createTools(ctx) {
1873
1949
  * Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
1874
1950
  * `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
1875
1951
  */
1876
- const saveLock = async (pi, what, header, value) => {
1952
+ const saveLock = async (pi, what, header, value, appKey) => {
1877
1953
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1878
- if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value } })).ok)
1954
+ // 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.
1955
+ const app = appKey !== undefined ? { appKey } : {};
1956
+ if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
1879
1957
  return { stored: false, failed: `The setup API did not store the ${what}.` };
1880
- if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
1958
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
1881
1959
  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.` };
1882
1960
  return { stored: true, failed: null };
1883
1961
  };
@@ -1885,6 +1963,22 @@ export function createTools(ctx) {
1885
1963
  let instructions = ctx.instructions;
1886
1964
  /** Whether the run signed in again for apps the scan proposed after the sign-in. */
1887
1965
  let renewedForApps = false;
1966
+ /** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
1967
+ let appsRepo = ctx.severalApps === true;
1968
+ /**
1969
+ * The apps this run sets up, as the apps question answered (after a skip, the ones on): undefined until it was asked.
1970
+ * On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
1971
+ * and host read for an app names one of these.
1972
+ */
1973
+ let pickedApps;
1974
+ /** This run confirmed the scan's proposal, so nothing was answered before: the question opens with every app picked. */
1975
+ let confirmedApps = false;
1976
+ /** Each app's folder, by key, as the state names it: what its host project or site must build. */
1977
+ const appFolders = new Map();
1978
+ /** The apps as the state last listed them. */
1979
+ let knownApps = null;
1980
+ /** An app's name as the state gives it, or its key. */
1981
+ const appName = (key) => knownApps?.find((a) => a.key === key)?.name ?? key;
1888
1982
  /**
1889
1983
  * A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
1890
1984
  * there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
@@ -1897,10 +1991,134 @@ export function createTools(ctx) {
1897
1991
  renewedForApps = true;
1898
1992
  res = await call(pi, `${ctx.setupApi}${path}`);
1899
1993
  }
1994
+ if (res.ok && severalApps(res.data))
1995
+ appsRepo = true;
1900
1996
  if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
1901
1997
  instructions = focusInstructions(ctx.allInstructions, { apps: true });
1902
1998
  return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
1903
1999
  };
2000
+ /** The repository's apps as the state lists them, each with its folder and whether it is on; null when the state could not be read. */
2001
+ const stateApps = async (pi) => {
2002
+ const res = await call(pi, `${ctx.setupApi}/state`);
2003
+ const repos = object(res.data)?.repos;
2004
+ const repo = res.ok && Array.isArray(repos) ? repos.map(object).find((r) => r?.name === ctx.repo) : undefined;
2005
+ if (!repo || !Array.isArray(repo.apps))
2006
+ return null;
2007
+ const apps = repo.apps.map(object).flatMap((a) => {
2008
+ const prefixes = Array.isArray(a?.pathPrefixes) ? a.pathPrefixes : [];
2009
+ const folder = folderOf(prefixes.find((p) => typeof p === 'string' && p) ?? '');
2010
+ return a && typeof a.key === 'string' && APP_KEY_SHAPE.test(a.key) && folder !== undefined
2011
+ ? [{ key: a.key, name: typeof a.name === 'string' && a.name ? a.name : a.key, folder, enabled: a.enabled !== false }] : [];
2012
+ });
2013
+ for (const app of apps)
2014
+ appFolders.set(app.key, app.folder);
2015
+ knownApps = apps;
2016
+ return apps;
2017
+ };
2018
+ /** An app's folder, read from the state the first time it is asked for. */
2019
+ const appFolder = async (pi, key) => (appFolders.has(key) ? appFolders.get(key) : (await stateApps(pi), appFolders.get(key)));
2020
+ /**
2021
+ * On a repository with several apps, why a step for `appKey` may not run yet: the apps question comes first, and only
2022
+ * a picked app is set up. Null where it may: one app, or a picked one.
2023
+ */
2024
+ const appRefusal = (appKey, what, forApp = true) => {
2025
+ if (!appsRepo)
2026
+ return null;
2027
+ if (!pickedApps)
2028
+ return `Refused: this repository has several apps, so the apps question comes first, on every run: impeccable_ask with the id "apps", as the monorepo case says. Nothing was ${what}.`;
2029
+ if (forApp && (typeof appKey !== 'string' || !pickedApps.has(appKey)))
2030
+ return `Refused: give "appKey", the key of an app this run sets up (${[...pickedApps].join(', ')}). Nothing was ${what}.`;
2031
+ return null;
2032
+ };
2033
+ /**
2034
+ * On a repository with several apps, why a step for another app than `appKey` may not run yet: an app's proof met a
2035
+ * 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
2036
+ * in place (#286). Null where it may: no lock met, or every one answered by its tool.
2037
+ */
2038
+ const lockRefusal = (appKey, what) => {
2039
+ for (const [key, gate] of lockPending) {
2040
+ if (key === appKey)
2041
+ continue;
2042
+ const { lock, tool } = LOCKS[gate];
2043
+ 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}.`;
2044
+ }
2045
+ return null;
2046
+ };
2047
+ /** How this run left each app the state lists (#286), for the report and the last screen. */
2048
+ const appOutcomes = async (pi) => {
2049
+ const apps = knownApps ?? (await stateApps(pi)) ?? [];
2050
+ return apps.map(({ key, name, folder }) => {
2051
+ const method = appMethods.get(key);
2052
+ const gate = lockMet.get(key);
2053
+ const proven = provenApps.has(key);
2054
+ if (!pickedApps?.has(key))
2055
+ return { key, name, folder, state: 'unpicked' };
2056
+ if (method !== undefined && (method !== 'preview-deploy' || proven))
2057
+ return { key, name, folder, state: 'ready' };
2058
+ if (gate && !proven)
2059
+ return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
2060
+ return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
2061
+ });
2062
+ };
2063
+ /** The report's section on the apps, one line each, from what this run saved and proved rather than from the model's words. */
2064
+ const appsSection = (apps) => `\n\n## Each app, as this run left it\n\n${apps.map((a) => {
2065
+ const where = `${a.name} (${a.folder || '/'})`;
2066
+ const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
2067
+ if (a.state === 'ready')
2068
+ return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.`;
2069
+ if (a.state === 'locked')
2070
+ return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2071
+ if (a.state === 'unset')
2072
+ return `- ${where}: not set up. No preview method was saved for it.`;
2073
+ return `- ${where}: not set up this run.`;
2074
+ }).join('\n')}`;
2075
+ /** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
2076
+ const vercelApps = new Map();
2077
+ /** 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. */
2078
+ async function vercelApp(pi, appKey, vercel) {
2079
+ let found = vercelApps.get(appKey);
2080
+ if (!found) {
2081
+ const folder = await appFolder(pi, appKey);
2082
+ if (folder === undefined)
2083
+ return { reason: `The setup state names no folder for the app ${appKey}.` };
2084
+ const resolved = await vercelProjectFor(vercel, repository, folder, (await readLink(pi.cwd))?.orgId);
2085
+ found = 'reason' in resolved ? resolved : { ...resolved, folder };
2086
+ if (!('reason' in found))
2087
+ vercelApps.set(appKey, found);
2088
+ }
2089
+ if ('reason' in found)
2090
+ return found;
2091
+ const project = await readOwnProject(vercel, found.link, repository);
2092
+ return 'reason' in project ? project : { ...found, project };
2093
+ }
2094
+ /**
2095
+ * The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
2096
+ * this run confirmed the scan's proposal); the command started for one app (--app) answers it without a screen. The
2097
+ * answer is the apps this run sets up; after a skip, those that are on.
2098
+ */
2099
+ async function appsQuestion(pi) {
2100
+ const apps = await stateApps(pi);
2101
+ if (!apps || apps.length < 2)
2102
+ return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
2103
+ // 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).
2104
+ const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
2105
+ if (named && !(await ask(namedAppQuestion(named))).skipped) {
2106
+ pickedApps = new Set([named.key]);
2107
+ return JSON.stringify({ values: [named.key], named: true });
2108
+ }
2109
+ // 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.
2110
+ const start = ctx.start ?? '';
2111
+ 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;
2112
+ const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
2113
+ 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 })) });
2114
+ if (answer.skipped) {
2115
+ pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
2116
+ return JSON.stringify({ skipped: true, on: [...pickedApps] });
2117
+ }
2118
+ const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
2119
+ pickedApps = new Set(values);
2120
+ return JSON.stringify({ values });
2121
+ }
1904
2122
  return [
1905
2123
  tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
1906
2124
  tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
@@ -1979,13 +2197,20 @@ export function createTools(ctx) {
1979
2197
  const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
1980
2198
  if ('error' in target)
1981
2199
  return `Refused: ${target.error}`;
2200
+ // 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.
2201
+ const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
2202
+ if (!target.url.endsWith('/apps')) {
2203
+ const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
2204
+ if (refused)
2205
+ return refused;
2206
+ }
1982
2207
  if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
1983
2208
  return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
1984
2209
  if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'ci-tunnel') {
1985
2210
  return "Refused: Impeccable no longer runs a server in CI for a new setup, so ci-tunnel is never saved. Pages that need a server get their host's pull request previews, as the server-previews case says. Nothing was saved.";
1986
2211
  }
1987
- if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
1988
- return "Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app. Nothing was saved.";
2212
+ if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && (appsRepo ? !provenApps.has(String(object(p.body)?.appKey)) : !hostedPreviewProven)) {
2213
+ return `Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app${appsRepo ? `, read for it with its appKey (${String(object(p.body)?.appKey)})` : ''}. Nothing was saved.`;
1989
2214
  }
1990
2215
  const saved = object(p.body);
1991
2216
  if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
@@ -1995,6 +2220,15 @@ export function createTools(ctx) {
1995
2220
  if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
1996
2221
  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.";
1997
2222
  }
2223
+ // 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.
2224
+ if (appsRepo && target.url.endsWith('/preview') && saved && typeof saved.beforeUrl === 'string') {
2225
+ const origin = new URL(saved.beforeUrl).origin;
2226
+ const owner = liveApps.get(origin);
2227
+ const folder = await appFolder(pi, String(saved.appKey));
2228
+ if (owner !== undefined ? owner !== saved.appKey : folder === undefined || !(await checkoutNames(pi.cwd, origin, folder))) {
2229
+ 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.`;
2230
+ }
2231
+ }
1998
2232
  const testAccounts = target.url.endsWith('/test-accounts');
1999
2233
  let body = p.body;
2000
2234
  if (testAccounts) {
@@ -2009,6 +2243,8 @@ export function createTools(ctx) {
2009
2243
  }
2010
2244
  const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
2011
2245
  const response = await call(pi, target.url, { method: target.method, body });
2246
+ if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
2247
+ confirmedApps = true;
2012
2248
  if (target.url.endsWith('/preview') && response.ok) {
2013
2249
  const keys = object(object(response.data)?.keys);
2014
2250
  if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
@@ -2017,6 +2253,8 @@ export function createTools(ctx) {
2017
2253
  savedHost = provenHost;
2018
2254
  if (saved?.appKey !== undefined)
2019
2255
  perApp = true;
2256
+ if (saved?.appKey !== undefined && typeof saved.intendedMethod === 'string')
2257
+ appMethods.set(String(saved.appKey), saved.intendedMethod);
2020
2258
  if (saved && 'intendedMethod' in saved)
2021
2259
  hostMethod = saved.intendedMethod === 'preview-deploy';
2022
2260
  for (const key of Object.keys(object(p.body) ?? {}))
@@ -2041,10 +2279,18 @@ export function createTools(ctx) {
2041
2279
  // A test account never comes back to the model, even if the API echoed it.
2042
2280
  return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
2043
2281
  }),
2044
- 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. 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) => {
2282
+ 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) => {
2045
2283
  // A skip never stops the run, so every question offers one.
2046
2284
  const asked = p;
2285
+ if (asked.id === APPS_QUESTION.id && appsRepo)
2286
+ return appsQuestion(pi);
2047
2287
  const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
2288
+ // 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.
2289
+ const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
2290
+ if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
2291
+ const projects = [...vercelDeployed.values()].filter(Boolean);
2292
+ 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.`;
2293
+ }
2048
2294
  if (host?.[2] === 'setup')
2049
2295
  return JSON.stringify(await hostSignIn(host[1], pi, signal));
2050
2296
  if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
@@ -2064,7 +2310,8 @@ export function createTools(ctx) {
2064
2310
  ctx.log.write('ask hidden (names a credential)', q.id);
2065
2311
  if (q.kind === 'text' && !q.sensitive)
2066
2312
  q.caution = NEVER_A_CREDENTIAL;
2067
- const answer = await askPerson(q);
2313
+ // The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
2314
+ const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
2068
2315
  if (answer.skipped) {
2069
2316
  // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
2070
2317
  if (host?.[1] === 'vercel')
@@ -2112,11 +2359,18 @@ export function createTools(ctx) {
2112
2359
  return `Refused: ${found.problem}.`;
2113
2360
  drive.url = found.url;
2114
2361
  // 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).
2115
- const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
2362
+ const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2116
2363
  const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
2117
2364
  if (files.length && files.every(({ status }) => status === 'A')) {
2118
2365
  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.`;
2119
2366
  }
2367
+ // 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.
2368
+ if (appsRepo && pickedApps) {
2369
+ const folders = (await Promise.all([...pickedApps].map((key) => appFolder(pi, key)))).filter((folder) => folder !== undefined);
2370
+ const outside = files.filter(({ status, path }) => status === 'A' && !folders.some((folder) => !folder || path.startsWith(`${folder}/`))).map(({ path }) => path);
2371
+ if (outside.length)
2372
+ 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.`;
2373
+ }
2120
2374
  let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
2121
2375
  if (setup) {
2122
2376
  // The setup pull request carries the preview attempts, so its binding cannot change.
@@ -2146,6 +2400,9 @@ export function createTools(ctx) {
2146
2400
  const control = left.find((line) => plainText(line) !== line);
2147
2401
  if (control)
2148
2402
  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.`;
2403
+ const pending = lockRefusal(undefined, 'saved');
2404
+ if (pending)
2405
+ return `${pending} Call impeccable_report again once it answered.`;
2149
2406
  // The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
2150
2407
  const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
2151
2408
  if (open.length)
@@ -2159,6 +2416,10 @@ export function createTools(ctx) {
2159
2416
  const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
2160
2417
  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.'}`;
2161
2418
  }
2419
+ // On a repository with several apps, how this run left each app, from what it saved and proved (#286).
2420
+ const apps = appsRepo && pickedApps ? await appOutcomes(pi) : undefined;
2421
+ if (apps)
2422
+ markdown += appsSection(apps);
2162
2423
  if (previews.active && retry.pr) {
2163
2424
  await observe(signal);
2164
2425
  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(', ')}.` : ''}`);
@@ -2182,24 +2443,60 @@ export function createTools(ctx) {
2182
2443
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
2183
2444
  }
2184
2445
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2185
- ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}) });
2446
+ ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
2186
2447
  pi.shutdown();
2187
2448
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2188
2449
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
2189
2450
  }),
2190
2451
  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) => {
2191
2452
  const branch = p.branch;
2192
- const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
2453
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2454
+ if (refused)
2455
+ return refused;
2456
+ // The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
2457
+ const reads = new Map();
2458
+ const vercel = (args) => {
2459
+ const key = JSON.stringify(args);
2460
+ if (!reads.has(key))
2461
+ reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
2462
+ return reads.get(key);
2463
+ };
2464
+ for (const origin of listedPreviews)
2465
+ listedApps.delete(origin);
2466
+ listedPreviews.clear();
2467
+ vercelMade.clear();
2468
+ publicProduction.clear();
2469
+ // On a repository with several apps, the app's own project (#281): the one whose Root Directory is the app's folder, never the newest deployment's or the root link's.
2470
+ const appKey = appsRepo ? p.appKey : undefined;
2471
+ const app = appKey !== undefined ? await vercelApp(pi, appKey, vercel) : undefined;
2472
+ if (app && 'reason' in app)
2473
+ return JSON.stringify({ appKey, deployments: [], unmatched: app.reason });
2474
+ const raw = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
2475
+ // Vercel names each project's deployments on GitHub "Preview – <project>"; one without a name is told by the project's own record of it.
2476
+ const owned = async (d) => {
2477
+ if (!app)
2478
+ return true;
2479
+ if (!d.vercel || !d.url)
2480
+ return false;
2481
+ const named = /^(?:preview|production)\s[–-]\s(.+)$/i.exec(d.environment ?? '')?.[1];
2482
+ return named !== undefined ? named === app.name : projectMadeDeployment(vercel, app.link, app.project, new URL(d.url).host);
2483
+ };
2484
+ const read = async () => {
2485
+ const evidence = await raw();
2486
+ if (!app)
2487
+ return evidence;
2488
+ const kept = await Promise.all(evidence.deployments.map(owned));
2489
+ return { ...evidence, deployments: evidence.deployments.filter((_, i) => kept[i]) };
2490
+ };
2193
2491
  // With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
2194
2492
  // project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
2195
2493
  const isPreview = (d) => /preview/i.test(d.environment ?? '');
2196
2494
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2197
2495
  // A head's build is still building until it has a final status.
2198
2496
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
2199
- 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'), vercelProject), signal) : await read();
2200
- listedPreviews.clear();
2201
- vercelMade.clear();
2202
- publicProduction.clear();
2497
+ 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();
2498
+ /** The project the evidence is of: the app's, else the one this folder is linked to. */
2499
+ const link = async () => (app ? app.link : readLink(pi.cwd));
2203
2500
  /** 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. */
2204
2501
  let locked;
2205
2502
  for (const d of evidence.deployments) {
@@ -2209,8 +2506,8 @@ export function createTools(ctx) {
2209
2506
  // A pushed branch's Production deployment is its preview only as the locked project's own: under Standard Protection a project's own address serves it to anyone, and another project of this repository may have made it.
2210
2507
  if (branch !== undefined && !isPreview(d)) {
2211
2508
  locked ??= await (async () => {
2212
- const link = await readLink(pi.cwd);
2213
- return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link, repository) : new Set();
2509
+ const linked = await link();
2510
+ return linked ? lockedDeploymentHosts(vercel, linked, repository) : new Set();
2214
2511
  })();
2215
2512
  if (!locked.has(url.host)) {
2216
2513
  publicProduction.add(url.origin);
@@ -2220,39 +2517,48 @@ export function createTools(ctx) {
2220
2517
  listedPreviews.add(url.origin);
2221
2518
  if (d.vercel)
2222
2519
  vercelMade.add(url.origin);
2520
+ if (app)
2521
+ listedApps.set(url.origin, { appKey: appKey, link: app.link, name: app.name });
2223
2522
  }
2224
- // What vercel.json and the linked project say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
2225
- // The fact and the live app read the same linked project: one `vercel api` call answers both.
2226
- const reads = new Map();
2227
- const vercel = (args) => {
2228
- const key = JSON.stringify(args);
2229
- if (!reads.has(key))
2230
- reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
2231
- return reads.get(key);
2232
- };
2523
+ // 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).
2524
+ if (branch === undefined) {
2525
+ if (evidence.deployments.some((d) => d.vercel && d.state === 'success'))
2526
+ vercelDeployed.set(appKey ?? '', app?.name ?? '');
2527
+ else
2528
+ vercelDeployed.delete(appKey ?? '');
2529
+ }
2530
+ // 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.
2233
2531
  if (branch === undefined) {
2234
- const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel);
2532
+ const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
2235
2533
  if (fact) {
2236
2534
  previewFacts.set('vercel', fact);
2237
2535
  evidence.pullRequestPreviews = fact;
2238
2536
  }
2239
2537
  }
2240
2538
  // The live app, where GitHub's record shows a production deployment Vercel made: its own address there is behind Vercel's login under Standard Protection, so the
2241
- // linked project is read for its alias, the project's own domain, and only as this repository's project.
2539
+ // project is read for its alias, the project's own domain, and only as this repository's project.
2242
2540
  let production;
2243
2541
  if (branch === undefined && evidence.deployments.some((d) => d.vercel && d.state === 'success' && !isPreview(d))) {
2244
- const link = await readLink(pi.cwd);
2245
- const project = link ? await readOwnProject(vercel, link, repository) : null;
2542
+ const linked = await link();
2543
+ const project = linked ? await readOwnProject(vercel, linked, repository) : null;
2246
2544
  production = project && !('reason' in project) ? project.productionUrl : undefined;
2247
- if (production && link)
2248
- vercelProduction = { origin: new URL(production).origin, projectId: link.projectId };
2249
- noteProduction(production, 'vercel');
2545
+ if (production && linked)
2546
+ vercelProductions.set(new URL(production).origin, linked.projectId);
2547
+ noteProduction(production, 'vercel', undefined, appKey);
2250
2548
  }
2251
- return JSON.stringify({ ...evidence, ...(production ? { production } : {}) });
2549
+ return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
2252
2550
  }),
2253
2551
  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) => {
2254
2552
  const pull = p.pull;
2255
- const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
2553
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2554
+ if (refused)
2555
+ return refused;
2556
+ // On a repository with several apps, only the site that builds the app's folder (#281).
2557
+ const appKey = appsRepo ? p.appKey : undefined;
2558
+ const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
2559
+ if (appKey !== undefined && directory === undefined)
2560
+ return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
2561
+ const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, ...(directory !== undefined ? { directory } : {}) });
2256
2562
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2257
2563
  const states = ['success', 'ready', 'failure', 'error'];
2258
2564
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
@@ -2262,21 +2568,42 @@ export function createTools(ctx) {
2262
2568
  const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
2263
2569
  return site ? `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews` : 'https://app.netlify.com/';
2264
2570
  }, signal) : await read();
2571
+ for (const origin of netlifyListed)
2572
+ listedApps.delete(origin);
2265
2573
  netlifyListed.clear();
2266
- for (const p of evidence.previews)
2267
- if (p.state === 'success' || p.state === 'ready')
2268
- netlifyListed.add(new URL(p.url).origin);
2574
+ for (const p of evidence.previews) {
2575
+ if (p.state !== 'success' && p.state !== 'ready')
2576
+ continue;
2577
+ netlifyListed.add(new URL(p.url).origin);
2578
+ if (appKey !== undefined)
2579
+ listedApps.set(new URL(p.url).origin, { appKey });
2580
+ }
2269
2581
  if (evidence.pullRequestPreviews)
2270
2582
  previewFacts.set('netlify', evidence.pullRequestPreviews);
2271
- noteProduction(evidence.production, 'netlify', liveSite(evidence));
2272
- return JSON.stringify(evidence);
2583
+ noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
2584
+ return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2273
2585
  }),
2274
2586
  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) => {
2275
2587
  const pull = p.pull;
2276
2588
  const branch = p.branch;
2277
2589
  if (pull !== undefined && branch !== undefined)
2278
2590
  return 'Invalid input: give pull or branch, not both.';
2279
- const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
2591
+ const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
2592
+ if (refused)
2593
+ return refused;
2594
+ // 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.
2595
+ const appKey = appsRepo ? p.appKey : undefined;
2596
+ const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
2597
+ if (appKey !== undefined && directory === undefined)
2598
+ return JSON.stringify({ appKey, previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
2599
+ const appProject = appKey !== undefined ? cloudflareApps.get(appKey) : undefined;
2600
+ if (appKey !== undefined && (pull !== undefined || branch !== undefined) && !appProject)
2601
+ return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
2602
+ 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 } : {}) });
2603
+ const read = async () => {
2604
+ const evidence = await readAll();
2605
+ return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
2606
+ };
2280
2607
  // Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
2281
2608
  // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2282
2609
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
@@ -2289,18 +2616,24 @@ export function createTools(ctx) {
2289
2616
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2290
2617
  if (!subject)
2291
2618
  cloudflareEvidence = evidence;
2619
+ if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project)
2620
+ cloudflareApps.set(appKey, { project: evidence.project, label: (evidence.production && pagesProject(evidence.production)) || evidence.project });
2621
+ for (const origin of cloudflareListed)
2622
+ listedApps.delete(origin);
2292
2623
  cloudflareListed.clear();
2293
2624
  for (const preview of evidence.previews) {
2294
2625
  if (preview.state !== 'success')
2295
2626
  continue;
2296
- cloudflareListed.add(new URL(preview.url).origin);
2297
- if (preview.alias)
2298
- cloudflareListed.add(new URL(preview.alias).origin);
2627
+ for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
2628
+ cloudflareListed.add(new URL(address).origin);
2629
+ if (appKey !== undefined)
2630
+ listedApps.set(new URL(address).origin, { appKey });
2631
+ }
2299
2632
  }
2300
2633
  if (evidence.pullRequestPreviews)
2301
2634
  previewFacts.set('cloudflare', evidence.pullRequestPreviews);
2302
- noteProduction(evidence.production, 'cloudflare');
2303
- return JSON.stringify(evidence);
2635
+ noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
2636
+ return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2304
2637
  }),
2305
2638
  tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
2306
2639
  const url = p.url;
@@ -2321,7 +2654,7 @@ export function createTools(ctx) {
2321
2654
  const proof = await provePreview(url, p.expect, pi.cwd, {
2322
2655
  fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
2323
2656
  // The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
2324
- ...(bypassSecret && (bypassOrigins.has(origin) || (live && origin === vercelProduction?.origin && vercelProduction.projectId === bypassProject)) ? { bypass: bypassSecret } : {}),
2657
+ ...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
2325
2658
  ...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
2326
2659
  ...(token ? { access: token } : {}),
2327
2660
  });
@@ -2331,15 +2664,29 @@ export function createTools(ctx) {
2331
2664
  provenLive.add(origin);
2332
2665
  return JSON.stringify(proof);
2333
2666
  }
2667
+ // On a repository with several apps, the app the address was listed for: its proof, its lock and its key are that app's alone (#281).
2668
+ const app = listedApps.get(origin);
2334
2669
  hostedPreviewProven ||= proof.proven;
2335
2670
  if (proof.proven)
2336
2671
  provenHost = previewHostName(url) ?? provenHost;
2672
+ if (proof.proven && app)
2673
+ provenApps.add(app.appKey);
2674
+ if (proof.proven && app)
2675
+ lockPending.delete(app.appKey);
2676
+ // 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.
2677
+ if (app && proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
2678
+ lockMet.set(app.appKey, proof.gate);
2679
+ lockPending.set(app.appKey, proof.gate);
2680
+ provenApps.delete(app.appKey);
2681
+ }
2337
2682
  if (proof.proven && proof.pattern)
2338
2683
  provenPattern = proof.pattern;
2339
2684
  if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
2340
- gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}) };
2685
+ gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
2341
2686
  if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
2342
- accessGated = { url, expect: p.expect };
2687
+ accessGated = { url, expect: p.expect, ...(app ? { app } : {}) };
2688
+ if (app)
2689
+ return JSON.stringify({ ...proof, appKey: app.appKey });
2343
2690
  return JSON.stringify(proof);
2344
2691
  }),
2345
2692
  tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
@@ -2455,90 +2802,112 @@ export function createTools(ctx) {
2455
2802
  return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
2456
2803
  }),
2457
2804
  tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
2805
+ const refused = appRefusal(undefined, 'connected', false);
2806
+ if (refused)
2807
+ return refused;
2458
2808
  const answer = await vercelConnect(pi, signal);
2459
2809
  // A project that deploys production already names the live app's address, for the before.
2460
2810
  const link = answer.productionUrl ? await readLink(pi.cwd) : null;
2461
2811
  if (answer.productionUrl && link)
2462
- vercelProduction = { origin: new URL(answer.productionUrl).origin, projectId: link.projectId };
2463
- noteProduction(answer.productionUrl, 'vercel');
2812
+ vercelProductions.set(new URL(answer.productionUrl).origin, link.projectId);
2813
+ noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
2464
2814
  return JSON.stringify(answer);
2465
2815
  }),
2466
2816
  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) 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) => {
2467
2817
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2468
2818
  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.';
2469
- if (bypassOutcome?.status === 'skipped')
2470
- return JSON.stringify(bypassOutcome);
2471
2819
  const at = gated;
2820
+ if (at.app)
2821
+ lockPending.delete(at.app.appKey);
2472
2822
  const { origin, host } = new URL(at.url);
2473
- // Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project the
2474
- // link file names, read as the person and connected to this repository, has to have made the deployment before any secret of it goes there.
2475
- const link = await readLink(pi.cwd);
2823
+ // Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
2824
+ const store = at.app?.appKey ?? '';
2825
+ const asked = bypassOutcomes.get(store);
2826
+ if (asked?.status === 'skipped')
2827
+ return JSON.stringify(asked);
2828
+ // 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
2829
+ // 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.
2830
+ const link = at.app?.link ?? (await readLink(pi.cwd));
2476
2831
  if (!link)
2477
2832
  return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
2478
2833
  // 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.
2834
+ // An app's project is read from Vercel's list of this repository's projects, never from the file.
2479
2835
  const links = linkedByRun(ctx.log);
2480
2836
  const own = links.get(link.projectId);
2481
- if ([...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
2837
+ if (!at.app && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
2482
2838
  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.`;
2483
2839
  const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
2484
2840
  const project = await readOwnProject(vercel, link, repository);
2841
+ const which = at.app ? 'The Vercel project that builds this app' : 'The Vercel project this folder is linked to';
2485
2842
  if ('reason' in project)
2486
2843
  return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
2487
2844
  // 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.
2488
2845
  if (!project.previewsProtected)
2489
2846
  return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
2490
2847
  if (!(await projectMadeDeployment(vercel, link, project, host)))
2491
- return `Refused: nothing was sent to ${at.url}. The Vercel project this folder is linked to${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview the linked project serves.`;
2492
- // A secret in hand already: another address of the same project opens with it.
2493
- if (bypassSaved && bypassSecret && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: bypassSecret })).proven) {
2494
- bypassOrigins.add(origin);
2495
- return JSON.stringify(bypassSaved);
2848
+ return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app ? 'that project' : 'the linked project'} serves.`;
2849
+ /** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
2850
+ const opened = () => {
2851
+ bypassOrigins.set(origin, store);
2852
+ hostedPreviewProven = true;
2853
+ provenHost = 'Vercel';
2854
+ if (at.app)
2855
+ provenApps.add(at.app.appKey);
2856
+ };
2857
+ // A secret in hand already for the same store and project: another address of the project opens with it.
2858
+ const held = bypassHeld.get(store);
2859
+ if (held && held.projectId === link.projectId && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: held.secret })).proven) {
2860
+ opened();
2861
+ return JSON.stringify(held.saved);
2496
2862
  }
2497
2863
  /** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
2498
2864
  let stored = false;
2499
2865
  const { value, ...told } = await passVercelLogin({
2500
2866
  run: vercel,
2501
2867
  link: async () => link,
2868
+ // 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.
2869
+ ...(at.app?.name ? { project: at.app.name } : {}),
2502
2870
  consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
2503
2871
  paste: async () => {
2504
- // The linked project's own settings page, in its team where the run knows it; else Vercel's picker.
2872
+ // The project's own settings page, in its team where the run knows it; else Vercel's picker.
2505
2873
  (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
2506
2874
  const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
2507
2875
  return answer.skipped ? undefined : answer.value?.trim();
2508
2876
  },
2509
2877
  prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
2510
2878
  save: async (secret) => {
2511
- const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
2879
+ const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
2512
2880
  stored = saved.stored;
2513
2881
  return saved.failed;
2514
2882
  },
2515
2883
  remember: noteSensitiveAnswer,
2516
2884
  });
2517
- bypassOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
2885
+ const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
2886
+ bypassOutcomes.set(store, outcome);
2518
2887
  if (value) {
2519
2888
  // What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
2520
- bypassSecret = value;
2521
- bypassProject = link.projectId;
2522
- bypassOrigins.clear();
2523
- bypassOrigins.add(origin);
2524
- hostedPreviewProven = true;
2525
- provenHost = 'Vercel';
2526
- bypassSaved = bypassOutcome;
2889
+ for (const [address, of] of bypassOrigins)
2890
+ if (of === store)
2891
+ bypassOrigins.delete(address);
2892
+ bypassHeld.set(store, { secret: value, projectId: link.projectId, saved: outcome });
2893
+ opened();
2527
2894
  }
2528
2895
  else if (told.status === 'save-failed' && stored) {
2529
2896
  // The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
2530
- bypassSecret = undefined;
2531
- bypassProject = undefined;
2532
- bypassOrigins.clear();
2533
- bypassSaved = undefined;
2897
+ bypassHeld.delete(store);
2898
+ for (const [address, of] of bypassOrigins)
2899
+ if (of === store)
2900
+ bypassOrigins.delete(address);
2534
2901
  }
2535
- return JSON.stringify(bypassOutcome);
2902
+ return JSON.stringify(outcome);
2536
2903
  }),
2537
2904
  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) => {
2538
2905
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2539
2906
  if (!gated || !site)
2540
2907
  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.';
2541
2908
  const at = gated;
2909
+ if (at.app)
2910
+ lockPending.delete(at.app.appKey);
2542
2911
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2543
2912
  let pattern;
2544
2913
  const prove = async (password, form = false) => {
@@ -2552,6 +2921,8 @@ export function createTools(ctx) {
2552
2921
  // A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
2553
2922
  if ((await prove(undefined)).proven) {
2554
2923
  provenPattern = pattern ?? provenPattern;
2924
+ if (at.app)
2925
+ provenApps.add(at.app.appKey);
2555
2926
  return JSON.stringify(stands);
2556
2927
  }
2557
2928
  passwordStands.delete(site);
@@ -2561,6 +2932,8 @@ export function createTools(ctx) {
2561
2932
  // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
2562
2933
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2563
2934
  provenPattern = pattern ?? provenPattern;
2935
+ if (at.app)
2936
+ provenApps.add(at.app.appKey);
2564
2937
  return JSON.stringify(passwordSaved);
2565
2938
  }
2566
2939
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
@@ -2594,7 +2967,7 @@ export function createTools(ctx) {
2594
2967
  },
2595
2968
  prove,
2596
2969
  save: async (password) => {
2597
- const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
2970
+ const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password), at.app?.appKey);
2598
2971
  stored = saved.stored;
2599
2972
  return saved.failed;
2600
2973
  },
@@ -2607,6 +2980,8 @@ export function createTools(ctx) {
2607
2980
  // The site is public now: its previews open with nothing sent.
2608
2981
  hostedPreviewProven = true;
2609
2982
  provenHost = 'Netlify';
2983
+ if (at.app)
2984
+ provenApps.add(at.app.appKey);
2610
2985
  provenPattern = pattern ?? provenPattern;
2611
2986
  }
2612
2987
  if (value) {
@@ -2616,6 +2991,8 @@ export function createTools(ctx) {
2616
2991
  passwordSites.add(site);
2617
2992
  hostedPreviewProven = true;
2618
2993
  provenHost = 'Netlify';
2994
+ if (at.app)
2995
+ provenApps.add(at.app.appKey);
2619
2996
  provenPattern = pattern ?? provenPattern;
2620
2997
  passwordSaved = passwordOutcome;
2621
2998
  }
@@ -2630,21 +3007,25 @@ export function createTools(ctx) {
2630
3007
  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) => {
2631
3008
  if (!accessGated)
2632
3009
  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.';
3010
+ const at = accessGated;
3011
+ // The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
3012
+ if (at.app)
3013
+ lockPending.delete(at.app.appKey);
2633
3014
  if (accessOutcome?.status === 'skipped')
2634
3015
  return JSON.stringify(accessOutcome);
2635
- const at = accessGated;
2636
3016
  let proof;
2637
3017
  const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
2638
3018
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
2639
3019
  const save = async (token) => {
2640
- if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret } })).ok)
3020
+ const app = at.app ? { appKey: at.app.appKey } : {};
3021
+ if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret, ...app } })).ok)
2641
3022
  return 'The setup API did not store the service token.';
2642
3023
  // An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
2643
3024
  const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
2644
3025
  const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
2645
3026
  if (lock?.authMethod === 'access')
2646
3027
  return null;
2647
- if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
3028
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access', ...app } })).ok)
2648
3029
  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.';
2649
3030
  return null;
2650
3031
  };
@@ -2695,6 +3076,8 @@ export function createTools(ctx) {
2695
3076
  if (told.status === 'saved') {
2696
3077
  hostedPreviewProven = true;
2697
3078
  provenHost = 'Cloudflare Pages';
3079
+ if (at.app)
3080
+ provenApps.add(at.app.appKey);
2698
3081
  if (pattern)
2699
3082
  provenPattern = pattern;
2700
3083
  }