@impeccable/reviews 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent.js +7 -1
- package/dist/cloudflare-access.js +36 -17
- package/dist/cloudflare.js +9 -4
- package/dist/fence.js +47 -15
- package/dist/host-questions.js +17 -2
- package/dist/hosted.js +67 -9
- package/dist/main.js +46 -4
- package/dist/netlify.js +8 -2
- package/dist/playbook/cases/cloudflare-previews.md +3 -3
- package/dist/playbook/cases/first-preview-failure.md +0 -1
- package/dist/playbook/cases/live-app-address.md +1 -1
- package/dist/playbook/cases/monorepo.md +5 -6
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/pull-requests.md +4 -5
- package/dist/playbook/cases/server-previews.md +3 -3
- package/dist/playbook/cases/vercel-previews.md +3 -3
- package/dist/playbook/cases/vercel-setup.md +2 -2
- package/dist/playbook.js +6 -5
- package/dist/preflight.js +56 -0
- package/dist/sandbox.js +3 -3
- package/dist/tools.js +162 -34
- package/dist/ui/look.js +24 -12
- package/dist/ui/run-state.js +0 -5
- package/dist/ui/screens.js +4 -4
- package/dist/vercel-bypass.js +1 -0
- package/dist/vercel.js +4 -2
- package/package.json +1 -1
- package/dist/playbook/cases/static-site.md +0 -16
package/dist/preflight.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { folderOf } from './host-previews.js';
|
|
6
6
|
import { UserError } from './log.js';
|
|
7
|
+
import { gitEnvironment } from './sandbox.js';
|
|
7
8
|
export const EFFORTS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
8
9
|
/** The environment variable that names the folder to set up when --install-dir does not. */
|
|
9
10
|
const INSTALL_DIR_ENV = 'IMPECCABLE_REVIEWS_INSTALL_DIR';
|
|
@@ -288,3 +289,58 @@ export function stateSaysNoFrontEnd(state, repo) {
|
|
|
288
289
|
const mine = Array.isArray(repos) ? repos.find((r) => r?.name === repo) : undefined;
|
|
289
290
|
return mine?.webSupport?.supported === false;
|
|
290
291
|
}
|
|
292
|
+
/** Whether a push put `branch` on origin: the remote-tracking ref a push records. */
|
|
293
|
+
export function onOrigin(root, branch) {
|
|
294
|
+
try {
|
|
295
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: root, stdio: 'ignore', env: gitEnvironment() });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** The branch the checkout is on; '' when HEAD is detached or git cannot say. */
|
|
303
|
+
export function currentBranch(root) {
|
|
304
|
+
try {
|
|
305
|
+
return execFileSync('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnvironment() }).trim();
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return '';
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The run makes its pull requests on branches of its own, and git leaves the checkout on the last it made: the end goes back
|
|
313
|
+
* to the branch the run started on (`start`), so what the person commits next does not land on the test drive. Only from one
|
|
314
|
+
* of the run's branches, and only with no change to a tracked file, which would go along; git refuses where an untracked
|
|
315
|
+
* file would be overwritten. Null when the checkout is back, or never left; else the line the done screen shows.
|
|
316
|
+
*/
|
|
317
|
+
export function backToStartBranch(root, start) {
|
|
318
|
+
const now = currentBranch(root);
|
|
319
|
+
if (now === start || !/^impeccable\/(?:setup|test-drive)(?:-\d+)?$/.test(now))
|
|
320
|
+
return null;
|
|
321
|
+
if (!start)
|
|
322
|
+
return `Your checkout is on ${now}, the branch the setup made.`;
|
|
323
|
+
const git = (args, input) => execFileSync('git', args, { cwd: root, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: gitEnvironment() });
|
|
324
|
+
try {
|
|
325
|
+
// A content filter is a program git runs outside the sandbox as it reads or writes a file, from settings that may name a script in the checkout a sandboxed command could have changed: a checkout with any file a filter takes, here or on the start branch, is left to the person. Neither read runs one.
|
|
326
|
+
const paths = [...new Set([...git(['ls-files', '-z']).split('\0'), ...git(['diff', '--name-only', '-z', '--no-ext-diff', '--no-textconv', 'HEAD', start, '--']).split('\0')].filter(Boolean))].map((path) => `${path}\0`).join('');
|
|
327
|
+
const listed = git(['check-attr', '--stdin', '-z', 'filter'], paths).split('\0');
|
|
328
|
+
// path, attribute, value, ...
|
|
329
|
+
for (let i = 2; i < listed.length; i += 3)
|
|
330
|
+
if (!['unspecified', 'unset'].includes(listed[i]))
|
|
331
|
+
throw new Error('a content filter');
|
|
332
|
+
// The start branch's own .gitattributes files, read as they are there (check-attr --source needs git 2.40): any that names a filter counts.
|
|
333
|
+
const attributes = git(['ls-tree', '-r', '-z', '--name-only', start]).split('\0').filter((path) => path === '.gitattributes' || path.endsWith('/.gitattributes'));
|
|
334
|
+
if (attributes.some((path) => /\bfilter\b/.test(git(['cat-file', 'blob', `${start}:${path}`]))))
|
|
335
|
+
throw new Error('a content filter on the start branch');
|
|
336
|
+
if (git(['status', '--porcelain', '--untracked-files=no']).trim())
|
|
337
|
+
throw new Error('changes to tracked files');
|
|
338
|
+
// The closing -- makes git read the name as the branch, never as a path.
|
|
339
|
+
// An ignored file of the person's where the start branch tracks a file of that name stops the checkout, rather than being overwritten.
|
|
340
|
+
git(['-c', 'submodule.recurse=false', 'checkout', '-q', '--no-overwrite-ignore', start, '--']);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return `Your checkout is still on ${now}: git checkout ${start} takes it back to the branch you started on.`;
|
|
345
|
+
}
|
|
346
|
+
}
|
package/dist/sandbox.js
CHANGED
|
@@ -281,7 +281,8 @@ function scrubbedGitConfig(root, home, log) {
|
|
|
281
281
|
includes.push(...gitIncludes(list(['--file', file, '--no-includes'], { unread: (said) => log.write('git include not read', { file, said }) }), home));
|
|
282
282
|
}
|
|
283
283
|
const path = join(ownFolder(), `gitconfig-${randomBytes(6).toString('hex')}`);
|
|
284
|
-
|
|
284
|
+
// Last, so it holds over the person's own: a branch made from origin/<branch> sets up no tracking, which git would write to the config the sandbox keeps and print its refusal twice. The push -u, as the person, sets it.
|
|
285
|
+
writeFileSync(path, `${text}[branch]\n\tautoSetupMerge = false\n`, { mode: 0o600 });
|
|
285
286
|
const execPath = execFileSync('git', ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env }).trim();
|
|
286
287
|
const programs = helpers.flatMap((name) => [...new Set([execPath, ...APPLE_GIT_CORE])].map((dir) => join(dir, `git-credential-${name}`))).filter(present);
|
|
287
288
|
return { path, files: [...seen], helpers: programs };
|
|
@@ -616,10 +617,9 @@ export async function startSandbox(o) {
|
|
|
616
617
|
}
|
|
617
618
|
},
|
|
618
619
|
};
|
|
619
|
-
const proven = "the preview job on the pull request proves a static site's build instead";
|
|
620
620
|
return {
|
|
621
621
|
warning: off && `${off[0].toUpperCase()}${off.slice(1)}, so the setup's commands run without a sandbox, with all your access.`,
|
|
622
|
-
noPreview: off ? `The local preview check, which needs a sandbox of its own, does not run either
|
|
622
|
+
noPreview: off ? `The local preview check, which needs a sandbox of its own, does not run either.` : platform === 'linux' ? `On Linux bubblewrap keeps the app from the local preview check, so the check does not run here.` : undefined,
|
|
623
623
|
shell,
|
|
624
624
|
finish: () => watched.settle(),
|
|
625
625
|
preview(folder) {
|
package/dist/tools.js
CHANGED
|
@@ -10,9 +10,9 @@ 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, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
|
-
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
15
|
+
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
17
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
18
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
@@ -167,6 +167,22 @@ export function ending(report) {
|
|
|
167
167
|
}
|
|
168
168
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
169
169
|
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
170
|
+
/** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
|
|
171
|
+
export function hostLines(used) {
|
|
172
|
+
const byHost = new Map();
|
|
173
|
+
for (const u of used) {
|
|
174
|
+
const entry = byHost.get(u.host) ?? { accounts: new Set(), projects: new Set() };
|
|
175
|
+
if (u.account)
|
|
176
|
+
entry.accounts.add(u.account);
|
|
177
|
+
entry.projects.add(u.project);
|
|
178
|
+
byHost.set(u.host, entry);
|
|
179
|
+
}
|
|
180
|
+
return [...byHost].map(([host, { accounts, projects }]) => {
|
|
181
|
+
const what = host === 'Netlify' ? 'site' : 'project';
|
|
182
|
+
const account = accounts.size ? `${host === 'Cloudflare' ? '' : 'team '}${[...accounts].join(', ')} · ` : '';
|
|
183
|
+
return `${host}: ${account}${projects.size > 1 ? `${what}s` : what} ${[...projects].join(', ')}`;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
170
186
|
const PROGRESS = {
|
|
171
187
|
type: 'object',
|
|
172
188
|
additionalProperties: false,
|
|
@@ -229,6 +245,14 @@ const TEST_PR = {
|
|
|
229
245
|
const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
230
246
|
/** 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. */
|
|
231
247
|
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.' };
|
|
248
|
+
const VERCEL_BYPASS = {
|
|
249
|
+
type: 'object',
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
properties: {
|
|
252
|
+
appKey: APP_KEY,
|
|
253
|
+
beforePush: { type: 'boolean', description: "True before the test drive's push on Vercel, where no preview met Vercel's login yet: reads whether the project's previews are protected and asks the consent now, before anything is pushed." },
|
|
254
|
+
},
|
|
255
|
+
};
|
|
232
256
|
const HOSTED_PREVIEWS = {
|
|
233
257
|
type: 'object',
|
|
234
258
|
additionalProperties: false,
|
|
@@ -1584,6 +1608,27 @@ export function createTools(ctx) {
|
|
|
1584
1608
|
/** Whether any preview write with a method was taken this run, and the host's lock the last gated proof met: the last screen names it when no preview ever opened (#321), and Preview method is completed only once a method is saved (#322). */
|
|
1585
1609
|
let methodSaved = false;
|
|
1586
1610
|
let lastLock;
|
|
1611
|
+
/** The host preview addresses a proof or a host's key opened, and the Cloudflare account and Netlify team of each project or site the host tools read, by host and project: the last screen names them (#360), and no tool's answer does. */
|
|
1612
|
+
const usedPreviews = new Set();
|
|
1613
|
+
const accountOf = new Map();
|
|
1614
|
+
/** The Vercel project the bypass tool read for each address its secret opened, and its team where that is not the CLI's current scope. */
|
|
1615
|
+
const vercelNamed = new Map();
|
|
1616
|
+
/** What the last screen says the run used on each host, from the previews it opened. */
|
|
1617
|
+
const hostsUsed = () => [...usedPreviews].flatMap((url) => {
|
|
1618
|
+
const origin = new URL(url).origin;
|
|
1619
|
+
const label = pagesProject(origin);
|
|
1620
|
+
if (label)
|
|
1621
|
+
return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
|
|
1622
|
+
const site = netlifySiteOf(url);
|
|
1623
|
+
if (site)
|
|
1624
|
+
return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
|
|
1625
|
+
const read = vercelNamed.get(origin);
|
|
1626
|
+
const named = read?.project ?? listedApps.get(origin)?.name;
|
|
1627
|
+
const vercel = vercelDeploymentOf(url, named);
|
|
1628
|
+
const team = vercel?.team ?? read?.team;
|
|
1629
|
+
const project = vercel?.project ?? named;
|
|
1630
|
+
return project ? [{ host: 'Vercel', project, ...(team ? { account: team } : {}) }] : [];
|
|
1631
|
+
});
|
|
1587
1632
|
/**
|
|
1588
1633
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1589
1634
|
* every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
@@ -1641,6 +1686,8 @@ export function createTools(ctx) {
|
|
|
1641
1686
|
const bypassOrigins = new Map();
|
|
1642
1687
|
/** What impeccable_vercel_bypass answered for a store, less the secret: a skip is asked once, so that answer stands. */
|
|
1643
1688
|
const bypassOutcomes = new Map();
|
|
1689
|
+
/** The stores whose consent was given before the test drive's push, each with the project it named (`<store>/<projectId>`): the call once the gate is met asks no second time for that project alone. */
|
|
1690
|
+
const bypassConsented = new Set();
|
|
1644
1691
|
/** 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. */
|
|
1645
1692
|
let sitePassword;
|
|
1646
1693
|
const passwordSites = new Set();
|
|
@@ -1659,6 +1706,8 @@ export function createTools(ctx) {
|
|
|
1659
1706
|
let accessStored = false;
|
|
1660
1707
|
/** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
|
|
1661
1708
|
let accessOutcome;
|
|
1709
|
+
/** The Access applications, by id, the person said yes to a token for. */
|
|
1710
|
+
const accessConsented = new Set();
|
|
1662
1711
|
/** What each host's settings last said of its pull-request previews, as the evidence tools read them: the one fact impeccable_enable_previews acts on. */
|
|
1663
1712
|
const previewFacts = new Map();
|
|
1664
1713
|
/** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
|
|
@@ -1722,9 +1771,10 @@ export function createTools(ctx) {
|
|
|
1722
1771
|
reportStatus(status);
|
|
1723
1772
|
};
|
|
1724
1773
|
/** A screen a tool puts up on its own: the web page shows the run waiting on it, as on the model's questions, until it is answered. */
|
|
1725
|
-
|
|
1774
|
+
/** A screen of the tool's own, which offers Skip unless `skippable` is false. */
|
|
1775
|
+
const ask = async (screen, skippable = true) => {
|
|
1726
1776
|
ctx.sync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
|
|
1727
|
-
const answer = await askPerson({ ...screen, skippable
|
|
1777
|
+
const answer = await askPerson({ ...screen, skippable });
|
|
1728
1778
|
ctx.sync.update({ phase: 'running', question: null });
|
|
1729
1779
|
return answer;
|
|
1730
1780
|
};
|
|
@@ -2234,12 +2284,16 @@ export function createTools(ctx) {
|
|
|
2234
2284
|
const start = ctx.start ?? '';
|
|
2235
2285
|
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;
|
|
2236
2286
|
const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
|
|
2237
|
-
|
|
2287
|
+
// Reached by Esc on the named app's screen ("Pick other apps"), the list has no Skip on Esc: a second Esc to go back would skip the apps (#324).
|
|
2288
|
+
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 })) }, !named);
|
|
2238
2289
|
if (answer.skipped) {
|
|
2239
2290
|
pick(new Set(apps.filter((a) => a.enabled).map((a) => a.key)));
|
|
2240
2291
|
return JSON.stringify({ skipped: true, on: [...pickedApps] });
|
|
2241
2292
|
}
|
|
2242
2293
|
const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
|
|
2294
|
+
// Nothing picked on the list the named app's screen opened, which has no Skip (a blank line in a piped run): the named app stands.
|
|
2295
|
+
if (named && !values.length)
|
|
2296
|
+
values.push(named.key);
|
|
2243
2297
|
pick(new Set(values));
|
|
2244
2298
|
return JSON.stringify({ values });
|
|
2245
2299
|
}
|
|
@@ -2344,13 +2398,16 @@ export function createTools(ctx) {
|
|
|
2344
2398
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
2345
2399
|
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.";
|
|
2346
2400
|
}
|
|
2347
|
-
// 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.
|
|
2401
|
+
// 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, or a line of the root README that names that app and no other (#367).
|
|
2348
2402
|
if (appsRepo && target.url.endsWith('/preview') && saved && typeof saved.beforeUrl === 'string') {
|
|
2349
2403
|
const origin = new URL(saved.beforeUrl).origin;
|
|
2350
2404
|
const owner = liveApps.get(origin);
|
|
2351
2405
|
const folder = await appFolder(pi, String(saved.appKey));
|
|
2352
|
-
|
|
2353
|
-
|
|
2406
|
+
const app = knownApps?.find((a) => a.key === saved.appKey);
|
|
2407
|
+
const named = async () => folder !== undefined && ((await checkoutNames(pi.cwd, origin, folder))
|
|
2408
|
+
|| (app !== undefined && (await readmeNamesApp(pi.cwd, origin, app, (knownApps ?? []).filter((a) => a.key !== app.key)))));
|
|
2409
|
+
if (owner !== undefined ? owner !== saved.appKey : !(await named())) {
|
|
2410
|
+
return `Refused: ${origin} is not the live address of ${String(saved.appKey)}: it is another app's, or neither a file in that app's folder nor a line of the root README that names that app (and no other) names it. Nothing was saved.`;
|
|
2354
2411
|
}
|
|
2355
2412
|
}
|
|
2356
2413
|
const testAccounts = target.url.endsWith('/test-accounts');
|
|
@@ -2384,8 +2441,6 @@ export function createTools(ctx) {
|
|
|
2384
2441
|
confirmedApps = true;
|
|
2385
2442
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
2386
2443
|
const keys = object(object(response.data)?.keys);
|
|
2387
|
-
if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
|
|
2388
|
-
retry.requireHistory = true;
|
|
2389
2444
|
if (object(p.body)?.intendedMethod === 'preview-deploy')
|
|
2390
2445
|
savedHost = provenHost;
|
|
2391
2446
|
if (saved?.appKey !== undefined)
|
|
@@ -2610,7 +2665,8 @@ export function createTools(ctx) {
|
|
|
2610
2665
|
return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
|
|
2611
2666
|
}
|
|
2612
2667
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2613
|
-
|
|
2668
|
+
const hosts = hostLines(hostsUsed());
|
|
2669
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2614
2670
|
pi.shutdown();
|
|
2615
2671
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2616
2672
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2620,6 +2676,9 @@ export function createTools(ctx) {
|
|
|
2620
2676
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
2621
2677
|
if (refused)
|
|
2622
2678
|
return refused;
|
|
2679
|
+
// The bypass secret skipped: Vercel's login keeps the test drive's preview from the review, so there is none to wait for (#356).
|
|
2680
|
+
if (branch !== undefined && bypassOutcomes.get(appsRepo ? p.appKey : '')?.status === 'skipped')
|
|
2681
|
+
return "Refused: the person skipped the bypass secret, and Vercel's login stands in front of the previews, so the test drive's preview could not be reviewed: push and open no test drive, as preview-protection's step 3 says. Nothing was read.";
|
|
2623
2682
|
// The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
|
|
2624
2683
|
const reads = new Map();
|
|
2625
2684
|
const vercel = (args) => {
|
|
@@ -2729,7 +2788,9 @@ export function createTools(ctx) {
|
|
|
2729
2788
|
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2730
2789
|
if (appKey !== undefined && directory === undefined)
|
|
2731
2790
|
return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
2732
|
-
|
|
2791
|
+
// Each site's team is for the last screen alone.
|
|
2792
|
+
const onTeams = (teams) => Object.entries(teams).forEach(([name, team]) => accountOf.set(`netlify ${name}`, team));
|
|
2793
|
+
const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, onTeams, ...(directory !== undefined ? { directory } : {}) });
|
|
2733
2794
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
2734
2795
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
2735
2796
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
@@ -2780,7 +2841,9 @@ export function createTools(ctx) {
|
|
|
2780
2841
|
const appProject = appKey !== undefined ? cloudflareApps.get(appKey) : undefined;
|
|
2781
2842
|
if (appKey !== undefined && (pull !== undefined || branch !== undefined) && !appProject)
|
|
2782
2843
|
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
2783
|
-
|
|
2844
|
+
// The name of the account the project was found in is for the last screen alone.
|
|
2845
|
+
let accountName;
|
|
2846
|
+
const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}) });
|
|
2784
2847
|
const read = async () => {
|
|
2785
2848
|
const evidence = await readAll();
|
|
2786
2849
|
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
@@ -2795,6 +2858,10 @@ export function createTools(ctx) {
|
|
|
2795
2858
|
const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false } : undefined;
|
|
2796
2859
|
const waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
2797
2860
|
const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
|
|
2861
|
+
// By the label the project's addresses carry.
|
|
2862
|
+
const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
|
|
2863
|
+
if (accountName && label && !evidence.unmatched)
|
|
2864
|
+
accountOf.set(`cloudflare ${label}`, accountName);
|
|
2798
2865
|
if (!subject)
|
|
2799
2866
|
cloudflareEvidence = evidence;
|
|
2800
2867
|
if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
|
|
@@ -2852,6 +2919,8 @@ export function createTools(ctx) {
|
|
|
2852
2919
|
hostedPreviewProven ||= proof.proven;
|
|
2853
2920
|
if (proof.proven)
|
|
2854
2921
|
provenHost = previewHostName(url) ?? provenHost;
|
|
2922
|
+
if (proof.proven)
|
|
2923
|
+
usedPreviews.add(url);
|
|
2855
2924
|
if (proof.proven && app)
|
|
2856
2925
|
proveApp(app.appKey, url);
|
|
2857
2926
|
if (proof.proven && app)
|
|
@@ -2862,8 +2931,16 @@ export function createTools(ctx) {
|
|
|
2862
2931
|
lockPending.set(app.appKey, proof.gate);
|
|
2863
2932
|
provenApps.delete(app.appKey);
|
|
2864
2933
|
}
|
|
2865
|
-
if (proof.gate && Object.hasOwn(LOCKS, proof.gate))
|
|
2934
|
+
if (proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
|
|
2866
2935
|
lastLock = proof.gate;
|
|
2936
|
+
// Pristine learns the lock from the session, proven past or not, for a preview it may never meet itself (#365): an app's on its summary, a repository of one app's beside them.
|
|
2937
|
+
const met = { gate: lastLock, preview_host: new URL(url).hostname };
|
|
2938
|
+
// The app is proven again only once something opens it, as `provenApps` has it.
|
|
2939
|
+
if (app)
|
|
2940
|
+
noteApp(app.appKey, { ...met, preview_proven: false });
|
|
2941
|
+
else if (!appsRepo)
|
|
2942
|
+
ctx.sync.update({ preview_gate: met });
|
|
2943
|
+
}
|
|
2867
2944
|
if (proof.proven)
|
|
2868
2945
|
provePattern(proof.pattern, app?.appKey);
|
|
2869
2946
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
@@ -2951,7 +3028,7 @@ export function createTools(ctx) {
|
|
|
2951
3028
|
return `Refused: the Pages project ${fact.project} is connected to the repository, and its settings turn pull-request previews off: impeccable_enable_previews turns them on.`;
|
|
2952
3029
|
const create = !fact;
|
|
2953
3030
|
if (create && !evidence.worker && !evidence.project)
|
|
2954
|
-
return 'Refused: nothing says the repository is set up for Cloudflare: no Pages project is built from it, the checkout names none, and no Worker is built from it. Set the preview up as the server-previews
|
|
3031
|
+
return 'Refused: nothing says the repository is set up for Cloudflare: no Pages project is built from it, the checkout names none, and no Worker is built from it. Set the preview up as the server-previews case says.';
|
|
2955
3032
|
const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
|
|
2956
3033
|
const answer = (status, more) => {
|
|
2957
3034
|
connectOutcome = { status, project, created: create, ...more };
|
|
@@ -3009,23 +3086,40 @@ export function createTools(ctx) {
|
|
|
3009
3086
|
noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
|
|
3010
3087
|
return JSON.stringify(answer);
|
|
3011
3088
|
}),
|
|
3012
|
-
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.',
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3089
|
+
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back. With beforePush (and the app\'s appKey on a repository with several apps), before the test drive\'s push, where no preview met Vercel\'s login yet (#356): it reads the project the same way, and where its settings show Deployment Protection in front of the previews and it holds no secret labelled "Impeccable reviews", shows the consent screen now, naming the project and the app, so the person answers before anything is pushed; the secret is made on the yes once the test drive\'s preview meets the login, with no second question. Answers {"protected": false} (no lock: push), {"protected": true, "consent": "given" or "not-needed"} (push, and call this again without beforePush once the proof meets the gate), {"protected": "unknown", "reason": …} (the project could not be read: push, and the consent comes when the gate is met) or {"status": "skipped", …} (push no test drive; reason says why).', VERCEL_BYPASS, async (p, pi, signal) => {
|
|
3090
|
+
const before = p.beforePush === true;
|
|
3091
|
+
if (before) {
|
|
3092
|
+
const refused = appRefusal(p.appKey, 'asked');
|
|
3093
|
+
if (refused)
|
|
3094
|
+
return refused;
|
|
3095
|
+
}
|
|
3096
|
+
else if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
3097
|
+
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first, or call it with beforePush before the test drive\'s push.';
|
|
3098
|
+
const at = before ? undefined : gated;
|
|
3099
|
+
if (at?.app)
|
|
3017
3100
|
lockPending.delete(at.app.appKey);
|
|
3018
|
-
const
|
|
3101
|
+
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
3102
|
+
// Before the push, the app's project as impeccable_hosted_previews reads it: the one that builds the app's folder (#281).
|
|
3103
|
+
let app = at?.app;
|
|
3104
|
+
if (before && appsRepo) {
|
|
3105
|
+
const read = await vercelApp(pi, p.appKey, vercel);
|
|
3106
|
+
if ('reason' in read)
|
|
3107
|
+
return JSON.stringify({ appKey: p.appKey, protected: 'unknown', reason: read.reason });
|
|
3108
|
+
app = { appKey: p.appKey, link: read.link, name: read.name };
|
|
3109
|
+
}
|
|
3019
3110
|
// Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
|
|
3020
|
-
const store =
|
|
3111
|
+
const store = app?.appKey ?? '';
|
|
3021
3112
|
const asked = bypassOutcomes.get(store);
|
|
3022
3113
|
if (asked?.status === 'skipped')
|
|
3023
3114
|
return JSON.stringify(asked);
|
|
3115
|
+
/** What stops the tool before the project is vouched for: the address got nothing, or, before the push, the lock is not known yet and the gate's call asks. */
|
|
3116
|
+
const nothing = (why) => (at ? `Refused: nothing was sent to ${at.url}. ${why}` : JSON.stringify({ ...(app ? { appKey: app.appKey } : {}), protected: 'unknown', reason: why }));
|
|
3117
|
+
/** The consent screen, naming the project and the app once they are known (#358). */
|
|
3118
|
+
const consentScreen = (project) => vercelBypassConsent(project ?? app?.name, app && appName(app.appKey));
|
|
3024
3119
|
// 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
|
|
3025
3120
|
// 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,
|
|
3026
3121
|
// else the one Vercel lists as connected to this repository (#310).
|
|
3027
|
-
const
|
|
3028
|
-
const fileLink = at.app ? null : await readLink(pi.cwd);
|
|
3122
|
+
const fileLink = app ? null : await readLink(pi.cwd);
|
|
3029
3123
|
/** The consent screen's yes, given before Vercel's sign-in, stands for the secret: it is not asked again. */
|
|
3030
3124
|
let consented = false;
|
|
3031
3125
|
/**
|
|
@@ -3039,7 +3133,7 @@ export function createTools(ctx) {
|
|
|
3039
3133
|
bypassOutcomes.set(store, outcome);
|
|
3040
3134
|
return JSON.stringify(outcome);
|
|
3041
3135
|
};
|
|
3042
|
-
if ((await ask(
|
|
3136
|
+
if ((await ask(consentScreen())).skipped)
|
|
3043
3137
|
return skip('The person skipped creating a bypass secret.');
|
|
3044
3138
|
consented = true;
|
|
3045
3139
|
const agreed = hostSetupAgreed(ctx.log, 'vercel');
|
|
@@ -3062,23 +3156,23 @@ export function createTools(ctx) {
|
|
|
3062
3156
|
// A folder Vercel deploys from GitHub has no link file unless vercel link ran in it (the file is gitignored), so the project is found on Vercel by this repository,
|
|
3063
3157
|
// with the CLI signed in as the person.
|
|
3064
3158
|
let found;
|
|
3065
|
-
if (!
|
|
3159
|
+
if (!app && !fileLink) {
|
|
3066
3160
|
const cli = (await vercelStatus(vercel)).cli;
|
|
3067
3161
|
const stop = cli === 'signed-out' ? await signIn() : null;
|
|
3068
3162
|
if (stop)
|
|
3069
3163
|
return stop;
|
|
3070
3164
|
const listed = cli === 'missing' ? { reason: 'The Vercel CLI is not installed here.' } : await vercelRepoProject(vercel, repository);
|
|
3071
3165
|
if ('reason' in listed)
|
|
3072
|
-
return `
|
|
3166
|
+
return nothing(`This folder is linked to no Vercel project (.vercel/project.json), and Vercel named none for this repository: ${listed.reason} A bypass secret goes only to a preview the repository's project serves.`);
|
|
3073
3167
|
found = listed;
|
|
3074
3168
|
}
|
|
3075
|
-
const link =
|
|
3169
|
+
const link = app?.link ?? fileLink ?? found.link;
|
|
3076
3170
|
// 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.
|
|
3077
3171
|
// An app's project, and one found with no link file, are read from Vercel's list of this repository's projects, never from the file.
|
|
3078
3172
|
const links = linkedByRun(ctx.log);
|
|
3079
3173
|
const own = links.get(link.projectId);
|
|
3080
3174
|
if (fileLink && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
3081
|
-
return
|
|
3175
|
+
return nothing("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.");
|
|
3082
3176
|
let project = await readOwnProject(vercel, link, repository);
|
|
3083
3177
|
// A linked project, or an app's, read nothing because the CLI is signed out: the same sign-in, then the read again.
|
|
3084
3178
|
if ('reason' in project && !consented && (await vercelStatus(vercel)).cli === 'signed-out') {
|
|
@@ -3087,9 +3181,27 @@ export function createTools(ctx) {
|
|
|
3087
3181
|
return stop;
|
|
3088
3182
|
project = await readOwnProject(vercel, link, repository);
|
|
3089
3183
|
}
|
|
3090
|
-
const which =
|
|
3184
|
+
const which = app ? 'The Vercel project that builds this app' : found ? 'The Vercel project connected to this repository' : 'The Vercel project this folder is linked to';
|
|
3091
3185
|
if ('reason' in project)
|
|
3092
|
-
return
|
|
3186
|
+
return nothing(`${project.reason} A bypass secret goes only to a preview the linked project serves.`);
|
|
3187
|
+
if (!at) {
|
|
3188
|
+
// Before the push: the project's settings say whether Vercel's login will stand in front of the test drive's preview, and the consent is asked now (#356).
|
|
3189
|
+
const of = { ...(app ? { appKey: app.appKey } : {}), ...(project.name ? { project: project.name } : {}) };
|
|
3190
|
+
if (!project.previewsProtected)
|
|
3191
|
+
return JSON.stringify({ ...of, protected: false });
|
|
3192
|
+
// A secret this run saved for the same store and project, or one labelled for Impeccable, opens the test drive's preview with no question; a yes given already stands.
|
|
3193
|
+
const held = bypassHeld.get(store);
|
|
3194
|
+
if (project.bypassLabelled || held?.projectId === link.projectId)
|
|
3195
|
+
return JSON.stringify({ ...of, protected: true, consent: 'not-needed' });
|
|
3196
|
+
if (consented || bypassConsented.has(`${store}/${link.projectId}`) || !(await ask(consentScreen(project.name))).skipped) {
|
|
3197
|
+
bypassConsented.add(`${store}/${link.projectId}`);
|
|
3198
|
+
return JSON.stringify({ ...of, protected: true, consent: 'given' });
|
|
3199
|
+
}
|
|
3200
|
+
const outcome = { status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` };
|
|
3201
|
+
bypassOutcomes.set(store, outcome);
|
|
3202
|
+
return JSON.stringify(outcome);
|
|
3203
|
+
}
|
|
3204
|
+
const { origin, host } = new URL(at.url);
|
|
3093
3205
|
// 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.
|
|
3094
3206
|
if (!project.previewsProtected && lastLock === 'vercel-protection')
|
|
3095
3207
|
lastLock = undefined;
|
|
@@ -3102,6 +3214,8 @@ export function createTools(ctx) {
|
|
|
3102
3214
|
bypassOrigins.set(origin, store);
|
|
3103
3215
|
hostedPreviewProven = true;
|
|
3104
3216
|
provenHost = 'Vercel';
|
|
3217
|
+
usedPreviews.add(at.url);
|
|
3218
|
+
vercelNamed.set(origin, { ...(project.name ? { project: project.name } : {}), ...(found?.scope ? { team: found.scope } : {}) });
|
|
3105
3219
|
if (at.app)
|
|
3106
3220
|
proveApp(at.app.appKey, at.url);
|
|
3107
3221
|
};
|
|
@@ -3118,7 +3232,8 @@ export function createTools(ctx) {
|
|
|
3118
3232
|
link: async () => link,
|
|
3119
3233
|
// A project the folder is not linked to is named on the CLI's reads and makes, in its own team; the reads are held to its id all the same.
|
|
3120
3234
|
...(at.app?.name ? { project: at.app.name } : found ? { project: found.name, ...(found.scope ? { scope: found.scope } : {}) } : {}),
|
|
3121
|
-
|
|
3235
|
+
// A yes before the push stands for this secret: the gate's call asks no second time (#356).
|
|
3236
|
+
consent: async () => consented || bypassConsented.has(`${store}/${link.projectId}`) || !(await ask(consentScreen(project.name))).skipped,
|
|
3122
3237
|
paste: async (stopped) => {
|
|
3123
3238
|
// 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.
|
|
3124
3239
|
(ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
|
|
@@ -3134,6 +3249,8 @@ export function createTools(ctx) {
|
|
|
3134
3249
|
return saved.failed;
|
|
3135
3250
|
},
|
|
3136
3251
|
remember: noteSensitiveAnswer,
|
|
3252
|
+
// A secret the run made stays in the person's Vercel project, whatever comes after: the end screen says so, and where it is revoked (#335).
|
|
3253
|
+
made: (held, name) => ctx.onKeyCreated?.(`Impeccable created a Vercel bypass secret${held === 'labelled' ? ', "Impeccable reviews",' : ' without a label'}${name ? ` on the ${name} project` : ''}. Revoke it under the project's Settings → Deployment Protection → Protection Bypass for Automation; reviews stop until a new one is saved.`),
|
|
3137
3254
|
});
|
|
3138
3255
|
const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
|
|
3139
3256
|
bypassOutcomes.set(store, outcome);
|
|
@@ -3236,6 +3353,7 @@ export function createTools(ctx) {
|
|
|
3236
3353
|
// The site is public now: its previews open with nothing sent.
|
|
3237
3354
|
hostedPreviewProven = true;
|
|
3238
3355
|
provenHost = 'Netlify';
|
|
3356
|
+
usedPreviews.add(at.url);
|
|
3239
3357
|
if (at.app)
|
|
3240
3358
|
proveApp(at.app.appKey, at.url);
|
|
3241
3359
|
provePattern(pattern, at.app?.appKey);
|
|
@@ -3247,6 +3365,7 @@ export function createTools(ctx) {
|
|
|
3247
3365
|
passwordSites.add(site);
|
|
3248
3366
|
hostedPreviewProven = true;
|
|
3249
3367
|
provenHost = 'Netlify';
|
|
3368
|
+
usedPreviews.add(at.url);
|
|
3250
3369
|
if (at.app)
|
|
3251
3370
|
proveApp(at.app.appKey, at.url);
|
|
3252
3371
|
provePattern(pattern, at.app?.appKey);
|
|
@@ -3260,7 +3379,7 @@ export function createTools(ctx) {
|
|
|
3260
3379
|
}
|
|
3261
3380
|
return JSON.stringify(passwordOutcome);
|
|
3262
3381
|
}),
|
|
3263
|
-
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 (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3382
|
+
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, naming the Access application in front of the preview and its Cloudflare account once cf, signed in here, found it (none found: refused, and nothing asked), Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3264
3383
|
if (!accessGated)
|
|
3265
3384
|
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.';
|
|
3266
3385
|
const at = accessGated;
|
|
@@ -3298,7 +3417,15 @@ export function createTools(ctx) {
|
|
|
3298
3417
|
else {
|
|
3299
3418
|
answer = await passCloudflareAccess({
|
|
3300
3419
|
url: at.url,
|
|
3301
|
-
|
|
3420
|
+
// Named once the Access application was found (#357), and asked once per application: a yes stands for another app's previews it covers.
|
|
3421
|
+
consent: async (found) => {
|
|
3422
|
+
if (found && accessConsented.has(found.id))
|
|
3423
|
+
return true;
|
|
3424
|
+
const yes = !(await ask(sentTo(found ? cloudflareAccessConsent(found, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
|
|
3425
|
+
if (yes && found)
|
|
3426
|
+
accessConsented.add(found.id);
|
|
3427
|
+
return yes;
|
|
3428
|
+
},
|
|
3302
3429
|
cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
|
|
3303
3430
|
login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
|
|
3304
3431
|
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
@@ -3335,6 +3462,7 @@ export function createTools(ctx) {
|
|
|
3335
3462
|
if (told.status === 'saved') {
|
|
3336
3463
|
hostedPreviewProven = true;
|
|
3337
3464
|
provenHost = 'Cloudflare Pages';
|
|
3465
|
+
usedPreviews.add(at.url);
|
|
3338
3466
|
if (at.app)
|
|
3339
3467
|
proveApp(at.app.appKey, at.url);
|
|
3340
3468
|
provePattern(pattern, at.app?.appKey);
|