@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/README.md +1 -1
- package/dist/bin.js +0 -0
- package/dist/fence.js +4 -0
- package/dist/host-questions.js +47 -1
- package/dist/netlify-password.js +62 -7
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +7 -5
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +2 -2
- package/dist/playbook/cases/vercel-setup.md +5 -5
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +2 -1
- package/dist/tools.js +126 -50
- package/dist/vercel.js +27 -3
- package/package.json +1 -1
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
|
-
|
|
1428
|
-
if (built(
|
|
1429
|
-
return
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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,
|
|
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
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
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
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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:
|
|
1639
|
-
if (result
|
|
1640
|
-
return { ...result, reason:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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),
|
|
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
|