@impeccable/reviews 0.1.3 → 0.1.4

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,7 +7,7 @@ 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, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
13
  import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
@@ -16,7 +16,7 @@ import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js'
16
16
  import { passCloudflareAccess } from './cloudflare-access.js';
17
17
  import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
18
18
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
19
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
19
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
20
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
21
21
  import { fitsTask, stagesOf } from './stages.js';
22
22
  import { redact } from './log.js';
@@ -1419,26 +1419,37 @@ export function createTools(ctx) {
1419
1419
  /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
1420
1420
  let provenHost;
1421
1421
  let savedHost;
1422
+ /** Whether the repository's last saved method is a host's previews, and whether any write named an app: a monorepo's apps each go their own way, and only a run whose host builds them all opens no workflow and no setup pull request. */
1423
+ let hostMethod = false;
1424
+ let perApp = false;
1422
1425
  /**
1423
1426
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1424
1427
  * every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1425
1428
  */
1426
- async function awaitBuild(host, status, read, built, signal) {
1427
- let evidence = await read();
1428
- if (built(evidence))
1429
- return evidence;
1429
+ async function awaitBuild(host, status, read, built, page, signal) {
1430
+ const first = await read();
1431
+ if (built(first))
1432
+ return first;
1430
1433
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1431
- said(status);
1432
- reportWait({ since: Date.now(), host });
1433
- try {
1434
- for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1435
- await sleep(wait.everyMs, undefined, { signal });
1434
+ const waited = async (evidence) => {
1435
+ said(status);
1436
+ reportWait({ since: Date.now(), host });
1437
+ try {
1438
+ for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1439
+ await sleep(wait.everyMs, undefined, { signal });
1440
+ }
1436
1441
  }
1437
- }
1438
- finally {
1439
- reportWait(null);
1440
- }
1441
- return evidence;
1442
+ finally {
1443
+ reportWait(null);
1444
+ }
1445
+ return evidence;
1446
+ };
1447
+ // No build in the time: the host's page where a build is turned on, and the wait again on the person's yes.
1448
+ return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
1449
+ skipped: `The person stopped waiting for ${host}'s build.`,
1450
+ still: `${host} built no preview in the time.`,
1451
+ stopped: `The run stopped while ${host}'s build was waited for.`,
1452
+ }, signal);
1442
1453
  }
1443
1454
  /** What a wait for a pull request's build is for: the test drive's preview, or another pull request's. */
1444
1455
  const subjectOf = async (pull) => {
@@ -1458,7 +1469,7 @@ export function createTools(ctx) {
1458
1469
  /** 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. */
1459
1470
  let sitePassword;
1460
1471
  const passwordSites = new Set();
1461
- /** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, and the team login stays. Another site of the repository is read on its own. */
1472
+ /** 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. */
1462
1473
  const passwordStands = new Map();
1463
1474
  /** 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. */
1464
1475
  const passwordConsented = new Set();
@@ -1542,6 +1553,9 @@ export function createTools(ctx) {
1542
1553
  endHostSetup(ctx.log, host);
1543
1554
  };
1544
1555
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1556
+ /** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
1557
+ let vercelProject;
1558
+ let netlifySite;
1545
1559
  const hostCli = (host, pi, signal) => {
1546
1560
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
1547
1561
  const cli = runner();
@@ -1622,25 +1636,37 @@ export function createTools(ctx) {
1622
1636
  }
1623
1637
  }
1624
1638
  /**
1625
- * The one-time access a host's GitHub app needs when it cannot reach the repository: a screen that opens the app's page
1626
- * on GitHub, a wait of up to five minutes for the host to reach it (`reaches`, tried again every so often), a retry
1627
- * screen, and after two failed retries the connection left to the person.
1639
+ * What the person does on a host's page, asked in the run: the screen `screen` (then `<screen>-retry`) opens the page,
1640
+ * `again` waits up to five minutes for it to be done, trying every so often, and after two failed retries the step is
1641
+ * left undone with `words.still`. `pending` says it is not done yet; the run carries on either way.
1628
1642
  */
1629
- async function hostAccess(host, result, reaches, signal) {
1630
- const name = HOST_NAMES[host];
1631
- for (let retries = 0; result.access && !signal?.aborted; retries++) {
1632
- if ((await ask(HOST_SCREENS[retries ? `${host}-access-retry` : `${host}-access`])).skipped)
1633
- return { ...result, reason: `The person skipped giving ${name}'s GitHub app access to the repository.` };
1634
- (ctx.openBrowser ?? openBrowser)(result.access);
1635
- said(`Waiting for ${name} to reach the repository`);
1636
- result = await reaches();
1643
+ async function onHostPage(screen, page, pending, result, again, words, signal) {
1644
+ for (let retries = 0; pending(result) && !signal?.aborted; retries++) {
1645
+ if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped)
1646
+ return { ...result, reason: words.skipped };
1647
+ const at = page(result);
1648
+ if (at)
1649
+ (ctx.openBrowser ?? openBrowser)(at);
1650
+ if (words.waiting)
1651
+ said(words.waiting);
1652
+ result = await again(result);
1637
1653
  if (signal?.aborted)
1638
- return { ...result, reason: `The run stopped while ${name} was waited for.` };
1639
- if (result.access && retries === 2)
1640
- return { ...result, reason: `${name}'s GitHub app still cannot reach the repository.` };
1654
+ return { ...result, reason: words.stopped };
1655
+ if (pending(result) && retries === 2)
1656
+ return { ...result, reason: words.still };
1641
1657
  }
1642
1658
  return result;
1643
1659
  }
1660
+ /** The one-time access a host's GitHub app needs when it cannot reach the repository, given on the app's page on GitHub while `reaches` tries again. */
1661
+ function hostAccess(host, result, reaches, signal) {
1662
+ const name = HOST_NAMES[host];
1663
+ return onHostPage(`${host}-access`, (r) => r.access, (r) => Boolean(r.access), result, reaches, {
1664
+ waiting: `Waiting for ${name} to reach the repository`,
1665
+ skipped: `The person skipped giving ${name}'s GitHub app access to the repository.`,
1666
+ still: `${name}'s GitHub app still cannot reach the repository.`,
1667
+ stopped: `The run stopped while ${name} was waited for.`,
1668
+ }, signal);
1669
+ }
1644
1670
  /** The connection of the linked Netlify site with this repository, through Netlify's site settings and its GitHub app, with the app's access asked when it cannot read the repository. */
1645
1671
  async function netlifyConnect(pi, settings, signal) {
1646
1672
  // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
@@ -1650,6 +1676,7 @@ export function createTools(ctx) {
1650
1676
  const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1651
1677
  const clone = ctx.hostWait?.clone;
1652
1678
  const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
1679
+ netlifySite = result.site?.name ?? netlifySite;
1653
1680
  // A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
1654
1681
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1655
1682
  ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
@@ -1673,7 +1700,18 @@ export function createTools(ctx) {
1673
1700
  settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
1674
1701
  return result;
1675
1702
  }
1676
- const first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1703
+ let first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1704
+ vercelProject = first.project ?? vercelProject;
1705
+ // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
1706
+ if (link && first.production === 'none' && !first.locked) {
1707
+ const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
1708
+ first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
1709
+ waiting: "Waiting for Vercel's login on the project",
1710
+ skipped: 'The person skipped locking the Vercel project.',
1711
+ still: "Vercel's login is still not in front of the project's addresses.",
1712
+ stopped: 'The run stopped while the lock was waited for.',
1713
+ }, signal);
1714
+ }
1677
1715
  if (link && (first.production === 'exists' || first.locked === true))
1678
1716
  settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
1679
1717
  return { ...result, ...first };
@@ -1862,6 +1900,10 @@ export function createTools(ctx) {
1862
1900
  retry.requireHistory = true;
1863
1901
  if (object(p.body)?.intendedMethod === 'preview-deploy')
1864
1902
  savedHost = provenHost;
1903
+ if (saved?.appKey !== undefined)
1904
+ perApp = true;
1905
+ if (saved && 'intendedMethod' in saved)
1906
+ hostMethod = saved.intendedMethod === 'preview-deploy';
1865
1907
  for (const key of Object.keys(object(p.body) ?? {}))
1866
1908
  if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
1867
1909
  previews.changes.add(key);
@@ -1921,12 +1963,16 @@ export function createTools(ctx) {
1921
1963
  typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
1922
1964
  return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
1923
1965
  }),
1924
- tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, and a status line about another task than the one in progress.', PROGRESS, async (p) => {
1966
+ tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
1925
1967
  const status = p.status.trim();
1926
1968
  const tasks = withAttempts(p.tasks);
1927
- const voice = statusVoiceError(status) ?? stageError(status, p.tasks.filter((t) => !t.id.startsWith('preview-attempt-')));
1969
+ const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
1970
+ const voice = statusVoiceError(status) ?? stageError(status, own);
1928
1971
  if (voice)
1929
1972
  return voice;
1973
+ const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
1974
+ if (unneeded.length)
1975
+ return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
1930
1976
  ctx.sync.update({ phase: 'running', status, tasks });
1931
1977
  reportStatus(status);
1932
1978
  reportTasks(tasks);
@@ -1979,16 +2025,17 @@ export function createTools(ctx) {
1979
2025
  if (!left.includes('Watch the review on the test drive'))
1980
2026
  left.push('Watch the review on the test drive');
1981
2027
  }
1982
- const { phase, status, question, report_markdown, test_pr } = ctx.sync.payload;
2028
+ const { phase, status, question, report_markdown, test_pr, left: leftBefore } = ctx.sync.payload;
1983
2029
  // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
1984
2030
  const covered = test_pr && recordedBranches?.setup
1985
2031
  ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
1986
2032
  const drive = covered === false ? undefined : test_pr;
1987
- ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive });
2033
+ // 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).
2034
+ ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
1988
2035
  const res = await ctx.sync.flush();
1989
2036
  if (res && res.status >= 400 && res.status < 500) {
1990
2037
  // 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.
1991
- ctx.sync.update({ phase, status, question, report_markdown, test_pr });
2038
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
1992
2039
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
1993
2040
  }
1994
2041
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
@@ -1997,7 +2044,7 @@ export function createTools(ctx) {
1997
2044
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
1998
2045
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
1999
2046
  }),
2000
- 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. 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. 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) => {
2047
+ 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. 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) => {
2001
2048
  const branch = p.branch;
2002
2049
  const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
2003
2050
  // 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
@@ -2006,7 +2053,7 @@ export function createTools(ctx) {
2006
2053
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2007
2054
  // A head's build is still building until it has a final status.
2008
2055
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
2009
- 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, signal) : await read();
2056
+ 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();
2010
2057
  listedPreviews.clear();
2011
2058
  vercelMade.clear();
2012
2059
  publicProduction.clear();
@@ -2033,20 +2080,25 @@ export function createTools(ctx) {
2033
2080
  }
2034
2081
  return JSON.stringify(evidence);
2035
2082
  }),
2036
- 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). 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. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2083
+ 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). 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) => {
2037
2084
  const pull = p.pull;
2038
2085
  const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
2039
2086
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2040
2087
  const states = ['success', 'ready', 'failure', 'error'];
2041
2088
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
2042
- const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2089
+ const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
2090
+ // The waited pull request's own site; else the one site known, so another app's site never opens.
2091
+ const known = new Set([...e.sites, ...(netlifySite ? [netlifySite] : [])]);
2092
+ const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
2093
+ return site ? `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews` : 'https://app.netlify.com/';
2094
+ }, signal) : await read();
2043
2095
  netlifyListed.clear();
2044
2096
  for (const p of evidence.previews)
2045
2097
  if (p.state === 'success' || p.state === 'ready')
2046
2098
  netlifyListed.add(new URL(p.url).origin);
2047
2099
  return JSON.stringify(evidence);
2048
2100
  }),
2049
- 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). 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. 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. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2101
+ 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). 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. 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. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2050
2102
  const pull = p.pull;
2051
2103
  const branch = p.branch;
2052
2104
  if (pull !== undefined && branch !== undefined)
@@ -2056,7 +2108,7 @@ export function createTools(ctx) {
2056
2108
  // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2057
2109
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2058
2110
  const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2059
- const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2111
+ const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal) : await read();
2060
2112
  cloudflareListed.clear();
2061
2113
  for (const preview of evidence.previews) {
2062
2114
  if (preview.state !== 'success')
@@ -2103,7 +2155,7 @@ export function createTools(ctx) {
2103
2155
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
2104
2156
  return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
2105
2157
  }),
2106
- 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: 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. 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. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
2158
+ 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: 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. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
2107
2159
  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 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) => {
2108
2160
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2109
2161
  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.';
@@ -2123,6 +2175,8 @@ export function createTools(ctx) {
2123
2175
  link: () => readLink(pi.cwd),
2124
2176
  consent: async () => !(await ask(HOST_SCREENS['vercel-bypass'])).skipped,
2125
2177
  paste: async () => {
2178
+ // The locked preview may be another project's than the one this run connected, so the person picks the project.
2179
+ (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection'));
2126
2180
  const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
2127
2181
  return answer.skipped ? undefined : answer.value?.trim();
2128
2182
  },
@@ -2152,13 +2206,10 @@ export function createTools(ctx) {
2152
2206
  }
2153
2207
  return JSON.stringify(bypassOutcome);
2154
2208
  }),
2155
- 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; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or Netlify does not give the password back, 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; password says whether it was existing or typed, and site which Netlify site it is), login (Netlify\'s team login stands in front, which no tool gets past: protection says so, and reason why), 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) => {
2209
+ 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; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or 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, 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) => {
2156
2210
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2157
2211
  if (!gated || !site)
2158
2212
  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.';
2159
- const stands = passwordStands.get(site);
2160
- if (stands)
2161
- return JSON.stringify(stands);
2162
2213
  const at = gated;
2163
2214
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2164
2215
  let pattern;
@@ -2168,6 +2219,17 @@ export function createTools(ctx) {
2168
2219
  pattern = proof.pattern;
2169
2220
  return proof;
2170
2221
  };
2222
+ const stands = passwordStands.get(site);
2223
+ if (stands?.status === 'opened') {
2224
+ // A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
2225
+ if ((await prove(undefined)).proven) {
2226
+ provenPattern = pattern ?? provenPattern;
2227
+ return JSON.stringify(stands);
2228
+ }
2229
+ passwordStands.delete(site);
2230
+ }
2231
+ else if (stands)
2232
+ return JSON.stringify(stands);
2171
2233
  // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
2172
2234
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2173
2235
  provenPattern = pattern ?? provenPattern;
@@ -2183,6 +2245,14 @@ export function createTools(ctx) {
2183
2245
  return passwordConsented.add(site), true;
2184
2246
  return false;
2185
2247
  },
2248
+ makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
2249
+ retry: async (page, first) => {
2250
+ if (first)
2251
+ (ctx.openBrowser ?? openBrowser)(page);
2252
+ // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
2253
+ const screen = HOST_SCREENS['netlify-visibility-retry'];
2254
+ return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
2255
+ },
2186
2256
  paste: async () => {
2187
2257
  const answer = await ask(HOST_SCREENS['netlify-password-key']);
2188
2258
  return answer.skipped ? undefined : answer.value?.trim();
@@ -2195,9 +2265,15 @@ export function createTools(ctx) {
2195
2265
  },
2196
2266
  remember: noteSensitiveAnswer,
2197
2267
  });
2198
- const passwordOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
2199
- if (told.status === 'skipped' || told.status === 'login')
2268
+ const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
2269
+ if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
2200
2270
  passwordStands.set(site, passwordOutcome);
2271
+ if (told.status === 'opened') {
2272
+ // The site is public now: its previews open with nothing sent.
2273
+ hostedPreviewProven = true;
2274
+ provenHost = 'Netlify';
2275
+ provenPattern = pattern ?? provenPattern;
2276
+ }
2201
2277
  if (value) {
2202
2278
  // What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
2203
2279
  sitePassword = value;
package/dist/vercel.js CHANGED
@@ -110,7 +110,7 @@ export async function readVercelProject(vercel, link) {
110
110
  const branch = record(project.link)?.productionBranch;
111
111
  const protection = scope(project);
112
112
  const hosts = [target, ...latest].flatMap((d) => (typeof d?.url === 'string' && d.url ? [d.url.replace(/^https?:\/\//, '').toLowerCase()] : []));
113
- return { production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), protection: typeof protection === 'string' ? protection : null, deploymentHosts: new Set(hosts) };
113
+ return { ...(typeof project.name === 'string' && project.name ? { name: project.name } : {}), production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), protection: typeof protection === 'string' ? protection : null, deploymentHosts: new Set(hosts) };
114
114
  }
115
115
  /**
116
116
  * The linked project before its first push: read through `vercel api`, and, with no production deployment, locked
@@ -118,15 +118,17 @@ export async function readVercelProject(vercel, link) {
118
118
  * branch it builds and Standard Protection serves production domains to anyone. A project that deploys production
119
119
  * already (live or building), or is locked already, is left as it is.
120
120
  */
121
- export async function prepareFirstDeployment(vercel, link) {
121
+ export async function prepareFirstDeployment(vercel, link, lock = true) {
122
122
  const project = await readVercelProject(vercel, link);
123
123
  if (!project)
124
124
  return { production: 'unknown', locked: false, reason: 'The Vercel CLI could not read the project (vercel api): it is signed out, or this CLI has no api command.' };
125
- const branch = project.productionBranch ? { productionBranch: project.productionBranch } : {};
125
+ const branch = { ...(project.productionBranch ? { productionBranch: project.productionBranch } : {}), ...(project.name ? { project: project.name } : {}) };
126
126
  if (project.production === 'exists')
127
127
  return { production: 'exists', ...branch };
128
128
  if (project.protection === 'all')
129
129
  return { production: 'none', locked: true, ...branch };
130
+ if (!lock)
131
+ return { production: 'none', locked: false, ...branch, reason: "Vercel's login is not in front of the project's addresses yet." };
130
132
  try {
131
133
  if (scope(await printedProject(vercel, lockProjectApi(link))) === 'all')
132
134
  return { production: 'none', locked: true, ...branch };
@@ -136,6 +138,28 @@ export async function prepareFirstDeployment(vercel, link) {
136
138
  }
137
139
  return { production: 'none', locked: false, ...branch, reason: "Vercel did not put its login in front of the project's addresses: that takes the Member role or Project Administrator." };
138
140
  }
141
+ /**
142
+ * A page of the project in Vercel's dashboard: by its team and name when the run knows both, else the dashboard's link
143
+ * that has the person pick them.
144
+ */
145
+ export function vercelProjectPage(page, team, project) {
146
+ return team && project ? `https://vercel.com/${encodeURIComponent(team)}/${encodeURIComponent(project)}/${page}` : `https://vercel.com/d?to=${encodeURIComponent(`/[team]/[project]/${page}`)}`;
147
+ }
148
+ /** Reads the project again every `wait.everyMs` until Vercel's login stands in front of every address of it, or it deploys production, or `wait.totalMs` has passed (a read that failed counts as neither): the person is locking it meanwhile. */
149
+ export async function waitForVercelLock(vercel, link, deps) {
150
+ for (const end = Date.now() + deps.wait.totalMs;;) {
151
+ const first = await prepareFirstDeployment(vercel, link, false);
152
+ // A read that failed says nothing yet, so the wait goes on.
153
+ if (first.production === 'exists' || first.locked || Date.now() >= end || deps.signal?.aborted)
154
+ return first;
155
+ try {
156
+ await (deps.sleep ?? ((ms) => sleep(ms, undefined, { signal: deps.signal })))(deps.wait.everyMs);
157
+ }
158
+ catch {
159
+ return first;
160
+ }
161
+ }
162
+ }
139
163
  /**
140
164
  * The hosts of the linked project's deployments while Vercel's login stands in front of every address of it, read
141
165
  * now: a Production deployment GitHub lists for a pushed branch is its preview only when it is one of these. Empty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,