@impeccable/reviews 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +347 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +123 -0
- package/dist/host-questions.js +101 -21
- 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 +37 -14
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +58 -16
- 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 +8 -8
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +7 -6
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/pull-requests.md +3 -3
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +3 -1
- package/dist/playbook/cases/vercel-setup.md +9 -5
- package/dist/playbook.js +3 -3
- package/dist/preflight.js +43 -8
- 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 +682 -140
- 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 +21 -12
- package/dist/vercel.js +147 -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 { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo, startedIn } from './host-questions.js';
|
|
14
|
+
import { enableNetlifyPreviews, folderOf } 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, vercelProjectFor, 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');
|
|
@@ -186,10 +179,15 @@ const TEST_PR = {
|
|
|
186
179
|
...PULL_REQUEST,
|
|
187
180
|
properties: { ...PULL_REQUEST.properties, setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' } },
|
|
188
181
|
};
|
|
182
|
+
/** An app's key as the setup state gives it. */
|
|
183
|
+
const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
184
|
+
/** The app a host tool reads for, on a repository with several apps: its previews are those of the project or site that builds the app's folder. */
|
|
185
|
+
const APP_KEY = { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app this is for, one the apps question picked: only the previews of the host project that builds that app\'s folder are read.' };
|
|
189
186
|
const HOSTED_PREVIEWS = {
|
|
190
187
|
type: 'object',
|
|
191
188
|
additionalProperties: false,
|
|
192
189
|
properties: {
|
|
190
|
+
appKey: APP_KEY,
|
|
193
191
|
branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the deployments of its head only, and waits up to five minutes for one to finish building.' },
|
|
194
192
|
},
|
|
195
193
|
};
|
|
@@ -197,6 +195,7 @@ const NETLIFY_PREVIEWS = {
|
|
|
197
195
|
type: 'object',
|
|
198
196
|
additionalProperties: false,
|
|
199
197
|
properties: {
|
|
198
|
+
appKey: APP_KEY,
|
|
200
199
|
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
|
|
201
200
|
},
|
|
202
201
|
};
|
|
@@ -204,6 +203,7 @@ const CLOUDFLARE_PREVIEWS = {
|
|
|
204
203
|
type: 'object',
|
|
205
204
|
additionalProperties: false,
|
|
206
205
|
properties: {
|
|
206
|
+
appKey: APP_KEY,
|
|
207
207
|
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
|
|
208
208
|
branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the build of its head only, and waits up to five minutes for it to finish.' },
|
|
209
209
|
},
|
|
@@ -213,7 +213,7 @@ const PROVE_PREVIEW = {
|
|
|
213
213
|
additionalProperties: false,
|
|
214
214
|
required: ['url', 'expect'],
|
|
215
215
|
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.' },
|
|
216
|
+
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
217
|
expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
|
|
218
218
|
},
|
|
219
219
|
};
|
|
@@ -254,6 +254,20 @@ const namesCredential = (q) => {
|
|
|
254
254
|
};
|
|
255
255
|
/** Under the field of every text question typed in the open, where the model reads the answer. */
|
|
256
256
|
const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
|
|
257
|
+
const ENABLE_PREVIEWS = {
|
|
258
|
+
type: 'object',
|
|
259
|
+
additionalProperties: false,
|
|
260
|
+
required: ['host'],
|
|
261
|
+
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." } },
|
|
262
|
+
};
|
|
263
|
+
const CLOUDFLARE_CONNECT = {
|
|
264
|
+
type: 'object',
|
|
265
|
+
additionalProperties: false,
|
|
266
|
+
properties: {
|
|
267
|
+
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." },
|
|
268
|
+
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.' },
|
|
269
|
+
},
|
|
270
|
+
};
|
|
257
271
|
const NETLIFY_CONNECT = {
|
|
258
272
|
type: 'object',
|
|
259
273
|
additionalProperties: false,
|
|
@@ -279,17 +293,28 @@ function askError(q) {
|
|
|
279
293
|
return 'input.options[].selected is for multi only';
|
|
280
294
|
return new Set(values).size === values.length ? null : 'input.options has a value twice';
|
|
281
295
|
}
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Pull request `number` of owner/repo on GitHub, when `url` is it: its address rebuilt from those validated parts, so no
|
|
298
|
+
* query, fragment or other character the model wrote reaches the browser or the clipboard. Null when `url` is not it.
|
|
299
|
+
*/
|
|
300
|
+
function canonicalPullRequest(url, number, owner, repo) {
|
|
301
|
+
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
302
|
+
const m = parsed && /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(parsed.pathname);
|
|
303
|
+
const ok = m && parsed.origin === 'https://github.com' && !parsed.username && !parsed.password
|
|
304
|
+
&& /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) && /^[A-Za-z0-9._-]{1,100}$/.test(repo)
|
|
305
|
+
&& m[1].toLowerCase() === owner.toLowerCase() && m[2].toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(number) && number > 0 && Number(m[3]) === number;
|
|
306
|
+
return ok ? `https://github.com/${owner}/${repo}/pull/${number}` : null;
|
|
307
|
+
}
|
|
308
|
+
/** `pr` with its address rebuilt as pull request `number` of owner/repo on GitHub, or undefined when it is not that. */
|
|
309
|
+
export function ownPullRequest(pr, owner, repo) {
|
|
310
|
+
const url = pr && canonicalPullRequest(pr.url, pr.number, owner, repo);
|
|
311
|
+
return url ? { number: pr.number, url } : undefined;
|
|
287
312
|
}
|
|
288
313
|
/** 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
314
|
async function runPullRequest(ctx, pr, branch, signal) {
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
return { problem:
|
|
315
|
+
const url = canonicalPullRequest(pr.url, pr.number, ctx.owner, ctx.repo);
|
|
316
|
+
if (!url)
|
|
317
|
+
return { problem: `${pr.url} is not pull request #${pr.number} of ${ctx.owner}/${ctx.repo} on GitHub` };
|
|
293
318
|
const found = await ctx.viewPullRequest(pr.number);
|
|
294
319
|
if ('error' in found)
|
|
295
320
|
return { problem: `gh pr view ${pr.number} did not find it (${found.error})` };
|
|
@@ -303,7 +328,7 @@ async function runPullRequest(ctx, pr, branch, signal) {
|
|
|
303
328
|
if (!await (ctx.setupPullHeadReader ?? readSetupPullHead)(ctx.owner, ctx.repo, pr.number, found.headRefName, { signal })) {
|
|
304
329
|
return { problem: `the branch of #${pr.number} could not be verified in this repository; a fork's branch cannot be recorded` };
|
|
305
330
|
}
|
|
306
|
-
return { branch: found.headRefName, ...(found.baseRefName ? { base: found.baseRefName } : {}) };
|
|
331
|
+
return { url, branch: found.headRefName, ...(found.baseRefName ? { base: found.baseRefName } : {}) };
|
|
307
332
|
}
|
|
308
333
|
const PREVIEW_COMMAND = { type: 'object', additionalProperties: false, required: ['command'], properties: {
|
|
309
334
|
command: { type: 'string', minLength: 1, description: 'A command of the repository\'s own scripts or tools, without cd.' },
|
|
@@ -518,10 +543,13 @@ export async function verifyLocalPreview(input, root, signal, sandbox) {
|
|
|
518
543
|
scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
|
|
519
544
|
const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
|
|
520
545
|
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.
|
|
546
|
+
// 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
547
|
const inSandbox = sandbox?.preview(scratch);
|
|
548
|
+
if (sandbox && !inSandbox)
|
|
549
|
+
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
550
|
const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
|
|
524
|
-
|
|
551
|
+
// 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.
|
|
552
|
+
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
553
|
const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
|
|
526
554
|
if (!listing.ok)
|
|
527
555
|
return fail(stage, 'Could not list checkout files.');
|
|
@@ -777,7 +805,14 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
|
|
|
777
805
|
*/
|
|
778
806
|
export async function pushedChanges(cwd, base, head) {
|
|
779
807
|
try {
|
|
780
|
-
|
|
808
|
+
// The two branches as the remote has them, fetched so the diff can be read here: refs/remotes/origin/* sit in the checkout's git folder, where a command in the sandbox can move them.
|
|
809
|
+
const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
|
|
810
|
+
const { stdout: heads } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
|
|
811
|
+
const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
|
|
812
|
+
if (!refs.every((ref) => sha.has(ref)))
|
|
813
|
+
return null;
|
|
814
|
+
await execFileAsync('git', ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
|
|
815
|
+
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() });
|
|
781
816
|
const fields = stdout.split('\0');
|
|
782
817
|
return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
|
|
783
818
|
}
|
|
@@ -1451,7 +1486,7 @@ export function createTools(ctx) {
|
|
|
1451
1486
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1452
1487
|
* 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.
|
|
1453
1488
|
*/
|
|
1454
|
-
async function awaitBuild(host, status, read, built, page, signal) {
|
|
1489
|
+
async function awaitBuild(host, status, read, built, page, signal, access) {
|
|
1455
1490
|
const first = await read();
|
|
1456
1491
|
if (built(first))
|
|
1457
1492
|
return first;
|
|
@@ -1469,7 +1504,15 @@ export function createTools(ctx) {
|
|
|
1469
1504
|
}
|
|
1470
1505
|
return evidence;
|
|
1471
1506
|
};
|
|
1472
|
-
// No build in the time: the host's page where a build is turned on, and the wait again on the person's yes.
|
|
1507
|
+
// 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
|
|
1508
|
+
// 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.
|
|
1509
|
+
if (access) {
|
|
1510
|
+
return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
|
|
1511
|
+
skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
|
|
1512
|
+
still: `${host}'s GitHub app still cannot see the repository: no build came.`,
|
|
1513
|
+
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1514
|
+
}, signal);
|
|
1515
|
+
}
|
|
1473
1516
|
return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
|
|
1474
1517
|
skipped: `The person stopped waiting for ${host}'s build.`,
|
|
1475
1518
|
still: `${host} built no preview in the time.`,
|
|
@@ -1483,14 +1526,19 @@ export function createTools(ctx) {
|
|
|
1483
1526
|
};
|
|
1484
1527
|
/** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
|
|
1485
1528
|
const vercelMade = new Set();
|
|
1486
|
-
/**
|
|
1529
|
+
/** On a repository with several apps, the app each listed origin was read for, with its own host project (#281): a key for that address goes to that project and is saved for that app. */
|
|
1530
|
+
const listedApps = new Map();
|
|
1531
|
+
/** The address impeccable_prove_preview met a host's lock at, the words it looked for there, whose lock it was, and the app it was listed for. */
|
|
1487
1532
|
let gated;
|
|
1488
|
-
/**
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1533
|
+
/**
|
|
1534
|
+
* The bypass secret saved in this run, by where the setup API keeps it (the app's key, or '' for the repository's), with
|
|
1535
|
+
* the project it is of and what the tool answered; never for the model. A secret is one project's, so it goes only to
|
|
1536
|
+
* the origins impeccable_vercel_bypass proved it on (each by that same store), never to another project's deployment.
|
|
1537
|
+
*/
|
|
1538
|
+
const bypassHeld = new Map();
|
|
1539
|
+
const bypassOrigins = new Map();
|
|
1540
|
+
/** What impeccable_vercel_bypass answered for a store, less the secret: a skip is asked once, so that answer stands. */
|
|
1541
|
+
const bypassOutcomes = new Map();
|
|
1494
1542
|
/** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
|
|
1495
1543
|
let sitePassword;
|
|
1496
1544
|
const passwordSites = new Set();
|
|
@@ -1509,6 +1557,16 @@ export function createTools(ctx) {
|
|
|
1509
1557
|
let accessStored = false;
|
|
1510
1558
|
/** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
|
|
1511
1559
|
let accessOutcome;
|
|
1560
|
+
/** 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. */
|
|
1561
|
+
const previewFacts = new Map();
|
|
1562
|
+
/** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
|
|
1563
|
+
const enableOutcomes = new Map();
|
|
1564
|
+
/** Cloudflare's plain evidence as last read: the project, the Worker and the GitHub app facts impeccable_cloudflare_connect and the wait for a build act on. */
|
|
1565
|
+
let cloudflareEvidence;
|
|
1566
|
+
/** Each app's Pages project, by key, as a plain read with its appKey told it: its name, and the label its addresses carry (its own host's, which a taken name suffixes). */
|
|
1567
|
+
const cloudflareApps = new Map();
|
|
1568
|
+
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1569
|
+
let connectOutcome;
|
|
1512
1570
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1513
1571
|
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
|
|
1514
1572
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
@@ -1568,7 +1626,7 @@ export function createTools(ctx) {
|
|
|
1568
1626
|
ctx.sync.update({ phase: 'running', question: null });
|
|
1569
1627
|
return answer;
|
|
1570
1628
|
};
|
|
1571
|
-
/** Each host's sign-in this run: confirmed on its screen,
|
|
1629
|
+
/** 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. */
|
|
1572
1630
|
const hostSignIns = new Map();
|
|
1573
1631
|
/** 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. */
|
|
1574
1632
|
const teamChoices = new Map();
|
|
@@ -1581,7 +1639,33 @@ export function createTools(ctx) {
|
|
|
1581
1639
|
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
|
|
1582
1640
|
/** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
|
|
1583
1641
|
let vercelProject;
|
|
1642
|
+
/** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
|
|
1643
|
+
const repository = `${ctx.owner}/${ctx.repo}`;
|
|
1584
1644
|
let netlifySite;
|
|
1645
|
+
/** The live app's addresses, by origin: those the host tools answered as `production`, each with its host and, on Netlify, its site, so a custom domain's lock is known and the site's key goes with it; and those a proof opened with this app, the only ones impeccable_write saves as the before. */
|
|
1646
|
+
const productionListed = new Map();
|
|
1647
|
+
const provenLive = new Set();
|
|
1648
|
+
/** On a repository with several apps: the app each production address a host tool answered is the live app of, read for that app's own project or site, and the apps whose host preview a proof opened. */
|
|
1649
|
+
const liveApps = new Map();
|
|
1650
|
+
const provenApps = new Set();
|
|
1651
|
+
/** Each Vercel project's production address, read from Vercel, by origin, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
|
|
1652
|
+
const vercelProductions = new Map();
|
|
1653
|
+
/** The bypass secret a proof sends to `origin`: the one proved there, or, for a live address, the one of the project whose production it is. */
|
|
1654
|
+
const bypassFor = (origin, live) => {
|
|
1655
|
+
const store = bypassOrigins.get(origin);
|
|
1656
|
+
if (store !== undefined)
|
|
1657
|
+
return bypassHeld.get(store)?.secret;
|
|
1658
|
+
const project = live ? vercelProductions.get(origin) : undefined;
|
|
1659
|
+
return project ? [...bypassHeld.values()].find((held) => held.projectId === project)?.secret : undefined;
|
|
1660
|
+
};
|
|
1661
|
+
/** A host tool answered the live app's address: the proof may open it as the before, with the host's key where the lock is the same site's or project's. */
|
|
1662
|
+
const noteProduction = (production, host, site, appKey) => {
|
|
1663
|
+
if (!production || !URL.canParse(production))
|
|
1664
|
+
return;
|
|
1665
|
+
productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
|
|
1666
|
+
if (appKey !== undefined)
|
|
1667
|
+
liveApps.set(new URL(production).origin, appKey);
|
|
1668
|
+
};
|
|
1585
1669
|
const hostCli = (host, pi, signal) => {
|
|
1586
1670
|
const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
|
|
1587
1671
|
const cli = runner();
|
|
@@ -1606,16 +1690,16 @@ export function createTools(ctx) {
|
|
|
1606
1690
|
};
|
|
1607
1691
|
};
|
|
1608
1692
|
/**
|
|
1609
|
-
* A host's sign-in, on `impeccable_ask` with the id `<host>-setup`:
|
|
1610
|
-
*
|
|
1611
|
-
* screen when it did not complete, and after two failed
|
|
1612
|
-
*
|
|
1693
|
+
* A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
|
|
1694
|
+
* already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
|
|
1695
|
+
* CLI's login in the browser, checked with the CLI, a retry screen when it did not complete, and after two failed
|
|
1696
|
+
* 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
|
|
1697
|
+
* install alone): never on anything the model writes, and never on a sign-in alone.
|
|
1613
1698
|
*/
|
|
1614
1699
|
async function hostSignIn(host, pi, signal) {
|
|
1615
1700
|
const cli = hostCli(host, pi, signal);
|
|
1616
1701
|
const signedIn = async () => {
|
|
1617
1702
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
1618
|
-
hostSignIns.set(host, 'confirmed');
|
|
1619
1703
|
const { team, choices } = await cli.team();
|
|
1620
1704
|
if (team)
|
|
1621
1705
|
chooseHostTeam(ctx.log, `${host}-team`, team);
|
|
@@ -1625,16 +1709,16 @@ export function createTools(ctx) {
|
|
|
1625
1709
|
if (hostSignIns.get(host) === 'skipped')
|
|
1626
1710
|
return { skipped: true };
|
|
1627
1711
|
let status = await cli.status();
|
|
1628
|
-
if (status.cli === 'signed-in')
|
|
1629
|
-
return signedIn();
|
|
1630
1712
|
if (!hostSignIns.has(host)) {
|
|
1631
|
-
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
|
|
1713
|
+
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...(status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : {}) });
|
|
1632
1714
|
if (answer.skipped) {
|
|
1633
1715
|
endSetup(host);
|
|
1634
1716
|
return { skipped: true };
|
|
1635
1717
|
}
|
|
1636
1718
|
hostSignIns.set(host, 'confirmed');
|
|
1637
1719
|
}
|
|
1720
|
+
if (status.cli === 'signed-in')
|
|
1721
|
+
return signedIn();
|
|
1638
1722
|
// The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
|
|
1639
1723
|
if (status.cli === 'missing') {
|
|
1640
1724
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
@@ -1701,44 +1785,84 @@ export function createTools(ctx) {
|
|
|
1701
1785
|
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
1702
1786
|
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
1703
1787
|
const clone = ctx.hostWait?.clone;
|
|
1704
|
-
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
|
|
1788
|
+
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}) });
|
|
1705
1789
|
netlifySite = result.site?.name ?? netlifySite;
|
|
1790
|
+
if (result.pullRequestPreviews)
|
|
1791
|
+
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
1706
1792
|
// A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
|
|
1707
1793
|
return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
|
|
1708
|
-
? { connected: true, site: result.site, how: 'github-app' } : result), signal);
|
|
1794
|
+
? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal);
|
|
1709
1795
|
}
|
|
1710
1796
|
/**
|
|
1711
|
-
* The connection of the linked Vercel project with this repository, `vercel git connect`,
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1797
|
+
* The connection of the linked Vercel project with this repository, `vercel git connect`, for a project this run's own
|
|
1798
|
+
* `vercel project add` made and `vercel link` linked (the link file alone, which the agent can write, proves nothing;
|
|
1799
|
+
* the link alone links an existing project of the folder's name, which may be one of the person's: that one is left
|
|
1800
|
+
* as it is), or one connected to this repository already, with the app's access asked when Vercel's GitHub app
|
|
1801
|
+
* cannot reach the repository; then, connected, the project locked behind Vercel's login at every address while it
|
|
1802
|
+
* has no production deployment, before anything is pushed: Vercel makes a new project's first deployment Production
|
|
1803
|
+
* whatever branch it builds, and serves it at the project's own address.
|
|
1715
1804
|
*/
|
|
1805
|
+
/** On a repository with several apps, the picked app whose folder the project impeccable_vercel_connect connects builds. */
|
|
1806
|
+
let connectedApp;
|
|
1716
1807
|
async function vercelConnect(pi, signal) {
|
|
1717
1808
|
if (!hostSetupAgreed(ctx.log, 'vercel'))
|
|
1718
1809
|
return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
|
|
1719
1810
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
1811
|
+
const link = await readLink(pi.cwd);
|
|
1812
|
+
if (!link)
|
|
1813
|
+
return { connected: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
|
|
1814
|
+
// 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.
|
|
1815
|
+
const own = linkedByRun(ctx.log).get(link.projectId);
|
|
1816
|
+
if (!own || own.orgId !== link.orgId)
|
|
1817
|
+
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." };
|
|
1818
|
+
// 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.
|
|
1819
|
+
const project = await readLinkedProject(vercel, link);
|
|
1820
|
+
// 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.
|
|
1821
|
+
if (project === 'missing')
|
|
1822
|
+
return { connected: false, reason: 'The project this folder is linked to no longer exists on Vercel, as far as the CLI can tell (.vercel/project.json is stale): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder\'s name again; else the Vercel setup ends here, and the person removes or relinks the file.' };
|
|
1823
|
+
if (!project)
|
|
1824
|
+
return { connected: false, reason: 'The Vercel CLI could not read the linked project (vercel api), so whether this run created it is unknown: nothing was connected, and the push stays held.' };
|
|
1825
|
+
// On a repository with several apps, a project builds one app's folder: one that builds no picked app's is connected and locked for none (#281).
|
|
1826
|
+
if (appsRepo) {
|
|
1827
|
+
const folders = new Map(await Promise.all([...(pickedApps ?? [])].map(async (key) => [await appFolder(pi, key), key])));
|
|
1828
|
+
connectedApp = project.rootDirectory !== undefined ? folders.get(project.rootDirectory) : undefined;
|
|
1829
|
+
if (connectedApp === undefined) {
|
|
1830
|
+
// A project not connected to this repository builds no push of it, so the push goes; one connected already would build it, so it stays held.
|
|
1831
|
+
if (project.repository !== repository.toLowerCase())
|
|
1832
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1833
|
+
return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} builds ${project.rootDirectory ? `the folder ${project.rootDirectory}` : "the repository's root"}, which is no app this run sets up: it stays as it is, and nothing was connected. The Vercel setup ends here.` };
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
// A project connected to this repository already is this repository's: the connection changes nothing on it, and the lock below is what it gets as the run's own project would.
|
|
1837
|
+
if (project.repository !== repository.toLowerCase()) {
|
|
1838
|
+
const foreign = !(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`)) ? `this run's vercel project add did not make it in ${own.team ? `the team ${own.team}` : 'the personal scope'}, so vercel link linked a project of that name that existed already` : null;
|
|
1839
|
+
if (foreign) {
|
|
1840
|
+
// 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.
|
|
1841
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1842
|
+
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.` };
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1720
1845
|
// Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
|
|
1721
1846
|
const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
|
|
1722
1847
|
// 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.
|
|
1723
|
-
const link = await readLink(pi.cwd);
|
|
1724
1848
|
if (!result.connected) {
|
|
1725
1849
|
if (result.access !== undefined)
|
|
1726
|
-
settleVercelFirstDeployment(ctx.log, { projectId: link
|
|
1850
|
+
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
|
|
1727
1851
|
return result;
|
|
1728
1852
|
}
|
|
1729
|
-
let first =
|
|
1853
|
+
let first = await prepareFirstDeployment(vercel, link, repository);
|
|
1730
1854
|
vercelProject = first.project ?? vercelProject;
|
|
1731
1855
|
// Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
|
|
1732
|
-
if (
|
|
1856
|
+
if (first.production === 'none' && !first.locked) {
|
|
1733
1857
|
const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
|
|
1734
|
-
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 }), {
|
|
1858
|
+
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 }), {
|
|
1735
1859
|
waiting: "Waiting for Vercel's login on the project",
|
|
1736
1860
|
skipped: 'The person skipped locking the Vercel project.',
|
|
1737
1861
|
still: "Vercel's login is still not in front of the project's addresses.",
|
|
1738
1862
|
stopped: 'The run stopped while the lock was waited for.',
|
|
1739
1863
|
}, signal);
|
|
1740
1864
|
}
|
|
1741
|
-
if (
|
|
1865
|
+
if (first.production === 'exists' || first.locked === true)
|
|
1742
1866
|
settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
|
|
1743
1867
|
return { ...result, ...first };
|
|
1744
1868
|
}
|
|
@@ -1788,11 +1912,13 @@ export function createTools(ctx) {
|
|
|
1788
1912
|
* Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
|
|
1789
1913
|
* `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
|
|
1790
1914
|
*/
|
|
1791
|
-
const saveLock = async (pi, what, header, value) => {
|
|
1915
|
+
const saveLock = async (pi, what, header, value, appKey) => {
|
|
1792
1916
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
1793
|
-
|
|
1917
|
+
// On a repository with several apps, the key is that app's own: each app's previews are its own project's or site's.
|
|
1918
|
+
const app = appKey !== undefined ? { appKey } : {};
|
|
1919
|
+
if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
|
|
1794
1920
|
return { stored: false, failed: `The setup API did not store the ${what}.` };
|
|
1795
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
|
|
1921
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
|
|
1796
1922
|
return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
|
|
1797
1923
|
return { stored: true, failed: null };
|
|
1798
1924
|
};
|
|
@@ -1800,6 +1926,18 @@ export function createTools(ctx) {
|
|
|
1800
1926
|
let instructions = ctx.instructions;
|
|
1801
1927
|
/** Whether the run signed in again for apps the scan proposed after the sign-in. */
|
|
1802
1928
|
let renewedForApps = false;
|
|
1929
|
+
/** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
|
|
1930
|
+
let appsRepo = ctx.severalApps === true;
|
|
1931
|
+
/**
|
|
1932
|
+
* The apps this run sets up, as the apps question answered (after a skip, the ones on): undefined until it was asked.
|
|
1933
|
+
* On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
|
|
1934
|
+
* and host read for an app names one of these.
|
|
1935
|
+
*/
|
|
1936
|
+
let pickedApps;
|
|
1937
|
+
/** This run confirmed the scan's proposal, so nothing was answered before: the question opens with every app picked. */
|
|
1938
|
+
let confirmedApps = false;
|
|
1939
|
+
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
1940
|
+
const appFolders = new Map();
|
|
1803
1941
|
/**
|
|
1804
1942
|
* A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
|
|
1805
1943
|
* there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
|
|
@@ -1812,10 +1950,89 @@ export function createTools(ctx) {
|
|
|
1812
1950
|
renewedForApps = true;
|
|
1813
1951
|
res = await call(pi, `${ctx.setupApi}${path}`);
|
|
1814
1952
|
}
|
|
1953
|
+
if (res.ok && severalApps(res.data))
|
|
1954
|
+
appsRepo = true;
|
|
1815
1955
|
if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
|
|
1816
1956
|
instructions = focusInstructions(ctx.allInstructions, { apps: true });
|
|
1817
1957
|
return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
|
|
1818
1958
|
};
|
|
1959
|
+
/** The repository's apps as the state lists them, each with its folder and whether it is on; null when the state could not be read. */
|
|
1960
|
+
const stateApps = async (pi) => {
|
|
1961
|
+
const res = await call(pi, `${ctx.setupApi}/state`);
|
|
1962
|
+
const repos = object(res.data)?.repos;
|
|
1963
|
+
const repo = res.ok && Array.isArray(repos) ? repos.map(object).find((r) => r?.name === ctx.repo) : undefined;
|
|
1964
|
+
if (!repo || !Array.isArray(repo.apps))
|
|
1965
|
+
return null;
|
|
1966
|
+
const apps = repo.apps.map(object).flatMap((a) => {
|
|
1967
|
+
const prefixes = Array.isArray(a?.pathPrefixes) ? a.pathPrefixes : [];
|
|
1968
|
+
const folder = folderOf(prefixes.find((p) => typeof p === 'string' && p) ?? '');
|
|
1969
|
+
return a && typeof a.key === 'string' && APP_KEY_SHAPE.test(a.key) && folder !== undefined
|
|
1970
|
+
? [{ key: a.key, name: typeof a.name === 'string' && a.name ? a.name : a.key, folder, enabled: a.enabled !== false }] : [];
|
|
1971
|
+
});
|
|
1972
|
+
for (const app of apps)
|
|
1973
|
+
appFolders.set(app.key, app.folder);
|
|
1974
|
+
return apps;
|
|
1975
|
+
};
|
|
1976
|
+
/** An app's folder, read from the state the first time it is asked for. */
|
|
1977
|
+
const appFolder = async (pi, key) => (appFolders.has(key) ? appFolders.get(key) : (await stateApps(pi), appFolders.get(key)));
|
|
1978
|
+
/**
|
|
1979
|
+
* On a repository with several apps, why a step for `appKey` may not run yet: the apps question comes first, and only
|
|
1980
|
+
* a picked app is set up. Null where it may: one app, or a picked one.
|
|
1981
|
+
*/
|
|
1982
|
+
const appRefusal = (appKey, what, forApp = true) => {
|
|
1983
|
+
if (!appsRepo)
|
|
1984
|
+
return null;
|
|
1985
|
+
if (!pickedApps)
|
|
1986
|
+
return `Refused: this repository has several apps, so the apps question comes first, on every run: impeccable_ask with the id "apps", as the monorepo case says. Nothing was ${what}.`;
|
|
1987
|
+
if (forApp && (typeof appKey !== 'string' || !pickedApps.has(appKey)))
|
|
1988
|
+
return `Refused: give "appKey", the key of an app this run sets up (${[...pickedApps].join(', ')}). Nothing was ${what}.`;
|
|
1989
|
+
return null;
|
|
1990
|
+
};
|
|
1991
|
+
/** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
|
|
1992
|
+
const vercelApps = new Map();
|
|
1993
|
+
/** The app's Vercel project as this repository's, read now for its deployments: the one that builds the app's folder, or why none can be told. */
|
|
1994
|
+
async function vercelApp(pi, appKey, vercel) {
|
|
1995
|
+
let found = vercelApps.get(appKey);
|
|
1996
|
+
if (!found) {
|
|
1997
|
+
const folder = await appFolder(pi, appKey);
|
|
1998
|
+
if (folder === undefined)
|
|
1999
|
+
return { reason: `The setup state names no folder for the app ${appKey}.` };
|
|
2000
|
+
const resolved = await vercelProjectFor(vercel, repository, folder, (await readLink(pi.cwd))?.orgId);
|
|
2001
|
+
found = 'reason' in resolved ? resolved : { ...resolved, folder };
|
|
2002
|
+
if (!('reason' in found))
|
|
2003
|
+
vercelApps.set(appKey, found);
|
|
2004
|
+
}
|
|
2005
|
+
if ('reason' in found)
|
|
2006
|
+
return found;
|
|
2007
|
+
const project = await readOwnProject(vercel, found.link, repository);
|
|
2008
|
+
return 'reason' in project ? project : { ...found, project };
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
|
|
2012
|
+
* this run confirmed the scan's proposal); the command started for one app (--app) answers it without a screen. The
|
|
2013
|
+
* answer is the apps this run sets up; after a skip, those that are on.
|
|
2014
|
+
*/
|
|
2015
|
+
async function appsQuestion(pi) {
|
|
2016
|
+
const apps = await stateApps(pi);
|
|
2017
|
+
if (!apps || apps.length < 2)
|
|
2018
|
+
return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
|
|
2019
|
+
if (ctx.app !== undefined && apps.some((a) => a.key === ctx.app)) {
|
|
2020
|
+
pickedApps = new Set([ctx.app]);
|
|
2021
|
+
return JSON.stringify({ values: [ctx.app], named: true });
|
|
2022
|
+
}
|
|
2023
|
+
// Started below the top: the app whose folder holds that folder (the deepest, never the root's) is ticked too, and the hint says so.
|
|
2024
|
+
const start = ctx.start ?? '';
|
|
2025
|
+
const here = start ? apps.filter((a) => a.folder && (start === a.folder || start.startsWith(`${a.folder}/`))).sort((a, b) => b.folder.length - a.folder.length)[0] : undefined;
|
|
2026
|
+
const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
|
|
2027
|
+
const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here })) });
|
|
2028
|
+
if (answer.skipped) {
|
|
2029
|
+
pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
|
|
2030
|
+
return JSON.stringify({ skipped: true, on: [...pickedApps] });
|
|
2031
|
+
}
|
|
2032
|
+
const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
|
|
2033
|
+
pickedApps = new Set(values);
|
|
2034
|
+
return JSON.stringify({ values });
|
|
2035
|
+
}
|
|
1819
2036
|
return [
|
|
1820
2037
|
tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
1821
2038
|
tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
|
|
@@ -1894,18 +2111,38 @@ export function createTools(ctx) {
|
|
|
1894
2111
|
const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
|
|
1895
2112
|
if ('error' in target)
|
|
1896
2113
|
return `Refused: ${target.error}`;
|
|
2114
|
+
// On a repository with several apps, nothing but the apps write goes before the apps question, and an app's setting or check names a picked app.
|
|
2115
|
+
const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
|
|
2116
|
+
if (!target.url.endsWith('/apps')) {
|
|
2117
|
+
const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp);
|
|
2118
|
+
if (refused)
|
|
2119
|
+
return refused;
|
|
2120
|
+
}
|
|
1897
2121
|
if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
|
|
1898
2122
|
return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
|
|
1899
2123
|
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'ci-tunnel') {
|
|
1900
2124
|
return "Refused: Impeccable no longer runs a server in CI for a new setup, so ci-tunnel is never saved. Pages that need a server get their host's pull request previews, as the server-previews case says. Nothing was saved.";
|
|
1901
2125
|
}
|
|
1902
|
-
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
|
|
1903
|
-
return
|
|
2126
|
+
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && (appsRepo ? !provenApps.has(String(object(p.body)?.appKey)) : !hostedPreviewProven)) {
|
|
2127
|
+
return `Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app${appsRepo ? `, read for it with its appKey (${String(object(p.body)?.appKey)})` : ''}. Nothing was saved.`;
|
|
1904
2128
|
}
|
|
1905
2129
|
const saved = object(p.body);
|
|
1906
2130
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
|
|
1907
2131
|
return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
|
|
1908
2132
|
}
|
|
2133
|
+
// 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.
|
|
2134
|
+
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
2135
|
+
return "Refused: beforeUrl is the live app's address that impeccable_prove_preview opened with this app in this run (the production address a host tool answered, or the one the README names), or a page on it; an address no key opens is not set, and a saved one is never cleared from here. Nothing was saved.";
|
|
2136
|
+
}
|
|
2137
|
+
// One app's before is its own: the production address its own project or site answered, or one a file in its own folder names.
|
|
2138
|
+
if (appsRepo && target.url.endsWith('/preview') && saved && typeof saved.beforeUrl === 'string') {
|
|
2139
|
+
const origin = new URL(saved.beforeUrl).origin;
|
|
2140
|
+
const owner = liveApps.get(origin);
|
|
2141
|
+
const folder = await appFolder(pi, String(saved.appKey));
|
|
2142
|
+
if (owner !== undefined ? owner !== saved.appKey : folder === undefined || !(await checkoutNames(pi.cwd, origin, folder))) {
|
|
2143
|
+
return `Refused: ${origin} is not the live address of ${String(saved.appKey)}: it is another app's, or no file in that app's folder names it. Nothing was saved.`;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1909
2146
|
const testAccounts = target.url.endsWith('/test-accounts');
|
|
1910
2147
|
let body = p.body;
|
|
1911
2148
|
if (testAccounts) {
|
|
@@ -1920,6 +2157,8 @@ export function createTools(ctx) {
|
|
|
1920
2157
|
}
|
|
1921
2158
|
const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
|
|
1922
2159
|
const response = await call(pi, target.url, { method: target.method, body });
|
|
2160
|
+
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2161
|
+
confirmedApps = true;
|
|
1923
2162
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
1924
2163
|
const keys = object(object(response.data)?.keys);
|
|
1925
2164
|
if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
|
|
@@ -1952,9 +2191,11 @@ export function createTools(ctx) {
|
|
|
1952
2191
|
// A test account never comes back to the model, even if the API echoed it.
|
|
1953
2192
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
1954
2193
|
}),
|
|
1955
|
-
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
2194
|
+
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app, or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
1956
2195
|
// A skip never stops the run, so every question offers one.
|
|
1957
2196
|
const asked = p;
|
|
2197
|
+
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2198
|
+
return appsQuestion(pi);
|
|
1958
2199
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
1959
2200
|
if (host?.[2] === 'setup')
|
|
1960
2201
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
@@ -1975,7 +2216,8 @@ export function createTools(ctx) {
|
|
|
1975
2216
|
ctx.log.write('ask hidden (names a credential)', q.id);
|
|
1976
2217
|
if (q.kind === 'text' && !q.sensitive)
|
|
1977
2218
|
q.caution = NEVER_A_CREDENTIAL;
|
|
1978
|
-
|
|
2219
|
+
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
|
|
2220
|
+
const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
|
|
1979
2221
|
if (answer.skipped) {
|
|
1980
2222
|
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
1981
2223
|
if (host?.[1] === 'vercel')
|
|
@@ -2021,25 +2263,28 @@ export function createTools(ctx) {
|
|
|
2021
2263
|
const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
|
|
2022
2264
|
if ('problem' in found)
|
|
2023
2265
|
return `Refused: ${found.problem}.`;
|
|
2266
|
+
drive.url = found.url;
|
|
2024
2267
|
// 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).
|
|
2025
2268
|
const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
|
|
2026
2269
|
const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
|
|
2027
2270
|
if (files.length && files.every(({ status }) => status === 'A')) {
|
|
2028
2271
|
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.`;
|
|
2029
2272
|
}
|
|
2030
|
-
let setupBranch = recordedBranches?.setup;
|
|
2273
|
+
let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
|
|
2031
2274
|
if (setup) {
|
|
2032
2275
|
// The setup pull request carries the preview attempts, so its binding cannot change.
|
|
2033
2276
|
const head = await runPullRequest(ctx, setup, 'impeccable/setup', signal);
|
|
2034
2277
|
if ('problem' in head)
|
|
2035
2278
|
return `Refused: ${head.problem}.`;
|
|
2279
|
+
setup.url = head.url;
|
|
2036
2280
|
const bound = bindPreviewPullRequest(retry, { ...setup, branch: head.branch });
|
|
2037
2281
|
if (bound.verdict !== 'allow')
|
|
2038
2282
|
return `Refused: ${bound.reason}.`;
|
|
2039
2283
|
setupBranch = head.branch;
|
|
2284
|
+
setupBase = head.base;
|
|
2040
2285
|
}
|
|
2041
2286
|
ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
|
|
2042
|
-
recordedBranches = { drive: found.branch, setup: setupBranch };
|
|
2287
|
+
recordedBranches = { drive: found.branch, setup: setupBranch, setupBase };
|
|
2043
2288
|
const res = await ctx.sync.flush();
|
|
2044
2289
|
const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
|
|
2045
2290
|
return res?.ok
|
|
@@ -2051,11 +2296,22 @@ export function createTools(ctx) {
|
|
|
2051
2296
|
const long = left.find((line) => line.length > STATUS_MAX);
|
|
2052
2297
|
if (long)
|
|
2053
2298
|
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.`;
|
|
2299
|
+
const control = left.find((line) => plainText(line) !== line);
|
|
2300
|
+
if (control)
|
|
2301
|
+
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.`;
|
|
2054
2302
|
// The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
|
|
2055
2303
|
const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
|
|
2056
2304
|
if (open.length)
|
|
2057
2305
|
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.`;
|
|
2058
2306
|
let markdown = p.markdown;
|
|
2307
|
+
// 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.
|
|
2308
|
+
if (recordedBranches?.setup) {
|
|
2309
|
+
const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
|
|
2310
|
+
const verbs = { A: 'added', M: 'changed', D: 'removed' };
|
|
2311
|
+
// A file name is git's to give: one with Markdown in it, or a line break, stays one plain entry.
|
|
2312
|
+
const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
|
|
2313
|
+
markdown += `\n\n## What the setup pull request changes\n\n${changes ? changes.map(({ status, path }) => `- ${shown(path)} (${verbs[status] ?? status})`).join('\n') || 'Nothing: it carries no file.' : 'This checkout could not list its files: read them on the pull request before merging.'}`;
|
|
2314
|
+
}
|
|
2059
2315
|
if (previews.active && retry.pr) {
|
|
2060
2316
|
await observe(signal);
|
|
2061
2317
|
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(', ')}.` : ''}`);
|
|
@@ -2084,19 +2340,55 @@ export function createTools(ctx) {
|
|
|
2084
2340
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2085
2341
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
2086
2342
|
}),
|
|
2087
|
-
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) => {
|
|
2343
|
+
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
2088
2344
|
const branch = p.branch;
|
|
2089
|
-
const
|
|
2345
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2346
|
+
if (refused)
|
|
2347
|
+
return refused;
|
|
2348
|
+
// The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
|
|
2349
|
+
const reads = new Map();
|
|
2350
|
+
const vercel = (args) => {
|
|
2351
|
+
const key = JSON.stringify(args);
|
|
2352
|
+
if (!reads.has(key))
|
|
2353
|
+
reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
|
|
2354
|
+
return reads.get(key);
|
|
2355
|
+
};
|
|
2356
|
+
for (const origin of listedPreviews)
|
|
2357
|
+
listedApps.delete(origin);
|
|
2358
|
+
listedPreviews.clear();
|
|
2359
|
+
vercelMade.clear();
|
|
2360
|
+
publicProduction.clear();
|
|
2361
|
+
// On a repository with several apps, the app's own project (#281): the one whose Root Directory is the app's folder, never the newest deployment's or the root link's.
|
|
2362
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2363
|
+
const app = appKey !== undefined ? await vercelApp(pi, appKey, vercel) : undefined;
|
|
2364
|
+
if (app && 'reason' in app)
|
|
2365
|
+
return JSON.stringify({ appKey, deployments: [], unmatched: app.reason });
|
|
2366
|
+
const raw = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
|
|
2367
|
+
// Vercel names each project's deployments on GitHub "Preview – <project>"; one without a name is told by the project's own record of it.
|
|
2368
|
+
const owned = async (d) => {
|
|
2369
|
+
if (!app)
|
|
2370
|
+
return true;
|
|
2371
|
+
if (!d.vercel || !d.url)
|
|
2372
|
+
return false;
|
|
2373
|
+
const named = /^(?:preview|production)\s[–-]\s(.+)$/i.exec(d.environment ?? '')?.[1];
|
|
2374
|
+
return named !== undefined ? named === app.name : projectMadeDeployment(vercel, app.link, app.project, new URL(d.url).host);
|
|
2375
|
+
};
|
|
2376
|
+
const read = async () => {
|
|
2377
|
+
const evidence = await raw();
|
|
2378
|
+
if (!app)
|
|
2379
|
+
return evidence;
|
|
2380
|
+
const kept = await Promise.all(evidence.deployments.map(owned));
|
|
2381
|
+
return { ...evidence, deployments: evidence.deployments.filter((_, i) => kept[i]) };
|
|
2382
|
+
};
|
|
2090
2383
|
// With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
|
|
2091
2384
|
// project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
|
|
2092
2385
|
const isPreview = (d) => /preview/i.test(d.environment ?? '');
|
|
2093
2386
|
const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
|
|
2094
2387
|
// A head's build is still building until it has a final status.
|
|
2095
2388
|
const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
|
|
2096
|
-
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), vercelProject), signal) : await read();
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
publicProduction.clear();
|
|
2389
|
+
const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), app?.name ?? vercelProject), signal) : await read();
|
|
2390
|
+
/** The project the evidence is of: the app's, else the one this folder is linked to. */
|
|
2391
|
+
const link = async () => (app ? app.link : readLink(pi.cwd));
|
|
2100
2392
|
/** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
|
|
2101
2393
|
let locked;
|
|
2102
2394
|
for (const d of evidence.deployments) {
|
|
@@ -2106,8 +2398,8 @@ export function createTools(ctx) {
|
|
|
2106
2398
|
// A pushed branch's Production deployment is its preview only as the locked project's own: under Standard Protection a project's own address serves it to anyone, and another project of this repository may have made it.
|
|
2107
2399
|
if (branch !== undefined && !isPreview(d)) {
|
|
2108
2400
|
locked ??= await (async () => {
|
|
2109
|
-
const
|
|
2110
|
-
return
|
|
2401
|
+
const linked = await link();
|
|
2402
|
+
return linked ? lockedDeploymentHosts(vercel, linked, repository) : new Set();
|
|
2111
2403
|
})();
|
|
2112
2404
|
if (!locked.has(url.host)) {
|
|
2113
2405
|
publicProduction.add(url.origin);
|
|
@@ -2117,12 +2409,41 @@ export function createTools(ctx) {
|
|
|
2117
2409
|
listedPreviews.add(url.origin);
|
|
2118
2410
|
if (d.vercel)
|
|
2119
2411
|
vercelMade.add(url.origin);
|
|
2412
|
+
if (app)
|
|
2413
|
+
listedApps.set(url.origin, { appKey: appKey, link: app.link, name: app.name });
|
|
2414
|
+
}
|
|
2415
|
+
// What vercel.json and the linked project (the app's, with its folder's vercel.json) say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
|
|
2416
|
+
if (branch === undefined) {
|
|
2417
|
+
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
|
|
2418
|
+
if (fact) {
|
|
2419
|
+
previewFacts.set('vercel', fact);
|
|
2420
|
+
evidence.pullRequestPreviews = fact;
|
|
2421
|
+
}
|
|
2120
2422
|
}
|
|
2121
|
-
|
|
2423
|
+
// The live app, where GitHub's record shows a production deployment Vercel made: its own address there is behind Vercel's login under Standard Protection, so the
|
|
2424
|
+
// project is read for its alias, the project's own domain, and only as this repository's project.
|
|
2425
|
+
let production;
|
|
2426
|
+
if (branch === undefined && evidence.deployments.some((d) => d.vercel && d.state === 'success' && !isPreview(d))) {
|
|
2427
|
+
const linked = await link();
|
|
2428
|
+
const project = linked ? await readOwnProject(vercel, linked, repository) : null;
|
|
2429
|
+
production = project && !('reason' in project) ? project.productionUrl : undefined;
|
|
2430
|
+
if (production && linked)
|
|
2431
|
+
vercelProductions.set(new URL(production).origin, linked.projectId);
|
|
2432
|
+
noteProduction(production, 'vercel', undefined, appKey);
|
|
2433
|
+
}
|
|
2434
|
+
return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
|
|
2122
2435
|
}),
|
|
2123
|
-
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) => {
|
|
2436
|
+
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
2124
2437
|
const pull = p.pull;
|
|
2125
|
-
const
|
|
2438
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2439
|
+
if (refused)
|
|
2440
|
+
return refused;
|
|
2441
|
+
// On a repository with several apps, only the site that builds the app's folder (#281).
|
|
2442
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2443
|
+
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2444
|
+
if (appKey !== undefined && directory === undefined)
|
|
2445
|
+
return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
2446
|
+
const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, ...(directory !== undefined ? { directory } : {}) });
|
|
2126
2447
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
2127
2448
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
2128
2449
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
@@ -2132,61 +2453,120 @@ export function createTools(ctx) {
|
|
|
2132
2453
|
const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
|
|
2133
2454
|
return site ? `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews` : 'https://app.netlify.com/';
|
|
2134
2455
|
}, signal) : await read();
|
|
2456
|
+
for (const origin of netlifyListed)
|
|
2457
|
+
listedApps.delete(origin);
|
|
2135
2458
|
netlifyListed.clear();
|
|
2136
|
-
for (const p of evidence.previews)
|
|
2137
|
-
if (p.state
|
|
2138
|
-
|
|
2139
|
-
|
|
2459
|
+
for (const p of evidence.previews) {
|
|
2460
|
+
if (p.state !== 'success' && p.state !== 'ready')
|
|
2461
|
+
continue;
|
|
2462
|
+
netlifyListed.add(new URL(p.url).origin);
|
|
2463
|
+
if (appKey !== undefined)
|
|
2464
|
+
listedApps.set(new URL(p.url).origin, { appKey });
|
|
2465
|
+
}
|
|
2466
|
+
if (evidence.pullRequestPreviews)
|
|
2467
|
+
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
2468
|
+
noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
|
|
2469
|
+
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
2140
2470
|
}),
|
|
2141
|
-
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) => {
|
|
2471
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
2142
2472
|
const pull = p.pull;
|
|
2143
2473
|
const branch = p.branch;
|
|
2144
2474
|
if (pull !== undefined && branch !== undefined)
|
|
2145
2475
|
return 'Invalid input: give pull or branch, not both.';
|
|
2146
|
-
const
|
|
2476
|
+
const refused = appRefusal(p.appKey, 'read');
|
|
2477
|
+
if (refused)
|
|
2478
|
+
return refused;
|
|
2479
|
+
// On a repository with several apps, only the Pages project that builds the app's folder (#281): told by the plain read, which a wait for a build keeps to.
|
|
2480
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2481
|
+
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2482
|
+
if (appKey !== undefined && directory === undefined)
|
|
2483
|
+
return JSON.stringify({ appKey, previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
2484
|
+
const appProject = appKey !== undefined ? cloudflareApps.get(appKey) : undefined;
|
|
2485
|
+
if (appKey !== undefined && (pull !== undefined || branch !== undefined) && !appProject)
|
|
2486
|
+
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
2487
|
+
const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}) });
|
|
2488
|
+
const read = async () => {
|
|
2489
|
+
const evidence = await readAll();
|
|
2490
|
+
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
2491
|
+
};
|
|
2147
2492
|
// Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
|
|
2148
2493
|
// GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
|
|
2149
2494
|
const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
|
|
2150
2495
|
const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
|
|
2151
|
-
|
|
2496
|
+
// 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),
|
|
2497
|
+
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
2498
|
+
const app = cloudflareEvidence?.githubApp;
|
|
2499
|
+
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;
|
|
2500
|
+
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();
|
|
2501
|
+
const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
|
|
2502
|
+
if (!subject)
|
|
2503
|
+
cloudflareEvidence = evidence;
|
|
2504
|
+
if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project)
|
|
2505
|
+
cloudflareApps.set(appKey, { project: evidence.project, label: (evidence.production && pagesProject(evidence.production)) || evidence.project });
|
|
2506
|
+
for (const origin of cloudflareListed)
|
|
2507
|
+
listedApps.delete(origin);
|
|
2152
2508
|
cloudflareListed.clear();
|
|
2153
2509
|
for (const preview of evidence.previews) {
|
|
2154
2510
|
if (preview.state !== 'success')
|
|
2155
2511
|
continue;
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2512
|
+
for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
|
|
2513
|
+
cloudflareListed.add(new URL(address).origin);
|
|
2514
|
+
if (appKey !== undefined)
|
|
2515
|
+
listedApps.set(new URL(address).origin, { appKey });
|
|
2516
|
+
}
|
|
2159
2517
|
}
|
|
2160
|
-
|
|
2518
|
+
if (evidence.pullRequestPreviews)
|
|
2519
|
+
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
2520
|
+
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
2521
|
+
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
2161
2522
|
}),
|
|
2162
|
-
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) => {
|
|
2523
|
+
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
2163
2524
|
const url = p.url;
|
|
2164
|
-
|
|
2525
|
+
const origin = URL.canParse(url) ? new URL(url).origin : null;
|
|
2526
|
+
if (origin && publicProduction.has(origin))
|
|
2165
2527
|
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.";
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
const
|
|
2528
|
+
const listed = origin !== null && (listedPreviews.has(origin) || netlifyListed.has(origin) || cloudflareListed.has(origin));
|
|
2529
|
+
// 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.
|
|
2530
|
+
const known = origin !== null ? productionListed.get(origin) : undefined;
|
|
2531
|
+
const live = origin !== null && !listed && (known !== undefined || (liveShaped(url) && (await checkoutNames(pi.cwd, origin))));
|
|
2532
|
+
if (!origin || (!listed && !live))
|
|
2533
|
+
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.";
|
|
2534
|
+
// 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.
|
|
2535
|
+
const site = netlifySiteOf(url) ?? known?.site;
|
|
2169
2536
|
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
2170
|
-
const project = pagesProject(
|
|
2537
|
+
const project = pagesProject(origin);
|
|
2171
2538
|
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
2172
2539
|
const proof = await provePreview(url, p.expect, pi.cwd, {
|
|
2173
|
-
fetch: ctx.previewFetch, signal,
|
|
2174
|
-
|
|
2540
|
+
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
2541
|
+
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
2542
|
+
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
2175
2543
|
...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
|
|
2176
2544
|
...(token ? { access: token } : {}),
|
|
2177
2545
|
});
|
|
2546
|
+
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
2547
|
+
if (live) {
|
|
2548
|
+
if (proof.proven)
|
|
2549
|
+
provenLive.add(origin);
|
|
2550
|
+
return JSON.stringify(proof);
|
|
2551
|
+
}
|
|
2552
|
+
// On a repository with several apps, the app the address was listed for: its proof, its lock and its key are that app's alone (#281).
|
|
2553
|
+
const app = listedApps.get(origin);
|
|
2178
2554
|
hostedPreviewProven ||= proof.proven;
|
|
2179
2555
|
if (proof.proven)
|
|
2180
2556
|
provenHost = previewHostName(url) ?? provenHost;
|
|
2557
|
+
if (proof.proven && app)
|
|
2558
|
+
provenApps.add(app.appKey);
|
|
2181
2559
|
if (proof.proven && proof.pattern)
|
|
2182
2560
|
provenPattern = proof.pattern;
|
|
2183
2561
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
2184
|
-
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}) };
|
|
2562
|
+
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
|
|
2185
2563
|
if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
|
|
2186
|
-
accessGated = { url, expect: p.expect };
|
|
2564
|
+
accessGated = { url, expect: p.expect, ...(app ? { app } : {}) };
|
|
2565
|
+
if (app)
|
|
2566
|
+
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
2187
2567
|
return JSON.stringify(proof);
|
|
2188
2568
|
}),
|
|
2189
|
-
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) => {
|
|
2569
|
+
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
2190
2570
|
const command = p.command;
|
|
2191
2571
|
const publish = p.publish;
|
|
2192
2572
|
if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
|
|
@@ -2195,58 +2575,208 @@ export function createTools(ctx) {
|
|
|
2195
2575
|
return 'Refused: publish is a folder relative to the checkout, such as "dist".';
|
|
2196
2576
|
return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
|
|
2197
2577
|
}),
|
|
2198
|
-
tool('
|
|
2199
|
-
|
|
2578
|
+
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) => {
|
|
2579
|
+
const host = p.host;
|
|
2580
|
+
const standing = enableOutcomes.get(host);
|
|
2581
|
+
if (standing)
|
|
2582
|
+
return JSON.stringify(standing);
|
|
2583
|
+
const fact = previewFacts.get(host);
|
|
2584
|
+
if (!fact || fact.enabled !== false)
|
|
2585
|
+
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.`;
|
|
2586
|
+
if (host === 'cloudflare' && fact.cause === 'source')
|
|
2587
|
+
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.`;
|
|
2588
|
+
const name = host === 'netlify' ? 'Netlify' : 'Cloudflare Pages';
|
|
2589
|
+
const answer = (status, more) => {
|
|
2590
|
+
const told = { status, project: fact.project, ...more };
|
|
2591
|
+
enableOutcomes.set(host, told);
|
|
2592
|
+
return JSON.stringify(told);
|
|
2593
|
+
};
|
|
2594
|
+
const screen = HOST_SCREENS[`${host}-previews`];
|
|
2595
|
+
if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
|
|
2596
|
+
return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
|
|
2597
|
+
said(`Turning on pull-request previews on ${name}`);
|
|
2598
|
+
const turnOn = async () => {
|
|
2599
|
+
if (host === 'netlify')
|
|
2600
|
+
return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
|
|
2601
|
+
const cf = cfRunner({}, ctx.hostRunner);
|
|
2602
|
+
return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
|
|
2603
|
+
};
|
|
2604
|
+
// 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.
|
|
2605
|
+
for (let tries = 0;; tries++) {
|
|
2606
|
+
const outcome = await turnOn();
|
|
2607
|
+
if (outcome.fact.enabled === true) {
|
|
2608
|
+
previewFacts.set(host, outcome.fact);
|
|
2609
|
+
return answer('enabled', { by: outcome.changed ? 'run' : 'person' });
|
|
2610
|
+
}
|
|
2611
|
+
const why = outcome.reason ?? outcome.fact.reason;
|
|
2612
|
+
if (tries === 2 || signal?.aborted)
|
|
2613
|
+
return answer('refused', { reason: why, page: outcome.page });
|
|
2614
|
+
const retry = HOST_SCREENS[`${host}-previews-retry`];
|
|
2615
|
+
// 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.
|
|
2616
|
+
if (tries === 0)
|
|
2617
|
+
(ctx.openBrowser ?? openBrowser)(outcome.page);
|
|
2618
|
+
if ((await ask({ ...retry, hint: `${retry.hint} If it did not open, open this link: ${outcome.page}` })).skipped)
|
|
2619
|
+
return answer('skipped', { reason: why, page: outcome.page });
|
|
2620
|
+
}
|
|
2621
|
+
}),
|
|
2622
|
+
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) => {
|
|
2623
|
+
if (connectOutcome)
|
|
2624
|
+
return JSON.stringify(connectOutcome);
|
|
2625
|
+
const evidence = cloudflareEvidence;
|
|
2626
|
+
if (!evidence)
|
|
2627
|
+
return 'Refused: read impeccable_cloudflare_previews first: it says whether a Pages project builds this repository.';
|
|
2628
|
+
const fact = evidence.pullRequestPreviews;
|
|
2629
|
+
if (fact && fact.enabled !== false)
|
|
2630
|
+
return `Refused: the Pages project ${fact.project} is connected to the repository already${fact.enabled === true ? ' and builds its pull requests' : ''}.`;
|
|
2631
|
+
if (fact && fact.cause !== 'source')
|
|
2632
|
+
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.`;
|
|
2633
|
+
const create = !fact;
|
|
2634
|
+
if (create && !evidence.worker && !evidence.project)
|
|
2635
|
+
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.';
|
|
2636
|
+
const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
|
|
2637
|
+
const answer = (status, more) => {
|
|
2638
|
+
connectOutcome = { status, project, created: create, ...more };
|
|
2639
|
+
return JSON.stringify(connectOutcome);
|
|
2640
|
+
};
|
|
2641
|
+
// The one screen: the connection of the project named, or the new project for the repository.
|
|
2642
|
+
const screen = HOST_SCREENS[create ? 'cloudflare-create' : 'cloudflare-connect'];
|
|
2643
|
+
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)
|
|
2644
|
+
return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
|
|
2645
|
+
// 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.
|
|
2646
|
+
const cf = cfRunner({}, ctx.hostRunner);
|
|
2647
|
+
const login = cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner);
|
|
2648
|
+
const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
|
|
2649
|
+
let status = await cfStatus(cf, signal);
|
|
2650
|
+
if (!writes(status)) {
|
|
2651
|
+
if (status.cli === 'missing' || status.cli === 'unknown')
|
|
2652
|
+
return answer('refused', { reason: "Cloudflare's CLI did not run here." });
|
|
2653
|
+
if ((await ask(HOST_SCREENS['cloudflare-setup'])).skipped)
|
|
2654
|
+
return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
|
|
2655
|
+
for (let attempt = 0;; attempt++) {
|
|
2656
|
+
said("Opening the browser for Cloudflare's login");
|
|
2657
|
+
const scopes = [...new Set([...(status.scopes ?? []).filter((scope) => scope !== 'offline_access'), 'account:read', 'user:read', 'pages:write'])];
|
|
2658
|
+
await login(['auth', 'login', '--force', '--no-device', '--scopes', ...scopes], { signal }).catch(() => undefined);
|
|
2659
|
+
signal?.throwIfAborted();
|
|
2660
|
+
status = await cfStatus(cf, signal);
|
|
2661
|
+
if (writes(status))
|
|
2662
|
+
break;
|
|
2663
|
+
if (status.cli === 'signed-in')
|
|
2664
|
+
return answer('refused', { reason: 'The Cloudflare sign-in gave the run no pages:write.' });
|
|
2665
|
+
if (attempt === 2)
|
|
2666
|
+
return answer('refused', { reason: 'The Cloudflare sign-in did not complete.' });
|
|
2667
|
+
if ((await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped)
|
|
2668
|
+
return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
said(create ? `Creating the Pages project ${project}` : `Connecting the repository to ${project}`);
|
|
2672
|
+
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
2673
|
+
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);
|
|
2674
|
+
if (outcome.status !== 'connected')
|
|
2675
|
+
return answer('refused', { reason: outcome.reason });
|
|
2676
|
+
if (outcome.fact)
|
|
2677
|
+
previewFacts.set('cloudflare', outcome.fact);
|
|
2678
|
+
cloudflareEvidence = { ...evidence, project, ...(outcome.fact ? { pullRequestPreviews: outcome.fact } : {}) };
|
|
2679
|
+
return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
|
|
2680
|
+
}),
|
|
2681
|
+
tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
|
|
2682
|
+
const refused = appRefusal(undefined, 'connected', false);
|
|
2683
|
+
if (refused)
|
|
2684
|
+
return refused;
|
|
2685
|
+
const answer = await vercelConnect(pi, signal);
|
|
2686
|
+
// A project that deploys production already names the live app's address, for the before.
|
|
2687
|
+
const link = answer.productionUrl ? await readLink(pi.cwd) : null;
|
|
2688
|
+
if (answer.productionUrl && link)
|
|
2689
|
+
vercelProductions.set(new URL(answer.productionUrl).origin, link.projectId);
|
|
2690
|
+
noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
|
|
2691
|
+
return JSON.stringify(answer);
|
|
2692
|
+
}),
|
|
2693
|
+
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2200
2694
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2201
2695
|
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
|
|
2202
|
-
if (bypassOutcome?.status === 'skipped')
|
|
2203
|
-
return JSON.stringify(bypassOutcome);
|
|
2204
2696
|
const at = gated;
|
|
2205
|
-
const origin = new URL(at.url)
|
|
2206
|
-
//
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2697
|
+
const { origin, host } = new URL(at.url);
|
|
2698
|
+
// Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
|
|
2699
|
+
const store = at.app?.appKey ?? '';
|
|
2700
|
+
const asked = bypassOutcomes.get(store);
|
|
2701
|
+
if (asked?.status === 'skipped')
|
|
2702
|
+
return JSON.stringify(asked);
|
|
2703
|
+
// Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project has to have
|
|
2704
|
+
// made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names.
|
|
2705
|
+
const link = at.app?.link ?? (await readLink(pi.cwd));
|
|
2706
|
+
if (!link)
|
|
2707
|
+
return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2708
|
+
// Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
|
|
2709
|
+
// An app's project is read from Vercel's list of this repository's projects, never from the file.
|
|
2710
|
+
const links = linkedByRun(ctx.log);
|
|
2711
|
+
const own = links.get(link.projectId);
|
|
2712
|
+
if (!at.app && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2713
|
+
return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2714
|
+
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
2715
|
+
const project = await readOwnProject(vercel, link, repository);
|
|
2716
|
+
const which = at.app ? 'The Vercel project that builds this app' : 'The Vercel project this folder is linked to';
|
|
2717
|
+
if ('reason' in project)
|
|
2718
|
+
return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
|
|
2719
|
+
// The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
|
|
2720
|
+
if (!project.previewsProtected)
|
|
2721
|
+
return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
|
|
2722
|
+
if (!(await projectMadeDeployment(vercel, link, project, host)))
|
|
2723
|
+
return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app ? 'that project' : 'the linked project'} serves.`;
|
|
2724
|
+
/** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
|
|
2725
|
+
const opened = () => {
|
|
2726
|
+
bypassOrigins.set(origin, store);
|
|
2727
|
+
hostedPreviewProven = true;
|
|
2728
|
+
provenHost = 'Vercel';
|
|
2729
|
+
if (at.app)
|
|
2730
|
+
provenApps.add(at.app.appKey);
|
|
2731
|
+
};
|
|
2732
|
+
// A secret in hand already for the same store and project: another address of the project opens with it.
|
|
2733
|
+
const held = bypassHeld.get(store);
|
|
2734
|
+
if (held && held.projectId === link.projectId && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: held.secret })).proven) {
|
|
2735
|
+
opened();
|
|
2736
|
+
return JSON.stringify(held.saved);
|
|
2210
2737
|
}
|
|
2211
2738
|
/** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
|
|
2212
2739
|
let stored = false;
|
|
2213
2740
|
const { value, ...told } = await passVercelLogin({
|
|
2214
|
-
run:
|
|
2215
|
-
link: () =>
|
|
2216
|
-
|
|
2741
|
+
run: vercel,
|
|
2742
|
+
link: async () => link,
|
|
2743
|
+
// A project the folder is not linked to is named on the CLI's reads and makes; the reads are held to its id all the same.
|
|
2744
|
+
...(at.app?.name ? { project: at.app.name } : {}),
|
|
2745
|
+
consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
2217
2746
|
paste: async () => {
|
|
2218
|
-
// The
|
|
2219
|
-
(ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection'));
|
|
2220
|
-
const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
|
|
2747
|
+
// The project's own settings page, in its team where the run knows it; else Vercel's picker.
|
|
2748
|
+
(ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
|
|
2749
|
+
const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
|
|
2221
2750
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
2222
2751
|
},
|
|
2223
2752
|
prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
|
|
2224
2753
|
save: async (secret) => {
|
|
2225
|
-
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
|
|
2754
|
+
const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
|
|
2226
2755
|
stored = saved.stored;
|
|
2227
2756
|
return saved.failed;
|
|
2228
2757
|
},
|
|
2229
2758
|
remember: noteSensitiveAnswer,
|
|
2230
2759
|
});
|
|
2231
|
-
|
|
2760
|
+
const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
|
|
2761
|
+
bypassOutcomes.set(store, outcome);
|
|
2232
2762
|
if (value) {
|
|
2233
2763
|
// What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
bypassSaved = bypassOutcome;
|
|
2764
|
+
for (const [address, of] of bypassOrigins)
|
|
2765
|
+
if (of === store)
|
|
2766
|
+
bypassOrigins.delete(address);
|
|
2767
|
+
bypassHeld.set(store, { secret: value, projectId: link.projectId, saved: outcome });
|
|
2768
|
+
opened();
|
|
2240
2769
|
}
|
|
2241
2770
|
else if (told.status === 'save-failed' && stored) {
|
|
2242
2771
|
// The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
|
|
2243
|
-
|
|
2244
|
-
bypassOrigins
|
|
2245
|
-
|
|
2772
|
+
bypassHeld.delete(store);
|
|
2773
|
+
for (const [address, of] of bypassOrigins)
|
|
2774
|
+
if (of === store)
|
|
2775
|
+
bypassOrigins.delete(address);
|
|
2246
2776
|
}
|
|
2247
|
-
return JSON.stringify(
|
|
2777
|
+
return JSON.stringify(outcome);
|
|
2248
2778
|
}),
|
|
2249
|
-
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
|
|
2779
|
+
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2250
2780
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
2251
2781
|
if (!gated || !site)
|
|
2252
2782
|
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.';
|
|
@@ -2264,6 +2794,8 @@ export function createTools(ctx) {
|
|
|
2264
2794
|
// A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
|
|
2265
2795
|
if ((await prove(undefined)).proven) {
|
|
2266
2796
|
provenPattern = pattern ?? provenPattern;
|
|
2797
|
+
if (at.app)
|
|
2798
|
+
provenApps.add(at.app.appKey);
|
|
2267
2799
|
return JSON.stringify(stands);
|
|
2268
2800
|
}
|
|
2269
2801
|
passwordStands.delete(site);
|
|
@@ -2273,6 +2805,8 @@ export function createTools(ctx) {
|
|
|
2273
2805
|
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
2274
2806
|
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
2275
2807
|
provenPattern = pattern ?? provenPattern;
|
|
2808
|
+
if (at.app)
|
|
2809
|
+
provenApps.add(at.app.appKey);
|
|
2276
2810
|
return JSON.stringify(passwordSaved);
|
|
2277
2811
|
}
|
|
2278
2812
|
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
@@ -2281,7 +2815,7 @@ export function createTools(ctx) {
|
|
|
2281
2815
|
run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
|
|
2282
2816
|
site,
|
|
2283
2817
|
consent: async () => {
|
|
2284
|
-
if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
|
|
2818
|
+
if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
|
|
2285
2819
|
return passwordConsented.add(site), true;
|
|
2286
2820
|
return false;
|
|
2287
2821
|
},
|
|
@@ -2301,12 +2835,12 @@ export function createTools(ctx) {
|
|
|
2301
2835
|
return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
|
|
2302
2836
|
},
|
|
2303
2837
|
paste: async () => {
|
|
2304
|
-
const answer = await ask(HOST_SCREENS['netlify-password-key']);
|
|
2838
|
+
const answer = await ask(sentTo(HOST_SCREENS['netlify-password-key'], at.url));
|
|
2305
2839
|
return answer.skipped ? undefined : answer.value?.trim();
|
|
2306
2840
|
},
|
|
2307
2841
|
prove,
|
|
2308
2842
|
save: async (password) => {
|
|
2309
|
-
const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
|
|
2843
|
+
const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password), at.app?.appKey);
|
|
2310
2844
|
stored = saved.stored;
|
|
2311
2845
|
return saved.failed;
|
|
2312
2846
|
},
|
|
@@ -2319,6 +2853,8 @@ export function createTools(ctx) {
|
|
|
2319
2853
|
// The site is public now: its previews open with nothing sent.
|
|
2320
2854
|
hostedPreviewProven = true;
|
|
2321
2855
|
provenHost = 'Netlify';
|
|
2856
|
+
if (at.app)
|
|
2857
|
+
provenApps.add(at.app.appKey);
|
|
2322
2858
|
provenPattern = pattern ?? provenPattern;
|
|
2323
2859
|
}
|
|
2324
2860
|
if (value) {
|
|
@@ -2328,6 +2864,8 @@ export function createTools(ctx) {
|
|
|
2328
2864
|
passwordSites.add(site);
|
|
2329
2865
|
hostedPreviewProven = true;
|
|
2330
2866
|
provenHost = 'Netlify';
|
|
2867
|
+
if (at.app)
|
|
2868
|
+
provenApps.add(at.app.appKey);
|
|
2331
2869
|
provenPattern = pattern ?? provenPattern;
|
|
2332
2870
|
passwordSaved = passwordOutcome;
|
|
2333
2871
|
}
|
|
@@ -2339,7 +2877,7 @@ export function createTools(ctx) {
|
|
|
2339
2877
|
}
|
|
2340
2878
|
return JSON.stringify(passwordOutcome);
|
|
2341
2879
|
}),
|
|
2342
|
-
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
|
|
2880
|
+
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) => {
|
|
2343
2881
|
if (!accessGated)
|
|
2344
2882
|
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.';
|
|
2345
2883
|
if (accessOutcome?.status === 'skipped')
|
|
@@ -2349,19 +2887,22 @@ export function createTools(ctx) {
|
|
|
2349
2887
|
const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
|
|
2350
2888
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
2351
2889
|
const save = async (token) => {
|
|
2352
|
-
|
|
2890
|
+
const app = at.app ? { appKey: at.app.appKey } : {};
|
|
2891
|
+
if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret, ...app } })).ok)
|
|
2353
2892
|
return 'The setup API did not store the service token.';
|
|
2354
2893
|
// An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
|
|
2355
2894
|
const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
|
|
2356
2895
|
const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
|
|
2357
2896
|
if (lock?.authMethod === 'access')
|
|
2358
2897
|
return null;
|
|
2359
|
-
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
|
|
2898
|
+
if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access', ...app } })).ok)
|
|
2360
2899
|
return 'The setup API stored the service token, but not the setting that sends it: it is turned on under Preview protection on the setup page.';
|
|
2361
2900
|
return null;
|
|
2362
2901
|
};
|
|
2363
2902
|
let answer;
|
|
2364
|
-
|
|
2903
|
+
const project = pagesProject(new URL(at.url).origin);
|
|
2904
|
+
// 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.
|
|
2905
|
+
if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
|
|
2365
2906
|
// The token in hand opens this address too: stored already, or stored now.
|
|
2366
2907
|
const failed = accessStored ? null : await save(accessToken);
|
|
2367
2908
|
answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
|
|
@@ -2369,7 +2910,7 @@ export function createTools(ctx) {
|
|
|
2369
2910
|
else {
|
|
2370
2911
|
answer = await passCloudflareAccess({
|
|
2371
2912
|
url: at.url,
|
|
2372
|
-
consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
|
|
2913
|
+
consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
|
|
2373
2914
|
cf: cfRunner({}, ctx.hostRunner),
|
|
2374
2915
|
login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
|
|
2375
2916
|
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
@@ -2379,7 +2920,7 @@ export function createTools(ctx) {
|
|
|
2379
2920
|
return typed.skipped ? undefined : typed.value;
|
|
2380
2921
|
},
|
|
2381
2922
|
paste: async () => {
|
|
2382
|
-
const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
|
|
2923
|
+
const typed = await ask(sentTo(HOST_SCREENS['cloudflare-access-key'], at.url));
|
|
2383
2924
|
return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
|
|
2384
2925
|
},
|
|
2385
2926
|
prove,
|
|
@@ -2397,7 +2938,6 @@ export function createTools(ctx) {
|
|
|
2397
2938
|
accessToken = value;
|
|
2398
2939
|
accessStored = told.status === 'saved';
|
|
2399
2940
|
accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
|
|
2400
|
-
const project = pagesProject(new URL(at.url).origin);
|
|
2401
2941
|
if (project)
|
|
2402
2942
|
accessProjects.add(project);
|
|
2403
2943
|
}
|
|
@@ -2406,6 +2946,8 @@ export function createTools(ctx) {
|
|
|
2406
2946
|
if (told.status === 'saved') {
|
|
2407
2947
|
hostedPreviewProven = true;
|
|
2408
2948
|
provenHost = 'Cloudflare Pages';
|
|
2949
|
+
if (at.app)
|
|
2950
|
+
provenApps.add(at.app.appKey);
|
|
2409
2951
|
if (pattern)
|
|
2410
2952
|
provenPattern = pattern;
|
|
2411
2953
|
}
|