@impeccable/reviews 0.1.20 → 0.1.22

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
@@ -7,22 +7,22 @@ import { request } from 'node:http';
7
7
  import { createServer } from 'node:net';
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { defineTool } from '@earendil-works/pi-coding-agent';
10
- import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
10
+ import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
- import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifySamePassword, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
13
+ import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
14
14
  import { enableNetlifyPreviews, folderOf } from './host-previews.js';
15
15
  import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
16
16
  import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
17
17
  import { passCloudflareAccess } from './cloudflare-access.js';
18
18
  import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
19
- import { passNetlifyLock } from './netlify-password.js';
19
+ import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-password.js';
20
20
  import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
21
21
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
22
- import { fitsTask, stagesOf } from './stages.js';
22
+ import { fitsTask, stagesOf, workStarted } from './stages.js';
23
23
  import { redact } from './log.js';
24
24
  import { scan } from './scan.js';
25
- import { gitEnvironment } from './sandbox.js';
25
+ import { gitEnvironment, installEnvironment, ownFolder } from './sandbox.js';
26
26
  import { openBrowser } from './login.js';
27
27
  import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
28
28
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
@@ -245,7 +245,11 @@ const PULL_REQUEST = {
245
245
  };
246
246
  const TEST_PR = {
247
247
  ...PULL_REQUEST,
248
- 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.' } },
248
+ properties: {
249
+ ...PULL_REQUEST.properties,
250
+ appKey: { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app whose own test drive this is.' },
251
+ setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' },
252
+ },
249
253
  };
250
254
  /** An app's key as the setup state gives it. */
251
255
  const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
@@ -1547,8 +1551,10 @@ export function createTools(ctx) {
1547
1551
  const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
1548
1552
  const retry = previewRetryState(ctx.log);
1549
1553
  retry.repository ??= `${ctx.owner}/${ctx.repo}`;
1550
- /** The branches of the recorded test drive and setup pull request, as GitHub named them. */
1554
+ /** The branches of the recorded test drives (by app key on a repository with several apps, '' on one) and setup pull request, as GitHub named them. */
1551
1555
  let recordedBranches;
1556
+ /** On a repository with several apps, each app's recorded test drive, in the order they were recorded (impeccable-reviews#397). */
1557
+ const appDrives = new Map();
1552
1558
  retry.recordedPr ??= ctx.sync.payload.setup_pr;
1553
1559
  let memory = previewMemories.get(ctx.log);
1554
1560
  if (!memory) {
@@ -1699,15 +1705,14 @@ export function createTools(ctx) {
1699
1705
  /**
1700
1706
  * The Netlify site passwords saved in this run, by site, for the proof of that site's addresses to send; never for the
1701
1707
  * model. Each is one site's, so it goes only to that site's deploy previews, and a monorepo's sites keep theirs side by
1702
- * side (#384): with what the tool answered, whether the person typed it, and the apps it is saved for.
1708
+ * side (#384): with what the tool answered, and the apps it is saved for.
1703
1709
  */
1704
1710
  const sitePasswords = new Map();
1705
1711
  /** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, the team login kept after the retries stays, and a site made public stays public. Another site of the repository is read on its own. */
1706
1712
  const passwordStands = new Map();
1707
- /** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
1713
+ /** The sites whose password the person said yes to storing, and those whose password they said yes to replacing, each its own: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
1708
1714
  const passwordConsented = new Set();
1709
- /** The sites whose last typed password did not open the preview: the paste screen, asked again, says so first (#375). */
1710
- const passwordRefused = new Set();
1715
+ const passwordReplaceConsented = new Set();
1711
1716
  /** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
1712
1717
  let accessGated;
1713
1718
  /** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
@@ -1799,7 +1804,7 @@ export function createTools(ctx) {
1799
1804
  teamChoices.delete(host);
1800
1805
  endHostSetup(ctx.log, host);
1801
1806
  };
1802
- const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1807
+ const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel', cloudflare: 'Cloudflare' };
1803
1808
  /** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
1804
1809
  let vercelProject;
1805
1810
  /** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
@@ -1876,6 +1881,93 @@ export function createTools(ctx) {
1876
1881
  team: async () => ({ choices: teams.length > 1 ? await netlifyTeams(cli) : [] }),
1877
1882
  };
1878
1883
  };
1884
+ /** Each host's CLI by its npm package, as its install screens name it (#396). */
1885
+ const CLI_PACKAGES = { vercel: 'vercel', netlify: 'netlify-cli', cloudflare: 'cf' };
1886
+ /** What the person chose on a host's CLI install screen, by host: the run's install, or their own. */
1887
+ const cliInstalls = new Map();
1888
+ /**
1889
+ * A host's CLI that is not on this machine (#396): one screen naming the package, the pinned version and the global
1890
+ * install with npm, before anything installs it. On Install, `install` runs it and answers whether it completed (cf,
1891
+ * which only the tools run), or, where the install is the agent's (Vercel's and Netlify's, through the fence's pinned
1892
+ * rule), this opens that install and answers `agent`. On the person's own install, the command to run in another
1893
+ * terminal, the CLI looked for on their yes, and the steps again, saying so, while it is still missing; an install that
1894
+ * did not complete, the run's or the agent's, shows those steps too. Answers `installed` once `present` finds the CLI;
1895
+ * a stopped run throws.
1896
+ */
1897
+ async function installHostCli(host, present, install, signal) {
1898
+ const pkg = CLI_PACKAGES[host];
1899
+ const cli = { name: HOST_NAMES[host], pkg, version: HOST_CLI_VERSIONS[pkg], command: hostCliInstallCommand(pkg) };
1900
+ const FAILED = "The install didn't complete.";
1901
+ const MISSING = `The ${cli.name} CLI still isn't on this machine.`;
1902
+ const runInstall = async () => {
1903
+ cliInstalls.set(host, 'run');
1904
+ if (install === 'agent') {
1905
+ agreeToCliInstall(ctx.log, host);
1906
+ return 'agent';
1907
+ }
1908
+ said(`Installing ${pkg} ${cli.version}`);
1909
+ const done = (await install()) && (await present());
1910
+ signal?.throwIfAborted();
1911
+ return done ? 'installed' : undefined;
1912
+ };
1913
+ // No answer at all (the input ended, or the run stopped) is neither way on: nothing is installed, and nothing more asked.
1914
+ const answered = (answer) => {
1915
+ signal?.throwIfAborted();
1916
+ if (answer.closed)
1917
+ throw new Error(`No answer came on the ${cli.name} CLI's screen, so nothing was installed.`);
1918
+ return answer;
1919
+ };
1920
+ let note;
1921
+ const chose = cliInstalls.get(host);
1922
+ if (!chose) {
1923
+ const answer = answered(await ask(cliInstallScreen(host, cli)));
1924
+ if (answer.skipped)
1925
+ cliInstalls.set(host, 'person');
1926
+ else {
1927
+ const done = await runInstall();
1928
+ if (done)
1929
+ return done;
1930
+ note = FAILED;
1931
+ }
1932
+ }
1933
+ else
1934
+ note = chose === 'run' ? FAILED : MISSING;
1935
+ const wait = ctx.hostWait?.cli ?? { totalMs: 30_000, everyMs: 2_000 };
1936
+ for (;;) {
1937
+ const answer = answered(await ask(cliManualScreen(host, cli, note)));
1938
+ if (answer.skipped) {
1939
+ const done = await runInstall();
1940
+ if (done)
1941
+ return done;
1942
+ note = FAILED;
1943
+ continue;
1944
+ }
1945
+ // Looked for at once, then every so often for a while: an install in the other terminal may still be finishing.
1946
+ said(`Looking for the ${cli.name} CLI`);
1947
+ for (const end = Date.now() + wait.totalMs;; await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined)) {
1948
+ signal?.throwIfAborted();
1949
+ if (await present())
1950
+ return 'installed';
1951
+ if (Date.now() >= end)
1952
+ break;
1953
+ }
1954
+ cliInstalls.set(host, 'person');
1955
+ note = MISSING;
1956
+ }
1957
+ }
1958
+ /** The run's own install of a host CLI's package, for installHostCli: npm's pinned global install, from the command's own folder and npm cache, as the agent's install of one runs; whether it completed. */
1959
+ const npmInstall = (pkg, signal) => async () => {
1960
+ try {
1961
+ await (ctx.hostRunner ?? commandRunner)('npm', ownFolder(), { signal, timeoutMs: 600_000, env: installEnvironment() })(hostCliInstallArgs(pkg));
1962
+ return true;
1963
+ }
1964
+ catch {
1965
+ return false;
1966
+ }
1967
+ };
1968
+ /** cf, Cloudflare's CLI, which only the tools run: where it is missing here, its install is asked first (#396). */
1969
+ const cfPresent = async (pi, signal) => (await cfStatus(cfRunner({ checkout: pi.cwd }, ctx.hostRunner), signal)).cli !== 'missing';
1970
+ const installCf = (pi, signal) => installHostCli('cloudflare', () => cfPresent(pi, signal), npmInstall('cf', signal), signal);
1879
1971
  /**
1880
1972
  * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
1881
1973
  * already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
@@ -1906,10 +1998,14 @@ export function createTools(ctx) {
1906
1998
  }
1907
1999
  if (status.cli === 'signed-in')
1908
2000
  return signedIn();
1909
- // The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
2001
+ // The CLI's own screen first (#396). On Install the agent installs it through the fence's pinned rule, which that yes opens, and this is asked again; the person's own install, once found, goes on to the sign-in.
1910
2002
  if (status.cli === 'missing') {
1911
2003
  agreeToHostSetup(ctx.log, `${host}-setup`);
1912
- return { confirmed: true, cli: 'missing' };
2004
+ if ((await installHostCli(host, async () => (await cli.status()).cli !== 'missing', 'agent', signal)) === 'agent')
2005
+ return { confirmed: true, cli: 'missing' };
2006
+ status = await cli.status();
2007
+ if (status.cli === 'signed-in')
2008
+ return signedIn();
1913
2009
  }
1914
2010
  for (let attempt = 0;; attempt++) {
1915
2011
  said(`Opening the browser for ${HOST_NAMES[host]}'s login`);
@@ -2131,8 +2227,12 @@ export function createTools(ctx) {
2131
2227
  /** What the run did for each picked app, as its own tool calls found it, sent with the session whenever it changes (#2307). */
2132
2228
  const appSummaries = new Map();
2133
2229
  const sendApps = () => ctx.sync.update({ apps: [...appSummaries.values()].slice(0, 50) });
2230
+ /** Whether this run gives each app a test drive of its own: it sets up more than one app of a repository with several (impeccable-reviews#397). */
2231
+ const perAppDrives = () => appsRepo && (pickedApps?.size ?? 0) > 1;
2134
2232
  const pick = (keys) => {
2135
2233
  pickedApps = keys;
2234
+ // Each app this run sets up gets a test drive of its own (impeccable-reviews#397).
2235
+ retry.drivesAllowed = Math.max(1, keys.size);
2136
2236
  const before = new Map(appSummaries);
2137
2237
  appSummaries.clear();
2138
2238
  for (const key of keys)
@@ -2228,8 +2328,13 @@ export function createTools(ctx) {
2228
2328
  }
2229
2329
  return null;
2230
2330
  };
2331
+ /**
2332
+ * Why a host's lock tool may not run yet: the list still shows the repository being read, so its consent and the push
2333
+ * after it would go by on "Understanding your app" (#390). Null once that task is done.
2334
+ */
2335
+ const readingRefusal = (tool) => workStarted(ctx.sync.payload.tasks) ? null : `Refused: the task list shows no work past reading the repository yet, so the screen still says "Understanding your app". Call impeccable_progress first with Read the repository completed and Preview method in progress, then ${tool} again. Nothing was asked or sent.`;
2231
2336
  /** How this run left each app the state lists (#286), for the report and the last screen. */
2232
- const appOutcomes = async (pi) => {
2337
+ const appOutcomes = async (pi, stale = new Set()) => {
2233
2338
  const apps = knownApps ?? (await stateApps(pi)) ?? [];
2234
2339
  return apps.map(({ key, name, folder }) => {
2235
2340
  const method = appMethods.get(key);
@@ -2240,9 +2345,12 @@ export function createTools(ctx) {
2240
2345
  // Impeccable's own last check of its preview met a wall: its reviews will meet it too (#383).
2241
2346
  if (method !== undefined && checkWalls.has(key))
2242
2347
  return { key, name, folder, state: 'walled', left: `${name}: Impeccable's check of its preview met ${checkWalls.get(key)}; open its Settings.` };
2348
+ // Its test drive ran on the setup branch before its last change, so it shows nothing of what is merged (#397).
2349
+ if (method !== undefined && stale.has(key))
2350
+ return { key, name, folder, state: 'stale', left: `${name}: its test drive predates the setup pull request's last change; run impeccable-reviews again.` };
2243
2351
  if (method !== undefined && (method !== 'preview-deploy' || proven)) {
2244
- // With a test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
2245
- return ctx.sync.payload.test_pr && !checkedApps.has(key)
2352
+ // With its test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
2353
+ return appDrives.has(key) && !checkedApps.has(key)
2246
2354
  ? { key, name, folder, state: 'ready', unchecked: true, left: `${name}: Impeccable's check of its preview did not run here; the test drive's review shows whether it reaches it.` }
2247
2355
  : { key, name, folder, state: 'ready' };
2248
2356
  }
@@ -2261,6 +2369,8 @@ export function createTools(ctx) {
2261
2369
  return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2262
2370
  if (a.state === 'unset')
2263
2371
  return `- ${where}: not set up. No preview method was saved for it.`;
2372
+ if (a.state === 'stale')
2373
+ return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but its test drive was branched before the setup branch's last change, so it was left out.`;
2264
2374
  if (a.state === 'walled')
2265
2375
  return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but Impeccable's own check of its preview met ${checkWalls.get(a.key)}.`;
2266
2376
  return `- ${where}: not set up this run.`;
@@ -2585,28 +2695,49 @@ export function createTools(ctx) {
2585
2695
  reportTasks(tasks);
2586
2696
  return 'Saved.';
2587
2697
  }),
2588
- tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
2698
+ tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open; on a repository with several apps, each app\'s own test drive with its appKey, as it opens. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
2589
2699
  const drive = { number: p.number, url: p.url.trim() };
2590
2700
  const given = p.setup_pr;
2591
2701
  const setup = given && { number: given.number, url: given.url.trim() };
2592
2702
  if (setup?.number === drive.number)
2593
2703
  return `Refused: #${drive.number} cannot be both the test drive and the setup pull request.`;
2704
+ // A run that sets up several apps of a repository gives each a test drive of its own (impeccable-reviews#397); one that sets up one app, its one test drive.
2705
+ const several = perAppDrives();
2706
+ const sole = appsRepo && pickedApps?.size === 1 ? [...pickedApps][0] : undefined;
2707
+ const appKey = several || p.appKey !== undefined ? p.appKey : sole;
2708
+ if (appsRepo) {
2709
+ // The apps question comes first; then the app named, which only a run that sets up one app may leave out.
2710
+ const refused = appRefusal(appKey, 'recorded');
2711
+ if (refused)
2712
+ return refused;
2713
+ const other = [...appDrives].find(([key, pr]) => key !== appKey && pr.number === drive.number);
2714
+ if (other)
2715
+ return `Refused: #${drive.number} is ${appName(other[0])}'s test drive. Each app gets a test drive of its own, as the monorepo case says. Nothing was recorded.`;
2716
+ }
2717
+ const key = typeof appKey === 'string' ? appKey : '';
2594
2718
  const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
2595
2719
  if ('problem' in found)
2596
2720
  return `Refused: ${found.problem}.`;
2597
2721
  drive.url = found.url;
2722
+ if (several && [...(recordedBranches?.drives ?? [])].some(([other, branch]) => other !== key && branch === found.branch)) {
2723
+ return `Refused: ${found.branch} is another app's test drive. Each app's test drive comes from a branch of its own (impeccable/test-drive-<appKey>), as the monorepo case says. Nothing was recorded.`;
2724
+ }
2598
2725
  // 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).
2599
- const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2726
+ const changes = recordedBranches?.drives.get(key) !== found.branch && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2600
2727
  const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
2601
2728
  if (files.length && files.every(({ status }) => status === 'A')) {
2602
2729
  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.`;
2603
2730
  }
2604
- // 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.
2731
+ // On a repository with several apps, an app's test drive adds its page to that app's own folder, and no other app is touched (#286, #397): a file it adds anywhere else, or changes in another app's (a root app's included), is refused, whatever its name. A file belongs to the app whose folder holds it most closely; one no app's folder holds (a shared root file) is no app's. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
2605
2732
  if (appsRepo && pickedApps) {
2606
- const folders = (await Promise.all([...pickedApps].map((key) => appFolder(pi, key)))).filter((folder) => folder !== undefined);
2607
- const outside = files.filter(({ status, path }) => status === 'A' && !folders.some((folder) => !folder || path.startsWith(`${folder}/`))).map(({ path }) => path);
2733
+ const apps = knownApps ?? (await stateApps(pi)) ?? [];
2734
+ const ownerOf = (path) => apps.filter((a) => !a.folder || path.startsWith(`${a.folder}/`)).sort((a, b) => b.folder.length - a.folder.length)[0];
2735
+ const outside = files.filter(({ status, path }) => {
2736
+ const owner = ownerOf(path);
2737
+ return status === 'A' ? owner?.key !== key : owner !== undefined && owner.key !== key;
2738
+ }).map(({ path }) => path);
2608
2739
  if (outside.length)
2609
- 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.`;
2740
+ return `Refused: ${found.branch} changes ${outside.join(', ')}, outside the folder of ${appName(key)} (${(await appFolder(pi, key)) || '/'}). Each app's test drive adds its page to its own app and touches no other, as the monorepo case says: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2610
2741
  }
2611
2742
  let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
2612
2743
  if (setup) {
@@ -2621,10 +2752,14 @@ export function createTools(ctx) {
2621
2752
  setupBranch = head.branch;
2622
2753
  setupBase = head.base;
2623
2754
  }
2624
- ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
2625
- recordedBranches = { drive: found.branch, setup: setupBranch, setupBase };
2755
+ if (appsRepo)
2756
+ appDrives.set(key, drive);
2757
+ // The first app's test drive stays the one the web page's status follows; each app's goes beside it, by its key.
2758
+ const first = several ? appDrives.values().next().value : drive;
2759
+ ctx.sync.update({ test_pr: first, ...(several ? { test_prs: Object.fromEntries(appDrives) } : {}), ...(setup ? { setup_pr: setup } : {}) });
2760
+ recordedBranches = { drives: new Map(recordedBranches?.drives).set(key, found.branch), setup: setupBranch, setupBase };
2626
2761
  const res = await ctx.sync.flush();
2627
- const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
2762
+ const what = `${several ? `${appName(key)}'s test drive` : 'the test drive'} #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
2628
2763
  return res?.ok
2629
2764
  ? `Recorded ${what}. The web page follows the test drive's review from now on.`
2630
2765
  : `Recorded ${what} here, but the web page did not take it yet (${res?.error ?? 'no answer'}). The report links them too.`;
@@ -2653,8 +2788,22 @@ export function createTools(ctx) {
2653
2788
  const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
2654
2789
  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.'}`;
2655
2790
  }
2656
- // On a repository with several apps, how this run left each app, from what it saved and proved (#286).
2657
- const apps = appsRepo && pickedApps ? await appOutcomes(pi) : undefined;
2791
+ // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one. On a repository with several apps, each app's is judged on its own.
2792
+ const covers = async (key) => {
2793
+ const branch = recordedBranches?.drives.get(key);
2794
+ // An app whose host builds its previews needs nothing of the setup branch: its test drive (from the default branch, in its host's setup case) stands (#397).
2795
+ if (appsRepo && appMethods.get(key) === 'preview-deploy')
2796
+ return true;
2797
+ return !recordedBranches?.setup || !branch || (await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, branch)) !== false;
2798
+ };
2799
+ const kept = [];
2800
+ for (const [key, pr] of appDrives)
2801
+ if (await covers(key))
2802
+ kept.push([key, pr]);
2803
+ const several = perAppDrives();
2804
+ const drive = several ? kept[0]?.[1] : ctx.sync.payload.test_pr && (await covers(appsRepo ? [...appDrives.keys()][0] ?? '' : '')) ? ctx.sync.payload.test_pr : undefined;
2805
+ // On a repository with several apps, how this run left each app, from what it saved and proved (#286); one whose test drive was left out is not set up (#397).
2806
+ const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
2658
2807
  if (apps)
2659
2808
  markdown += appsSection(apps);
2660
2809
  if (previews.active && retry.pr) {
@@ -2666,11 +2815,7 @@ export function createTools(ctx) {
2666
2815
  if (!left.includes('Watch the review on the test drive'))
2667
2816
  left.push('Watch the review on the test drive');
2668
2817
  }
2669
- const { phase, status, question, report_markdown, test_pr, left: leftBefore } = ctx.sync.payload;
2670
- // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
2671
- const covered = test_pr && recordedBranches?.setup
2672
- ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
2673
- const drive = covered === false ? undefined : test_pr;
2818
+ const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
2674
2819
  // Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
2675
2820
  const appsBefore = new Map(appSummaries);
2676
2821
  for (const { appKey, reason } of p.apps ?? []) {
@@ -2680,11 +2825,11 @@ export function createTools(ctx) {
2680
2825
  noteApp(appKey, { reason: plain });
2681
2826
  }
2682
2827
  // The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
2683
- ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
2828
+ ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
2684
2829
  const res = await ctx.sync.flush();
2685
2830
  if (res && res.status >= 400 && res.status < 500) {
2686
2831
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
2687
- ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
2832
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore });
2688
2833
  // Its reasons go with it: the report sent again gives its own.
2689
2834
  if (appsBefore.size) {
2690
2835
  for (const [key, was] of appsBefore)
@@ -2695,7 +2840,7 @@ export function createTools(ctx) {
2695
2840
  }
2696
2841
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2697
2842
  const hosts = hostLines(hostsUsed());
2698
- ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
2843
+ ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
2699
2844
  pi.shutdown();
2700
2845
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2701
2846
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
@@ -2854,7 +2999,7 @@ export function createTools(ctx) {
2854
2999
  noteApp(appKey, { host_project: site });
2855
3000
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2856
3001
  }),
2857
- 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) => {
3002
+ 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; where cf is not installed here, the person is asked to install it first, on its own screen). 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) => {
2858
3003
  const pull = p.pull;
2859
3004
  const branch = p.branch;
2860
3005
  if (pull !== undefined && branch !== undefined)
@@ -2885,12 +3030,19 @@ export function createTools(ctx) {
2885
3030
  // unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
2886
3031
  const app = cloudflareEvidence?.githubApp;
2887
3032
  const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false } : undefined;
2888
- const waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3033
+ let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3034
+ // cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
3035
+ if (!subject && waited.cli === 'missing') {
3036
+ await installCf(pi, signal);
3037
+ waited = await read();
3038
+ }
2889
3039
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2890
3040
  // By the label the project's addresses carry.
2891
3041
  const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
2892
3042
  if (accountName && label && !evidence.unmatched)
2893
3043
  accountOf.set(`cloudflare ${label}`, accountName);
3044
+ if (accountName && evidence.account)
3045
+ accountOf.set(`cloudflare account ${evidence.account}`, accountName);
2894
3046
  if (!subject)
2895
3047
  cloudflareEvidence = evidence;
2896
3048
  if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
@@ -3029,6 +3181,7 @@ export function createTools(ctx) {
3029
3181
  if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
3030
3182
  return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
3031
3183
  said(`Turning on pull-request previews on ${name}`);
3184
+ // cf is installed here: the fact this acts on is one a read through cf found.
3032
3185
  const turnOn = async () => {
3033
3186
  if (host === 'netlify')
3034
3187
  return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
@@ -3081,6 +3234,11 @@ export function createTools(ctx) {
3081
3234
  const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
3082
3235
  const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
3083
3236
  let status = await cfStatus(cf, signal);
3237
+ // cf not on this machine (#396): its install is asked first.
3238
+ if (status.cli === 'missing') {
3239
+ await installCf(pi, signal);
3240
+ status = await cfStatus(cf, signal);
3241
+ }
3084
3242
  if (!writes(status)) {
3085
3243
  if (status.cli === 'missing' || status.cli === 'unknown')
3086
3244
  return answer('refused', { reason: "Cloudflare's CLI did not run here." });
@@ -3126,6 +3284,9 @@ export function createTools(ctx) {
3126
3284
  }),
3127
3285
  tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back. With beforePush (and the app\'s appKey on a repository with several apps), before the test drive\'s push, where no preview met Vercel\'s login yet (#356): it reads the project the same way, and where its settings show Deployment Protection in front of the previews and it holds no secret labelled "Impeccable reviews", shows the consent screen now, naming the project and the app, so the person answers before anything is pushed; the secret is made on the yes once the test drive\'s preview meets the login, with no second question. Answers {"protected": false} (no lock: push), {"protected": true, "consent": "given" or "not-needed"} (push, and call this again without beforePush once the proof meets the gate), {"protected": "unknown", "reason": …} (the project could not be read: push, and the consent comes when the gate is met) or {"status": "skipped", …} (push no test drive; reason says why).', VERCEL_BYPASS, async (p, pi, signal) => {
3128
3286
  const before = p.beforePush === true;
3287
+ const reading = readingRefusal('impeccable_vercel_bypass');
3288
+ if (reading)
3289
+ return reading;
3129
3290
  if (before) {
3130
3291
  const refused = appRefusal(p.appKey, 'asked');
3131
3292
  if (refused)
@@ -3134,6 +3295,9 @@ export function createTools(ctx) {
3134
3295
  else if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
3135
3296
  return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first, or call it with beforePush before the test drive\'s push.';
3136
3297
  const at = before ? undefined : gated;
3298
+ // The work after the 302 takes a while (the project's read, the secret, its proof): the screen says what it is (#390).
3299
+ if (at)
3300
+ said("Getting past Vercel's login");
3137
3301
  if (at?.app)
3138
3302
  lockPending.delete(at.app.appKey);
3139
3303
  const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
@@ -3309,11 +3473,15 @@ export function createTools(ctx) {
3309
3473
  }
3310
3474
  return JSON.stringify(outcome);
3311
3475
  }),
3312
- tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one (after one offering the password the person typed for another site of this run, where there is 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) => {
3476
+ tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen asking to set a new one, which replaces the site\'s current password, and on that yes sets a long random one on the site as the person with the Netlify CLI\'s sign-in (it never comes back, and nothing is pasted); 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 the site\'s existing one or a new one the run set, and site which Netlify site it is; replaced, on any answer, says the run set a new site password, which replaced the one before, saved or not), 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) => {
3313
3477
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
3314
3478
  if (!gated || !site)
3315
3479
  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.';
3480
+ const reading = readingRefusal('impeccable_netlify_password');
3481
+ if (reading)
3482
+ return reading;
3316
3483
  const at = gated;
3484
+ said("Getting past Netlify's password");
3317
3485
  if (at.app)
3318
3486
  lockPending.delete(at.app.appKey);
3319
3487
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
@@ -3354,62 +3522,85 @@ export function createTools(ctx) {
3354
3522
  }
3355
3523
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
3356
3524
  let stored = false;
3357
- /** The person typed a password on the paste screen in this call. */
3358
- let typed = false;
3359
- const { value, ...told } = await passNetlifyLock({
3360
- run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
3361
- site,
3362
- consent: async () => {
3363
- if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
3364
- return passwordConsented.add(site), true;
3365
- return false;
3366
- },
3367
- makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
3368
- ...(at.teamLogin ? { teamLogin: true } : {}),
3369
- // The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
3370
- signIn: async () => {
3371
- if (!hostSignIns.has('netlify'))
3372
- hostSignIns.set('netlify', 'confirmed');
3373
- return (await hostSignIn('netlify', pi, signal)).signedIn === true;
3374
- },
3375
- retry: async (page, first) => {
3376
- if (first)
3377
- (ctx.openBrowser ?? openBrowser)(page);
3378
- // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
3379
- const screen = HOST_SCREENS['netlify-visibility-retry'];
3380
- return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
3381
- },
3382
- paste: async () => {
3383
- // One password for several sites is common: the one typed for another site of this run is offered first, once, never after it was refused here (#384).
3384
- const other = passwordRefused.has(site) ? undefined : [...sitePasswords].find(([name, h]) => name !== site && h.typed);
3385
- if (other && !(await ask(netlifySamePassword(site, other[0]))).skipped) {
3386
- typed = true;
3387
- return other[1].value;
3388
- }
3389
- const screen = sentTo(HOST_SCREENS['netlify-password-key'], at.url);
3390
- const answer = await ask(passwordRefused.has(site) ? { ...screen, prompt: `The password typed last didn't open the preview on Netlify's form, so nothing was saved. Check it and type it again. ${screen.prompt}` } : screen);
3391
- typed = !answer.skipped;
3392
- return answer.skipped ? undefined : answer.value?.trim();
3393
- },
3394
- prove,
3395
- save: async (password) => {
3396
- // Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
3397
- const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
3398
- stored = saved.stored;
3399
- return saved.failed;
3400
- },
3401
- remember: noteSensitiveAnswer,
3402
- });
3525
+ /** Netlify took a new password the run set in this call: the end screen says so, saved or not, and on a cancel too. */
3526
+ let replaced = false;
3527
+ let answer;
3528
+ const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
3529
+ // The Netlify CLI not on this machine (#396): its install is asked first, the run's own since no agent's turn comes in between, and the command runs again.
3530
+ const run = async (args) => {
3531
+ try {
3532
+ return await netlify(args);
3533
+ }
3534
+ catch (err) {
3535
+ if (!err.missing)
3536
+ throw err;
3537
+ await installHostCli('netlify', async () => (await netlifyStatus(netlify)).cli !== 'missing', npmInstall('netlify-cli', signal), signal);
3538
+ return netlify(args);
3539
+ }
3540
+ };
3541
+ try {
3542
+ answer = await passNetlifyLock({
3543
+ run,
3544
+ site,
3545
+ consent: async () => {
3546
+ if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
3547
+ return passwordConsented.add(site), true;
3548
+ return false;
3549
+ },
3550
+ makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
3551
+ ...(at.teamLogin ? { teamLogin: true } : {}),
3552
+ // The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
3553
+ signIn: async () => {
3554
+ if (!hostSignIns.has('netlify'))
3555
+ hostSignIns.set('netlify', 'confirmed');
3556
+ return (await hostSignIn('netlify', pi, signal)).signedIn === true;
3557
+ },
3558
+ retry: async (page, first) => {
3559
+ if (first)
3560
+ (ctx.openBrowser ?? openBrowser)(page);
3561
+ // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
3562
+ const screen = HOST_SCREENS['netlify-visibility-retry'];
3563
+ return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
3564
+ },
3565
+ replace: async (live) => {
3566
+ if (passwordReplaceConsented.has(site) || !(await ask(netlifyPasswordReplace(site, { ...(at.app ? { app: appName(at.app.appKey) } : {}), live }))).skipped)
3567
+ return passwordReplaceConsented.add(site), true;
3568
+ return false;
3569
+ },
3570
+ // Netlify's API, called from this process with the token the CLI signed in with: the password never goes on a command line (#395).
3571
+ setPassword: (id, password) => {
3572
+ const token = (ctx.netlifyToken ?? netlifyToken)();
3573
+ if (token)
3574
+ noteSensitiveAnswer(token);
3575
+ return setSitePassword(id, password, { token, fetch: ctx.netlifyFetch ?? fetch, signal });
3576
+ },
3577
+ changed: () => void (replaced = true),
3578
+ ...(ctx.hostWait?.password ? { wait: ctx.hostWait.password } : {}),
3579
+ signal,
3580
+ prove,
3581
+ save: async (password) => {
3582
+ // Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
3583
+ const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
3584
+ stored = saved.stored;
3585
+ return saved.failed;
3586
+ },
3587
+ remember: noteSensitiveAnswer,
3588
+ });
3589
+ }
3590
+ finally {
3591
+ if (replaced) {
3592
+ ctx.onKeyCreated?.(answer?.status === 'saved'
3593
+ ? `Impeccable set a new Netlify site password on ${site} as you and saved it for reviews, so its previews stay password-protected. It replaced the one before, which no longer opens them: set one people know in Netlify (${NETLIFY_PASSWORD_WHERE}) any time, and put it in the app's Settings too, under "A way past your host's lock", or reviews stop.`
3594
+ : `Impeccable set a new Netlify site password on ${site} as you, which replaced the one before, but it was not saved for reviews. Set a password people know in Netlify (${NETLIFY_PASSWORD_WHERE}), and put it in the app's Settings too, under "A way past your host's lock".`, `netlify-password:${site}`);
3595
+ }
3596
+ }
3597
+ const { value, ...told } = answer;
3403
3598
  const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
3404
3599
  // Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321). Else they say which lock it is.
3405
3600
  if (told.protection === 'none' && lastLock === 'netlify-protection')
3406
3601
  lastLock = undefined;
3407
3602
  if (told.protection === 'password' || told.protection === 'team-login')
3408
3603
  lastTeamLogin = told.protection === 'team-login';
3409
- if (told.status === 'refused' && typed)
3410
- passwordRefused.add(site);
3411
- else if (told.status === 'saved')
3412
- passwordRefused.delete(site);
3413
3604
  if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
3414
3605
  passwordStands.set(site, passwordOutcome);
3415
3606
  if (told.status === 'opened') {
@@ -3422,8 +3613,13 @@ export function createTools(ctx) {
3422
3613
  provePattern(pattern, at.app?.appKey);
3423
3614
  }
3424
3615
  if (value) {
3616
+ // Said once it opened the preview and was saved, so the screen does not go straight back to the tasks (#390); the end screen names it and where it changes.
3617
+ said(`Password works: saved for ${at.app ? appName(at.app.appKey) : site}`);
3618
+ // A password the run set is named on the end screen as it was set, above. Either line takes the place of an earlier one for the site, such as a replaced password not saved.
3619
+ if (told.password !== 'set')
3620
+ ctx.onKeyCreated?.(`Impeccable saved the Netlify site password of ${site} for reviews, so its previews stay password-protected. A new password set in Netlify (${NETLIFY_PASSWORD_WHERE}) goes in the app's Settings too, under "A way past your host's lock", or reviews stop.`, `netlify-password:${site}`);
3425
3621
  // What is in hand follows the store: the password saved now is this site's, beside every other site's.
3426
- sitePasswords.set(site, { value, saved: passwordOutcome, typed: told.password === 'typed', apps: new Set([at.app?.appKey ?? '']) });
3622
+ sitePasswords.set(site, { value, saved: passwordOutcome, apps: new Set([at.app?.appKey ?? '']) });
3427
3623
  hostedPreviewProven = true;
3428
3624
  provenHost = 'Netlify';
3429
3625
  usedPreviews.add(at.url);
@@ -3440,7 +3636,11 @@ export function createTools(ctx) {
3440
3636
  tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, naming the Access application in front of the preview and its Cloudflare account once cf, signed in here, found it (none found: refused, and nothing asked), Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
3441
3637
  if (!accessGated)
3442
3638
  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.';
3639
+ const reading = readingRefusal('impeccable_cloudflare_access');
3640
+ if (reading)
3641
+ return reading;
3443
3642
  const at = accessGated;
3643
+ said('Getting past Cloudflare Access');
3444
3644
  // The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
3445
3645
  if (at.app)
3446
3646
  lockPending.delete(at.app.appKey);
@@ -3479,7 +3679,9 @@ export function createTools(ctx) {
3479
3679
  consent: async (found) => {
3480
3680
  if (found && accessConsented.has(found.id))
3481
3681
  return true;
3482
- const yes = !(await ask(sentTo(found ? cloudflareAccessConsent(found, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
3682
+ // The account by the name the last screen gives it, where a read found it; else its id (#390).
3683
+ const named = found && { ...found, account: accountOf.get(`cloudflare account ${found.account}`) ?? found.account };
3684
+ const yes = !(await ask(sentTo(named ? cloudflareAccessConsent(named, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
3483
3685
  if (yes && found)
3484
3686
  accessConsented.add(found.id);
3485
3687
  return yes;
@@ -3487,6 +3689,7 @@ export function createTools(ctx) {
3487
3689
  cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
3488
3690
  login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
3489
3691
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
3692
+ install: async () => void (await installCf(pi, signal)),
3490
3693
  status: said,
3491
3694
  apiToken: async () => {
3492
3695
  const typed = await ask(HOST_SCREENS['cloudflare-access-api']);