@impeccable/reviews 0.1.5 → 0.1.7
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 +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +32 -17
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +8 -7
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +9 -6
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +397 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -7,25 +7,27 @@ 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, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
|
|
10
|
+
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo } from './host-questions.js';
|
|
14
|
+
import { enableNetlifyPreviews } from './host-previews.js';
|
|
15
|
+
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
16
|
+
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
16
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
17
|
-
import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
18
|
+
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
18
19
|
import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
|
|
19
|
-
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
20
|
+
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
20
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
21
22
|
import { fitsTask, stagesOf } from './stages.js';
|
|
22
23
|
import { redact } from './log.js';
|
|
23
24
|
import { gitEnvironment } from './sandbox.js';
|
|
24
25
|
import { openBrowser } from './login.js';
|
|
25
|
-
import { TASK_STATUSES } from './sync.js';
|
|
26
|
+
import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
|
|
26
27
|
import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
27
28
|
import { SEE_THE_REVIEW } from './ui/look.js';
|
|
28
|
-
import { noteSensitiveAnswer } from './ui/sensitive.js';
|
|
29
|
+
import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
|
|
30
|
+
export { STATUS_MAX, statusVoiceError } from './sync.js';
|
|
29
31
|
/** The first way `value` breaks `schema`, or null when it fits. */
|
|
30
32
|
export function schemaError(schema, value, at = 'input') {
|
|
31
33
|
const types = schema.type === undefined ? [] : [schema.type].flat();
|
|
@@ -106,15 +108,6 @@ export function resolveWrite(instructions, setupApi, repo, path) {
|
|
|
106
108
|
return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
|
|
107
109
|
}
|
|
108
110
|
const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
|
|
109
|
-
/** The most a status line holds; longer text belongs in the report. */
|
|
110
|
-
export const STATUS_MAX = 70;
|
|
111
|
-
const STATUS_RULE = `Status lines are in the product voice: a present-tense verb phrase of at most ${STATUS_MAX} characters that says what is happening, such as "Running the build" or "Opening the setup pull request". No "I'm", "I am", "Let me", "I'll" or "trying to"; detail goes in the report. Nothing was saved: call impeccable_progress again with the status rephrased.`;
|
|
112
|
-
/** Why a status line breaks the voice, with the rule to rephrase it by; null when it keeps it. The line is never rewritten here. */
|
|
113
|
-
export function statusVoiceError(line) {
|
|
114
|
-
const text = line.trim();
|
|
115
|
-
const why = /^(i['’]m|i am|let me|i['’]ll)\b/i.test(text) || /\btrying to\b/i.test(text) ? 'it narrates like an assistant' : text.length > STATUS_MAX ? `it is ${text.length} characters long` : null;
|
|
116
|
-
return why ? `Refused: "${text}" does not fit the status line, because ${why}. ${STATUS_RULE}` : null;
|
|
117
|
-
}
|
|
118
111
|
/** Why the status line and the task list say different things, one task in progress at a time and the status its detail; null when they agree. */
|
|
119
112
|
function stageError(status, tasks) {
|
|
120
113
|
const running = tasks.filter((t) => t.status === 'in_progress');
|
|
@@ -213,7 +206,7 @@ const PROVE_PREVIEW = {
|
|
|
213
206
|
additionalProperties: false,
|
|
214
207
|
required: ['url', 'expect'],
|
|
215
208
|
properties: {
|
|
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.' },
|
|
209
|
+
url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or the live app\'s address one of them answered as production or a file of this checkout names, or a page on it.' },
|
|
217
210
|
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.' },
|
|
218
211
|
},
|
|
219
212
|
};
|
|
@@ -254,6 +247,20 @@ const namesCredential = (q) => {
|
|
|
254
247
|
};
|
|
255
248
|
/** Under the field of every text question typed in the open, where the model reads the answer. */
|
|
256
249
|
const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
|
|
250
|
+
const ENABLE_PREVIEWS = {
|
|
251
|
+
type: 'object',
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
required: ['host'],
|
|
254
|
+
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." } },
|
|
255
|
+
};
|
|
256
|
+
const CLOUDFLARE_CONNECT = {
|
|
257
|
+
type: 'object',
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
properties: {
|
|
260
|
+
command: { type: 'string', minLength: 1, description: "The build command Cloudflare runs for a project the run creates: the package's build script; none for plain HTML." },
|
|
261
|
+
outputDir: { type: 'string', minLength: 1, description: 'The folder the build writes the pages to, relative to the checkout, for a project the run creates; "." for plain HTML at the root.' },
|
|
262
|
+
},
|
|
263
|
+
};
|
|
257
264
|
const NETLIFY_CONNECT = {
|
|
258
265
|
type: 'object',
|
|
259
266
|
additionalProperties: false,
|
|
@@ -279,17 +286,28 @@ function askError(q) {
|
|
|
279
286
|
return 'input.options[].selected is for multi only';
|
|
280
287
|
return new Set(values).size === values.length ? null : 'input.options has a value twice';
|
|
281
288
|
}
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Pull request `number` of owner/repo on GitHub, when `url` is it: its address rebuilt from those validated parts, so no
|
|
291
|
+
* query, fragment or other character the model wrote reaches the browser or the clipboard. Null when `url` is not it.
|
|
292
|
+
*/
|
|
293
|
+
function canonicalPullRequest(url, number, owner, repo) {
|
|
294
|
+
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
295
|
+
const m = parsed && /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(parsed.pathname);
|
|
296
|
+
const ok = m && parsed.origin === 'https://github.com' && !parsed.username && !parsed.password
|
|
297
|
+
&& /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) && /^[A-Za-z0-9._-]{1,100}$/.test(repo)
|
|
298
|
+
&& m[1].toLowerCase() === owner.toLowerCase() && m[2].toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(number) && number > 0 && Number(m[3]) === number;
|
|
299
|
+
return ok ? `https://github.com/${owner}/${repo}/pull/${number}` : null;
|
|
300
|
+
}
|
|
301
|
+
/** `pr` with its address rebuilt as pull request `number` of owner/repo on GitHub, or undefined when it is not that. */
|
|
302
|
+
export function ownPullRequest(pr, owner, repo) {
|
|
303
|
+
const url = pr && canonicalPullRequest(pr.url, pr.number, owner, repo);
|
|
304
|
+
return url ? { number: pr.number, url } : undefined;
|
|
287
305
|
}
|
|
288
306
|
/** This run's open pull request from `branch`, its head in this repository rather than a fork, with that head's name; or why it is not. */
|
|
289
307
|
async function runPullRequest(ctx, pr, branch, signal) {
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
return { problem:
|
|
308
|
+
const url = canonicalPullRequest(pr.url, pr.number, ctx.owner, ctx.repo);
|
|
309
|
+
if (!url)
|
|
310
|
+
return { problem: `${pr.url} is not pull request #${pr.number} of ${ctx.owner}/${ctx.repo} on GitHub` };
|
|
293
311
|
const found = await ctx.viewPullRequest(pr.number);
|
|
294
312
|
if ('error' in found)
|
|
295
313
|
return { problem: `gh pr view ${pr.number} did not find it (${found.error})` };
|
|
@@ -303,7 +321,7 @@ async function runPullRequest(ctx, pr, branch, signal) {
|
|
|
303
321
|
if (!await (ctx.setupPullHeadReader ?? readSetupPullHead)(ctx.owner, ctx.repo, pr.number, found.headRefName, { signal })) {
|
|
304
322
|
return { problem: `the branch of #${pr.number} could not be verified in this repository; a fork's branch cannot be recorded` };
|
|
305
323
|
}
|
|
306
|
-
return { branch: found.headRefName };
|
|
324
|
+
return { url, branch: found.headRefName, ...(found.baseRefName ? { base: found.baseRefName } : {}) };
|
|
307
325
|
}
|
|
308
326
|
const PREVIEW_COMMAND = { type: 'object', additionalProperties: false, required: ['command'], properties: {
|
|
309
327
|
command: { type: 'string', minLength: 1, description: 'A command of the repository\'s own scripts or tools, without cd.' },
|
|
@@ -518,10 +536,13 @@ export async function verifyLocalPreview(input, root, signal, sandbox) {
|
|
|
518
536
|
scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
|
|
519
537
|
const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
|
|
520
538
|
await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
|
|
521
|
-
// 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.
|
|
539
|
+
// 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. Without one they would run the repository's code with all the person's access, so the check refuses; only the tests, which hand over no sandbox, run them plain.
|
|
522
540
|
const inSandbox = sandbox?.preview(scratch);
|
|
541
|
+
if (sandbox && !inSandbox)
|
|
542
|
+
return fail('environment', 'Local preview verification needs a sandbox of its own, and this machine has none for it (Linux, or --no-sandbox): save the build the repository names, leave its proof to the preview job on the pull request, and say so under what is not verified.');
|
|
523
543
|
const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
|
|
524
|
-
|
|
544
|
+
// Built afresh rather than from process.env, so it carries Windows' guard against a git.exe in the checkout itself (IMP-23), after what the model supplied.
|
|
545
|
+
env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), NoDefaultCurrentDirectoryInExePath: '1', 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') };
|
|
525
546
|
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
526
547
|
if (!listing.ok)
|
|
527
548
|
return fail(stage, 'Could not list checkout files.');
|
|
@@ -771,6 +792,27 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
|
|
|
771
792
|
return err.code === 1 ? false : null;
|
|
772
793
|
}
|
|
773
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* What the pushed `head` changes against `base`, as their remote-tracking refs in this checkout show GitHub: each file's
|
|
797
|
+
* `git diff --name-status` letter and its path as it is, never quoted; a rename as a removal and an addition. Null when either ref is missing or git fails.
|
|
798
|
+
*/
|
|
799
|
+
export async function pushedChanges(cwd, base, head) {
|
|
800
|
+
try {
|
|
801
|
+
// The two branches as the remote has them, fetched so the diff can be read here: refs/remotes/origin/* sit in the checkout's git folder, where a command in the sandbox can move them.
|
|
802
|
+
const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
|
|
803
|
+
const { stdout: heads } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
|
|
804
|
+
const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
|
|
805
|
+
if (!refs.every((ref) => sha.has(ref)))
|
|
806
|
+
return null;
|
|
807
|
+
await execFileAsync('git', ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
|
|
808
|
+
const { stdout } = await execFileAsync('git', ['diff', '-z', '--name-status', '--no-renames', `${sha.get(refs[0])}...${sha.get(refs[1])}`], { cwd, timeout: 10_000, env: gitEnvironment() });
|
|
809
|
+
const fields = stdout.split('\0');
|
|
810
|
+
return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
774
816
|
const LIMIT = 20;
|
|
775
817
|
const WORKFLOW = '.github/workflows/impeccable-preview.yml';
|
|
776
818
|
const STATUSES = ['queued', 'requested', 'waiting', 'pending', 'in_progress', 'completed'];
|
|
@@ -1437,7 +1479,7 @@ export function createTools(ctx) {
|
|
|
1437
1479
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1438
1480
|
* 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.
|
|
1439
1481
|
*/
|
|
1440
|
-
async function awaitBuild(host, status, read, built, page, signal) {
|
|
1482
|
+
async function awaitBuild(host, status, read, built, page, signal, access) {
|
|
1441
1483
|
const first = await read();
|
|
1442
1484
|
if (built(first))
|
|
1443
1485
|
return first;
|
|
@@ -1455,7 +1497,15 @@ export function createTools(ctx) {
|
|
|
1455
1497
|
}
|
|
1456
1498
|
return evidence;
|
|
1457
1499
|
};
|
|
1458
|
-
// No build in the time: the host's page where a build is turned on, and the wait again on the person's yes.
|
|
1500
|
+
// No build in the time: the host's page where a build is turned on, and the wait again on the person's yes. Where the host's settings build every
|
|
1501
|
+
// branch and still none came, its GitHub app cannot see the repository: the app's installation page instead, at once when the app is not installed at all.
|
|
1502
|
+
if (access) {
|
|
1503
|
+
return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
|
|
1504
|
+
skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
|
|
1505
|
+
still: `${host}'s GitHub app still cannot see the repository: no build came.`,
|
|
1506
|
+
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1507
|
+
}, signal);
|
|
1508
|
+
}
|
|
1459
1509
|
return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
|
|
1460
1510
|
skipped: `The person stopped waiting for ${host}'s build.`,
|
|
1461
1511
|
still: `${host} built no preview in the time.`,
|
|
@@ -1495,6 +1545,14 @@ export function createTools(ctx) {
|
|
|
1495
1545
|
let accessStored = false;
|
|
1496
1546
|
/** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
|
|
1497
1547
|
let accessOutcome;
|
|
1548
|
+
/** 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. */
|
|
1549
|
+
const previewFacts = new Map();
|
|
1550
|
+
/** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
|
|
1551
|
+
const enableOutcomes = new Map();
|
|
1552
|
+
/** Cloudflare's plain evidence as last read: the project, the Worker and the GitHub app facts impeccable_cloudflare_connect and the wait for a build act on. */
|
|
1553
|
+
let cloudflareEvidence;
|
|
1554
|
+
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1555
|
+
let connectOutcome;
|
|
1498
1556
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1499
1557
|
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
|
|
1500
1558
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
@@ -1554,7 +1612,7 @@ export function createTools(ctx) {
|
|
|
1554
1612
|
ctx.sync.update({ phase: 'running', question: null });
|
|
1555
1613
|
return answer;
|
|
1556
1614
|
};
|
|
1557
|
-
/** Each host's sign-in this run: confirmed on its screen,
|
|
1615
|
+
/** Each host's sign-in this run: confirmed on its screen, so the screen shows once; skipped, so nothing more is asked for that host. */
|
|
1558
1616
|
const hostSignIns = new Map();
|
|
1559
1617
|
/** 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. */
|
|
1560
1618
|
const teamChoices = new Map();
|
|
@@ -1567,7 +1625,21 @@ export function createTools(ctx) {
|
|
|
1567
1625
|
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
|
|
1568
1626
|
/** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
|
|
1569
1627
|
let vercelProject;
|
|
1628
|
+
/** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
|
|
1629
|
+
const repository = `${ctx.owner}/${ctx.repo}`;
|
|
1570
1630
|
let netlifySite;
|
|
1631
|
+
/** The live app's addresses, by origin: those the host tools answered as `production`, each with its host and, on Netlify, its site, so a custom domain's lock is known and the site's key goes with it; and those a proof opened with this app, the only ones impeccable_write saves as the before. */
|
|
1632
|
+
const productionListed = new Map();
|
|
1633
|
+
const provenLive = new Set();
|
|
1634
|
+
/** The linked Vercel project's production address, read from Vercel, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
|
|
1635
|
+
let vercelProduction;
|
|
1636
|
+
/** The project whose bypass secret is in hand, by the link it was read or made on. */
|
|
1637
|
+
let bypassProject;
|
|
1638
|
+
/** A host tool answered the live app's address: the proof may open it as the before, with the host's key where the lock is the same site's or project's. */
|
|
1639
|
+
const noteProduction = (production, host, site) => {
|
|
1640
|
+
if (production && URL.canParse(production))
|
|
1641
|
+
productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
|
|
1642
|
+
};
|
|
1571
1643
|
const hostCli = (host, pi, signal) => {
|
|
1572
1644
|
const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
|
|
1573
1645
|
const cli = runner();
|
|
@@ -1592,16 +1664,16 @@ export function createTools(ctx) {
|
|
|
1592
1664
|
};
|
|
1593
1665
|
};
|
|
1594
1666
|
/**
|
|
1595
|
-
* A host's sign-in, on `impeccable_ask` with the id `<host>-setup`:
|
|
1596
|
-
*
|
|
1597
|
-
* screen when it did not complete, and after two failed
|
|
1598
|
-
*
|
|
1667
|
+
* A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
|
|
1668
|
+
* already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
|
|
1669
|
+
* CLI's login in the browser, checked with the CLI, a retry screen when it did not complete, and after two failed
|
|
1670
|
+
* retries a skip. The fence opens on the yes, once the sign-in is found or completed (and on the yes for the CLI's
|
|
1671
|
+
* install alone): never on anything the model writes, and never on a sign-in alone.
|
|
1599
1672
|
*/
|
|
1600
1673
|
async function hostSignIn(host, pi, signal) {
|
|
1601
1674
|
const cli = hostCli(host, pi, signal);
|
|
1602
1675
|
const signedIn = async () => {
|
|
1603
1676
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
1604
|
-
hostSignIns.set(host, 'confirmed');
|
|
1605
1677
|
const { team, choices } = await cli.team();
|
|
1606
1678
|
if (team)
|
|
1607
1679
|
chooseHostTeam(ctx.log, `${host}-team`, team);
|
|
@@ -1611,16 +1683,16 @@ export function createTools(ctx) {
|
|
|
1611
1683
|
if (hostSignIns.get(host) === 'skipped')
|
|
1612
1684
|
return { skipped: true };
|
|
1613
1685
|
let status = await cli.status();
|
|
1614
|
-
if (status.cli === 'signed-in')
|
|
1615
|
-
return signedIn();
|
|
1616
1686
|
if (!hostSignIns.has(host)) {
|
|
1617
|
-
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
|
|
1687
|
+
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...(status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : {}) });
|
|
1618
1688
|
if (answer.skipped) {
|
|
1619
1689
|
endSetup(host);
|
|
1620
1690
|
return { skipped: true };
|
|
1621
1691
|
}
|
|
1622
1692
|
hostSignIns.set(host, 'confirmed');
|
|
1623
1693
|
}
|
|
1694
|
+
if (status.cli === 'signed-in')
|
|
1695
|
+
return signedIn();
|
|
1624
1696
|
// The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
|
|
1625
1697
|
if (status.cli === 'missing') {
|
|
1626
1698
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
@@ -1687,44 +1759,71 @@ export function createTools(ctx) {
|
|
|
1687
1759
|
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
1688
1760
|
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
1689
1761
|
const clone = ctx.hostWait?.clone;
|
|
1690
|
-
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
|
|
1762
|
+
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}) });
|
|
1691
1763
|
netlifySite = result.site?.name ?? netlifySite;
|
|
1764
|
+
if (result.pullRequestPreviews)
|
|
1765
|
+
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
1692
1766
|
// A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
|
|
1693
1767
|
return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
|
|
1694
|
-
? { connected: true, site: result.site, how: 'github-app' } : result), signal);
|
|
1768
|
+
? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal);
|
|
1695
1769
|
}
|
|
1696
1770
|
/**
|
|
1697
|
-
* The connection of the linked Vercel project with this repository, `vercel git connect`,
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1771
|
+
* The connection of the linked Vercel project with this repository, `vercel git connect`, for a project this run's own
|
|
1772
|
+
* `vercel project add` made and `vercel link` linked (the link file alone, which the agent can write, proves nothing;
|
|
1773
|
+
* the link alone links an existing project of the folder's name, which may be one of the person's: that one is left
|
|
1774
|
+
* as it is), or one connected to this repository already, with the app's access asked when Vercel's GitHub app
|
|
1775
|
+
* cannot reach the repository; then, connected, the project locked behind Vercel's login at every address while it
|
|
1776
|
+
* has no production deployment, before anything is pushed: Vercel makes a new project's first deployment Production
|
|
1777
|
+
* whatever branch it builds, and serves it at the project's own address.
|
|
1701
1778
|
*/
|
|
1702
1779
|
async function vercelConnect(pi, signal) {
|
|
1703
1780
|
if (!hostSetupAgreed(ctx.log, 'vercel'))
|
|
1704
1781
|
return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
|
|
1705
1782
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
1783
|
+
const link = await readLink(pi.cwd);
|
|
1784
|
+
if (!link)
|
|
1785
|
+
return { connected: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
|
|
1786
|
+
// The link file is the agent's to write: only one the run's own vercel link wrote, project and team alike, says which project the reads below are of. Any other holds the push, since what it would build cannot be told.
|
|
1787
|
+
const own = linkedByRun(ctx.log).get(link.projectId);
|
|
1788
|
+
if (!own || own.orgId !== link.orgId)
|
|
1789
|
+
return { connected: false, reason: "This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder's name again; else the Vercel setup ends here, and the person removes or relinks the file." };
|
|
1790
|
+
// Read before the connection, which is what would change the project: whether it is this run's. The read after it is another, since the connection sets the production branch.
|
|
1791
|
+
const project = await readLinkedProject(vercel, link);
|
|
1792
|
+
// A project Vercel has no record of, or one the CLI cannot read, may still be one that builds a push: the hold stands, by design.
|
|
1793
|
+
if (project === 'missing')
|
|
1794
|
+
return { connected: false, reason: 'The project this folder is linked to no longer exists on Vercel, as far as the CLI can tell (.vercel/project.json is stale): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder\'s name again; else the Vercel setup ends here, and the person removes or relinks the file.' };
|
|
1795
|
+
if (!project)
|
|
1796
|
+
return { connected: false, reason: 'The Vercel CLI could not read the linked project (vercel api), so whether this run created it is unknown: nothing was connected, and the push stays held.' };
|
|
1797
|
+
// A project connected to this repository already is this repository's: the connection changes nothing on it, and the lock below is what it gets as the run's own project would.
|
|
1798
|
+
if (project.repository !== repository.toLowerCase()) {
|
|
1799
|
+
const foreign = !(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`)) ? `this run's vercel project add did not make it in ${own.team ? `the team ${own.team}` : 'the personal scope'}, so vercel link linked a project of that name that existed already` : null;
|
|
1800
|
+
if (foreign) {
|
|
1801
|
+
// Nothing builds a push to a repository the project is not connected to, so the push goes; a deploy from here still would build it, and the shell's git connect never runs.
|
|
1802
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1803
|
+
return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} is not this run's: ${foreign}. It stays as it is, and nothing was connected. The Vercel setup ends here.` };
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1706
1806
|
// Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
|
|
1707
1807
|
const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
|
|
1708
1808
|
// 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.
|
|
1709
|
-
const link = await readLink(pi.cwd);
|
|
1710
1809
|
if (!result.connected) {
|
|
1711
1810
|
if (result.access !== undefined)
|
|
1712
|
-
settleVercelFirstDeployment(ctx.log, { projectId: link
|
|
1811
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1713
1812
|
return result;
|
|
1714
1813
|
}
|
|
1715
|
-
let first =
|
|
1814
|
+
let first = await prepareFirstDeployment(vercel, link, repository);
|
|
1716
1815
|
vercelProject = first.project ?? vercelProject;
|
|
1717
1816
|
// Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
|
|
1718
|
-
if (
|
|
1817
|
+
if (first.production === 'none' && !first.locked) {
|
|
1719
1818
|
const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
|
|
1720
|
-
first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
|
|
1819
|
+
first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, repository, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
|
|
1721
1820
|
waiting: "Waiting for Vercel's login on the project",
|
|
1722
1821
|
skipped: 'The person skipped locking the Vercel project.',
|
|
1723
1822
|
still: "Vercel's login is still not in front of the project's addresses.",
|
|
1724
1823
|
stopped: 'The run stopped while the lock was waited for.',
|
|
1725
1824
|
}, signal);
|
|
1726
1825
|
}
|
|
1727
|
-
if (
|
|
1826
|
+
if (first.production === 'exists' || first.locked === true)
|
|
1728
1827
|
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
|
|
1729
1828
|
return { ...result, ...first };
|
|
1730
1829
|
}
|
|
@@ -1892,6 +1991,10 @@ export function createTools(ctx) {
|
|
|
1892
1991
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
|
|
1893
1992
|
return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
|
|
1894
1993
|
}
|
|
1994
|
+
// The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
|
|
1995
|
+
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
1996
|
+
return "Refused: beforeUrl is the live app's address that impeccable_prove_preview opened with this app in this run (the production address a host tool answered, or the one the README names), or a page on it; an address no key opens is not set, and a saved one is never cleared from here. Nothing was saved.";
|
|
1997
|
+
}
|
|
1895
1998
|
const testAccounts = target.url.endsWith('/test-accounts');
|
|
1896
1999
|
let body = p.body;
|
|
1897
2000
|
if (testAccounts) {
|
|
@@ -1998,7 +2101,7 @@ export function createTools(ctx) {
|
|
|
1998
2101
|
reportTasks(tasks);
|
|
1999
2102
|
return 'Saved.';
|
|
2000
2103
|
}),
|
|
2001
|
-
tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch; the setup pull request, once recorded, cannot change.', TEST_PR, async (p,
|
|
2104
|
+
tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
|
|
2002
2105
|
const drive = { number: p.number, url: p.url.trim() };
|
|
2003
2106
|
const given = p.setup_pr;
|
|
2004
2107
|
const setup = given && { number: given.number, url: given.url.trim() };
|
|
@@ -2007,19 +2110,28 @@ export function createTools(ctx) {
|
|
|
2007
2110
|
const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
|
|
2008
2111
|
if ('problem' in found)
|
|
2009
2112
|
return `Refused: ${found.problem}.`;
|
|
2010
|
-
|
|
2113
|
+
drive.url = found.url;
|
|
2114
|
+
// The page that links to the test drive is an existing file the branch changes; files under dot folders (.github, .netlify) and dotfiles never link to it (#241).
|
|
2115
|
+
const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
|
|
2116
|
+
const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
|
|
2117
|
+
if (files.length && files.every(({ status }) => status === 'A')) {
|
|
2118
|
+
return `Refused: ${found.branch} only adds ${files.map(({ path }) => path).join(', ')} and changes no existing page, so nothing links to the test drive. Commit the page that links to it and push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
|
|
2119
|
+
}
|
|
2120
|
+
let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
|
|
2011
2121
|
if (setup) {
|
|
2012
2122
|
// The setup pull request carries the preview attempts, so its binding cannot change.
|
|
2013
2123
|
const head = await runPullRequest(ctx, setup, 'impeccable/setup', signal);
|
|
2014
2124
|
if ('problem' in head)
|
|
2015
2125
|
return `Refused: ${head.problem}.`;
|
|
2126
|
+
setup.url = head.url;
|
|
2016
2127
|
const bound = bindPreviewPullRequest(retry, { ...setup, branch: head.branch });
|
|
2017
2128
|
if (bound.verdict !== 'allow')
|
|
2018
2129
|
return `Refused: ${bound.reason}.`;
|
|
2019
2130
|
setupBranch = head.branch;
|
|
2131
|
+
setupBase = head.base;
|
|
2020
2132
|
}
|
|
2021
2133
|
ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
|
|
2022
|
-
recordedBranches = { drive: found.branch, setup: setupBranch };
|
|
2134
|
+
recordedBranches = { drive: found.branch, setup: setupBranch, setupBase };
|
|
2023
2135
|
const res = await ctx.sync.flush();
|
|
2024
2136
|
const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
|
|
2025
2137
|
return res?.ok
|
|
@@ -2031,11 +2143,22 @@ export function createTools(ctx) {
|
|
|
2031
2143
|
const long = left.find((line) => line.length > STATUS_MAX);
|
|
2032
2144
|
if (long)
|
|
2033
2145
|
return `Refused: "${long}" is longer than ${STATUS_MAX} characters. Each line of "left" is one short line the person can act on; the detail belongs in the report. Nothing was saved: call impeccable_report again.`;
|
|
2146
|
+
const control = left.find((line) => plainText(line) !== line);
|
|
2147
|
+
if (control)
|
|
2148
|
+
return `Refused: "${control}" holds a control character or an escape sequence. Each line of "left" is plain text. Nothing was saved: call impeccable_report again.`;
|
|
2034
2149
|
// The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
|
|
2035
2150
|
const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
|
|
2036
2151
|
if (open.length)
|
|
2037
2152
|
return `Refused: the task list still shows ${open.map((t) => `"${t.title}" as ${t.status.replace('_', ' ')}`).join(', ')}. Call impeccable_progress with every task's final status (completed, skipped or failed), then impeccable_report again. Nothing was saved.`;
|
|
2038
2153
|
let markdown = p.markdown;
|
|
2154
|
+
// The files the setup pull request carries, so one the setup never meant to change stands out: a sandboxed script writes in the checkout too.
|
|
2155
|
+
if (recordedBranches?.setup) {
|
|
2156
|
+
const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
|
|
2157
|
+
const verbs = { A: 'added', M: 'changed', D: 'removed' };
|
|
2158
|
+
// A file name is git's to give: one with Markdown in it, or a line break, stays one plain entry.
|
|
2159
|
+
const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
|
|
2160
|
+
markdown += `\n\n## What the setup pull request changes\n\n${changes ? changes.map(({ status, path }) => `- ${shown(path)} (${verbs[status] ?? status})`).join('\n') || 'Nothing: it carries no file.' : 'This checkout could not list its files: read them on the pull request before merging.'}`;
|
|
2161
|
+
}
|
|
2039
2162
|
if (previews.active && retry.pr) {
|
|
2040
2163
|
await observe(signal);
|
|
2041
2164
|
const lines = attempts().map((a, i) => `${i + 1}. [Attempt ${i + 1}](${a.url}) (run attempt ${a.attempt}): ${a.status}. ${a.diagnostics.map((d) => `${d.appKey}: step ${d.stage ?? 'unverified'}. ${d.reason}`).join(' ')}${a.changes.length ? ` Changed settings: ${a.changes.join(', ')}.` : ''}`);
|
|
@@ -2064,7 +2187,7 @@ export function createTools(ctx) {
|
|
|
2064
2187
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2065
2188
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
2066
2189
|
}),
|
|
2067
|
-
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
2190
|
+
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
2068
2191
|
const branch = p.branch;
|
|
2069
2192
|
const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
|
|
2070
2193
|
// 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
|
|
@@ -2087,7 +2210,7 @@ export function createTools(ctx) {
|
|
|
2087
2210
|
if (branch !== undefined && !isPreview(d)) {
|
|
2088
2211
|
locked ??= await (async () => {
|
|
2089
2212
|
const link = await readLink(pi.cwd);
|
|
2090
|
-
return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link) : new Set();
|
|
2213
|
+
return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link, repository) : new Set();
|
|
2091
2214
|
})();
|
|
2092
2215
|
if (!locked.has(url.host)) {
|
|
2093
2216
|
publicProduction.add(url.origin);
|
|
@@ -2098,9 +2221,36 @@ export function createTools(ctx) {
|
|
|
2098
2221
|
if (d.vercel)
|
|
2099
2222
|
vercelMade.add(url.origin);
|
|
2100
2223
|
}
|
|
2101
|
-
|
|
2224
|
+
// What vercel.json and the linked project say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
|
|
2225
|
+
// The fact and the live app read the same linked project: one `vercel api` call answers both.
|
|
2226
|
+
const reads = new Map();
|
|
2227
|
+
const vercel = (args) => {
|
|
2228
|
+
const key = JSON.stringify(args);
|
|
2229
|
+
if (!reads.has(key))
|
|
2230
|
+
reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
|
|
2231
|
+
return reads.get(key);
|
|
2232
|
+
};
|
|
2233
|
+
if (branch === undefined) {
|
|
2234
|
+
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel);
|
|
2235
|
+
if (fact) {
|
|
2236
|
+
previewFacts.set('vercel', fact);
|
|
2237
|
+
evidence.pullRequestPreviews = fact;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
// The live app, where GitHub's record shows a production deployment Vercel made: its own address there is behind Vercel's login under Standard Protection, so the
|
|
2241
|
+
// linked project is read for its alias, the project's own domain, and only as this repository's project.
|
|
2242
|
+
let production;
|
|
2243
|
+
if (branch === undefined && evidence.deployments.some((d) => d.vercel && d.state === 'success' && !isPreview(d))) {
|
|
2244
|
+
const link = await readLink(pi.cwd);
|
|
2245
|
+
const project = link ? await readOwnProject(vercel, link, repository) : null;
|
|
2246
|
+
production = project && !('reason' in project) ? project.productionUrl : undefined;
|
|
2247
|
+
if (production && link)
|
|
2248
|
+
vercelProduction = { origin: new URL(production).origin, projectId: link.projectId };
|
|
2249
|
+
noteProduction(production, 'vercel');
|
|
2250
|
+
}
|
|
2251
|
+
return JSON.stringify({ ...evidence, ...(production ? { production } : {}) });
|
|
2102
2252
|
}),
|
|
2103
|
-
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
2253
|
+
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
2104
2254
|
const pull = p.pull;
|
|
2105
2255
|
const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
|
|
2106
2256
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
@@ -2116,9 +2266,12 @@ export function createTools(ctx) {
|
|
|
2116
2266
|
for (const p of evidence.previews)
|
|
2117
2267
|
if (p.state === 'success' || p.state === 'ready')
|
|
2118
2268
|
netlifyListed.add(new URL(p.url).origin);
|
|
2269
|
+
if (evidence.pullRequestPreviews)
|
|
2270
|
+
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
2271
|
+
noteProduction(evidence.production, 'netlify', liveSite(evidence));
|
|
2119
2272
|
return JSON.stringify(evidence);
|
|
2120
2273
|
}),
|
|
2121
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2274
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2122
2275
|
const pull = p.pull;
|
|
2123
2276
|
const branch = p.branch;
|
|
2124
2277
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -2128,7 +2281,14 @@ export function createTools(ctx) {
|
|
|
2128
2281
|
// GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
|
|
2129
2282
|
const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
|
|
2130
2283
|
const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
|
|
2131
|
-
|
|
2284
|
+
// A pushed branch of a project connected with previews on that gets no build: Cloudflare's GitHub app cannot see the repository (its installation covers selected ones, or none),
|
|
2285
|
+
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
2286
|
+
const app = cloudflareEvidence?.githubApp;
|
|
2287
|
+
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;
|
|
2288
|
+
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();
|
|
2289
|
+
const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
|
|
2290
|
+
if (!subject)
|
|
2291
|
+
cloudflareEvidence = evidence;
|
|
2132
2292
|
cloudflareListed.clear();
|
|
2133
2293
|
for (const preview of evidence.previews) {
|
|
2134
2294
|
if (preview.state !== 'success')
|
|
@@ -2137,24 +2297,40 @@ export function createTools(ctx) {
|
|
|
2137
2297
|
if (preview.alias)
|
|
2138
2298
|
cloudflareListed.add(new URL(preview.alias).origin);
|
|
2139
2299
|
}
|
|
2300
|
+
if (evidence.pullRequestPreviews)
|
|
2301
|
+
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
2302
|
+
noteProduction(evidence.production, 'cloudflare');
|
|
2140
2303
|
return JSON.stringify(evidence);
|
|
2141
2304
|
}),
|
|
2142
|
-
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) => {
|
|
2305
|
+
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
2143
2306
|
const url = p.url;
|
|
2144
|
-
|
|
2307
|
+
const origin = URL.canParse(url) ? new URL(url).origin : null;
|
|
2308
|
+
if (origin && publicProduction.has(origin))
|
|
2145
2309
|
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.";
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
const
|
|
2310
|
+
const listed = origin !== null && (listedPreviews.has(origin) || netlifyListed.has(origin) || cloudflareListed.has(origin));
|
|
2311
|
+
// The live app's address, for the before: one a host tool answered as production, or one a file the checkout tracks names, such as the README's live-site line, when it is the site's or project's own address on one of the hosts: a deploy preview or branch alias a file links stays a preview, listed or not, and an address elsewhere, which could be on the person's own network, is only a host tool's to name.
|
|
2312
|
+
const known = origin !== null ? productionListed.get(origin) : undefined;
|
|
2313
|
+
const live = origin !== null && !listed && (known !== undefined || (liveShaped(url) && (await checkoutNames(pi.cwd, origin))));
|
|
2314
|
+
if (!origin || (!listed && !live))
|
|
2315
|
+
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; or, for the before, the live app's address one of them answered as production, or the site's or project's own address on one of the hosts that a file of this checkout names.";
|
|
2316
|
+
// The Netlify site, by the address's shape or, for the site's custom domain, as its tool named it: its password goes to the site's own addresses.
|
|
2317
|
+
const site = netlifySiteOf(url) ?? known?.site;
|
|
2149
2318
|
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
2150
|
-
const project = pagesProject(
|
|
2319
|
+
const project = pagesProject(origin);
|
|
2151
2320
|
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
2152
2321
|
const proof = await provePreview(url, p.expect, pi.cwd, {
|
|
2153
|
-
fetch: ctx.previewFetch, signal,
|
|
2154
|
-
|
|
2322
|
+
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
2323
|
+
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
2324
|
+
...(bypassSecret && (bypassOrigins.has(origin) || (live && origin === vercelProduction?.origin && vercelProduction.projectId === bypassProject)) ? { bypass: bypassSecret } : {}),
|
|
2155
2325
|
...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
|
|
2156
2326
|
...(token ? { access: token } : {}),
|
|
2157
2327
|
});
|
|
2328
|
+
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
2329
|
+
if (live) {
|
|
2330
|
+
if (proof.proven)
|
|
2331
|
+
provenLive.add(origin);
|
|
2332
|
+
return JSON.stringify(proof);
|
|
2333
|
+
}
|
|
2158
2334
|
hostedPreviewProven ||= proof.proven;
|
|
2159
2335
|
if (proof.proven)
|
|
2160
2336
|
provenHost = previewHostName(url) ?? provenHost;
|
|
@@ -2166,7 +2342,7 @@ export function createTools(ctx) {
|
|
|
2166
2342
|
accessGated = { url, expect: p.expect };
|
|
2167
2343
|
return JSON.stringify(proof);
|
|
2168
2344
|
}),
|
|
2169
|
-
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) => {
|
|
2345
|
+
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
2170
2346
|
const command = p.command;
|
|
2171
2347
|
const publish = p.publish;
|
|
2172
2348
|
if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
|
|
@@ -2175,15 +2351,145 @@ export function createTools(ctx) {
|
|
|
2175
2351
|
return 'Refused: publish is a folder relative to the checkout, such as "dist".';
|
|
2176
2352
|
return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
|
|
2177
2353
|
}),
|
|
2178
|
-
tool('
|
|
2179
|
-
|
|
2354
|
+
tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect or impeccable_cloudflare_previews said (enabled false). It does the rest as the person: one screen asking to turn them on, then the host's CLI changes that one setting (Netlify: Deploy Previews on; Cloudflare Pages: preview branch control set to every non-production branch, or the test drive's branch added to the custom includes) and reads it back; where the CLI cannot (the role, a cf sign-in without pages:write, builds stopped altogether), it opens the host's settings page and asks the person to try again once they changed it there. Answers {\"status\": …}: enabled (pull-request previews are on now; by says whether the run or the person turned them on, and project which project or site), skipped (the person skipped a screen; reason says what came before) or refused (still off after the retries; reason says why, and page where it is changed). Vercel builds every branch once Git is connected, so its fact is reported and never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
|
|
2355
|
+
const host = p.host;
|
|
2356
|
+
const standing = enableOutcomes.get(host);
|
|
2357
|
+
if (standing)
|
|
2358
|
+
return JSON.stringify(standing);
|
|
2359
|
+
const fact = previewFacts.get(host);
|
|
2360
|
+
if (!fact || fact.enabled !== false)
|
|
2361
|
+
return `Refused: this is for a host whose pullRequestPreviews said enabled false in this run. Read ${host === 'netlify' ? 'impeccable_netlify_previews or impeccable_netlify_connect' : 'impeccable_cloudflare_previews'} first.`;
|
|
2362
|
+
if (host === 'cloudflare' && fact.cause === 'source')
|
|
2363
|
+
return `Refused: the Pages project ${fact.project} is connected to no repository, so there is no setting to turn on. impeccable_cloudflare_connect connects it.`;
|
|
2364
|
+
const name = host === 'netlify' ? 'Netlify' : 'Cloudflare Pages';
|
|
2365
|
+
const answer = (status, more) => {
|
|
2366
|
+
const told = { status, project: fact.project, ...more };
|
|
2367
|
+
enableOutcomes.set(host, told);
|
|
2368
|
+
return JSON.stringify(told);
|
|
2369
|
+
};
|
|
2370
|
+
const screen = HOST_SCREENS[`${host}-previews`];
|
|
2371
|
+
if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
|
|
2372
|
+
return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
|
|
2373
|
+
said(`Turning on pull-request previews on ${name}`);
|
|
2374
|
+
const turnOn = async () => {
|
|
2375
|
+
if (host === 'netlify')
|
|
2376
|
+
return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
|
|
2377
|
+
const cf = cfRunner({}, ctx.hostRunner);
|
|
2378
|
+
return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
|
|
2379
|
+
};
|
|
2380
|
+
// The run's change, read back; where the host kept the setting, the person changes it on the host's page and the run reads again, twice at most.
|
|
2381
|
+
for (let tries = 0;; tries++) {
|
|
2382
|
+
const outcome = await turnOn();
|
|
2383
|
+
if (outcome.fact.enabled === true) {
|
|
2384
|
+
previewFacts.set(host, outcome.fact);
|
|
2385
|
+
return answer('enabled', { by: outcome.changed ? 'run' : 'person' });
|
|
2386
|
+
}
|
|
2387
|
+
const why = outcome.reason ?? outcome.fact.reason;
|
|
2388
|
+
if (tries === 2 || signal?.aborted)
|
|
2389
|
+
return answer('refused', { reason: why, page: outcome.page });
|
|
2390
|
+
const retry = HOST_SCREENS[`${host}-previews-retry`];
|
|
2391
|
+
// The host's page opens once, before the first retry screen, which speaks of it; the browser may not have opened (BROWSER=none, no opener), so the address is on the screen as well.
|
|
2392
|
+
if (tries === 0)
|
|
2393
|
+
(ctx.openBrowser ?? openBrowser)(outcome.page);
|
|
2394
|
+
if ((await ask({ ...retry, hint: `${retry.hint} If it did not open, open this link: ${outcome.page}` })).skipped)
|
|
2395
|
+
return answer('skipped', { reason: why, page: outcome.page });
|
|
2396
|
+
}
|
|
2397
|
+
}),
|
|
2398
|
+
tool('impeccable_cloudflare_connect', 'Connect the repository to Cloudflare Pages', "Connect this repository to a Cloudflare Pages project, so that Cloudflare builds every pull request as a preview, where impeccable_cloudflare_previews found no Pages project building them: the project the checkout names when it exists with no Git source (pullRequestPreviews with cause source), else a new one named after the repository where Cloudflare builds a Worker from it (worker in the answer) or the checkout is set up for a Pages project that does not exist yet. It does the rest as the person: one screen asking to connect or to create (Cloudflare's sign-in in the browser first, with a retry, where cf is not signed in here with pages:write), then cf creates the project with the build the checkout runs (command and outputDir, for a new project) and connects the repository as GitHub identifies it, every branch but the default a preview, and reads it back. Answers {\"status\": …}: connected (project names the Pages project, created whether the run made it, subdomain its own address under pages.dev, productionBranch the branch Cloudflare builds as production; pullRequestPreviews is on now), skipped (the person skipped a screen; reason says which) or refused (cf could not, or Cloudflare refused; reason says why). Cloudflare's GitHub app then has to see the repository for a push to build: the wait for the test drive's build asks for its access when none comes.", CLOUDFLARE_CONNECT, async (p, pi, signal) => {
|
|
2399
|
+
if (connectOutcome)
|
|
2400
|
+
return JSON.stringify(connectOutcome);
|
|
2401
|
+
const evidence = cloudflareEvidence;
|
|
2402
|
+
if (!evidence)
|
|
2403
|
+
return 'Refused: read impeccable_cloudflare_previews first: it says whether a Pages project builds this repository.';
|
|
2404
|
+
const fact = evidence.pullRequestPreviews;
|
|
2405
|
+
if (fact && fact.enabled !== false)
|
|
2406
|
+
return `Refused: the Pages project ${fact.project} is connected to the repository already${fact.enabled === true ? ' and builds its pull requests' : ''}.`;
|
|
2407
|
+
if (fact && fact.cause !== 'source')
|
|
2408
|
+
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.`;
|
|
2409
|
+
const create = !fact;
|
|
2410
|
+
if (create && !evidence.worker && !evidence.project)
|
|
2411
|
+
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 or static-site case says.';
|
|
2412
|
+
const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
|
|
2413
|
+
const answer = (status, more) => {
|
|
2414
|
+
connectOutcome = { status, project, created: create, ...more };
|
|
2415
|
+
return JSON.stringify(connectOutcome);
|
|
2416
|
+
};
|
|
2417
|
+
// The one screen: the connection of the project named, or the new project for the repository.
|
|
2418
|
+
const screen = HOST_SCREENS[create ? 'cloudflare-create' : 'cloudflare-connect'];
|
|
2419
|
+
if ((await ask({ ...screen, prompt: create ? `Create the Pages project ${project} for ${ctx.owner}/${ctx.repo}?` : `Connect ${ctx.owner}/${ctx.repo} to the Pages project ${project}?` })).skipped)
|
|
2420
|
+
return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
|
|
2421
|
+
// cf signed in with pages:write, as it is or after Cloudflare's sign-in in the browser, keeping the scopes it held; a sign-in that did not complete offers a retry, twice.
|
|
2422
|
+
const cf = cfRunner({}, ctx.hostRunner);
|
|
2423
|
+
const login = cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner);
|
|
2424
|
+
const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
|
|
2425
|
+
let status = await cfStatus(cf, signal);
|
|
2426
|
+
if (!writes(status)) {
|
|
2427
|
+
if (status.cli === 'missing' || status.cli === 'unknown')
|
|
2428
|
+
return answer('refused', { reason: "Cloudflare's CLI did not run here." });
|
|
2429
|
+
if ((await ask(HOST_SCREENS['cloudflare-setup'])).skipped)
|
|
2430
|
+
return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
|
|
2431
|
+
for (let attempt = 0;; attempt++) {
|
|
2432
|
+
said("Opening the browser for Cloudflare's login");
|
|
2433
|
+
const scopes = [...new Set([...(status.scopes ?? []).filter((scope) => scope !== 'offline_access'), 'account:read', 'user:read', 'pages:write'])];
|
|
2434
|
+
await login(['auth', 'login', '--force', '--no-device', '--scopes', ...scopes], { signal }).catch(() => undefined);
|
|
2435
|
+
signal?.throwIfAborted();
|
|
2436
|
+
status = await cfStatus(cf, signal);
|
|
2437
|
+
if (writes(status))
|
|
2438
|
+
break;
|
|
2439
|
+
if (status.cli === 'signed-in')
|
|
2440
|
+
return answer('refused', { reason: 'The Cloudflare sign-in gave the run no pages:write.' });
|
|
2441
|
+
if (attempt === 2)
|
|
2442
|
+
return answer('refused', { reason: 'The Cloudflare sign-in did not complete.' });
|
|
2443
|
+
if ((await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped)
|
|
2444
|
+
return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
said(create ? `Creating the Pages project ${project}` : `Connecting the repository to ${project}`);
|
|
2448
|
+
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
2449
|
+
const outcome = await connectCloudflareProject(cf, gh, status, { owner: ctx.owner, repo: ctx.repo }, { project, ...(evidence.account ? { account: evidence.account } : {}), ...(create ? { create: { command: p.command, outputDir: p.outputDir } } : {}) }, signal);
|
|
2450
|
+
if (outcome.status !== 'connected')
|
|
2451
|
+
return answer('refused', { reason: outcome.reason });
|
|
2452
|
+
if (outcome.fact)
|
|
2453
|
+
previewFacts.set('cloudflare', outcome.fact);
|
|
2454
|
+
cloudflareEvidence = { ...evidence, project, ...(outcome.fact ? { pullRequestPreviews: outcome.fact } : {}) };
|
|
2455
|
+
return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
|
|
2456
|
+
}),
|
|
2457
|
+
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
|
|
2458
|
+
const answer = await vercelConnect(pi, signal);
|
|
2459
|
+
// A project that deploys production already names the live app's address, for the before.
|
|
2460
|
+
const link = answer.productionUrl ? await readLink(pi.cwd) : null;
|
|
2461
|
+
if (answer.productionUrl && link)
|
|
2462
|
+
vercelProduction = { origin: new URL(answer.productionUrl).origin, projectId: link.projectId };
|
|
2463
|
+
noteProduction(answer.productionUrl, 'vercel');
|
|
2464
|
+
return JSON.stringify(answer);
|
|
2465
|
+
}),
|
|
2466
|
+
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2180
2467
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2181
2468
|
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.';
|
|
2182
2469
|
if (bypassOutcome?.status === 'skipped')
|
|
2183
2470
|
return JSON.stringify(bypassOutcome);
|
|
2184
2471
|
const at = gated;
|
|
2185
|
-
const origin = new URL(at.url)
|
|
2186
|
-
//
|
|
2472
|
+
const { origin, host } = new URL(at.url);
|
|
2473
|
+
// Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project the
|
|
2474
|
+
// link file names, read as the person and connected to this repository, has to have made the deployment before any secret of it goes there.
|
|
2475
|
+
const link = await readLink(pi.cwd);
|
|
2476
|
+
if (!link)
|
|
2477
|
+
return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2478
|
+
// 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.
|
|
2479
|
+
const links = linkedByRun(ctx.log);
|
|
2480
|
+
const own = links.get(link.projectId);
|
|
2481
|
+
if ([...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2482
|
+
return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2483
|
+
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
2484
|
+
const project = await readOwnProject(vercel, link, repository);
|
|
2485
|
+
if ('reason' in project)
|
|
2486
|
+
return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
|
|
2487
|
+
// The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
|
|
2488
|
+
if (!project.previewsProtected)
|
|
2489
|
+
return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
|
|
2490
|
+
if (!(await projectMadeDeployment(vercel, link, project, host)))
|
|
2491
|
+
return `Refused: nothing was sent to ${at.url}. The Vercel project this folder is linked to${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview the linked project serves.`;
|
|
2492
|
+
// A secret in hand already: another address of the same project opens with it.
|
|
2187
2493
|
if (bypassSaved && bypassSecret && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: bypassSecret })).proven) {
|
|
2188
2494
|
bypassOrigins.add(origin);
|
|
2189
2495
|
return JSON.stringify(bypassSaved);
|
|
@@ -2191,13 +2497,13 @@ export function createTools(ctx) {
|
|
|
2191
2497
|
/** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
|
|
2192
2498
|
let stored = false;
|
|
2193
2499
|
const { value, ...told } = await passVercelLogin({
|
|
2194
|
-
run:
|
|
2195
|
-
link: () =>
|
|
2196
|
-
consent: async () => !(await ask(HOST_SCREENS['vercel-bypass'])).skipped,
|
|
2500
|
+
run: vercel,
|
|
2501
|
+
link: async () => link,
|
|
2502
|
+
consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
2197
2503
|
paste: async () => {
|
|
2198
|
-
// The
|
|
2199
|
-
(ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection'));
|
|
2200
|
-
const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
|
|
2504
|
+
// The linked project's own settings page, in its team where the run knows it; else Vercel's picker.
|
|
2505
|
+
(ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
|
|
2506
|
+
const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
|
|
2201
2507
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
2202
2508
|
},
|
|
2203
2509
|
prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
|
|
@@ -2212,6 +2518,7 @@ export function createTools(ctx) {
|
|
|
2212
2518
|
if (value) {
|
|
2213
2519
|
// What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
|
|
2214
2520
|
bypassSecret = value;
|
|
2521
|
+
bypassProject = link.projectId;
|
|
2215
2522
|
bypassOrigins.clear();
|
|
2216
2523
|
bypassOrigins.add(origin);
|
|
2217
2524
|
hostedPreviewProven = true;
|
|
@@ -2221,12 +2528,13 @@ export function createTools(ctx) {
|
|
|
2221
2528
|
else if (told.status === 'save-failed' && stored) {
|
|
2222
2529
|
// The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
|
|
2223
2530
|
bypassSecret = undefined;
|
|
2531
|
+
bypassProject = undefined;
|
|
2224
2532
|
bypassOrigins.clear();
|
|
2225
2533
|
bypassSaved = undefined;
|
|
2226
2534
|
}
|
|
2227
2535
|
return JSON.stringify(bypassOutcome);
|
|
2228
2536
|
}),
|
|
2229
|
-
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
|
|
2537
|
+
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2230
2538
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
2231
2539
|
if (!gated || !site)
|
|
2232
2540
|
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.';
|
|
@@ -2261,7 +2569,7 @@ export function createTools(ctx) {
|
|
|
2261
2569
|
run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
|
|
2262
2570
|
site,
|
|
2263
2571
|
consent: async () => {
|
|
2264
|
-
if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
|
|
2572
|
+
if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
|
|
2265
2573
|
return passwordConsented.add(site), true;
|
|
2266
2574
|
return false;
|
|
2267
2575
|
},
|
|
@@ -2281,7 +2589,7 @@ export function createTools(ctx) {
|
|
|
2281
2589
|
return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
|
|
2282
2590
|
},
|
|
2283
2591
|
paste: async () => {
|
|
2284
|
-
const answer = await ask(HOST_SCREENS['netlify-password-key']);
|
|
2592
|
+
const answer = await ask(sentTo(HOST_SCREENS['netlify-password-key'], at.url));
|
|
2285
2593
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
2286
2594
|
},
|
|
2287
2595
|
prove,
|
|
@@ -2319,7 +2627,7 @@ export function createTools(ctx) {
|
|
|
2319
2627
|
}
|
|
2320
2628
|
return JSON.stringify(passwordOutcome);
|
|
2321
2629
|
}),
|
|
2322
|
-
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
|
|
2630
|
+
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2323
2631
|
if (!accessGated)
|
|
2324
2632
|
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.';
|
|
2325
2633
|
if (accessOutcome?.status === 'skipped')
|
|
@@ -2341,7 +2649,9 @@ export function createTools(ctx) {
|
|
|
2341
2649
|
return null;
|
|
2342
2650
|
};
|
|
2343
2651
|
let answer;
|
|
2344
|
-
|
|
2652
|
+
const project = pagesProject(new URL(at.url).origin);
|
|
2653
|
+
// The token in hand goes only to a project it opened already, as the proofs send it: any other address is read for an application of the person's first.
|
|
2654
|
+
if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
|
|
2345
2655
|
// The token in hand opens this address too: stored already, or stored now.
|
|
2346
2656
|
const failed = accessStored ? null : await save(accessToken);
|
|
2347
2657
|
answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
|
|
@@ -2349,7 +2659,7 @@ export function createTools(ctx) {
|
|
|
2349
2659
|
else {
|
|
2350
2660
|
answer = await passCloudflareAccess({
|
|
2351
2661
|
url: at.url,
|
|
2352
|
-
consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
|
|
2662
|
+
consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
|
|
2353
2663
|
cf: cfRunner({}, ctx.hostRunner),
|
|
2354
2664
|
login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
|
|
2355
2665
|
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
@@ -2359,7 +2669,7 @@ export function createTools(ctx) {
|
|
|
2359
2669
|
return typed.skipped ? undefined : typed.value;
|
|
2360
2670
|
},
|
|
2361
2671
|
paste: async () => {
|
|
2362
|
-
const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
|
|
2672
|
+
const typed = await ask(sentTo(HOST_SCREENS['cloudflare-access-key'], at.url));
|
|
2363
2673
|
return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
|
|
2364
2674
|
},
|
|
2365
2675
|
prove,
|
|
@@ -2377,7 +2687,6 @@ export function createTools(ctx) {
|
|
|
2377
2687
|
accessToken = value;
|
|
2378
2688
|
accessStored = told.status === 'saved';
|
|
2379
2689
|
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
2380
|
-
const project = pagesProject(new URL(at.url).origin);
|
|
2381
2690
|
if (project)
|
|
2382
2691
|
accessProjects.add(project);
|
|
2383
2692
|
}
|