@impeccable/reviews 0.1.0 → 0.1.2
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/LICENSE +14 -0
- package/README.md +29 -8
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +321 -0
- package/dist/cloudflare.js +291 -0
- package/dist/fence.js +309 -46
- package/dist/focus.js +49 -21
- package/dist/host-questions.js +47 -1
- package/dist/hosted.js +95 -16
- package/dist/log.js +6 -1
- package/dist/login.js +9 -7
- package/dist/main.js +86 -27
- package/dist/netlify-password.js +89 -0
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +17 -0
- package/dist/playbook/cases/monorepo.md +22 -0
- package/dist/playbook/cases/netlify-previews.md +1 -1
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +13 -11
- package/dist/playbook/cases/vercel-setup.md +7 -5
- package/dist/playbook.js +4 -3
- package/dist/preflight.js +56 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/scan.js +111 -0
- package/dist/tools.js +337 -63
- package/dist/ui/bridge.js +6 -3
- package/dist/ui/look.js +21 -7
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +31 -25
- package/dist/vercel.js +77 -0
- package/package.json +3 -2
package/dist/tools.js
CHANGED
|
@@ -7,16 +7,20 @@ import { request } from 'node:http';
|
|
|
7
7
|
import { createServer } from 'node:net';
|
|
8
8
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
9
|
import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
10
|
-
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState } from './fence.js';
|
|
10
|
+
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
|
-
import { focusCheckup, focusState } from './focus.js';
|
|
12
|
+
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
13
|
import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
|
|
14
|
-
import { previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
14
|
+
import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
15
|
+
import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
|
|
16
|
+
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
15
17
|
import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
16
|
-
import {
|
|
18
|
+
import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
|
|
19
|
+
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
|
|
17
20
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
18
21
|
import { fitsTask, stagesOf } from './stages.js';
|
|
19
22
|
import { redact } from './log.js';
|
|
23
|
+
import { gitEnvironment } from './sandbox.js';
|
|
20
24
|
import { openBrowser } from './login.js';
|
|
21
25
|
import { TASK_STATUSES } from './sync.js';
|
|
22
26
|
import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
@@ -196,12 +200,20 @@ const NETLIFY_PREVIEWS = {
|
|
|
196
200
|
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.' },
|
|
197
201
|
},
|
|
198
202
|
};
|
|
203
|
+
const CLOUDFLARE_PREVIEWS = {
|
|
204
|
+
type: 'object',
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
properties: {
|
|
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.' },
|
|
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.' },
|
|
209
|
+
},
|
|
210
|
+
};
|
|
199
211
|
const PROVE_PREVIEW = {
|
|
200
212
|
type: 'object',
|
|
201
213
|
additionalProperties: false,
|
|
202
214
|
required: ['url', 'expect'],
|
|
203
215
|
properties: {
|
|
204
|
-
url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews or
|
|
216
|
+
url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or a page on it.' },
|
|
205
217
|
expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
|
|
206
218
|
},
|
|
207
219
|
};
|
|
@@ -222,7 +234,7 @@ const ASK = {
|
|
|
222
234
|
options: {
|
|
223
235
|
type: 'array',
|
|
224
236
|
description: 'single and multi: the choices, at least two.',
|
|
225
|
-
items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 } } },
|
|
237
|
+
items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 }, selected: { type: 'boolean', description: 'multi only: picked when the question opens; the person can unpick it.' } } },
|
|
226
238
|
},
|
|
227
239
|
prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
|
|
228
240
|
confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
|
|
@@ -252,6 +264,8 @@ function askError(q) {
|
|
|
252
264
|
return q.options ? 'input.options is for single and multi only' : null;
|
|
253
265
|
if (values.length < 2)
|
|
254
266
|
return `input.options needs at least two choices for ${q.kind}`;
|
|
267
|
+
if (q.kind === 'single' && q.options.some((o) => o.selected !== undefined))
|
|
268
|
+
return 'input.options[].selected is for multi only';
|
|
255
269
|
return new Set(values).size === values.length ? null : 'input.options has a value twice';
|
|
256
270
|
}
|
|
257
271
|
/** Why `url` is not pull request `number` of owner/repo on GitHub; null when it is. */
|
|
@@ -396,7 +410,7 @@ async function stopPreviewChild(child) {
|
|
|
396
410
|
catch { /* already stopped */ }
|
|
397
411
|
}
|
|
398
412
|
/** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
|
|
399
|
-
export async function verifyLocalPreview(input, root, signal) {
|
|
413
|
+
export async function verifyLocalPreview(input, root, signal, sandbox) {
|
|
400
414
|
const probes = [];
|
|
401
415
|
const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
|
|
402
416
|
if (process.platform === 'win32')
|
|
@@ -493,8 +507,11 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
493
507
|
scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
|
|
494
508
|
const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
|
|
495
509
|
await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
|
|
510
|
+
// The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it.
|
|
511
|
+
const inSandbox = sandbox?.preview(scratch);
|
|
512
|
+
const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
|
|
496
513
|
env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
|
|
497
|
-
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true);
|
|
514
|
+
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
498
515
|
if (!listing.ok)
|
|
499
516
|
return fail(stage, 'Could not list checkout files.');
|
|
500
517
|
const files = [...new Set(listing.output.split('\0').filter(Boolean))];
|
|
@@ -534,7 +551,7 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
534
551
|
await copyFile(source, target);
|
|
535
552
|
await chmod(target, info.mode & 0o777);
|
|
536
553
|
if (file.split('/').at(-1) === 'package.json') {
|
|
537
|
-
const baseline = await run('git', ['show', `HEAD:${file}`], root, true);
|
|
554
|
+
const baseline = await run('git', ['show', `HEAD:${file}`], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
538
555
|
const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
539
556
|
scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
|
|
540
557
|
}
|
|
@@ -616,8 +633,9 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
616
633
|
}
|
|
617
634
|
if (!executable)
|
|
618
635
|
return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
|
|
619
|
-
// Precreate even for explicit bootstrap steps so every command in a compound step uses it.
|
|
620
|
-
|
|
636
|
+
// Precreate even for explicit bootstrap steps so every command in a compound step uses it. `-m` imports from the folder, the checkout's code, so it runs in the sandbox too.
|
|
637
|
+
const venv = confined(`'${executable.replace(/'/g, `'\\''`)}' -m venv .venv`, env);
|
|
638
|
+
if (!(await run(venv.file, venv.args, directory, false, 120_000, signal ?? null, venv.env)).ok)
|
|
621
639
|
return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
|
|
622
640
|
}
|
|
623
641
|
const environmentFor = (directory) => {
|
|
@@ -653,7 +671,8 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
653
671
|
}
|
|
654
672
|
for (const [index, step] of input.steps.entries()) {
|
|
655
673
|
stage = step.phase;
|
|
656
|
-
|
|
674
|
+
const command = confined(step.command, environmentFor(directories[index]));
|
|
675
|
+
if (!(await run(command.file, command.args, directories[index], false, 120_000, signal ?? null, command.env)).ok)
|
|
657
676
|
return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
|
|
658
677
|
}
|
|
659
678
|
if (staticInput) {
|
|
@@ -669,7 +688,8 @@ export async function verifyLocalPreview(input, root, signal) {
|
|
|
669
688
|
const free = await new Promise((done) => { const check = createServer(); check.once('error', () => done(false)); check.listen(serverInput.port, '127.0.0.1', () => check.close(() => done(true))); });
|
|
670
689
|
if (!free)
|
|
671
690
|
return fail(stage, 'The preview port is already in use; an existing service was not probed.');
|
|
672
|
-
|
|
691
|
+
const start = confined(serverInput.start.command, environmentFor(directories.at(-1)));
|
|
692
|
+
server = spawn(start.file, start.args, { cwd: directories.at(-1), env: start.env, detached: true, stdio: 'ignore' });
|
|
673
693
|
let exited = false;
|
|
674
694
|
server.once('error', () => { exited = true; });
|
|
675
695
|
server.once('exit', () => { exited = true; });
|
|
@@ -733,7 +753,7 @@ const execFileAsync = promisify(execFile);
|
|
|
733
753
|
*/
|
|
734
754
|
export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
|
|
735
755
|
try {
|
|
736
|
-
await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000 });
|
|
756
|
+
await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000, env: gitEnvironment() });
|
|
737
757
|
return true;
|
|
738
758
|
}
|
|
739
759
|
catch (err) {
|
|
@@ -968,11 +988,11 @@ export function workflowGitEnvironment() {
|
|
|
968
988
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
|
|
969
989
|
'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
|
|
970
990
|
delete env[key];
|
|
971
|
-
return Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
|
|
991
|
+
return gitEnvironment(Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
|
|
972
992
|
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
|
|
973
993
|
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
|
|
974
994
|
// Removing the variables alone still permits globally configured trace2 targets.
|
|
975
|
-
GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
|
|
995
|
+
GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' }));
|
|
976
996
|
}
|
|
977
997
|
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
978
998
|
export function workflowDefaultBranch(value) {
|
|
@@ -1388,25 +1408,28 @@ export function createTools(ctx) {
|
|
|
1388
1408
|
}
|
|
1389
1409
|
/** What the person typed into sensitive questions, by the reference the model holds instead. */
|
|
1390
1410
|
const typed = new Map();
|
|
1391
|
-
/** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built
|
|
1411
|
+
/** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built previews Netlify's and Cloudflare's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify or Cloudflare proof answered. */
|
|
1392
1412
|
const listedPreviews = new Set();
|
|
1393
1413
|
const netlifyListed = new Set();
|
|
1414
|
+
/** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
|
|
1415
|
+
const publicProduction = new Set();
|
|
1416
|
+
const cloudflareListed = new Set();
|
|
1394
1417
|
let hostedPreviewProven = false;
|
|
1395
1418
|
let provenPattern;
|
|
1396
1419
|
/** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
|
|
1397
1420
|
let provenHost;
|
|
1398
1421
|
let savedHost;
|
|
1399
1422
|
/**
|
|
1400
|
-
* A host's build, waited for on the screen as the task in progress with
|
|
1401
|
-
* `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
1423
|
+
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1424
|
+
* 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.
|
|
1402
1425
|
*/
|
|
1403
|
-
async function awaitBuild(status, read, built, signal) {
|
|
1426
|
+
async function awaitBuild(host, status, read, built, signal) {
|
|
1404
1427
|
let evidence = await read();
|
|
1405
1428
|
if (built(evidence))
|
|
1406
1429
|
return evidence;
|
|
1407
1430
|
const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
|
|
1408
1431
|
said(status);
|
|
1409
|
-
reportWait({ since: Date.now(),
|
|
1432
|
+
reportWait({ since: Date.now(), host });
|
|
1410
1433
|
try {
|
|
1411
1434
|
for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
|
|
1412
1435
|
await sleep(wait.everyMs, undefined, { signal });
|
|
@@ -1424,7 +1447,7 @@ export function createTools(ctx) {
|
|
|
1424
1447
|
};
|
|
1425
1448
|
/** 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. */
|
|
1426
1449
|
const vercelMade = new Set();
|
|
1427
|
-
/** The address impeccable_prove_preview met
|
|
1450
|
+
/** The address impeccable_prove_preview met a host's lock at, the words it looked for there, and whose lock it was. */
|
|
1428
1451
|
let gated;
|
|
1429
1452
|
/** The bypass secret saved in this run, for the proof of a Vercel address to send; never for the model. It is one project's, so it goes only to the origins impeccable_vercel_bypass proved it on, never to another project's deployment of this repository. */
|
|
1430
1453
|
let bypassSecret;
|
|
@@ -1432,6 +1455,24 @@ export function createTools(ctx) {
|
|
|
1432
1455
|
/** What impeccable_vercel_bypass answered, less the secret: a skip is asked once, so that answer stands; the saved one is what an address the secret opens later gets. */
|
|
1433
1456
|
let bypassOutcome;
|
|
1434
1457
|
let bypassSaved;
|
|
1458
|
+
/** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
|
|
1459
|
+
let sitePassword;
|
|
1460
|
+
const passwordSites = new Set();
|
|
1461
|
+
/** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, and the team login stays. Another site of the repository is read on its own. */
|
|
1462
|
+
const passwordStands = new Map();
|
|
1463
|
+
/** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
|
|
1464
|
+
const passwordConsented = new Set();
|
|
1465
|
+
/** The saved answer, what an address the password opens later gets. */
|
|
1466
|
+
let passwordSaved;
|
|
1467
|
+
/** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
|
|
1468
|
+
let accessGated;
|
|
1469
|
+
/** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
|
|
1470
|
+
let accessToken;
|
|
1471
|
+
let accessMade = {};
|
|
1472
|
+
const accessProjects = new Set();
|
|
1473
|
+
let accessStored = false;
|
|
1474
|
+
/** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
|
|
1475
|
+
let accessOutcome;
|
|
1435
1476
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1436
1477
|
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
|
|
1437
1478
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
@@ -1494,6 +1535,12 @@ export function createTools(ctx) {
|
|
|
1494
1535
|
const hostSignIns = new Map();
|
|
1495
1536
|
/** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
|
|
1496
1537
|
const teamChoices = new Map();
|
|
1538
|
+
/** A host's setup ended before anything was set up on the host: nothing more is asked for it, and the fence lets its CLI and its hold on pushes go. */
|
|
1539
|
+
const endSetup = (host) => {
|
|
1540
|
+
hostSignIns.set(host, 'skipped');
|
|
1541
|
+
teamChoices.delete(host);
|
|
1542
|
+
endHostSetup(ctx.log, host);
|
|
1543
|
+
};
|
|
1497
1544
|
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
|
|
1498
1545
|
const hostCli = (host, pi, signal) => {
|
|
1499
1546
|
const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
|
|
@@ -1543,7 +1590,7 @@ export function createTools(ctx) {
|
|
|
1543
1590
|
if (!hostSignIns.has(host)) {
|
|
1544
1591
|
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
|
|
1545
1592
|
if (answer.skipped) {
|
|
1546
|
-
|
|
1593
|
+
endSetup(host);
|
|
1547
1594
|
return { skipped: true };
|
|
1548
1595
|
}
|
|
1549
1596
|
hostSignIns.set(host, 'confirmed');
|
|
@@ -1565,11 +1612,11 @@ export function createTools(ctx) {
|
|
|
1565
1612
|
if (status.cli === 'signed-in')
|
|
1566
1613
|
return signedIn();
|
|
1567
1614
|
if (attempt === 2) {
|
|
1568
|
-
|
|
1615
|
+
endSetup(host);
|
|
1569
1616
|
return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
|
|
1570
1617
|
}
|
|
1571
1618
|
if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
|
|
1572
|
-
|
|
1619
|
+
endSetup(host);
|
|
1573
1620
|
return { skipped: true };
|
|
1574
1621
|
}
|
|
1575
1622
|
}
|
|
@@ -1607,14 +1654,29 @@ export function createTools(ctx) {
|
|
|
1607
1654
|
return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
|
|
1608
1655
|
? { connected: true, site: result.site, how: 'github-app' } : result), signal);
|
|
1609
1656
|
}
|
|
1610
|
-
/**
|
|
1657
|
+
/**
|
|
1658
|
+
* The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked
|
|
1659
|
+
* when Vercel's GitHub app cannot reach the repository; then, connected, the project locked behind Vercel's login at
|
|
1660
|
+
* every address while it has no production deployment, before anything is pushed: Vercel makes a new project's first
|
|
1661
|
+
* deployment Production whatever branch it builds, and serves it at the project's own address.
|
|
1662
|
+
*/
|
|
1611
1663
|
async function vercelConnect(pi, signal) {
|
|
1612
1664
|
if (!hostSetupAgreed(ctx.log, 'vercel'))
|
|
1613
1665
|
return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
|
|
1614
1666
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
1615
|
-
const result = await connectVercelProject(vercel);
|
|
1616
1667
|
// Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
|
|
1617
|
-
|
|
1668
|
+
const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
|
|
1669
|
+
// The fence holds pushes and deploys from the sign-in until the linked project's first deployment is settled: Vercel's app cannot reach the repository (a push builds nothing), production is deployed already, or the project is locked at every address.
|
|
1670
|
+
const link = await readLink(pi.cwd);
|
|
1671
|
+
if (!result.connected) {
|
|
1672
|
+
if (result.access !== undefined)
|
|
1673
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
|
|
1674
|
+
return result;
|
|
1675
|
+
}
|
|
1676
|
+
const first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
|
|
1677
|
+
if (link && (first.production === 'exists' || first.locked === true))
|
|
1678
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
|
|
1679
|
+
return { ...result, ...first };
|
|
1618
1680
|
}
|
|
1619
1681
|
async function syncWorkflowTool(pi, signal) {
|
|
1620
1682
|
try {
|
|
@@ -1658,15 +1720,42 @@ export function createTools(ctx) {
|
|
|
1658
1720
|
const res = await requestJson(url, { ...init, token: await ctx.auth.token(pi) }, ctx.log);
|
|
1659
1721
|
return res.status === 401 ? requestJson(url, { ...init, token: await ctx.auth.renew(pi) }, ctx.log) : res;
|
|
1660
1722
|
};
|
|
1661
|
-
/**
|
|
1723
|
+
/**
|
|
1724
|
+
* Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
|
|
1725
|
+
* `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
|
|
1726
|
+
*/
|
|
1727
|
+
const saveLock = async (pi, what, header, value) => {
|
|
1728
|
+
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
1729
|
+
if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value } })).ok)
|
|
1730
|
+
return { stored: false, failed: `The setup API did not store the ${what}.` };
|
|
1731
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
|
|
1732
|
+
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.` };
|
|
1733
|
+
return { stored: true, failed: null };
|
|
1734
|
+
};
|
|
1735
|
+
/** The instructions the writes are checked against: the first state may have come before the scan proposed the apps, so a later read that shows them brings the apps write in. */
|
|
1736
|
+
let instructions = ctx.instructions;
|
|
1737
|
+
/** Whether the run signed in again for apps the scan proposed after the sign-in. */
|
|
1738
|
+
let renewedForApps = false;
|
|
1739
|
+
/**
|
|
1740
|
+
* A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
|
|
1741
|
+
* there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
|
|
1742
|
+
* that covers every app there is now, and reads again. Once it has, a card still left out goes into the report.
|
|
1743
|
+
*/
|
|
1662
1744
|
const get = async (pi, path, focus) => {
|
|
1663
|
-
|
|
1745
|
+
let res = await call(pi, `${ctx.setupApi}${path}`);
|
|
1746
|
+
if (res.ok && !renewedForApps && appsLeftOut(res.data, ctx.repo)) {
|
|
1747
|
+
await ctx.auth.renew(pi, 'apps');
|
|
1748
|
+
renewedForApps = true;
|
|
1749
|
+
res = await call(pi, `${ctx.setupApi}${path}`);
|
|
1750
|
+
}
|
|
1751
|
+
if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
|
|
1752
|
+
instructions = focusInstructions(ctx.allInstructions, { apps: true });
|
|
1664
1753
|
return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
|
|
1665
1754
|
};
|
|
1666
1755
|
return [
|
|
1667
1756
|
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. Only the push itself proves Git may push.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
1668
1757
|
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)),
|
|
1669
|
-
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal))),
|
|
1758
|
+
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
|
|
1670
1759
|
tool('impeccable_preview_run', 'Read the preview attempt', 'On a rerun, once impeccable_test_pr recorded the setup pull request: read its preview workflow\'s runs. Answers each attempt with its failed stage and a fixed reason (never log text), the three-attempt budget (used, remaining, historyKnown), and adds the attempts to the task list. Reading is free.', PREVIEW_RUN, async (p, pi, signal) => {
|
|
1671
1760
|
if (!retry.pr)
|
|
1672
1761
|
return 'Refused: record the setup pull request with impeccable_test_pr first.';
|
|
@@ -1735,20 +1824,20 @@ export function createTools(ctx) {
|
|
|
1735
1824
|
reportTasks(tasks);
|
|
1736
1825
|
return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
|
|
1737
1826
|
}),
|
|
1738
|
-
tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
|
|
1827
|
+
tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, its apps item where the scan proposed several apps, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
|
|
1739
1828
|
tool('impeccable_checkup', 'Checkup', 'Run the checkup for this setup\'s items: it reads GitHub again and answers status (complete, waiting or incomplete), next (what to do, in order), forPerson (what only the person can do) and waitingFor (what needs time).', NO_INPUT, (_p, pi) => get(pi, '/checkup', focusCheckup)),
|
|
1740
1829
|
tool('impeccable_write', 'Write a setting', 'Write a setting through the setup API, at an endpoint the instructions list and with the method they give.', WRITE, async (p, pi, signal) => {
|
|
1741
|
-
const target = resolveWrite(
|
|
1830
|
+
const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
|
|
1742
1831
|
if ('error' in target)
|
|
1743
1832
|
return `Refused: ${target.error}`;
|
|
1744
1833
|
if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
|
|
1745
|
-
return
|
|
1834
|
+
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.";
|
|
1746
1835
|
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
|
|
1747
1836
|
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. Nothing was saved.";
|
|
1748
1837
|
}
|
|
1749
1838
|
const saved = object(p.body);
|
|
1750
1839
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
|
|
1751
|
-
return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify preview. Nothing was saved.';
|
|
1840
|
+
return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
|
|
1752
1841
|
}
|
|
1753
1842
|
const testAccounts = target.url.endsWith('/test-accounts');
|
|
1754
1843
|
let body = p.body;
|
|
@@ -1808,8 +1897,12 @@ export function createTools(ctx) {
|
|
|
1808
1897
|
if (error)
|
|
1809
1898
|
return `Invalid input: ${error}.`;
|
|
1810
1899
|
const answer = await askPerson(q);
|
|
1811
|
-
if (answer.skipped)
|
|
1900
|
+
if (answer.skipped) {
|
|
1901
|
+
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
1902
|
+
if (host?.[1] === 'vercel')
|
|
1903
|
+
endSetup('vercel');
|
|
1812
1904
|
return JSON.stringify({ skipped: true });
|
|
1905
|
+
}
|
|
1813
1906
|
if (q.kind === 'multi')
|
|
1814
1907
|
return JSON.stringify({ values: answer.values ?? [] });
|
|
1815
1908
|
if (q.kind === 'confirm') {
|
|
@@ -1901,24 +1994,39 @@ export function createTools(ctx) {
|
|
|
1901
1994
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
1902
1995
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
1903
1996
|
}),
|
|
1904
|
-
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, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer (a project\'s first
|
|
1997
|
+
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
1905
1998
|
const branch = p.branch;
|
|
1906
1999
|
const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
|
|
1907
|
-
// With `branch` the evidence holds only the deployments of its head, and Vercel's is its
|
|
2000
|
+
// 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
|
|
1908
2001
|
// 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.
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const
|
|
2002
|
+
const isPreview = (d) => /preview/i.test(d.environment ?? '');
|
|
2003
|
+
const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
|
|
2004
|
+
// A head's build is still building until it has a final status.
|
|
2005
|
+
const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
|
|
2006
|
+
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
|
|
1913
2007
|
listedPreviews.clear();
|
|
1914
2008
|
vercelMade.clear();
|
|
2009
|
+
publicProduction.clear();
|
|
2010
|
+
/** 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. */
|
|
2011
|
+
let locked;
|
|
1915
2012
|
for (const d of evidence.deployments) {
|
|
1916
|
-
if (d.state
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2013
|
+
if (d.state !== 'success' || !d.url || !ofBranch(d) || !(d.pullRequests.length || branch !== undefined))
|
|
2014
|
+
continue;
|
|
2015
|
+
const url = new URL(d.url);
|
|
2016
|
+
// 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.
|
|
2017
|
+
if (branch !== undefined && !isPreview(d)) {
|
|
2018
|
+
locked ??= await (async () => {
|
|
2019
|
+
const link = await readLink(pi.cwd);
|
|
2020
|
+
return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link) : new Set();
|
|
2021
|
+
})();
|
|
2022
|
+
if (!locked.has(url.host)) {
|
|
2023
|
+
publicProduction.add(url.origin);
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
1921
2026
|
}
|
|
2027
|
+
listedPreviews.add(url.origin);
|
|
2028
|
+
if (d.vercel)
|
|
2029
|
+
vercelMade.add(url.origin);
|
|
1922
2030
|
}
|
|
1923
2031
|
return JSON.stringify(evidence);
|
|
1924
2032
|
}),
|
|
@@ -1928,25 +2036,59 @@ export function createTools(ctx) {
|
|
|
1928
2036
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
1929
2037
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
1930
2038
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
1931
|
-
const evidence = pull !== undefined ? await awaitBuild(`Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
|
|
2039
|
+
const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
|
|
1932
2040
|
netlifyListed.clear();
|
|
1933
2041
|
for (const p of evidence.previews)
|
|
1934
2042
|
if (p.state === 'success' || p.state === 'ready')
|
|
1935
2043
|
netlifyListed.add(new URL(p.url).origin);
|
|
1936
2044
|
return JSON.stringify(evidence);
|
|
1937
2045
|
}),
|
|
1938
|
-
tool('
|
|
2046
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2047
|
+
const pull = p.pull;
|
|
2048
|
+
const branch = p.branch;
|
|
2049
|
+
if (pull !== undefined && branch !== undefined)
|
|
2050
|
+
return 'Invalid input: give pull or branch, not both.';
|
|
2051
|
+
const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
|
|
2052
|
+
// Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
|
|
2053
|
+
// GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
|
|
2054
|
+
const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
|
|
2055
|
+
const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
|
|
2056
|
+
const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
|
|
2057
|
+
cloudflareListed.clear();
|
|
2058
|
+
for (const preview of evidence.previews) {
|
|
2059
|
+
if (preview.state !== 'success')
|
|
2060
|
+
continue;
|
|
2061
|
+
cloudflareListed.add(new URL(preview.url).origin);
|
|
2062
|
+
if (preview.alias)
|
|
2063
|
+
cloudflareListed.add(new URL(preview.alias).origin);
|
|
2064
|
+
}
|
|
2065
|
+
return JSON.stringify(evidence);
|
|
2066
|
+
}),
|
|
2067
|
+
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. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
1939
2068
|
const url = p.url;
|
|
1940
|
-
if (
|
|
1941
|
-
return
|
|
1942
|
-
|
|
2069
|
+
if (URL.canParse(url) && publicProduction.has(new URL(url).origin))
|
|
2070
|
+
return "Refused: Vercel called this deployment Production, as it calls a project's first whatever branch it builds, and no project of this folder's that is locked at every address made it, so it is served at a project's own address without a login: it is no preview, and nothing was opened. No test drive, as the vercel-setup case says.";
|
|
2071
|
+
if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin) || cloudflareListed.has(new URL(url).origin)))
|
|
2072
|
+
return 'Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch, or a page on it.';
|
|
2073
|
+
const site = netlifySiteOf(url);
|
|
2074
|
+
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
2075
|
+
const project = pagesProject(new URL(url).origin);
|
|
2076
|
+
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
2077
|
+
const proof = await provePreview(url, p.expect, pi.cwd, {
|
|
2078
|
+
fetch: ctx.previewFetch, signal,
|
|
2079
|
+
...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}),
|
|
2080
|
+
...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
|
|
2081
|
+
...(token ? { access: token } : {}),
|
|
2082
|
+
});
|
|
1943
2083
|
hostedPreviewProven ||= proof.proven;
|
|
1944
2084
|
if (proof.proven)
|
|
1945
2085
|
provenHost = previewHostName(url) ?? provenHost;
|
|
1946
2086
|
if (proof.proven && proof.pattern)
|
|
1947
2087
|
provenPattern = proof.pattern;
|
|
1948
|
-
if (proof.gate === 'vercel-protection')
|
|
1949
|
-
gated = { url, expect: p.expect };
|
|
2088
|
+
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
2089
|
+
gated = { url, expect: p.expect, gate: proof.gate };
|
|
2090
|
+
if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
|
|
2091
|
+
accessGated = { url, expect: p.expect };
|
|
1950
2092
|
return JSON.stringify(proof);
|
|
1951
2093
|
}),
|
|
1952
2094
|
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. 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) => {
|
|
@@ -1958,9 +2100,9 @@ export function createTools(ctx) {
|
|
|
1958
2100
|
return 'Refused: publish is a folder relative to the checkout, such as "dist".';
|
|
1959
2101
|
return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
|
|
1960
2102
|
}),
|
|
1961
|
-
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. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
|
|
2103
|
+
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet: Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
|
|
1962
2104
|
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
1963
|
-
if (
|
|
2105
|
+
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
1964
2106
|
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.';
|
|
1965
2107
|
if (bypassOutcome?.status === 'skipped')
|
|
1966
2108
|
return JSON.stringify(bypassOutcome);
|
|
@@ -1983,13 +2125,9 @@ export function createTools(ctx) {
|
|
|
1983
2125
|
},
|
|
1984
2126
|
prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
|
|
1985
2127
|
save: async (secret) => {
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
stored = true;
|
|
1990
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: BYPASS_HEADER } })).ok)
|
|
1991
|
-
return 'The setup API stored the secret, but not the setting that sends it: it is turned on under Deployment protection on the setup page.';
|
|
1992
|
-
return null;
|
|
2128
|
+
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
|
|
2129
|
+
stored = saved.stored;
|
|
2130
|
+
return saved.failed;
|
|
1993
2131
|
},
|
|
1994
2132
|
remember: noteSensitiveAnswer,
|
|
1995
2133
|
});
|
|
@@ -2011,5 +2149,141 @@ export function createTools(ctx) {
|
|
|
2011
2149
|
}
|
|
2012
2150
|
return JSON.stringify(bypassOutcome);
|
|
2013
2151
|
}),
|
|
2152
|
+
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), login (Netlify\'s team login stands in front, which no tool gets past: protection says so, and reason why), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2153
|
+
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
2154
|
+
if (!gated || !site)
|
|
2155
|
+
return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
|
|
2156
|
+
const stands = passwordStands.get(site);
|
|
2157
|
+
if (stands)
|
|
2158
|
+
return JSON.stringify(stands);
|
|
2159
|
+
const at = gated;
|
|
2160
|
+
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
2161
|
+
let pattern;
|
|
2162
|
+
const prove = async (password, form = false) => {
|
|
2163
|
+
const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
|
|
2164
|
+
if (proof.proven && proof.pattern)
|
|
2165
|
+
pattern = proof.pattern;
|
|
2166
|
+
return proof;
|
|
2167
|
+
};
|
|
2168
|
+
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
2169
|
+
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
2170
|
+
provenPattern = pattern ?? provenPattern;
|
|
2171
|
+
return JSON.stringify(passwordSaved);
|
|
2172
|
+
}
|
|
2173
|
+
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
2174
|
+
let stored = false;
|
|
2175
|
+
const { value, ...told } = await passNetlifyLock({
|
|
2176
|
+
run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
|
|
2177
|
+
site,
|
|
2178
|
+
consent: async () => {
|
|
2179
|
+
if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
|
|
2180
|
+
return passwordConsented.add(site), true;
|
|
2181
|
+
return false;
|
|
2182
|
+
},
|
|
2183
|
+
paste: async () => {
|
|
2184
|
+
const answer = await ask(HOST_SCREENS['netlify-password-key']);
|
|
2185
|
+
return answer.skipped ? undefined : answer.value?.trim();
|
|
2186
|
+
},
|
|
2187
|
+
prove,
|
|
2188
|
+
save: async (password) => {
|
|
2189
|
+
const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
|
|
2190
|
+
stored = saved.stored;
|
|
2191
|
+
return saved.failed;
|
|
2192
|
+
},
|
|
2193
|
+
remember: noteSensitiveAnswer,
|
|
2194
|
+
});
|
|
2195
|
+
const passwordOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
|
|
2196
|
+
if (told.status === 'skipped' || told.status === 'login')
|
|
2197
|
+
passwordStands.set(site, passwordOutcome);
|
|
2198
|
+
if (value) {
|
|
2199
|
+
// What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
|
|
2200
|
+
sitePassword = value;
|
|
2201
|
+
passwordSites.clear();
|
|
2202
|
+
passwordSites.add(site);
|
|
2203
|
+
hostedPreviewProven = true;
|
|
2204
|
+
provenHost = 'Netlify';
|
|
2205
|
+
provenPattern = pattern ?? provenPattern;
|
|
2206
|
+
passwordSaved = passwordOutcome;
|
|
2207
|
+
}
|
|
2208
|
+
else if (told.status === 'save-failed' && stored) {
|
|
2209
|
+
// The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through.
|
|
2210
|
+
sitePassword = undefined;
|
|
2211
|
+
passwordSites.clear();
|
|
2212
|
+
passwordSaved = undefined;
|
|
2213
|
+
}
|
|
2214
|
+
return JSON.stringify(passwordOutcome);
|
|
2215
|
+
}),
|
|
2216
|
+
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. 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 (the typed token did not open 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 token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2217
|
+
if (!accessGated)
|
|
2218
|
+
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.';
|
|
2219
|
+
if (accessOutcome?.status === 'skipped')
|
|
2220
|
+
return JSON.stringify(accessOutcome);
|
|
2221
|
+
const at = accessGated;
|
|
2222
|
+
let proof;
|
|
2223
|
+
const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
|
|
2224
|
+
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
2225
|
+
const save = async (token) => {
|
|
2226
|
+
if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret } })).ok)
|
|
2227
|
+
return 'The setup API did not store the service token.';
|
|
2228
|
+
// An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
|
|
2229
|
+
const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
|
|
2230
|
+
const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
|
|
2231
|
+
if (lock?.authMethod === 'access')
|
|
2232
|
+
return null;
|
|
2233
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
|
|
2234
|
+
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.';
|
|
2235
|
+
return null;
|
|
2236
|
+
};
|
|
2237
|
+
let answer;
|
|
2238
|
+
if (accessToken && (await prove(accessToken)).proven) {
|
|
2239
|
+
// The token in hand opens this address too: stored already, or stored now.
|
|
2240
|
+
const failed = accessStored ? null : await save(accessToken);
|
|
2241
|
+
answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
|
|
2242
|
+
}
|
|
2243
|
+
else {
|
|
2244
|
+
answer = await passCloudflareAccess({
|
|
2245
|
+
url: at.url,
|
|
2246
|
+
consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
|
|
2247
|
+
cf: cfRunner({}, ctx.hostRunner),
|
|
2248
|
+
login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
|
|
2249
|
+
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
2250
|
+
status: said,
|
|
2251
|
+
apiToken: async () => {
|
|
2252
|
+
const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
|
|
2253
|
+
return typed.skipped ? undefined : typed.value;
|
|
2254
|
+
},
|
|
2255
|
+
paste: async () => {
|
|
2256
|
+
const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
|
|
2257
|
+
return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
|
|
2258
|
+
},
|
|
2259
|
+
prove,
|
|
2260
|
+
save,
|
|
2261
|
+
remember: noteSensitiveAnswer,
|
|
2262
|
+
signal,
|
|
2263
|
+
...(ctx.hostWait?.policy ? { wait: ctx.hostWait.policy } : {}),
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
const { value, ...told } = answer;
|
|
2267
|
+
if (value) {
|
|
2268
|
+
// What is in hand follows the store, which holds one token: a new one is the one saved now, and the projects the last one opened no longer stand.
|
|
2269
|
+
if (value !== accessToken)
|
|
2270
|
+
accessProjects.clear();
|
|
2271
|
+
accessToken = value;
|
|
2272
|
+
accessStored = told.status === 'saved';
|
|
2273
|
+
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
2274
|
+
const project = pagesProject(new URL(at.url).origin);
|
|
2275
|
+
if (project)
|
|
2276
|
+
accessProjects.add(project);
|
|
2277
|
+
}
|
|
2278
|
+
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
2279
|
+
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
|
2280
|
+
if (told.status === 'saved') {
|
|
2281
|
+
hostedPreviewProven = true;
|
|
2282
|
+
provenHost = 'Cloudflare Pages';
|
|
2283
|
+
if (pattern)
|
|
2284
|
+
provenPattern = pattern;
|
|
2285
|
+
}
|
|
2286
|
+
return JSON.stringify(accessOutcome);
|
|
2287
|
+
}),
|
|
2014
2288
|
];
|
|
2015
2289
|
}
|