@impeccable/reviews 0.1.7 → 0.1.8
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 -0
- package/dist/cloudflare.js +26 -4
- package/dist/host-previews.js +16 -0
- package/dist/host-questions.js +11 -1
- package/dist/hosted.js +3 -3
- package/dist/main.js +8 -0
- package/dist/netlify.js +16 -3
- package/dist/playbook/cases/monorepo.md +7 -7
- package/dist/playbook/cases/preview-protection.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +1 -0
- package/dist/playbook.js +1 -1
- package/dist/preflight.js +8 -2
- package/dist/tools.js +347 -94
- package/dist/vercel-bypass.js +14 -10
- package/dist/vercel.js +43 -6
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -10,14 +10,14 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo } from './host-questions.js';
|
|
14
|
-
import { enableNetlifyPreviews } from './host-previews.js';
|
|
13
|
+
import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo, startedIn } from './host-questions.js';
|
|
14
|
+
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
17
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
18
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
19
19
|
import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
|
|
20
|
-
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
20
|
+
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
21
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
22
22
|
import { fitsTask, stagesOf } from './stages.js';
|
|
23
23
|
import { redact } from './log.js';
|
|
@@ -179,10 +179,15 @@ const TEST_PR = {
|
|
|
179
179
|
...PULL_REQUEST,
|
|
180
180
|
properties: { ...PULL_REQUEST.properties, setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' } },
|
|
181
181
|
};
|
|
182
|
+
/** An app's key as the setup state gives it. */
|
|
183
|
+
const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
184
|
+
/** The app a host tool reads for, on a repository with several apps: its previews are those of the project or site that builds the app's folder. */
|
|
185
|
+
const APP_KEY = { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app this is for, one the apps question picked: only the previews of the host project that builds that app\'s folder are read.' };
|
|
182
186
|
const HOSTED_PREVIEWS = {
|
|
183
187
|
type: 'object',
|
|
184
188
|
additionalProperties: false,
|
|
185
189
|
properties: {
|
|
190
|
+
appKey: APP_KEY,
|
|
186
191
|
branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the deployments of its head only, and waits up to five minutes for one to finish building.' },
|
|
187
192
|
},
|
|
188
193
|
};
|
|
@@ -190,6 +195,7 @@ const NETLIFY_PREVIEWS = {
|
|
|
190
195
|
type: 'object',
|
|
191
196
|
additionalProperties: false,
|
|
192
197
|
properties: {
|
|
198
|
+
appKey: APP_KEY,
|
|
193
199
|
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
|
|
194
200
|
},
|
|
195
201
|
};
|
|
@@ -197,6 +203,7 @@ const CLOUDFLARE_PREVIEWS = {
|
|
|
197
203
|
type: 'object',
|
|
198
204
|
additionalProperties: false,
|
|
199
205
|
properties: {
|
|
206
|
+
appKey: APP_KEY,
|
|
200
207
|
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
|
|
201
208
|
branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the build of its head only, and waits up to five minutes for it to finish.' },
|
|
202
209
|
},
|
|
@@ -1519,14 +1526,19 @@ export function createTools(ctx) {
|
|
|
1519
1526
|
};
|
|
1520
1527
|
/** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
|
|
1521
1528
|
const vercelMade = new Set();
|
|
1522
|
-
/**
|
|
1529
|
+
/** On a repository with several apps, the app each listed origin was read for, with its own host project (#281): a key for that address goes to that project and is saved for that app. */
|
|
1530
|
+
const listedApps = new Map();
|
|
1531
|
+
/** The address impeccable_prove_preview met a host's lock at, the words it looked for there, whose lock it was, and the app it was listed for. */
|
|
1523
1532
|
let gated;
|
|
1524
|
-
/**
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1533
|
+
/**
|
|
1534
|
+
* The bypass secret saved in this run, by where the setup API keeps it (the app's key, or '' for the repository's), with
|
|
1535
|
+
* the project it is of and what the tool answered; never for the model. A secret is one project's, so it goes only to
|
|
1536
|
+
* the origins impeccable_vercel_bypass proved it on (each by that same store), never to another project's deployment.
|
|
1537
|
+
*/
|
|
1538
|
+
const bypassHeld = new Map();
|
|
1539
|
+
const bypassOrigins = new Map();
|
|
1540
|
+
/** What impeccable_vercel_bypass answered for a store, less the secret: a skip is asked once, so that answer stands. */
|
|
1541
|
+
const bypassOutcomes = new Map();
|
|
1530
1542
|
/** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
|
|
1531
1543
|
let sitePassword;
|
|
1532
1544
|
const passwordSites = new Set();
|
|
@@ -1551,6 +1563,8 @@ export function createTools(ctx) {
|
|
|
1551
1563
|
const enableOutcomes = new Map();
|
|
1552
1564
|
/** Cloudflare's plain evidence as last read: the project, the Worker and the GitHub app facts impeccable_cloudflare_connect and the wait for a build act on. */
|
|
1553
1565
|
let cloudflareEvidence;
|
|
1566
|
+
/** Each app's Pages project, by key, as a plain read with its appKey told it: its name, and the label its addresses carry (its own host's, which a taken name suffixes). */
|
|
1567
|
+
const cloudflareApps = new Map();
|
|
1554
1568
|
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1555
1569
|
let connectOutcome;
|
|
1556
1570
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
@@ -1631,14 +1645,26 @@ export function createTools(ctx) {
|
|
|
1631
1645
|
/** The live app's addresses, by origin: those the host tools answered as `production`, each with its host and, on Netlify, its site, so a custom domain's lock is known and the site's key goes with it; and those a proof opened with this app, the only ones impeccable_write saves as the before. */
|
|
1632
1646
|
const productionListed = new Map();
|
|
1633
1647
|
const provenLive = new Set();
|
|
1634
|
-
/**
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1648
|
+
/** 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
|
+
const liveApps = new Map();
|
|
1650
|
+
const provenApps = new Set();
|
|
1651
|
+
/** 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
|
+
const vercelProductions = new Map();
|
|
1653
|
+
/** 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
|
+
const bypassFor = (origin, live) => {
|
|
1655
|
+
const store = bypassOrigins.get(origin);
|
|
1656
|
+
if (store !== undefined)
|
|
1657
|
+
return bypassHeld.get(store)?.secret;
|
|
1658
|
+
const project = live ? vercelProductions.get(origin) : undefined;
|
|
1659
|
+
return project ? [...bypassHeld.values()].find((held) => held.projectId === project)?.secret : undefined;
|
|
1660
|
+
};
|
|
1638
1661
|
/** A host tool answered the live app's address: the proof may open it as the before, with the host's key where the lock is the same site's or project's. */
|
|
1639
|
-
const noteProduction = (production, host, site) => {
|
|
1640
|
-
if (production
|
|
1641
|
-
|
|
1662
|
+
const noteProduction = (production, host, site, appKey) => {
|
|
1663
|
+
if (!production || !URL.canParse(production))
|
|
1664
|
+
return;
|
|
1665
|
+
productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
|
|
1666
|
+
if (appKey !== undefined)
|
|
1667
|
+
liveApps.set(new URL(production).origin, appKey);
|
|
1642
1668
|
};
|
|
1643
1669
|
const hostCli = (host, pi, signal) => {
|
|
1644
1670
|
const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
|
|
@@ -1776,6 +1802,8 @@ export function createTools(ctx) {
|
|
|
1776
1802
|
* has no production deployment, before anything is pushed: Vercel makes a new project's first deployment Production
|
|
1777
1803
|
* whatever branch it builds, and serves it at the project's own address.
|
|
1778
1804
|
*/
|
|
1805
|
+
/** On a repository with several apps, the picked app whose folder the project impeccable_vercel_connect connects builds. */
|
|
1806
|
+
let connectedApp;
|
|
1779
1807
|
async function vercelConnect(pi, signal) {
|
|
1780
1808
|
if (!hostSetupAgreed(ctx.log, 'vercel'))
|
|
1781
1809
|
return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
|
|
@@ -1794,6 +1822,17 @@ export function createTools(ctx) {
|
|
|
1794
1822
|
return { connected: false, reason: 'The project this folder is linked to no longer exists on Vercel, as far as the CLI can tell (.vercel/project.json is stale): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder\'s name again; else the Vercel setup ends here, and the person removes or relinks the file.' };
|
|
1795
1823
|
if (!project)
|
|
1796
1824
|
return { connected: false, reason: 'The Vercel CLI could not read the linked project (vercel api), so whether this run created it is unknown: nothing was connected, and the push stays held.' };
|
|
1825
|
+
// On a repository with several apps, a project builds one app's folder: one that builds no picked app's is connected and locked for none (#281).
|
|
1826
|
+
if (appsRepo) {
|
|
1827
|
+
const folders = new Map(await Promise.all([...(pickedApps ?? [])].map(async (key) => [await appFolder(pi, key), key])));
|
|
1828
|
+
connectedApp = project.rootDirectory !== undefined ? folders.get(project.rootDirectory) : undefined;
|
|
1829
|
+
if (connectedApp === undefined) {
|
|
1830
|
+
// 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
|
+
if (project.repository !== repository.toLowerCase())
|
|
1832
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1833
|
+
return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} builds ${project.rootDirectory ? `the folder ${project.rootDirectory}` : "the repository's root"}, which is no app this run sets up: it stays as it is, and nothing was connected. The Vercel setup ends here.` };
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1797
1836
|
// A project connected to this repository already is this repository's: the connection changes nothing on it, and the lock below is what it gets as the run's own project would.
|
|
1798
1837
|
if (project.repository !== repository.toLowerCase()) {
|
|
1799
1838
|
const foreign = !(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`)) ? `this run's vercel project add did not make it in ${own.team ? `the team ${own.team}` : 'the personal scope'}, so vercel link linked a project of that name that existed already` : null;
|
|
@@ -1873,11 +1912,13 @@ export function createTools(ctx) {
|
|
|
1873
1912
|
* Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
|
|
1874
1913
|
* `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
|
|
1875
1914
|
*/
|
|
1876
|
-
const saveLock = async (pi, what, header, value) => {
|
|
1915
|
+
const saveLock = async (pi, what, header, value, appKey) => {
|
|
1877
1916
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
1878
|
-
|
|
1917
|
+
// On a repository with several apps, the key is that app's own: each app's previews are its own project's or site's.
|
|
1918
|
+
const app = appKey !== undefined ? { appKey } : {};
|
|
1919
|
+
if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
|
|
1879
1920
|
return { stored: false, failed: `The setup API did not store the ${what}.` };
|
|
1880
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
|
|
1921
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
|
|
1881
1922
|
return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
|
|
1882
1923
|
return { stored: true, failed: null };
|
|
1883
1924
|
};
|
|
@@ -1885,6 +1926,18 @@ export function createTools(ctx) {
|
|
|
1885
1926
|
let instructions = ctx.instructions;
|
|
1886
1927
|
/** Whether the run signed in again for apps the scan proposed after the sign-in. */
|
|
1887
1928
|
let renewedForApps = false;
|
|
1929
|
+
/** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
|
|
1930
|
+
let appsRepo = ctx.severalApps === true;
|
|
1931
|
+
/**
|
|
1932
|
+
* The apps this run sets up, as the apps question answered (after a skip, the ones on): undefined until it was asked.
|
|
1933
|
+
* On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
|
|
1934
|
+
* and host read for an app names one of these.
|
|
1935
|
+
*/
|
|
1936
|
+
let pickedApps;
|
|
1937
|
+
/** This run confirmed the scan's proposal, so nothing was answered before: the question opens with every app picked. */
|
|
1938
|
+
let confirmedApps = false;
|
|
1939
|
+
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
1940
|
+
const appFolders = new Map();
|
|
1888
1941
|
/**
|
|
1889
1942
|
* A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
|
|
1890
1943
|
* there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
|
|
@@ -1897,10 +1950,89 @@ export function createTools(ctx) {
|
|
|
1897
1950
|
renewedForApps = true;
|
|
1898
1951
|
res = await call(pi, `${ctx.setupApi}${path}`);
|
|
1899
1952
|
}
|
|
1953
|
+
if (res.ok && severalApps(res.data))
|
|
1954
|
+
appsRepo = true;
|
|
1900
1955
|
if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
|
|
1901
1956
|
instructions = focusInstructions(ctx.allInstructions, { apps: true });
|
|
1902
1957
|
return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
|
|
1903
1958
|
};
|
|
1959
|
+
/** The repository's apps as the state lists them, each with its folder and whether it is on; null when the state could not be read. */
|
|
1960
|
+
const stateApps = async (pi) => {
|
|
1961
|
+
const res = await call(pi, `${ctx.setupApi}/state`);
|
|
1962
|
+
const repos = object(res.data)?.repos;
|
|
1963
|
+
const repo = res.ok && Array.isArray(repos) ? repos.map(object).find((r) => r?.name === ctx.repo) : undefined;
|
|
1964
|
+
if (!repo || !Array.isArray(repo.apps))
|
|
1965
|
+
return null;
|
|
1966
|
+
const apps = repo.apps.map(object).flatMap((a) => {
|
|
1967
|
+
const prefixes = Array.isArray(a?.pathPrefixes) ? a.pathPrefixes : [];
|
|
1968
|
+
const folder = folderOf(prefixes.find((p) => typeof p === 'string' && p) ?? '');
|
|
1969
|
+
return a && typeof a.key === 'string' && APP_KEY_SHAPE.test(a.key) && folder !== undefined
|
|
1970
|
+
? [{ key: a.key, name: typeof a.name === 'string' && a.name ? a.name : a.key, folder, enabled: a.enabled !== false }] : [];
|
|
1971
|
+
});
|
|
1972
|
+
for (const app of apps)
|
|
1973
|
+
appFolders.set(app.key, app.folder);
|
|
1974
|
+
return apps;
|
|
1975
|
+
};
|
|
1976
|
+
/** An app's folder, read from the state the first time it is asked for. */
|
|
1977
|
+
const appFolder = async (pi, key) => (appFolders.has(key) ? appFolders.get(key) : (await stateApps(pi), appFolders.get(key)));
|
|
1978
|
+
/**
|
|
1979
|
+
* On a repository with several apps, why a step for `appKey` may not run yet: the apps question comes first, and only
|
|
1980
|
+
* a picked app is set up. Null where it may: one app, or a picked one.
|
|
1981
|
+
*/
|
|
1982
|
+
const appRefusal = (appKey, what, forApp = true) => {
|
|
1983
|
+
if (!appsRepo)
|
|
1984
|
+
return null;
|
|
1985
|
+
if (!pickedApps)
|
|
1986
|
+
return `Refused: this repository has several apps, so the apps question comes first, on every run: impeccable_ask with the id "apps", as the monorepo case says. Nothing was ${what}.`;
|
|
1987
|
+
if (forApp && (typeof appKey !== 'string' || !pickedApps.has(appKey)))
|
|
1988
|
+
return `Refused: give "appKey", the key of an app this run sets up (${[...pickedApps].join(', ')}). Nothing was ${what}.`;
|
|
1989
|
+
return null;
|
|
1990
|
+
};
|
|
1991
|
+
/** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
|
|
1992
|
+
const vercelApps = new Map();
|
|
1993
|
+
/** 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. */
|
|
1994
|
+
async function vercelApp(pi, appKey, vercel) {
|
|
1995
|
+
let found = vercelApps.get(appKey);
|
|
1996
|
+
if (!found) {
|
|
1997
|
+
const folder = await appFolder(pi, appKey);
|
|
1998
|
+
if (folder === undefined)
|
|
1999
|
+
return { reason: `The setup state names no folder for the app ${appKey}.` };
|
|
2000
|
+
const resolved = await vercelProjectFor(vercel, repository, folder, (await readLink(pi.cwd))?.orgId);
|
|
2001
|
+
found = 'reason' in resolved ? resolved : { ...resolved, folder };
|
|
2002
|
+
if (!('reason' in found))
|
|
2003
|
+
vercelApps.set(appKey, found);
|
|
2004
|
+
}
|
|
2005
|
+
if ('reason' in found)
|
|
2006
|
+
return found;
|
|
2007
|
+
const project = await readOwnProject(vercel, found.link, repository);
|
|
2008
|
+
return 'reason' in project ? project : { ...found, project };
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
|
|
2012
|
+
* this run confirmed the scan's proposal); the command started for one app (--app) answers it without a screen. The
|
|
2013
|
+
* answer is the apps this run sets up; after a skip, those that are on.
|
|
2014
|
+
*/
|
|
2015
|
+
async function appsQuestion(pi) {
|
|
2016
|
+
const apps = await stateApps(pi);
|
|
2017
|
+
if (!apps || apps.length < 2)
|
|
2018
|
+
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
|
+
if (ctx.app !== undefined && apps.some((a) => a.key === ctx.app)) {
|
|
2020
|
+
pickedApps = new Set([ctx.app]);
|
|
2021
|
+
return JSON.stringify({ values: [ctx.app], named: true });
|
|
2022
|
+
}
|
|
2023
|
+
// 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
|
+
const start = ctx.start ?? '';
|
|
2025
|
+
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
|
+
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 })) });
|
|
2028
|
+
if (answer.skipped) {
|
|
2029
|
+
pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
|
|
2030
|
+
return JSON.stringify({ skipped: true, on: [...pickedApps] });
|
|
2031
|
+
}
|
|
2032
|
+
const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
|
|
2033
|
+
pickedApps = new Set(values);
|
|
2034
|
+
return JSON.stringify({ values });
|
|
2035
|
+
}
|
|
1904
2036
|
return [
|
|
1905
2037
|
tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
1906
2038
|
tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
|
|
@@ -1979,13 +2111,20 @@ export function createTools(ctx) {
|
|
|
1979
2111
|
const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
|
|
1980
2112
|
if ('error' in target)
|
|
1981
2113
|
return `Refused: ${target.error}`;
|
|
2114
|
+
// 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
|
+
const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
|
|
2116
|
+
if (!target.url.endsWith('/apps')) {
|
|
2117
|
+
const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp);
|
|
2118
|
+
if (refused)
|
|
2119
|
+
return refused;
|
|
2120
|
+
}
|
|
1982
2121
|
if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
|
|
1983
2122
|
return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
|
|
1984
2123
|
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'ci-tunnel') {
|
|
1985
2124
|
return "Refused: Impeccable no longer runs a server in CI for a new setup, so ci-tunnel is never saved. Pages that need a server get their host's pull request previews, as the server-previews case says. Nothing was saved.";
|
|
1986
2125
|
}
|
|
1987
|
-
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
|
|
1988
|
-
return
|
|
2126
|
+
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && (appsRepo ? !provenApps.has(String(object(p.body)?.appKey)) : !hostedPreviewProven)) {
|
|
2127
|
+
return `Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app${appsRepo ? `, read for it with its appKey (${String(object(p.body)?.appKey)})` : ''}. Nothing was saved.`;
|
|
1989
2128
|
}
|
|
1990
2129
|
const saved = object(p.body);
|
|
1991
2130
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
|
|
@@ -1995,6 +2134,15 @@ export function createTools(ctx) {
|
|
|
1995
2134
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
1996
2135
|
return "Refused: beforeUrl is the live app's address that impeccable_prove_preview opened with this app in this run (the production address a host tool answered, or the one the README names), or a page on it; an address no key opens is not set, and a saved one is never cleared from here. Nothing was saved.";
|
|
1997
2136
|
}
|
|
2137
|
+
// One app's before is its own: the production address its own project or site answered, or one a file in its own folder names.
|
|
2138
|
+
if (appsRepo && target.url.endsWith('/preview') && saved && typeof saved.beforeUrl === 'string') {
|
|
2139
|
+
const origin = new URL(saved.beforeUrl).origin;
|
|
2140
|
+
const owner = liveApps.get(origin);
|
|
2141
|
+
const folder = await appFolder(pi, String(saved.appKey));
|
|
2142
|
+
if (owner !== undefined ? owner !== saved.appKey : folder === undefined || !(await checkoutNames(pi.cwd, origin, folder))) {
|
|
2143
|
+
return `Refused: ${origin} is not the live address of ${String(saved.appKey)}: it is another app's, or no file in that app's folder names it. Nothing was saved.`;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1998
2146
|
const testAccounts = target.url.endsWith('/test-accounts');
|
|
1999
2147
|
let body = p.body;
|
|
2000
2148
|
if (testAccounts) {
|
|
@@ -2009,6 +2157,8 @@ export function createTools(ctx) {
|
|
|
2009
2157
|
}
|
|
2010
2158
|
const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
|
|
2011
2159
|
const response = await call(pi, target.url, { method: target.method, body });
|
|
2160
|
+
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2161
|
+
confirmedApps = true;
|
|
2012
2162
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
2013
2163
|
const keys = object(object(response.data)?.keys);
|
|
2014
2164
|
if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
|
|
@@ -2041,9 +2191,11 @@ export function createTools(ctx) {
|
|
|
2041
2191
|
// A test account never comes back to the model, even if the API echoed it.
|
|
2042
2192
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
2043
2193
|
}),
|
|
2044
|
-
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
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) => {
|
|
2045
2195
|
// A skip never stops the run, so every question offers one.
|
|
2046
2196
|
const asked = p;
|
|
2197
|
+
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2198
|
+
return appsQuestion(pi);
|
|
2047
2199
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
2048
2200
|
if (host?.[2] === 'setup')
|
|
2049
2201
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
@@ -2064,7 +2216,8 @@ export function createTools(ctx) {
|
|
|
2064
2216
|
ctx.log.write('ask hidden (names a credential)', q.id);
|
|
2065
2217
|
if (q.kind === 'text' && !q.sensitive)
|
|
2066
2218
|
q.caution = NEVER_A_CREDENTIAL;
|
|
2067
|
-
|
|
2219
|
+
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
|
|
2220
|
+
const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
|
|
2068
2221
|
if (answer.skipped) {
|
|
2069
2222
|
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
2070
2223
|
if (host?.[1] === 'vercel')
|
|
@@ -2189,17 +2342,53 @@ export function createTools(ctx) {
|
|
|
2189
2342
|
}),
|
|
2190
2343
|
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
2191
2344
|
const branch = p.branch;
|
|
2192
|
-
const
|
|
2345
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2346
|
+
if (refused)
|
|
2347
|
+
return refused;
|
|
2348
|
+
// The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
|
|
2349
|
+
const reads = new Map();
|
|
2350
|
+
const vercel = (args) => {
|
|
2351
|
+
const key = JSON.stringify(args);
|
|
2352
|
+
if (!reads.has(key))
|
|
2353
|
+
reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
|
|
2354
|
+
return reads.get(key);
|
|
2355
|
+
};
|
|
2356
|
+
for (const origin of listedPreviews)
|
|
2357
|
+
listedApps.delete(origin);
|
|
2358
|
+
listedPreviews.clear();
|
|
2359
|
+
vercelMade.clear();
|
|
2360
|
+
publicProduction.clear();
|
|
2361
|
+
// On a repository with several apps, the app's own project (#281): the one whose Root Directory is the app's folder, never the newest deployment's or the root link's.
|
|
2362
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2363
|
+
const app = appKey !== undefined ? await vercelApp(pi, appKey, vercel) : undefined;
|
|
2364
|
+
if (app && 'reason' in app)
|
|
2365
|
+
return JSON.stringify({ appKey, deployments: [], unmatched: app.reason });
|
|
2366
|
+
const raw = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
|
|
2367
|
+
// Vercel names each project's deployments on GitHub "Preview – <project>"; one without a name is told by the project's own record of it.
|
|
2368
|
+
const owned = async (d) => {
|
|
2369
|
+
if (!app)
|
|
2370
|
+
return true;
|
|
2371
|
+
if (!d.vercel || !d.url)
|
|
2372
|
+
return false;
|
|
2373
|
+
const named = /^(?:preview|production)\s[–-]\s(.+)$/i.exec(d.environment ?? '')?.[1];
|
|
2374
|
+
return named !== undefined ? named === app.name : projectMadeDeployment(vercel, app.link, app.project, new URL(d.url).host);
|
|
2375
|
+
};
|
|
2376
|
+
const read = async () => {
|
|
2377
|
+
const evidence = await raw();
|
|
2378
|
+
if (!app)
|
|
2379
|
+
return evidence;
|
|
2380
|
+
const kept = await Promise.all(evidence.deployments.map(owned));
|
|
2381
|
+
return { ...evidence, deployments: evidence.deployments.filter((_, i) => kept[i]) };
|
|
2382
|
+
};
|
|
2193
2383
|
// With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
|
|
2194
2384
|
// project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
|
|
2195
2385
|
const isPreview = (d) => /preview/i.test(d.environment ?? '');
|
|
2196
2386
|
const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
|
|
2197
2387
|
// A head's build is still building until it has a final status.
|
|
2198
2388
|
const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
|
|
2199
|
-
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), vercelProject), signal) : await read();
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
publicProduction.clear();
|
|
2389
|
+
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), app?.name ?? vercelProject), signal) : await read();
|
|
2390
|
+
/** The project the evidence is of: the app's, else the one this folder is linked to. */
|
|
2391
|
+
const link = async () => (app ? app.link : readLink(pi.cwd));
|
|
2203
2392
|
/** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
|
|
2204
2393
|
let locked;
|
|
2205
2394
|
for (const d of evidence.deployments) {
|
|
@@ -2209,8 +2398,8 @@ export function createTools(ctx) {
|
|
|
2209
2398
|
// A pushed branch's Production deployment is its preview only as the locked project's own: under Standard Protection a project's own address serves it to anyone, and another project of this repository may have made it.
|
|
2210
2399
|
if (branch !== undefined && !isPreview(d)) {
|
|
2211
2400
|
locked ??= await (async () => {
|
|
2212
|
-
const
|
|
2213
|
-
return
|
|
2401
|
+
const linked = await link();
|
|
2402
|
+
return linked ? lockedDeploymentHosts(vercel, linked, repository) : new Set();
|
|
2214
2403
|
})();
|
|
2215
2404
|
if (!locked.has(url.host)) {
|
|
2216
2405
|
publicProduction.add(url.origin);
|
|
@@ -2220,39 +2409,41 @@ export function createTools(ctx) {
|
|
|
2220
2409
|
listedPreviews.add(url.origin);
|
|
2221
2410
|
if (d.vercel)
|
|
2222
2411
|
vercelMade.add(url.origin);
|
|
2412
|
+
if (app)
|
|
2413
|
+
listedApps.set(url.origin, { appKey: appKey, link: app.link, name: app.name });
|
|
2223
2414
|
}
|
|
2224
|
-
// What vercel.json and the linked project say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
|
|
2225
|
-
// The fact and the live app read the same linked project: one `vercel api` call answers both.
|
|
2226
|
-
const reads = new Map();
|
|
2227
|
-
const vercel = (args) => {
|
|
2228
|
-
const key = JSON.stringify(args);
|
|
2229
|
-
if (!reads.has(key))
|
|
2230
|
-
reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
|
|
2231
|
-
return reads.get(key);
|
|
2232
|
-
};
|
|
2415
|
+
// What vercel.json and the linked project (the app's, with its folder's vercel.json) say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
|
|
2233
2416
|
if (branch === undefined) {
|
|
2234
|
-
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel);
|
|
2417
|
+
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
|
|
2235
2418
|
if (fact) {
|
|
2236
2419
|
previewFacts.set('vercel', fact);
|
|
2237
2420
|
evidence.pullRequestPreviews = fact;
|
|
2238
2421
|
}
|
|
2239
2422
|
}
|
|
2240
2423
|
// The live app, where GitHub's record shows a production deployment Vercel made: its own address there is behind Vercel's login under Standard Protection, so the
|
|
2241
|
-
//
|
|
2424
|
+
// project is read for its alias, the project's own domain, and only as this repository's project.
|
|
2242
2425
|
let production;
|
|
2243
2426
|
if (branch === undefined && evidence.deployments.some((d) => d.vercel && d.state === 'success' && !isPreview(d))) {
|
|
2244
|
-
const
|
|
2245
|
-
const project =
|
|
2427
|
+
const linked = await link();
|
|
2428
|
+
const project = linked ? await readOwnProject(vercel, linked, repository) : null;
|
|
2246
2429
|
production = project && !('reason' in project) ? project.productionUrl : undefined;
|
|
2247
|
-
if (production &&
|
|
2248
|
-
|
|
2249
|
-
noteProduction(production, 'vercel');
|
|
2430
|
+
if (production && linked)
|
|
2431
|
+
vercelProductions.set(new URL(production).origin, linked.projectId);
|
|
2432
|
+
noteProduction(production, 'vercel', undefined, appKey);
|
|
2250
2433
|
}
|
|
2251
|
-
return JSON.stringify({ ...evidence, ...(production ? { production } : {}) });
|
|
2434
|
+
return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
|
|
2252
2435
|
}),
|
|
2253
2436
|
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
2254
2437
|
const pull = p.pull;
|
|
2255
|
-
const
|
|
2438
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2439
|
+
if (refused)
|
|
2440
|
+
return refused;
|
|
2441
|
+
// On a repository with several apps, only the site that builds the app's folder (#281).
|
|
2442
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2443
|
+
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2444
|
+
if (appKey !== undefined && directory === undefined)
|
|
2445
|
+
return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
2446
|
+
const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, ...(directory !== undefined ? { directory } : {}) });
|
|
2256
2447
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
2257
2448
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
2258
2449
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
@@ -2262,21 +2453,42 @@ export function createTools(ctx) {
|
|
|
2262
2453
|
const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
|
|
2263
2454
|
return site ? `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews` : 'https://app.netlify.com/';
|
|
2264
2455
|
}, signal) : await read();
|
|
2456
|
+
for (const origin of netlifyListed)
|
|
2457
|
+
listedApps.delete(origin);
|
|
2265
2458
|
netlifyListed.clear();
|
|
2266
|
-
for (const p of evidence.previews)
|
|
2267
|
-
if (p.state
|
|
2268
|
-
|
|
2459
|
+
for (const p of evidence.previews) {
|
|
2460
|
+
if (p.state !== 'success' && p.state !== 'ready')
|
|
2461
|
+
continue;
|
|
2462
|
+
netlifyListed.add(new URL(p.url).origin);
|
|
2463
|
+
if (appKey !== undefined)
|
|
2464
|
+
listedApps.set(new URL(p.url).origin, { appKey });
|
|
2465
|
+
}
|
|
2269
2466
|
if (evidence.pullRequestPreviews)
|
|
2270
2467
|
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
2271
|
-
noteProduction(evidence.production, 'netlify', liveSite(evidence));
|
|
2272
|
-
return JSON.stringify(evidence);
|
|
2468
|
+
noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
|
|
2469
|
+
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
2273
2470
|
}),
|
|
2274
2471
|
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2275
2472
|
const pull = p.pull;
|
|
2276
2473
|
const branch = p.branch;
|
|
2277
2474
|
if (pull !== undefined && branch !== undefined)
|
|
2278
2475
|
return 'Invalid input: give pull or branch, not both.';
|
|
2279
|
-
const
|
|
2476
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2477
|
+
if (refused)
|
|
2478
|
+
return refused;
|
|
2479
|
+
// 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.
|
|
2480
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2481
|
+
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2482
|
+
if (appKey !== undefined && directory === undefined)
|
|
2483
|
+
return JSON.stringify({ appKey, previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
2484
|
+
const appProject = appKey !== undefined ? cloudflareApps.get(appKey) : undefined;
|
|
2485
|
+
if (appKey !== undefined && (pull !== undefined || branch !== undefined) && !appProject)
|
|
2486
|
+
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
2487
|
+
const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}) });
|
|
2488
|
+
const read = async () => {
|
|
2489
|
+
const evidence = await readAll();
|
|
2490
|
+
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
2491
|
+
};
|
|
2280
2492
|
// Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
|
|
2281
2493
|
// GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
|
|
2282
2494
|
const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
|
|
@@ -2289,18 +2501,24 @@ export function createTools(ctx) {
|
|
|
2289
2501
|
const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
|
|
2290
2502
|
if (!subject)
|
|
2291
2503
|
cloudflareEvidence = evidence;
|
|
2504
|
+
if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project)
|
|
2505
|
+
cloudflareApps.set(appKey, { project: evidence.project, label: (evidence.production && pagesProject(evidence.production)) || evidence.project });
|
|
2506
|
+
for (const origin of cloudflareListed)
|
|
2507
|
+
listedApps.delete(origin);
|
|
2292
2508
|
cloudflareListed.clear();
|
|
2293
2509
|
for (const preview of evidence.previews) {
|
|
2294
2510
|
if (preview.state !== 'success')
|
|
2295
2511
|
continue;
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2512
|
+
for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
|
|
2513
|
+
cloudflareListed.add(new URL(address).origin);
|
|
2514
|
+
if (appKey !== undefined)
|
|
2515
|
+
listedApps.set(new URL(address).origin, { appKey });
|
|
2516
|
+
}
|
|
2299
2517
|
}
|
|
2300
2518
|
if (evidence.pullRequestPreviews)
|
|
2301
2519
|
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
2302
|
-
noteProduction(evidence.production, 'cloudflare');
|
|
2303
|
-
return JSON.stringify(evidence);
|
|
2520
|
+
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
2521
|
+
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
2304
2522
|
}),
|
|
2305
2523
|
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
2306
2524
|
const url = p.url;
|
|
@@ -2321,7 +2539,7 @@ export function createTools(ctx) {
|
|
|
2321
2539
|
const proof = await provePreview(url, p.expect, pi.cwd, {
|
|
2322
2540
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
2323
2541
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
2324
|
-
...(
|
|
2542
|
+
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
2325
2543
|
...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
|
|
2326
2544
|
...(token ? { access: token } : {}),
|
|
2327
2545
|
});
|
|
@@ -2331,15 +2549,21 @@ export function createTools(ctx) {
|
|
|
2331
2549
|
provenLive.add(origin);
|
|
2332
2550
|
return JSON.stringify(proof);
|
|
2333
2551
|
}
|
|
2552
|
+
// On a repository with several apps, the app the address was listed for: its proof, its lock and its key are that app's alone (#281).
|
|
2553
|
+
const app = listedApps.get(origin);
|
|
2334
2554
|
hostedPreviewProven ||= proof.proven;
|
|
2335
2555
|
if (proof.proven)
|
|
2336
2556
|
provenHost = previewHostName(url) ?? provenHost;
|
|
2557
|
+
if (proof.proven && app)
|
|
2558
|
+
provenApps.add(app.appKey);
|
|
2337
2559
|
if (proof.proven && proof.pattern)
|
|
2338
2560
|
provenPattern = proof.pattern;
|
|
2339
2561
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
2340
|
-
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}) };
|
|
2562
|
+
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
|
|
2341
2563
|
if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
|
|
2342
|
-
accessGated = { url, expect: p.expect };
|
|
2564
|
+
accessGated = { url, expect: p.expect, ...(app ? { app } : {}) };
|
|
2565
|
+
if (app)
|
|
2566
|
+
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
2343
2567
|
return JSON.stringify(proof);
|
|
2344
2568
|
}),
|
|
2345
2569
|
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
@@ -2455,84 +2679,102 @@ export function createTools(ctx) {
|
|
|
2455
2679
|
return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
|
|
2456
2680
|
}),
|
|
2457
2681
|
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
|
|
2682
|
+
const refused = appRefusal(undefined, 'connected', false);
|
|
2683
|
+
if (refused)
|
|
2684
|
+
return refused;
|
|
2458
2685
|
const answer = await vercelConnect(pi, signal);
|
|
2459
2686
|
// A project that deploys production already names the live app's address, for the before.
|
|
2460
2687
|
const link = answer.productionUrl ? await readLink(pi.cwd) : null;
|
|
2461
2688
|
if (answer.productionUrl && link)
|
|
2462
|
-
|
|
2463
|
-
noteProduction(answer.productionUrl, 'vercel');
|
|
2689
|
+
vercelProductions.set(new URL(answer.productionUrl).origin, link.projectId);
|
|
2690
|
+
noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
|
|
2464
2691
|
return JSON.stringify(answer);
|
|
2465
2692
|
}),
|
|
2466
2693
|
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2467
2694
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2468
2695
|
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
|
|
2469
|
-
if (bypassOutcome?.status === 'skipped')
|
|
2470
|
-
return JSON.stringify(bypassOutcome);
|
|
2471
2696
|
const at = gated;
|
|
2472
2697
|
const { origin, host } = new URL(at.url);
|
|
2473
|
-
//
|
|
2474
|
-
|
|
2475
|
-
const
|
|
2698
|
+
// Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
|
|
2699
|
+
const store = at.app?.appKey ?? '';
|
|
2700
|
+
const asked = bypassOutcomes.get(store);
|
|
2701
|
+
if (asked?.status === 'skipped')
|
|
2702
|
+
return JSON.stringify(asked);
|
|
2703
|
+
// Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project has to have
|
|
2704
|
+
// made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names.
|
|
2705
|
+
const link = at.app?.link ?? (await readLink(pi.cwd));
|
|
2476
2706
|
if (!link)
|
|
2477
2707
|
return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2478
2708
|
// Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
|
|
2709
|
+
// An app's project is read from Vercel's list of this repository's projects, never from the file.
|
|
2479
2710
|
const links = linkedByRun(ctx.log);
|
|
2480
2711
|
const own = links.get(link.projectId);
|
|
2481
|
-
if ([...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2712
|
+
if (!at.app && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2482
2713
|
return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2483
2714
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
2484
2715
|
const project = await readOwnProject(vercel, link, repository);
|
|
2716
|
+
const which = at.app ? 'The Vercel project that builds this app' : 'The Vercel project this folder is linked to';
|
|
2485
2717
|
if ('reason' in project)
|
|
2486
2718
|
return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
|
|
2487
2719
|
// The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
|
|
2488
2720
|
if (!project.previewsProtected)
|
|
2489
2721
|
return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
|
|
2490
2722
|
if (!(await projectMadeDeployment(vercel, link, project, host)))
|
|
2491
|
-
return `Refused: nothing was sent to ${at.url}.
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
bypassOrigins.
|
|
2495
|
-
|
|
2723
|
+
return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app ? 'that project' : 'the linked project'} serves.`;
|
|
2724
|
+
/** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
|
|
2725
|
+
const opened = () => {
|
|
2726
|
+
bypassOrigins.set(origin, store);
|
|
2727
|
+
hostedPreviewProven = true;
|
|
2728
|
+
provenHost = 'Vercel';
|
|
2729
|
+
if (at.app)
|
|
2730
|
+
provenApps.add(at.app.appKey);
|
|
2731
|
+
};
|
|
2732
|
+
// A secret in hand already for the same store and project: another address of the project opens with it.
|
|
2733
|
+
const held = bypassHeld.get(store);
|
|
2734
|
+
if (held && held.projectId === link.projectId && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: held.secret })).proven) {
|
|
2735
|
+
opened();
|
|
2736
|
+
return JSON.stringify(held.saved);
|
|
2496
2737
|
}
|
|
2497
2738
|
/** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
|
|
2498
2739
|
let stored = false;
|
|
2499
2740
|
const { value, ...told } = await passVercelLogin({
|
|
2500
2741
|
run: vercel,
|
|
2501
2742
|
link: async () => link,
|
|
2743
|
+
// 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
|
+
...(at.app?.name ? { project: at.app.name } : {}),
|
|
2502
2745
|
consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
2503
2746
|
paste: async () => {
|
|
2504
|
-
// The
|
|
2747
|
+
// The project's own settings page, in its team where the run knows it; else Vercel's picker.
|
|
2505
2748
|
(ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
|
|
2506
2749
|
const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
|
|
2507
2750
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
2508
2751
|
},
|
|
2509
2752
|
prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
|
|
2510
2753
|
save: async (secret) => {
|
|
2511
|
-
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
|
|
2754
|
+
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
|
|
2512
2755
|
stored = saved.stored;
|
|
2513
2756
|
return saved.failed;
|
|
2514
2757
|
},
|
|
2515
2758
|
remember: noteSensitiveAnswer,
|
|
2516
2759
|
});
|
|
2517
|
-
|
|
2760
|
+
const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
|
|
2761
|
+
bypassOutcomes.set(store, outcome);
|
|
2518
2762
|
if (value) {
|
|
2519
2763
|
// What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
provenHost = 'Vercel';
|
|
2526
|
-
bypassSaved = bypassOutcome;
|
|
2764
|
+
for (const [address, of] of bypassOrigins)
|
|
2765
|
+
if (of === store)
|
|
2766
|
+
bypassOrigins.delete(address);
|
|
2767
|
+
bypassHeld.set(store, { secret: value, projectId: link.projectId, saved: outcome });
|
|
2768
|
+
opened();
|
|
2527
2769
|
}
|
|
2528
2770
|
else if (told.status === 'save-failed' && stored) {
|
|
2529
2771
|
// The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2772
|
+
bypassHeld.delete(store);
|
|
2773
|
+
for (const [address, of] of bypassOrigins)
|
|
2774
|
+
if (of === store)
|
|
2775
|
+
bypassOrigins.delete(address);
|
|
2534
2776
|
}
|
|
2535
|
-
return JSON.stringify(
|
|
2777
|
+
return JSON.stringify(outcome);
|
|
2536
2778
|
}),
|
|
2537
2779
|
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2538
2780
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
@@ -2552,6 +2794,8 @@ export function createTools(ctx) {
|
|
|
2552
2794
|
// A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
|
|
2553
2795
|
if ((await prove(undefined)).proven) {
|
|
2554
2796
|
provenPattern = pattern ?? provenPattern;
|
|
2797
|
+
if (at.app)
|
|
2798
|
+
provenApps.add(at.app.appKey);
|
|
2555
2799
|
return JSON.stringify(stands);
|
|
2556
2800
|
}
|
|
2557
2801
|
passwordStands.delete(site);
|
|
@@ -2561,6 +2805,8 @@ export function createTools(ctx) {
|
|
|
2561
2805
|
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
2562
2806
|
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
2563
2807
|
provenPattern = pattern ?? provenPattern;
|
|
2808
|
+
if (at.app)
|
|
2809
|
+
provenApps.add(at.app.appKey);
|
|
2564
2810
|
return JSON.stringify(passwordSaved);
|
|
2565
2811
|
}
|
|
2566
2812
|
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
@@ -2594,7 +2840,7 @@ export function createTools(ctx) {
|
|
|
2594
2840
|
},
|
|
2595
2841
|
prove,
|
|
2596
2842
|
save: async (password) => {
|
|
2597
|
-
const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
|
|
2843
|
+
const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password), at.app?.appKey);
|
|
2598
2844
|
stored = saved.stored;
|
|
2599
2845
|
return saved.failed;
|
|
2600
2846
|
},
|
|
@@ -2607,6 +2853,8 @@ export function createTools(ctx) {
|
|
|
2607
2853
|
// The site is public now: its previews open with nothing sent.
|
|
2608
2854
|
hostedPreviewProven = true;
|
|
2609
2855
|
provenHost = 'Netlify';
|
|
2856
|
+
if (at.app)
|
|
2857
|
+
provenApps.add(at.app.appKey);
|
|
2610
2858
|
provenPattern = pattern ?? provenPattern;
|
|
2611
2859
|
}
|
|
2612
2860
|
if (value) {
|
|
@@ -2616,6 +2864,8 @@ export function createTools(ctx) {
|
|
|
2616
2864
|
passwordSites.add(site);
|
|
2617
2865
|
hostedPreviewProven = true;
|
|
2618
2866
|
provenHost = 'Netlify';
|
|
2867
|
+
if (at.app)
|
|
2868
|
+
provenApps.add(at.app.appKey);
|
|
2619
2869
|
provenPattern = pattern ?? provenPattern;
|
|
2620
2870
|
passwordSaved = passwordOutcome;
|
|
2621
2871
|
}
|
|
@@ -2637,14 +2887,15 @@ export function createTools(ctx) {
|
|
|
2637
2887
|
const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
|
|
2638
2888
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
2639
2889
|
const save = async (token) => {
|
|
2640
|
-
|
|
2890
|
+
const app = at.app ? { appKey: at.app.appKey } : {};
|
|
2891
|
+
if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret, ...app } })).ok)
|
|
2641
2892
|
return 'The setup API did not store the service token.';
|
|
2642
2893
|
// An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
|
|
2643
2894
|
const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
|
|
2644
2895
|
const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
|
|
2645
2896
|
if (lock?.authMethod === 'access')
|
|
2646
2897
|
return null;
|
|
2647
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
|
|
2898
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access', ...app } })).ok)
|
|
2648
2899
|
return 'The setup API stored the service token, but not the setting that sends it: it is turned on under Preview protection on the setup page.';
|
|
2649
2900
|
return null;
|
|
2650
2901
|
};
|
|
@@ -2695,6 +2946,8 @@ export function createTools(ctx) {
|
|
|
2695
2946
|
if (told.status === 'saved') {
|
|
2696
2947
|
hostedPreviewProven = true;
|
|
2697
2948
|
provenHost = 'Cloudflare Pages';
|
|
2949
|
+
if (at.app)
|
|
2950
|
+
provenApps.add(at.app.appKey);
|
|
2698
2951
|
if (pattern)
|
|
2699
2952
|
provenPattern = pattern;
|
|
2700
2953
|
}
|