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