@impeccable/reviews 0.1.21 → 0.1.23
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/dist/cloudflare-access.js +4 -0
- package/dist/cloudflare.js +15 -18
- package/dist/fence.js +47 -26
- package/dist/host-questions.js +42 -19
- package/dist/main.js +38 -17
- package/dist/netlify-password.js +114 -37
- package/dist/playbook/cases/monorepo.md +5 -5
- package/dist/playbook/cases/netlify-setup.md +2 -2
- package/dist/playbook/cases/preview-protection.md +5 -4
- package/dist/playbook/cases/pull-requests.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +2 -2
- package/dist/playbook.js +2 -2
- package/dist/pr-summary.js +45 -0
- package/dist/preflight.js +1 -1
- package/dist/sandbox.js +4 -4
- package/dist/tools.js +312 -116
- package/dist/ui/bridge.js +5 -3
- package/dist/ui/look.js +3 -2
- package/dist/ui/screens.js +6 -6
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -7,22 +7,22 @@ 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, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
10
|
+
import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE,
|
|
13
|
+
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
17
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
18
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
19
|
-
import { passNetlifyLock } from './netlify-password.js';
|
|
19
|
+
import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-password.js';
|
|
20
20
|
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
21
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
22
22
|
import { fitsTask, stagesOf, workStarted } from './stages.js';
|
|
23
23
|
import { redact } from './log.js';
|
|
24
24
|
import { scan } from './scan.js';
|
|
25
|
-
import { gitEnvironment } from './sandbox.js';
|
|
25
|
+
import { gitEnvironment, installEnvironment, ownFolder } from './sandbox.js';
|
|
26
26
|
import { openBrowser } from './login.js';
|
|
27
27
|
import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
|
|
28
28
|
import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
@@ -245,7 +245,11 @@ const PULL_REQUEST = {
|
|
|
245
245
|
};
|
|
246
246
|
const TEST_PR = {
|
|
247
247
|
...PULL_REQUEST,
|
|
248
|
-
properties: {
|
|
248
|
+
properties: {
|
|
249
|
+
...PULL_REQUEST.properties,
|
|
250
|
+
appKey: { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app whose own test drive this is.' },
|
|
251
|
+
setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' },
|
|
252
|
+
},
|
|
249
253
|
};
|
|
250
254
|
/** An app's key as the setup state gives it. */
|
|
251
255
|
const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
@@ -315,7 +319,7 @@ const ASK = {
|
|
|
315
319
|
prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
|
|
316
320
|
confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
|
|
317
321
|
skipLabel: { type: 'string', minLength: 1, description: 'confirm only: the other way, such as "Skip for now".' },
|
|
318
|
-
skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says.' },
|
|
322
|
+
skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says, but the apps question.' },
|
|
319
323
|
sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed. A text question whose title, prompt or hint names a token, a password, a secret, a key or a credential is asked this way whatever this says.' },
|
|
320
324
|
},
|
|
321
325
|
};
|
|
@@ -1547,8 +1551,10 @@ export function createTools(ctx) {
|
|
|
1547
1551
|
const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
|
|
1548
1552
|
const retry = previewRetryState(ctx.log);
|
|
1549
1553
|
retry.repository ??= `${ctx.owner}/${ctx.repo}`;
|
|
1550
|
-
/** The branches of the recorded test
|
|
1554
|
+
/** The branches of the recorded test drives (by app key on a repository with several apps, '' on one) and setup pull request, as GitHub named them. */
|
|
1551
1555
|
let recordedBranches;
|
|
1556
|
+
/** On a repository with several apps, each app's recorded test drive, in the order they were recorded (impeccable-reviews#397). */
|
|
1557
|
+
const appDrives = new Map();
|
|
1552
1558
|
retry.recordedPr ??= ctx.sync.payload.setup_pr;
|
|
1553
1559
|
let memory = previewMemories.get(ctx.log);
|
|
1554
1560
|
if (!memory) {
|
|
@@ -1699,15 +1705,14 @@ export function createTools(ctx) {
|
|
|
1699
1705
|
/**
|
|
1700
1706
|
* The Netlify site passwords saved in this run, by site, for the proof of that site's addresses to send; never for the
|
|
1701
1707
|
* model. Each is one site's, so it goes only to that site's deploy previews, and a monorepo's sites keep theirs side by
|
|
1702
|
-
* side (#384): with what the tool answered,
|
|
1708
|
+
* side (#384): with what the tool answered, and the apps it is saved for.
|
|
1703
1709
|
*/
|
|
1704
1710
|
const sitePasswords = new Map();
|
|
1705
1711
|
/** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, the team login kept after the retries stays, and a site made public stays public. Another site of the repository is read on its own. */
|
|
1706
1712
|
const passwordStands = new Map();
|
|
1707
|
-
/** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
|
|
1713
|
+
/** The sites whose password the person said yes to storing, and those whose password they said yes to replacing, each its own: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
|
|
1708
1714
|
const passwordConsented = new Set();
|
|
1709
|
-
|
|
1710
|
-
const passwordRefused = new Set();
|
|
1715
|
+
const passwordReplaceConsented = new Set();
|
|
1711
1716
|
/** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
|
|
1712
1717
|
let accessGated;
|
|
1713
1718
|
/** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
|
|
@@ -1799,7 +1804,7 @@ export function createTools(ctx) {
|
|
|
1799
1804
|
teamChoices.delete(host);
|
|
1800
1805
|
endHostSetup(ctx.log, host);
|
|
1801
1806
|
};
|
|
1802
|
-
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
|
|
1807
|
+
const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel', cloudflare: 'Cloudflare' };
|
|
1803
1808
|
/** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
|
|
1804
1809
|
let vercelProject;
|
|
1805
1810
|
/** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
|
|
@@ -1876,6 +1881,93 @@ export function createTools(ctx) {
|
|
|
1876
1881
|
team: async () => ({ choices: teams.length > 1 ? await netlifyTeams(cli) : [] }),
|
|
1877
1882
|
};
|
|
1878
1883
|
};
|
|
1884
|
+
/** Each host's CLI by its npm package, as its install screens name it (#396). */
|
|
1885
|
+
const CLI_PACKAGES = { vercel: 'vercel', netlify: 'netlify-cli', cloudflare: 'cf' };
|
|
1886
|
+
/** What the person chose on a host's CLI install screen, by host: the run's install, or their own. */
|
|
1887
|
+
const cliInstalls = new Map();
|
|
1888
|
+
/**
|
|
1889
|
+
* A host's CLI that is not on this machine (#396): one screen naming the package, the pinned version and the global
|
|
1890
|
+
* install with npm, before anything installs it. On Install, `install` runs it and answers whether it completed (cf,
|
|
1891
|
+
* which only the tools run), or, where the install is the agent's (Vercel's and Netlify's, through the fence's pinned
|
|
1892
|
+
* rule), this opens that install and answers `agent`. On the person's own install, the command to run in another
|
|
1893
|
+
* terminal, the CLI looked for on their yes, and the steps again, saying so, while it is still missing; an install that
|
|
1894
|
+
* did not complete, the run's or the agent's, shows those steps too. Answers `installed` once `present` finds the CLI;
|
|
1895
|
+
* a stopped run throws.
|
|
1896
|
+
*/
|
|
1897
|
+
async function installHostCli(host, present, install, signal) {
|
|
1898
|
+
const pkg = CLI_PACKAGES[host];
|
|
1899
|
+
const cli = { name: HOST_NAMES[host], pkg, version: HOST_CLI_VERSIONS[pkg], command: hostCliInstallCommand(pkg) };
|
|
1900
|
+
const FAILED = "The install didn't complete.";
|
|
1901
|
+
const MISSING = `The ${cli.name} CLI still isn't on this machine.`;
|
|
1902
|
+
const runInstall = async () => {
|
|
1903
|
+
cliInstalls.set(host, 'run');
|
|
1904
|
+
if (install === 'agent') {
|
|
1905
|
+
agreeToCliInstall(ctx.log, host);
|
|
1906
|
+
return 'agent';
|
|
1907
|
+
}
|
|
1908
|
+
said(`Installing ${pkg} ${cli.version}`);
|
|
1909
|
+
const done = (await install()) && (await present());
|
|
1910
|
+
signal?.throwIfAborted();
|
|
1911
|
+
return done ? 'installed' : undefined;
|
|
1912
|
+
};
|
|
1913
|
+
// No answer at all (the input ended, or the run stopped) is neither way on: nothing is installed, and nothing more asked.
|
|
1914
|
+
const answered = (answer) => {
|
|
1915
|
+
signal?.throwIfAborted();
|
|
1916
|
+
if (answer.closed)
|
|
1917
|
+
throw new Error(`No answer came on the ${cli.name} CLI's screen, so nothing was installed.`);
|
|
1918
|
+
return answer;
|
|
1919
|
+
};
|
|
1920
|
+
let note;
|
|
1921
|
+
const chose = cliInstalls.get(host);
|
|
1922
|
+
if (!chose) {
|
|
1923
|
+
const answer = answered(await ask(cliInstallScreen(host, cli)));
|
|
1924
|
+
if (answer.skipped)
|
|
1925
|
+
cliInstalls.set(host, 'person');
|
|
1926
|
+
else {
|
|
1927
|
+
const done = await runInstall();
|
|
1928
|
+
if (done)
|
|
1929
|
+
return done;
|
|
1930
|
+
note = FAILED;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
else
|
|
1934
|
+
note = chose === 'run' ? FAILED : MISSING;
|
|
1935
|
+
const wait = ctx.hostWait?.cli ?? { totalMs: 30_000, everyMs: 2_000 };
|
|
1936
|
+
for (;;) {
|
|
1937
|
+
const answer = answered(await ask(cliManualScreen(host, cli, note)));
|
|
1938
|
+
if (answer.skipped) {
|
|
1939
|
+
const done = await runInstall();
|
|
1940
|
+
if (done)
|
|
1941
|
+
return done;
|
|
1942
|
+
note = FAILED;
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
// Looked for at once, then every so often for a while: an install in the other terminal may still be finishing.
|
|
1946
|
+
said(`Looking for the ${cli.name} CLI`);
|
|
1947
|
+
for (const end = Date.now() + wait.totalMs;; await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined)) {
|
|
1948
|
+
signal?.throwIfAborted();
|
|
1949
|
+
if (await present())
|
|
1950
|
+
return 'installed';
|
|
1951
|
+
if (Date.now() >= end)
|
|
1952
|
+
break;
|
|
1953
|
+
}
|
|
1954
|
+
cliInstalls.set(host, 'person');
|
|
1955
|
+
note = MISSING;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
/** The run's own install of a host CLI's package, for installHostCli: npm's pinned global install, from the command's own folder and npm cache, as the agent's install of one runs; whether it completed. */
|
|
1959
|
+
const npmInstall = (pkg, signal) => async () => {
|
|
1960
|
+
try {
|
|
1961
|
+
await (ctx.hostRunner ?? commandRunner)('npm', ownFolder(), { signal, timeoutMs: 600_000, env: installEnvironment() })(hostCliInstallArgs(pkg));
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1964
|
+
catch {
|
|
1965
|
+
return false;
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
/** cf, Cloudflare's CLI, which only the tools run: where it is missing here, its install is asked first (#396). */
|
|
1969
|
+
const cfPresent = async (pi, signal) => (await cfStatus(cfRunner({ checkout: pi.cwd }, ctx.hostRunner), signal)).cli !== 'missing';
|
|
1970
|
+
const installCf = (pi, signal) => installHostCli('cloudflare', () => cfPresent(pi, signal), npmInstall('cf', signal), signal);
|
|
1879
1971
|
/**
|
|
1880
1972
|
* A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
|
|
1881
1973
|
* already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
|
|
@@ -1906,10 +1998,14 @@ export function createTools(ctx) {
|
|
|
1906
1998
|
}
|
|
1907
1999
|
if (status.cli === 'signed-in')
|
|
1908
2000
|
return signedIn();
|
|
1909
|
-
// The CLI
|
|
2001
|
+
// The CLI's own screen first (#396). On Install the agent installs it through the fence's pinned rule, which that yes opens, and this is asked again; the person's own install, once found, goes on to the sign-in.
|
|
1910
2002
|
if (status.cli === 'missing') {
|
|
1911
2003
|
agreeToHostSetup(ctx.log, `${host}-setup`);
|
|
1912
|
-
|
|
2004
|
+
if ((await installHostCli(host, async () => (await cli.status()).cli !== 'missing', 'agent', signal)) === 'agent')
|
|
2005
|
+
return { confirmed: true, cli: 'missing' };
|
|
2006
|
+
status = await cli.status();
|
|
2007
|
+
if (status.cli === 'signed-in')
|
|
2008
|
+
return signedIn();
|
|
1913
2009
|
}
|
|
1914
2010
|
for (let attempt = 0;; attempt++) {
|
|
1915
2011
|
said(`Opening the browser for ${HOST_NAMES[host]}'s login`);
|
|
@@ -2121,7 +2217,7 @@ export function createTools(ctx) {
|
|
|
2121
2217
|
/** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
|
|
2122
2218
|
let appsRepo = ctx.severalApps === true;
|
|
2123
2219
|
/**
|
|
2124
|
-
* The apps this run sets up, as the apps question answered
|
|
2220
|
+
* The apps this run sets up, as the apps question answered: undefined until it was answered.
|
|
2125
2221
|
* On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
|
|
2126
2222
|
* and host read for an app names one of these.
|
|
2127
2223
|
*/
|
|
@@ -2131,8 +2227,12 @@ export function createTools(ctx) {
|
|
|
2131
2227
|
/** What the run did for each picked app, as its own tool calls found it, sent with the session whenever it changes (#2307). */
|
|
2132
2228
|
const appSummaries = new Map();
|
|
2133
2229
|
const sendApps = () => ctx.sync.update({ apps: [...appSummaries.values()].slice(0, 50) });
|
|
2230
|
+
/** Whether this run gives each app a test drive of its own: it sets up more than one app of a repository with several (impeccable-reviews#397). */
|
|
2231
|
+
const perAppDrives = () => appsRepo && (pickedApps?.size ?? 0) > 1;
|
|
2134
2232
|
const pick = (keys) => {
|
|
2135
2233
|
pickedApps = keys;
|
|
2234
|
+
// Each app this run sets up gets a test drive of its own (impeccable-reviews#397).
|
|
2235
|
+
retry.drivesAllowed = Math.max(1, keys.size);
|
|
2136
2236
|
const before = new Map(appSummaries);
|
|
2137
2237
|
appSummaries.clear();
|
|
2138
2238
|
for (const key of keys)
|
|
@@ -2234,7 +2334,7 @@ export function createTools(ctx) {
|
|
|
2234
2334
|
*/
|
|
2235
2335
|
const readingRefusal = (tool) => workStarted(ctx.sync.payload.tasks) ? null : `Refused: the task list shows no work past reading the repository yet, so the screen still says "Understanding your app". Call impeccable_progress first with Read the repository completed and Preview method in progress, then ${tool} again. Nothing was asked or sent.`;
|
|
2236
2336
|
/** How this run left each app the state lists (#286), for the report and the last screen. */
|
|
2237
|
-
const appOutcomes = async (pi) => {
|
|
2337
|
+
const appOutcomes = async (pi, stale = new Set()) => {
|
|
2238
2338
|
const apps = knownApps ?? (await stateApps(pi)) ?? [];
|
|
2239
2339
|
return apps.map(({ key, name, folder }) => {
|
|
2240
2340
|
const method = appMethods.get(key);
|
|
@@ -2245,9 +2345,12 @@ export function createTools(ctx) {
|
|
|
2245
2345
|
// Impeccable's own last check of its preview met a wall: its reviews will meet it too (#383).
|
|
2246
2346
|
if (method !== undefined && checkWalls.has(key))
|
|
2247
2347
|
return { key, name, folder, state: 'walled', left: `${name}: Impeccable's check of its preview met ${checkWalls.get(key)}; open its Settings.` };
|
|
2348
|
+
// Its test drive ran on the setup branch before its last change, so it shows nothing of what is merged (#397).
|
|
2349
|
+
if (method !== undefined && stale.has(key))
|
|
2350
|
+
return { key, name, folder, state: 'stale', left: `${name}: its test drive predates the setup pull request's last change; run impeccable-reviews again.` };
|
|
2248
2351
|
if (method !== undefined && (method !== 'preview-deploy' || proven)) {
|
|
2249
|
-
// With
|
|
2250
|
-
return
|
|
2352
|
+
// With its test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
|
|
2353
|
+
return appDrives.has(key) && !checkedApps.has(key)
|
|
2251
2354
|
? { key, name, folder, state: 'ready', unchecked: true, left: `${name}: Impeccable's check of its preview did not run here; the test drive's review shows whether it reaches it.` }
|
|
2252
2355
|
: { key, name, folder, state: 'ready' };
|
|
2253
2356
|
}
|
|
@@ -2266,6 +2369,8 @@ export function createTools(ctx) {
|
|
|
2266
2369
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2267
2370
|
if (a.state === 'unset')
|
|
2268
2371
|
return `- ${where}: not set up. No preview method was saved for it.`;
|
|
2372
|
+
if (a.state === 'stale')
|
|
2373
|
+
return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but its test drive was branched before the setup branch's last change, so it was left out.`;
|
|
2269
2374
|
if (a.state === 'walled')
|
|
2270
2375
|
return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but Impeccable's own check of its preview met ${checkWalls.get(a.key)}.`;
|
|
2271
2376
|
return `- ${where}: not set up this run.`;
|
|
@@ -2291,35 +2396,53 @@ export function createTools(ctx) {
|
|
|
2291
2396
|
}
|
|
2292
2397
|
/**
|
|
2293
2398
|
* The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
|
|
2294
|
-
* this run confirmed the scan's proposal); the command started for one app (--app) answers it
|
|
2295
|
-
* answer is the apps this run sets up
|
|
2399
|
+
* this run confirmed the scan's proposal); the command started for one app (--app) answers it on one confirm screen.
|
|
2400
|
+
* The answer is the apps this run sets up, at least one: the question has no Skip (#405).
|
|
2296
2401
|
*/
|
|
2297
|
-
async function appsQuestion(pi) {
|
|
2402
|
+
async function appsQuestion(pi, signal) {
|
|
2298
2403
|
const apps = await stateApps(pi);
|
|
2299
2404
|
if (!apps || apps.length < 2)
|
|
2300
2405
|
return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
|
|
2301
|
-
// Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose
|
|
2406
|
+
// Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Pick other apps opens the list for someone who wants another app (#286).
|
|
2302
2407
|
const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
|
|
2303
|
-
if (named
|
|
2304
|
-
|
|
2305
|
-
|
|
2408
|
+
if (named) {
|
|
2409
|
+
const screen = namedAppQuestion(named);
|
|
2410
|
+
const answer = await ask(screen);
|
|
2411
|
+
if (answer.closed)
|
|
2412
|
+
return unanswered(pi, screen, signal);
|
|
2413
|
+
if (!answer.skipped) {
|
|
2414
|
+
pick(new Set([named.key]));
|
|
2415
|
+
return JSON.stringify({ values: [named.key], named: true });
|
|
2416
|
+
}
|
|
2306
2417
|
}
|
|
2307
2418
|
// 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.
|
|
2308
2419
|
const start = ctx.start ?? '';
|
|
2309
2420
|
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;
|
|
2310
2421
|
const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2422
|
+
const list = { ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) };
|
|
2423
|
+
// No Skip: Continue is the only way on, with at least one app picked (#405). A blank line in a piped run picks none, so the list goes again; on the list the named app's screen opened, the named app stands.
|
|
2424
|
+
for (;;) {
|
|
2425
|
+
const answer = await ask(list, false);
|
|
2426
|
+
if (answer.skipped)
|
|
2427
|
+
return unanswered(pi, list, signal);
|
|
2428
|
+
const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
|
|
2429
|
+
if (named && !values.length)
|
|
2430
|
+
values.push(named.key);
|
|
2431
|
+
if (!values.length)
|
|
2432
|
+
continue;
|
|
2433
|
+
pick(new Set(values));
|
|
2434
|
+
return JSON.stringify({ values });
|
|
2316
2435
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* No answer came on the apps question (the input ended, or the run stopped with it up), which has no Skip: nothing is
|
|
2439
|
+
* written, and the run ends here as stopped while waiting for that answer, which the end screen and the web page name.
|
|
2440
|
+
*/
|
|
2441
|
+
function unanswered(pi, screen, signal) {
|
|
2442
|
+
signal?.throwIfAborted();
|
|
2443
|
+
ctx.sync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
|
|
2444
|
+
pi.shutdown();
|
|
2445
|
+
return { text: 'No answer came on the apps question, so no app was picked and nothing was written. The run ends here.', terminate: true };
|
|
2323
2446
|
}
|
|
2324
2447
|
return [
|
|
2325
2448
|
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)),
|
|
@@ -2509,11 +2632,11 @@ export function createTools(ctx) {
|
|
|
2509
2632
|
// A test account never comes back to the model, even if the API echoed it.
|
|
2510
2633
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
2511
2634
|
}),
|
|
2512
|
-
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": […]}
|
|
2513
|
-
// A skip never stops the run, so every question offers one.
|
|
2635
|
+
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, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. 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) => {
|
|
2636
|
+
// A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
|
|
2514
2637
|
const asked = p;
|
|
2515
2638
|
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2516
|
-
return appsQuestion(pi);
|
|
2639
|
+
return appsQuestion(pi, signal);
|
|
2517
2640
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
2518
2641
|
// The Vercel setup is for a repository Vercel has never deployed: one it deploys already (every app this run sets up, on a repository with several), as impeccable_hosted_previews read it, gets its previews from the first pull request on (#286). An app with no project of its own still gets the screen, and a setup under way (asked again once the CLI was installed) is not stopped here.
|
|
2519
2642
|
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
@@ -2545,8 +2668,10 @@ export function createTools(ctx) {
|
|
|
2545
2668
|
ctx.log.write('ask hidden (names a credential)', q.id);
|
|
2546
2669
|
if (q.kind === 'text' && !q.sensitive)
|
|
2547
2670
|
q.caution = NEVER_A_CREDENTIAL;
|
|
2548
|
-
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
|
|
2549
|
-
const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
|
|
2671
|
+
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here; it has no Skip (#405).
|
|
2672
|
+
const answer = q.id === APPS_QUESTION.id ? await ask(q, false) : await askPerson(q);
|
|
2673
|
+
if (q.id === APPS_QUESTION.id && answer.skipped)
|
|
2674
|
+
return unanswered(pi, q, signal);
|
|
2550
2675
|
if (answer.skipped) {
|
|
2551
2676
|
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
2552
2677
|
if (host?.[1] === 'vercel')
|
|
@@ -2590,28 +2715,49 @@ export function createTools(ctx) {
|
|
|
2590
2715
|
reportTasks(tasks);
|
|
2591
2716
|
return 'Saved.';
|
|
2592
2717
|
}),
|
|
2593
|
-
tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
|
|
2718
|
+
tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open; on a repository with several apps, each app\'s own test drive with its appKey, as it opens. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
|
|
2594
2719
|
const drive = { number: p.number, url: p.url.trim() };
|
|
2595
2720
|
const given = p.setup_pr;
|
|
2596
2721
|
const setup = given && { number: given.number, url: given.url.trim() };
|
|
2597
2722
|
if (setup?.number === drive.number)
|
|
2598
2723
|
return `Refused: #${drive.number} cannot be both the test drive and the setup pull request.`;
|
|
2724
|
+
// A run that sets up several apps of a repository gives each a test drive of its own (impeccable-reviews#397); one that sets up one app, its one test drive.
|
|
2725
|
+
const several = perAppDrives();
|
|
2726
|
+
const sole = appsRepo && pickedApps?.size === 1 ? [...pickedApps][0] : undefined;
|
|
2727
|
+
const appKey = several || p.appKey !== undefined ? p.appKey : sole;
|
|
2728
|
+
if (appsRepo) {
|
|
2729
|
+
// The apps question comes first; then the app named, which only a run that sets up one app may leave out.
|
|
2730
|
+
const refused = appRefusal(appKey, 'recorded');
|
|
2731
|
+
if (refused)
|
|
2732
|
+
return refused;
|
|
2733
|
+
const other = [...appDrives].find(([key, pr]) => key !== appKey && pr.number === drive.number);
|
|
2734
|
+
if (other)
|
|
2735
|
+
return `Refused: #${drive.number} is ${appName(other[0])}'s test drive. Each app gets a test drive of its own, as the monorepo case says. Nothing was recorded.`;
|
|
2736
|
+
}
|
|
2737
|
+
const key = typeof appKey === 'string' ? appKey : '';
|
|
2599
2738
|
const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
|
|
2600
2739
|
if ('problem' in found)
|
|
2601
2740
|
return `Refused: ${found.problem}.`;
|
|
2602
2741
|
drive.url = found.url;
|
|
2742
|
+
if (several && [...(recordedBranches?.drives ?? [])].some(([other, branch]) => other !== key && branch === found.branch)) {
|
|
2743
|
+
return `Refused: ${found.branch} is another app's test drive. Each app's test drive comes from a branch of its own (impeccable/test-drive-<appKey>), as the monorepo case says. Nothing was recorded.`;
|
|
2744
|
+
}
|
|
2603
2745
|
// 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).
|
|
2604
|
-
const changes =
|
|
2746
|
+
const changes = recordedBranches?.drives.get(key) !== found.branch && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
|
|
2605
2747
|
const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
|
|
2606
2748
|
if (files.length && files.every(({ status }) => status === 'A')) {
|
|
2607
2749
|
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.`;
|
|
2608
2750
|
}
|
|
2609
|
-
// On a repository with several apps,
|
|
2751
|
+
// On a repository with several apps, an app's test drive adds its page to that app's own folder, and no other app is touched (#286, #397): a file it adds anywhere else, or changes in another app's (a root app's included), is refused, whatever its name. A file belongs to the app whose folder holds it most closely; one no app's folder holds (a shared root file) is no app's. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
|
|
2610
2752
|
if (appsRepo && pickedApps) {
|
|
2611
|
-
const
|
|
2612
|
-
const
|
|
2753
|
+
const apps = knownApps ?? (await stateApps(pi)) ?? [];
|
|
2754
|
+
const ownerOf = (path) => apps.filter((a) => !a.folder || path.startsWith(`${a.folder}/`)).sort((a, b) => b.folder.length - a.folder.length)[0];
|
|
2755
|
+
const outside = files.filter(({ status, path }) => {
|
|
2756
|
+
const owner = ownerOf(path);
|
|
2757
|
+
return status === 'A' ? owner?.key !== key : owner !== undefined && owner.key !== key;
|
|
2758
|
+
}).map(({ path }) => path);
|
|
2613
2759
|
if (outside.length)
|
|
2614
|
-
return `Refused: ${found.branch}
|
|
2760
|
+
return `Refused: ${found.branch} changes ${outside.join(', ')}, outside the folder of ${appName(key)} (${(await appFolder(pi, key)) || '/'}). Each app's test drive adds its page to its own app and touches no other, as the monorepo case says: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
|
|
2615
2761
|
}
|
|
2616
2762
|
let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
|
|
2617
2763
|
if (setup) {
|
|
@@ -2626,10 +2772,14 @@ export function createTools(ctx) {
|
|
|
2626
2772
|
setupBranch = head.branch;
|
|
2627
2773
|
setupBase = head.base;
|
|
2628
2774
|
}
|
|
2629
|
-
|
|
2630
|
-
|
|
2775
|
+
if (appsRepo)
|
|
2776
|
+
appDrives.set(key, drive);
|
|
2777
|
+
// The first app's test drive stays the one the web page's status follows; each app's goes beside it, by its key.
|
|
2778
|
+
const first = several ? appDrives.values().next().value : drive;
|
|
2779
|
+
ctx.sync.update({ test_pr: first, ...(several ? { test_prs: Object.fromEntries(appDrives) } : {}), ...(setup ? { setup_pr: setup } : {}) });
|
|
2780
|
+
recordedBranches = { drives: new Map(recordedBranches?.drives).set(key, found.branch), setup: setupBranch, setupBase };
|
|
2631
2781
|
const res = await ctx.sync.flush();
|
|
2632
|
-
const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
|
|
2782
|
+
const what = `${several ? `${appName(key)}'s test drive` : 'the test drive'} #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
|
|
2633
2783
|
return res?.ok
|
|
2634
2784
|
? `Recorded ${what}. The web page follows the test drive's review from now on.`
|
|
2635
2785
|
: `Recorded ${what} here, but the web page did not take it yet (${res?.error ?? 'no answer'}). The report links them too.`;
|
|
@@ -2658,8 +2808,22 @@ export function createTools(ctx) {
|
|
|
2658
2808
|
const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
|
|
2659
2809
|
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.'}`;
|
|
2660
2810
|
}
|
|
2661
|
-
// On a repository with several apps,
|
|
2662
|
-
const
|
|
2811
|
+
// A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one. On a repository with several apps, each app's is judged on its own.
|
|
2812
|
+
const covers = async (key) => {
|
|
2813
|
+
const branch = recordedBranches?.drives.get(key);
|
|
2814
|
+
// An app whose host builds its previews needs nothing of the setup branch: its test drive (from the default branch, in its host's setup case) stands (#397).
|
|
2815
|
+
if (appsRepo && appMethods.get(key) === 'preview-deploy')
|
|
2816
|
+
return true;
|
|
2817
|
+
return !recordedBranches?.setup || !branch || (await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, branch)) !== false;
|
|
2818
|
+
};
|
|
2819
|
+
const kept = [];
|
|
2820
|
+
for (const [key, pr] of appDrives)
|
|
2821
|
+
if (await covers(key))
|
|
2822
|
+
kept.push([key, pr]);
|
|
2823
|
+
const several = perAppDrives();
|
|
2824
|
+
const drive = several ? kept[0]?.[1] : ctx.sync.payload.test_pr && (await covers(appsRepo ? [...appDrives.keys()][0] ?? '' : '')) ? ctx.sync.payload.test_pr : undefined;
|
|
2825
|
+
// On a repository with several apps, how this run left each app, from what it saved and proved (#286); one whose test drive was left out is not set up (#397).
|
|
2826
|
+
const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
|
|
2663
2827
|
if (apps)
|
|
2664
2828
|
markdown += appsSection(apps);
|
|
2665
2829
|
if (previews.active && retry.pr) {
|
|
@@ -2671,11 +2835,7 @@ export function createTools(ctx) {
|
|
|
2671
2835
|
if (!left.includes('Watch the review on the test drive'))
|
|
2672
2836
|
left.push('Watch the review on the test drive');
|
|
2673
2837
|
}
|
|
2674
|
-
const { phase, status, question, report_markdown, test_pr, left: leftBefore } = ctx.sync.payload;
|
|
2675
|
-
// A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
|
|
2676
|
-
const covered = test_pr && recordedBranches?.setup
|
|
2677
|
-
? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
|
|
2678
|
-
const drive = covered === false ? undefined : test_pr;
|
|
2838
|
+
const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
|
|
2679
2839
|
// Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
|
|
2680
2840
|
const appsBefore = new Map(appSummaries);
|
|
2681
2841
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
@@ -2685,11 +2845,11 @@ export function createTools(ctx) {
|
|
|
2685
2845
|
noteApp(appKey, { reason: plain });
|
|
2686
2846
|
}
|
|
2687
2847
|
// The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
|
|
2688
|
-
ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
|
|
2848
|
+
ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
|
|
2689
2849
|
const res = await ctx.sync.flush();
|
|
2690
2850
|
if (res && res.status >= 400 && res.status < 500) {
|
|
2691
2851
|
// A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
|
|
2692
|
-
ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
|
|
2852
|
+
ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore });
|
|
2693
2853
|
// Its reasons go with it: the report sent again gives its own.
|
|
2694
2854
|
if (appsBefore.size) {
|
|
2695
2855
|
for (const [key, was] of appsBefore)
|
|
@@ -2700,7 +2860,7 @@ export function createTools(ctx) {
|
|
|
2700
2860
|
}
|
|
2701
2861
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2702
2862
|
const hosts = hostLines(hostsUsed());
|
|
2703
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2863
|
+
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2704
2864
|
pi.shutdown();
|
|
2705
2865
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2706
2866
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2859,7 +3019,7 @@ export function createTools(ctx) {
|
|
|
2859
3019
|
noteApp(appKey, { host_project: site });
|
|
2860
3020
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
2861
3021
|
}),
|
|
2862
|
-
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) => {
|
|
3022
|
+
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; where cf is not installed here, the person is asked to install it first, on its own screen). 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) => {
|
|
2863
3023
|
const pull = p.pull;
|
|
2864
3024
|
const branch = p.branch;
|
|
2865
3025
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -2890,7 +3050,12 @@ export function createTools(ctx) {
|
|
|
2890
3050
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
2891
3051
|
const app = cloudflareEvidence?.githubApp;
|
|
2892
3052
|
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;
|
|
2893
|
-
|
|
3053
|
+
let 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();
|
|
3054
|
+
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3055
|
+
if (!subject && waited.cli === 'missing') {
|
|
3056
|
+
await installCf(pi, signal);
|
|
3057
|
+
waited = await read();
|
|
3058
|
+
}
|
|
2894
3059
|
const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
|
|
2895
3060
|
// By the label the project's addresses carry.
|
|
2896
3061
|
const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
|
|
@@ -3036,6 +3201,7 @@ export function createTools(ctx) {
|
|
|
3036
3201
|
if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
|
|
3037
3202
|
return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
|
|
3038
3203
|
said(`Turning on pull-request previews on ${name}`);
|
|
3204
|
+
// cf is installed here: the fact this acts on is one a read through cf found.
|
|
3039
3205
|
const turnOn = async () => {
|
|
3040
3206
|
if (host === 'netlify')
|
|
3041
3207
|
return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
|
|
@@ -3088,6 +3254,11 @@ export function createTools(ctx) {
|
|
|
3088
3254
|
const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
|
|
3089
3255
|
const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
|
|
3090
3256
|
let status = await cfStatus(cf, signal);
|
|
3257
|
+
// cf not on this machine (#396): its install is asked first.
|
|
3258
|
+
if (status.cli === 'missing') {
|
|
3259
|
+
await installCf(pi, signal);
|
|
3260
|
+
status = await cfStatus(cf, signal);
|
|
3261
|
+
}
|
|
3091
3262
|
if (!writes(status)) {
|
|
3092
3263
|
if (status.cli === 'missing' || status.cli === 'unknown')
|
|
3093
3264
|
return answer('refused', { reason: "Cloudflare's CLI did not run here." });
|
|
@@ -3322,7 +3493,7 @@ export function createTools(ctx) {
|
|
|
3322
3493
|
}
|
|
3323
3494
|
return JSON.stringify(outcome);
|
|
3324
3495
|
}),
|
|
3325
|
-
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, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). 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
|
|
3496
|
+
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, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). 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 asking to set a new one, which replaces the site\'s current password, and on that yes sets a long random one on the site as the person with the Netlify CLI\'s sign-in (it never comes back, and nothing is pasted); 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 the site\'s existing one or a new one the run set, and site which Netlify site it is; replaced, on any answer, says the run set a new site password, which replaced the one before, saved or not), 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) => {
|
|
3326
3497
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
3327
3498
|
if (!gated || !site)
|
|
3328
3499
|
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.';
|
|
@@ -3371,63 +3542,85 @@ export function createTools(ctx) {
|
|
|
3371
3542
|
}
|
|
3372
3543
|
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
3373
3544
|
let stored = false;
|
|
3374
|
-
/**
|
|
3375
|
-
let
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
return
|
|
3406
|
-
}
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3545
|
+
/** Netlify took a new password the run set in this call: the end screen says so, saved or not, and on a cancel too. */
|
|
3546
|
+
let replaced = false;
|
|
3547
|
+
let answer;
|
|
3548
|
+
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
3549
|
+
// The Netlify CLI not on this machine (#396): its install is asked first, the run's own since no agent's turn comes in between, and the command runs again.
|
|
3550
|
+
const run = async (args) => {
|
|
3551
|
+
try {
|
|
3552
|
+
return await netlify(args);
|
|
3553
|
+
}
|
|
3554
|
+
catch (err) {
|
|
3555
|
+
if (!err.missing)
|
|
3556
|
+
throw err;
|
|
3557
|
+
await installHostCli('netlify', async () => (await netlifyStatus(netlify)).cli !== 'missing', npmInstall('netlify-cli', signal), signal);
|
|
3558
|
+
return netlify(args);
|
|
3559
|
+
}
|
|
3560
|
+
};
|
|
3561
|
+
try {
|
|
3562
|
+
answer = await passNetlifyLock({
|
|
3563
|
+
run,
|
|
3564
|
+
site,
|
|
3565
|
+
consent: async () => {
|
|
3566
|
+
if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
|
|
3567
|
+
return passwordConsented.add(site), true;
|
|
3568
|
+
return false;
|
|
3569
|
+
},
|
|
3570
|
+
makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
|
|
3571
|
+
...(at.teamLogin ? { teamLogin: true } : {}),
|
|
3572
|
+
// The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
|
|
3573
|
+
signIn: async () => {
|
|
3574
|
+
if (!hostSignIns.has('netlify'))
|
|
3575
|
+
hostSignIns.set('netlify', 'confirmed');
|
|
3576
|
+
return (await hostSignIn('netlify', pi, signal)).signedIn === true;
|
|
3577
|
+
},
|
|
3578
|
+
retry: async (page, first) => {
|
|
3579
|
+
if (first)
|
|
3580
|
+
(ctx.openBrowser ?? openBrowser)(page);
|
|
3581
|
+
// The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
|
|
3582
|
+
const screen = HOST_SCREENS['netlify-visibility-retry'];
|
|
3583
|
+
return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
|
|
3584
|
+
},
|
|
3585
|
+
replace: async (live) => {
|
|
3586
|
+
if (passwordReplaceConsented.has(site) || !(await ask(netlifyPasswordReplace(site, { ...(at.app ? { app: appName(at.app.appKey) } : {}), live }))).skipped)
|
|
3587
|
+
return passwordReplaceConsented.add(site), true;
|
|
3588
|
+
return false;
|
|
3589
|
+
},
|
|
3590
|
+
// Netlify's API, called from this process with the token the CLI signed in with: the password never goes on a command line (#395).
|
|
3591
|
+
setPassword: (id, password) => {
|
|
3592
|
+
const token = (ctx.netlifyToken ?? netlifyToken)();
|
|
3593
|
+
if (token)
|
|
3594
|
+
noteSensitiveAnswer(token);
|
|
3595
|
+
return setSitePassword(id, password, { token, fetch: ctx.netlifyFetch ?? fetch, signal });
|
|
3596
|
+
},
|
|
3597
|
+
changed: () => void (replaced = true),
|
|
3598
|
+
...(ctx.hostWait?.password ? { wait: ctx.hostWait.password } : {}),
|
|
3599
|
+
signal,
|
|
3600
|
+
prove,
|
|
3601
|
+
save: async (password) => {
|
|
3602
|
+
// Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
|
|
3603
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
|
|
3604
|
+
stored = saved.stored;
|
|
3605
|
+
return saved.failed;
|
|
3606
|
+
},
|
|
3607
|
+
remember: noteSensitiveAnswer,
|
|
3608
|
+
});
|
|
3609
|
+
}
|
|
3610
|
+
finally {
|
|
3611
|
+
if (replaced) {
|
|
3612
|
+
ctx.onKeyCreated?.(answer?.status === 'saved'
|
|
3613
|
+
? `Impeccable set a new Netlify site password on ${site} as you and saved it for reviews, so its previews stay password-protected. It replaced the one before, which no longer opens them: set one people know in Netlify (${NETLIFY_PASSWORD_WHERE}) any time, and put it in the app's Settings too, under "A way past your host's lock", or reviews stop.`
|
|
3614
|
+
: `Impeccable set a new Netlify site password on ${site} as you, which replaced the one before, but it was not saved for reviews. Set a password people know in Netlify (${NETLIFY_PASSWORD_WHERE}), and put it in the app's Settings too, under "A way past your host's lock".`, `netlify-password:${site}`);
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
const { value, ...told } = answer;
|
|
3421
3618
|
const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
|
|
3422
3619
|
// Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321). Else they say which lock it is.
|
|
3423
3620
|
if (told.protection === 'none' && lastLock === 'netlify-protection')
|
|
3424
3621
|
lastLock = undefined;
|
|
3425
3622
|
if (told.protection === 'password' || told.protection === 'team-login')
|
|
3426
3623
|
lastTeamLogin = told.protection === 'team-login';
|
|
3427
|
-
if (told.status === 'refused' && typed)
|
|
3428
|
-
passwordRefused.add(site);
|
|
3429
|
-
else if (told.status === 'saved')
|
|
3430
|
-
passwordRefused.delete(site);
|
|
3431
3624
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3432
3625
|
passwordStands.set(site, passwordOutcome);
|
|
3433
3626
|
if (told.status === 'opened') {
|
|
@@ -3442,9 +3635,11 @@ export function createTools(ctx) {
|
|
|
3442
3635
|
if (value) {
|
|
3443
3636
|
// Said once it opened the preview and was saved, so the screen does not go straight back to the tasks (#390); the end screen names it and where it changes.
|
|
3444
3637
|
said(`Password works: saved for ${at.app ? appName(at.app.appKey) : site}`);
|
|
3445
|
-
|
|
3638
|
+
// A password the run set is named on the end screen as it was set, above. Either line takes the place of an earlier one for the site, such as a replaced password not saved.
|
|
3639
|
+
if (told.password !== 'set')
|
|
3640
|
+
ctx.onKeyCreated?.(`Impeccable saved the Netlify site password of ${site} for reviews, so its previews stay password-protected. A new password set in Netlify (${NETLIFY_PASSWORD_WHERE}) goes in the app's Settings too, under "A way past your host's lock", or reviews stop.`, `netlify-password:${site}`);
|
|
3446
3641
|
// What is in hand follows the store: the password saved now is this site's, beside every other site's.
|
|
3447
|
-
sitePasswords.set(site, { value, saved: passwordOutcome,
|
|
3642
|
+
sitePasswords.set(site, { value, saved: passwordOutcome, apps: new Set([at.app?.appKey ?? '']) });
|
|
3448
3643
|
hostedPreviewProven = true;
|
|
3449
3644
|
provenHost = 'Netlify';
|
|
3450
3645
|
usedPreviews.add(at.url);
|
|
@@ -3514,6 +3709,7 @@ export function createTools(ctx) {
|
|
|
3514
3709
|
cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
|
|
3515
3710
|
login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
|
|
3516
3711
|
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
3712
|
+
install: async () => void (await installCf(pi, signal)),
|
|
3517
3713
|
status: said,
|
|
3518
3714
|
apiToken: async () => {
|
|
3519
3715
|
const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
|