@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
|
@@ -107,6 +107,10 @@ const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.ever
|
|
|
107
107
|
async function reach(deps, read) {
|
|
108
108
|
const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
|
|
109
109
|
let status = read;
|
|
110
|
+
if (status.cli === 'missing' && deps.install) {
|
|
111
|
+
await deps.install();
|
|
112
|
+
status = await cfStatus(deps.cf, deps.signal);
|
|
113
|
+
}
|
|
110
114
|
if (granted(status))
|
|
111
115
|
return through(status);
|
|
112
116
|
if (status.cli === 'missing' || status.cli === 'unknown')
|
package/dist/cloudflare.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve, sep } from 'node:path';
|
|
4
4
|
import { cloudflarePreviewsFact, cloudflarePreviewsPage, folderOf } from './host-previews.js';
|
|
5
5
|
import { commandRunner } from './netlify.js';
|
|
6
|
-
import { ownFolder
|
|
6
|
+
import { ownFolder } from './sandbox.js';
|
|
7
7
|
/**
|
|
8
8
|
* Cloudflare Pages: the preview deployments this repository's pull requests
|
|
9
9
|
* get, from GitHub's record and from cf, Cloudflare's CLI, on this machine
|
|
@@ -234,30 +234,26 @@ export function plainJson(text) {
|
|
|
234
234
|
}
|
|
235
235
|
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
236
236
|
}
|
|
237
|
-
/** cf, Cloudflare's CLI, at the version this command is built against, run through npx, which installs it on first use. */
|
|
238
|
-
export const CF_PACKAGE = 'cf@1.0.0-beta.13';
|
|
239
237
|
/**
|
|
240
|
-
* cf here
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* command names its project or its account, so none needs the checkout. Only the sign-in
|
|
245
|
-
* `checkout`, each command runs as the profile cf binds to it (`cf auth activate`), which
|
|
246
|
-
* too, so the account is the one the person's cf uses there; where reading the bindings
|
|
247
|
-
* never falling back to another sign-in.
|
|
248
|
-
* sandboxed install could have planted a cf under the registry's live ETag; the registry is asked afresh, and no install
|
|
249
|
-
* script of cf's runs, which it does without.
|
|
238
|
+
* cf here, Cloudflare's CLI as npm installs it globally (`cf`, at the version fence.ts pins for an install the person said
|
|
239
|
+
* yes to, #396): its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to
|
|
240
|
+
* pick when they have several. `browser` lets its sign-in open one. It runs from a folder of the command's own, never the
|
|
241
|
+
* checkout, from where cf would read the checkout's .env into its token and account and evaluate its
|
|
242
|
+
* cloudflare.config.ts: every cf command names its project or its account, so none needs the checkout. Only the sign-in
|
|
243
|
+
* cf picks by folder does: with `checkout`, each command runs as the profile cf binds to it (`cf auth activate`), which
|
|
244
|
+
* cf names from its own folder too, so the account is the one the person's cf uses there; where reading the bindings
|
|
245
|
+
* failed, the command fails as well, never falling back to another sign-in. Not installed, a command fails `missing`.
|
|
250
246
|
*/
|
|
251
247
|
export function cfRunner(opts = {}, runner = commandRunner) {
|
|
252
248
|
const { checkout, ...run } = opts;
|
|
253
|
-
const
|
|
249
|
+
const exec = (args, signal, env = {}) => runner('cf', ownFolder(), { timeoutMs: 120_000, ...run, signal, env: { NO_COLOR: '1', ...env } })(args);
|
|
254
250
|
let profile;
|
|
255
251
|
return async (args, { account, signal } = {}) => {
|
|
256
252
|
// Read once a runner; a failed read is read again by the next command.
|
|
257
253
|
if (checkout && !profile)
|
|
258
|
-
profile =
|
|
254
|
+
profile = exec(['auth', 'list'], signal).then((listed) => boundProfile(listed, checkout)).catch((err) => { profile = undefined; throw err; });
|
|
259
255
|
const bound = await profile;
|
|
260
|
-
return
|
|
256
|
+
return exec([...(bound ? ['--profile', bound] : []), ...args], signal, account ? { CLOUDFLARE_ACCOUNT_ID: account } : {});
|
|
261
257
|
};
|
|
262
258
|
}
|
|
263
259
|
/** The profile `cf auth list` binds to `checkout`, found as cf finds it from there: the longest bound folder that is the checkout's real path or holds it; none where no folder does. A list that is not cf's throws. */
|
|
@@ -431,7 +427,8 @@ async function listCloudflareProjects(cf, accounts, signal) {
|
|
|
431
427
|
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
|
|
432
428
|
*/
|
|
433
429
|
export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
434
|
-
|
|
430
|
+
// cf is not asked here, so whether it is installed is unknown.
|
|
431
|
+
const unread = () => ({ previews: [], cli: 'unknown', error: "Cloudflare's previews could not be read.", unread: ['GitHub'] });
|
|
435
432
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..')
|
|
436
433
|
return unread();
|
|
437
434
|
if (deps.branch !== undefined && !branchName(deps.branch))
|
|
@@ -445,7 +442,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
445
442
|
const app = deps.directory !== undefined && plain;
|
|
446
443
|
// `project`, one app's told by an earlier read, is the one read: never the project the first check run names, which may be another app's.
|
|
447
444
|
const named = app ? undefined : deps.project ?? github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
|
|
448
|
-
//
|
|
445
|
+
// The project's settings are read with the plain evidence, not while a build is waited for. cf missing here reads as `cli: missing`: nothing installs it (#396).
|
|
449
446
|
const cf = await fromCf(named, deps.cf ?? cfRunner({ checkout: cwd }, deps.run), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages, ...(app ? { directory: deps.directory } : {}) }, plain, deps.signal);
|
|
450
447
|
const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
|
|
451
448
|
const unmatched = !app ? undefined
|
package/dist/fence.js
CHANGED
|
@@ -628,12 +628,14 @@ function reader(cmd, args, where) {
|
|
|
628
628
|
return deny('reads a .env file, or a pattern that can match one');
|
|
629
629
|
return allow();
|
|
630
630
|
}
|
|
631
|
-
/** A host's CLI package, by host: the one global install
|
|
631
|
+
/** A host's CLI package, by host: the one global install the agent runs, and only once the person said Install on that CLI's screen (#396). */
|
|
632
632
|
const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
|
|
633
|
-
/** The version of each the run installs, raised with a release of this command: never the registry's latest of the day. */
|
|
634
|
-
export const HOST_CLI_VERSIONS = { 'netlify-cli': '27.11.2', vercel: '62.7.0' };
|
|
635
|
-
/**
|
|
636
|
-
export const
|
|
633
|
+
/** The version of each the run installs, raised with a release of this command: never the registry's latest of the day. cf, Cloudflare's, the run installs itself, since only the tools run it. */
|
|
634
|
+
export const HOST_CLI_VERSIONS = { 'netlify-cli': '27.11.2', vercel: '62.7.0', cf: '1.0.0-beta.13' };
|
|
635
|
+
/** npm's arguments for the one form of a host CLI's install that runs: global, of the pinned version; cf's with no install script, as npx ran it before (it needs none). */
|
|
636
|
+
export const hostCliInstallArgs = (pkg) => ['install', '-g', ...(pkg === 'cf' ? ['--ignore-scripts'] : []), `${pkg}@${HOST_CLI_VERSIONS[pkg]}`];
|
|
637
|
+
/** That install as the person types it, in another terminal or the agent's shell. */
|
|
638
|
+
export const hostCliInstallCommand = (pkg) => `npm ${hostCliInstallArgs(pkg).join(' ')}`;
|
|
637
639
|
/** git commit and push, gh and the host CLIs: what uses the person's GitHub, Vercel, Netlify or Cloudflare sign-in, keys or keychain. */
|
|
638
640
|
function asPerson(words) {
|
|
639
641
|
const [cmd = '', ...args] = words;
|
|
@@ -944,8 +946,8 @@ function simpleCommand(seg, where, opts) {
|
|
|
944
946
|
if (!PACKAGE_MANAGERS.has(cmd))
|
|
945
947
|
return deny(`${cmd} is not on the allowlist`);
|
|
946
948
|
const add = hostCliAdd(cmd, args);
|
|
947
|
-
if (add && opts.
|
|
948
|
-
return pinned(cmd, add) ? allow('installs the CLI
|
|
949
|
+
if (add && opts.installs.has(add.host))
|
|
950
|
+
return pinned(cmd, add) ? allow('installs the CLI the person said Install to') : deny(`the run installs ${add.pkg} with npm, pinned to the version it knows: ${hostCliInstallCommand(add.pkg)}`);
|
|
949
951
|
return packageManager(cmd, args, where, opts.scriptsChanged);
|
|
950
952
|
}
|
|
951
953
|
}
|
|
@@ -1200,7 +1202,7 @@ function unsyncedWorkflows(where, named, carried, synced) {
|
|
|
1200
1202
|
}
|
|
1201
1203
|
/** The fence's answer for one shell command run from the checkout's top folder. */
|
|
1202
1204
|
export function classifyCommand(command, root, options = {}) {
|
|
1203
|
-
const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
|
|
1205
|
+
const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), installs: options.installs ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
|
|
1204
1206
|
const segments = parseCommand(command);
|
|
1205
1207
|
if (!segments) {
|
|
1206
1208
|
// A here-document is how a file or a message gets written in one command: the write tool, -m and --body do that.
|
|
@@ -1618,13 +1620,14 @@ const previewRetries = new WeakMap();
|
|
|
1618
1620
|
export function previewRetryState(log) {
|
|
1619
1621
|
let state = previewRetries.get(log);
|
|
1620
1622
|
if (!state) {
|
|
1621
|
-
state = { used: 0, pushReservations: 0, creationReserved: false,
|
|
1623
|
+
state = { used: 0, pushReservations: 0, creationReserved: false, drives: new Map(), drivesAllowed: 1, requireHistory: false, historyKnown: false, observed: new Map() };
|
|
1622
1624
|
previewRetries.set(log, state);
|
|
1623
1625
|
}
|
|
1624
1626
|
return state;
|
|
1625
1627
|
}
|
|
1626
1628
|
const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
|
|
1627
|
-
|
|
1629
|
+
/** The test drive's branch: impeccable/test-drive, an app's impeccable/test-drive-<app key> on a repository with several, each with -2, -3 where an earlier run took it. */
|
|
1630
|
+
const driveBranch = (branch) => /^impeccable\/test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch) && !branch.includes('..') && !branch.endsWith('.') && !branch.endsWith('.lock');
|
|
1628
1631
|
/** A GitHub repository as `<owner>/<name>`. */
|
|
1629
1632
|
const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/;
|
|
1630
1633
|
/** `gh pr ready <the test drive's branch> [--repo <owner>/<name>]`, and nothing else (never `--undo`, a number or another branch): the branch and the repository named, or null. */
|
|
@@ -1707,7 +1710,13 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1707
1710
|
return deny('unsupported command syntax');
|
|
1708
1711
|
const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
|
|
1709
1712
|
let used = state.used, pushes = state.pushReservations, create = state.createBranch, reserved = state.creationReserved;
|
|
1710
|
-
|
|
1713
|
+
const drives = new Map([...state.drives].map(([branch, d]) => [branch, { ...d }]));
|
|
1714
|
+
// A branch the run has not used for a test drive yet, while it may open another.
|
|
1715
|
+
const driveOf = (branch) => {
|
|
1716
|
+
if (!drives.has(branch) && drives.size < state.drivesAllowed)
|
|
1717
|
+
drives.set(branch, { pushes: 0, created: false });
|
|
1718
|
+
return drives.get(branch);
|
|
1719
|
+
};
|
|
1711
1720
|
for (const { words } of segments) {
|
|
1712
1721
|
if (words[0] === 'cd') {
|
|
1713
1722
|
where.cwd = resolve(where.cwd, words[1]);
|
|
@@ -1716,14 +1725,14 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1716
1725
|
const gitWords = words[0] === 'git' ? gitArgs(words.slice(1)) : [];
|
|
1717
1726
|
if (!gitWords)
|
|
1718
1727
|
return deny('a git option before the command');
|
|
1719
|
-
// The test drive is the run's other pull request: one branch, one pull request, a few pushes, none of the setup pull request's attempts.
|
|
1728
|
+
// The test drive is the run's other pull request (one an app on a repository with several): one branch, one pull request, a few pushes, none of the setup pull request's attempts.
|
|
1720
1729
|
const drivePush = pushBranch(gitWords, driveBranch);
|
|
1721
1730
|
if (drivePush) {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1731
|
+
const drive = driveOf(drivePush);
|
|
1732
|
+
if (!drive)
|
|
1733
|
+
return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each on one branch` : 'keep the test drive on one branch');
|
|
1734
|
+
if (++drive.pushes > 3)
|
|
1725
1735
|
return deny('the test drive has been pushed three times already');
|
|
1726
|
-
drive = drivePush;
|
|
1727
1736
|
continue;
|
|
1728
1737
|
}
|
|
1729
1738
|
if (gitWords[0] === 'push') {
|
|
@@ -1752,12 +1761,12 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1752
1761
|
|| (!parsed.repository && realPath(where.cwd) !== where.root))
|
|
1753
1762
|
return deny('name this repository explicitly when creating a pull request');
|
|
1754
1763
|
if (parsed.head && driveBranch(parsed.head)) {
|
|
1755
|
-
|
|
1764
|
+
const drive = driveOf(parsed.head);
|
|
1765
|
+
if (!drive)
|
|
1766
|
+
return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each opened from the branch that was pushed` : 'open the test drive from the branch that was pushed');
|
|
1767
|
+
if (drive.created)
|
|
1756
1768
|
return deny('the test drive is already open');
|
|
1757
|
-
|
|
1758
|
-
return deny('open the test drive from the branch that was pushed');
|
|
1759
|
-
drive = parsed.head;
|
|
1760
|
-
driveCreated = true;
|
|
1769
|
+
drive.created = true;
|
|
1761
1770
|
continue;
|
|
1762
1771
|
}
|
|
1763
1772
|
if (state.recordedPr || state.pr || reserved)
|
|
@@ -1775,7 +1784,7 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1775
1784
|
if (!state.repository || (parsed.repository !== undefined && parsed.repository.toLowerCase() !== state.repository.toLowerCase())
|
|
1776
1785
|
|| (!parsed.repository && realPath(where.cwd) !== where.root))
|
|
1777
1786
|
return deny('name this repository explicitly when marking the test drive ready');
|
|
1778
|
-
if (!
|
|
1787
|
+
if (!drives.get(parsed.branch)?.created)
|
|
1779
1788
|
return deny('mark ready the test drive this run opened');
|
|
1780
1789
|
}
|
|
1781
1790
|
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
|
|
@@ -1794,9 +1803,7 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1794
1803
|
state.pushReservations = pushes;
|
|
1795
1804
|
state.createBranch = create;
|
|
1796
1805
|
state.creationReserved = reserved;
|
|
1797
|
-
state.
|
|
1798
|
-
state.drivePushes = drivePushes;
|
|
1799
|
-
state.driveCreated = driveCreated;
|
|
1806
|
+
state.drives = drives;
|
|
1800
1807
|
return allow();
|
|
1801
1808
|
}
|
|
1802
1809
|
function canonicalPreviewPr(state, pr) {
|
|
@@ -1859,6 +1866,19 @@ export function agreeToHostSetup(log, questionId) {
|
|
|
1859
1866
|
if (host && Object.hasOwn(HOST_CLIS, host))
|
|
1860
1867
|
hostsAgreed(log).add(host);
|
|
1861
1868
|
}
|
|
1869
|
+
/** The hosts whose missing CLI the person said Install to in this run, by the run's log: the agent's pinned install of it runs. */
|
|
1870
|
+
const agreedInstalls = new WeakMap();
|
|
1871
|
+
function installsAgreed(log) {
|
|
1872
|
+
let hosts = agreedInstalls.get(log);
|
|
1873
|
+
if (!hosts)
|
|
1874
|
+
agreedInstalls.set(log, (hosts = new Set()));
|
|
1875
|
+
return hosts;
|
|
1876
|
+
}
|
|
1877
|
+
/** Install on a host's CLI screen (#396), for a host whose CLI the agent installs: its pinned install runs for the rest of the setup. */
|
|
1878
|
+
export function agreeToCliInstall(log, host) {
|
|
1879
|
+
if (Object.hasOwn(HOST_CLIS, host))
|
|
1880
|
+
installsAgreed(log).add(host);
|
|
1881
|
+
}
|
|
1862
1882
|
/** Whether the person opened this host's setup in this run: the tools that change something on the host ask this, as the fence does for the CLI. */
|
|
1863
1883
|
export function hostSetupAgreed(log, host) {
|
|
1864
1884
|
return hostsAgreed(log).has(host);
|
|
@@ -1871,6 +1891,7 @@ const linkedVercel = new WeakSet();
|
|
|
1871
1891
|
* Vercel project linked in this run keeps its hold until it is settled.
|
|
1872
1892
|
*/
|
|
1873
1893
|
export function endHostSetup(log, host) {
|
|
1894
|
+
installsAgreed(log).delete(host);
|
|
1874
1895
|
if (host !== 'vercel' || !linkedVercel.has(log))
|
|
1875
1896
|
hostsAgreed(log).delete(host);
|
|
1876
1897
|
}
|
|
@@ -2104,7 +2125,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
|
|
|
2104
2125
|
let shown;
|
|
2105
2126
|
if (event.toolName === 'bash') {
|
|
2106
2127
|
shown = String(input.command ?? '');
|
|
2107
|
-
decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), touched, synced, repository: retry.repository });
|
|
2128
|
+
decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), installs: installsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), touched, synced, repository: retry.repository });
|
|
2108
2129
|
if (decision.verdict === 'allow')
|
|
2109
2130
|
decision = reservePreviewCommand(retry, shown, root, scratch);
|
|
2110
2131
|
if (decision.verdict === 'allow') {
|
package/dist/host-questions.js
CHANGED
|
@@ -45,6 +45,33 @@ export const HOST_SIGNED_IN = {
|
|
|
45
45
|
confirmLabel: 'Set up',
|
|
46
46
|
},
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* A host's CLI that is not on this machine (#396), asked before anything installs it: the package, the version and the
|
|
50
|
+
* global install with npm. There is no Skip, since the host's setup cannot go on without its CLI: Esc is the person's own install.
|
|
51
|
+
*/
|
|
52
|
+
export function cliInstallScreen(id, cli) {
|
|
53
|
+
return {
|
|
54
|
+
id: `${id}-cli-install`,
|
|
55
|
+
kind: 'confirm',
|
|
56
|
+
title: `The ${cli.name} CLI isn't installed`,
|
|
57
|
+
hint: `Setting ${cli.name} up runs its CLI, which isn't on this machine.\n\nThe run installs it for you globally, with npm: the package ${cli.pkg}, version ${cli.version}.`,
|
|
58
|
+
prompt: `Install ${cli.pkg} ${cli.version}?`,
|
|
59
|
+
confirmLabel: 'Install',
|
|
60
|
+
skipLabel: "I'll install it myself",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** The person's own install of a host's CLI: the command to run in another terminal, and the check for the CLI on their yes; `note` says what came before, when the CLI is still missing. */
|
|
64
|
+
export function cliManualScreen(id, cli, note) {
|
|
65
|
+
return {
|
|
66
|
+
id: `${id}-cli-manual`,
|
|
67
|
+
kind: 'confirm',
|
|
68
|
+
title: `The ${cli.name} CLI isn't installed`,
|
|
69
|
+
hint: `${note ? `${note}\n\n` : ''}Run this in another terminal:\n\n${cli.command}\n\nThen come back here: the run checks for the ${cli.name} CLI and carries on.`,
|
|
70
|
+
prompt: 'Installed it?',
|
|
71
|
+
confirmLabel: note ? 'Check again' : 'Done',
|
|
72
|
+
skipLabel: 'Install it for me',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
48
75
|
/** Where a person reads or changes a site's password in Netlify, as Netlify's docs name the page on every plan. */
|
|
49
76
|
export const NETLIFY_PASSWORD_WHERE = 'Project configuration → General → Visitor access';
|
|
50
77
|
/** The wait for a host's build that ran out: the page where the host's builds of pull requests are turned on, and the wait again. */
|
|
@@ -155,7 +182,7 @@ export const HOST_SCREENS = {
|
|
|
155
182
|
prompt: 'Vercel keeps your previews behind a login, and the run did not get a bypass secret in place for them. Make one yourself: your project → Settings → Deployment Protection → Protection Bypass for Automation → Create, named "Impeccable reviews". It is stored encrypted and used only to open previews.',
|
|
156
183
|
hint: 'Paste the secret',
|
|
157
184
|
},
|
|
158
|
-
// Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person
|
|
185
|
+
// Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person (netlifyPasswordReplace where it cannot read it).
|
|
159
186
|
'netlify-password': {
|
|
160
187
|
id: 'netlify-password',
|
|
161
188
|
kind: 'confirm',
|
|
@@ -165,14 +192,6 @@ export const HOST_SCREENS = {
|
|
|
165
192
|
confirmLabel: 'Store',
|
|
166
193
|
skipLabel: 'Skip',
|
|
167
194
|
},
|
|
168
|
-
'netlify-password-key': {
|
|
169
|
-
id: 'netlify-password-key',
|
|
170
|
-
kind: 'text',
|
|
171
|
-
sensitive: true,
|
|
172
|
-
title: 'Your Netlify previews are protected',
|
|
173
|
-
prompt: `Netlify keeps your previews behind the site password, and Netlify does not give it back to the run. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
|
|
174
|
-
hint: 'Paste the password',
|
|
175
|
-
},
|
|
176
195
|
// Netlify's team login in front of the previews: the one consent to making the site public as the person, and the retry once they changed it on Netlify's page where Netlify refused.
|
|
177
196
|
'netlify-visibility': {
|
|
178
197
|
id: 'netlify-visibility',
|
|
@@ -228,17 +247,21 @@ export function sentTo(screen, url) {
|
|
|
228
247
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
229
248
|
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
230
249
|
}
|
|
231
|
-
/**
|
|
232
|
-
|
|
233
|
-
|
|
250
|
+
/**
|
|
251
|
+
* The consent to a new site password where Netlify does not give the current one back (#395): the run sets it as the
|
|
252
|
+
* person with the Netlify CLI's sign-in, and it replaces the current one, which the screen says before the yes. It
|
|
253
|
+
* names the site, after its app on a repository with several (#390), and the live site where the password guards it too.
|
|
254
|
+
*/
|
|
255
|
+
export function netlifyPasswordReplace(site, opts = {}) {
|
|
256
|
+
const whose = opts.app ? `${opts.app}'s previews, built by the Netlify site ${site},` : `the previews of the Netlify site ${site}`;
|
|
234
257
|
return {
|
|
235
|
-
id: 'netlify-password-
|
|
258
|
+
id: 'netlify-password-set',
|
|
236
259
|
kind: 'confirm',
|
|
237
260
|
title: 'Your Netlify previews are protected',
|
|
238
|
-
hint:
|
|
239
|
-
prompt: `
|
|
240
|
-
confirmLabel: '
|
|
241
|
-
skipLabel: '
|
|
261
|
+
hint: `Impeccable reviews each pull request on its preview, and Netlify keeps ${whose} behind the site password, which Netlify doesn't give back to tools.\n\nWith the Netlify CLI's sign-in on this machine, the run sets a new site password as you: a long random one, stored encrypted and used only to open previews. It replaces the current password${opts.live ? ', which the live site asks for too' : ''}, so anyone who uses that one will need a new one. You can set your own any time in ${NETLIFY_PASSWORD_WHERE}; put it in the app's Settings too, or reviews stop.`,
|
|
262
|
+
prompt: `Set a new password on ${site}?`,
|
|
263
|
+
confirmLabel: 'Set password',
|
|
264
|
+
skipLabel: 'Skip',
|
|
242
265
|
};
|
|
243
266
|
}
|
|
244
267
|
/** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
|
|
@@ -283,7 +306,7 @@ export function vercelBypassPaste(stopped) {
|
|
|
283
306
|
: `Make one yourself: ${where} → Create, named "Impeccable reviews".`;
|
|
284
307
|
return { ...HOST_SCREENS['vercel-bypass-key'], prompt: `Vercel keeps your previews behind a login. ${stopped.reason} ${next} It is stored encrypted and used only to open previews.` };
|
|
285
308
|
}
|
|
286
|
-
/** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. */
|
|
309
|
+
/** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. It has no Skip: at least one app is picked (#405). */
|
|
287
310
|
export const APPS_QUESTION = {
|
|
288
311
|
id: 'apps',
|
|
289
312
|
kind: 'multi',
|
|
@@ -293,7 +316,7 @@ export const APPS_QUESTION = {
|
|
|
293
316
|
};
|
|
294
317
|
/** The apps question's hint when the command was started below the repository's top: `where` is an app's folder ("Tally Board's folder") or the folder itself. */
|
|
295
318
|
export const startedIn = (where) => `You started in ${where}. Impeccable sets up from the top of the repository.`;
|
|
296
|
-
/** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind
|
|
319
|
+
/** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind Pick other apps for someone who wants another app (#286); there is no Skip (#405). */
|
|
297
320
|
export const namedAppQuestion = (app) => ({
|
|
298
321
|
id: APPS_QUESTION.id,
|
|
299
322
|
kind: 'confirm',
|
package/dist/main.js
CHANGED
|
@@ -16,6 +16,7 @@ import { scanRecorder } from './scan.js';
|
|
|
16
16
|
import { createReporter, reportingOff } from './report.js';
|
|
17
17
|
import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
|
|
18
18
|
import { checkPushAccess, readPushAccess } from './push-access.js';
|
|
19
|
+
import { askPrSummary, PR_SUMMARY_OFF } from './pr-summary.js';
|
|
19
20
|
import { appsSummary, createTools, ending, ownPullRequest, resultLine } from './tools.js';
|
|
20
21
|
import { transcriptPaths } from './transcript.js';
|
|
21
22
|
import { askPerson } from './ui/bridge.js';
|
|
@@ -79,7 +80,8 @@ export async function main(argv) {
|
|
|
79
80
|
/** Takes the checkout back to the branch the run started on, once the run has begun; the line the person reads when it cannot. */
|
|
80
81
|
let backToStart;
|
|
81
82
|
/** The keys the run created in the person's host accounts, each as the line the end screen shows, or an error's. */
|
|
82
|
-
|
|
83
|
+
/** The end screen's line for each key the run created, by what it is about: a later line about the same thing takes the earlier one's place. */
|
|
84
|
+
const keysCreated = new Map();
|
|
83
85
|
// Nothing is reported until the flags say where to and whether to.
|
|
84
86
|
let reporter = createReporter({ host: '', off: true });
|
|
85
87
|
// The tab the person approved in goes on to the web page once the run is minted, so the page has a run to show; a stop before then is what it says instead.
|
|
@@ -226,18 +228,27 @@ export async function main(argv) {
|
|
|
226
228
|
throw new UserError(`--app ${flags.app} is not an app of ${slug}. Its apps are ${appKeys.join(', ')}. Run it again with one of them, or without --app.`);
|
|
227
229
|
}
|
|
228
230
|
// The setup pushes as the person, so whether git may push is asked of GitHub now, before anything is created.
|
|
229
|
-
|
|
231
|
+
// A question asked before the agent starts. Ctrl+C there answers it as closed while stopEarly winds the run down and
|
|
232
|
+
// exits, so nothing after it runs: no update over the stop, and no agent started.
|
|
233
|
+
const askUpFront = async (screen) => {
|
|
230
234
|
liveSync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
|
|
231
235
|
const answer = await askPerson({ ...screen, skippable: true });
|
|
236
|
+
if (stopping)
|
|
237
|
+
return new Promise(() => { });
|
|
232
238
|
liveSync.update({ phase: 'running', question: null });
|
|
233
239
|
return answer;
|
|
234
|
-
}
|
|
240
|
+
};
|
|
241
|
+
const push = await checkPushAccess(slug, () => readPushAccess(checkout.root), askUpFront);
|
|
235
242
|
log.write('push access', push.outcome);
|
|
236
243
|
if (push.outcome === 'refused' || push.outcome === 'signed-out') {
|
|
237
244
|
await nothingToSetUp(liveSync, reporter, 'stopped', "Git can't push", `Git can't push to ${slug}${push.account ? ` as ${push.account}` : ''}, so nothing was set up. Sign git in as an account that can, then run this again.`, [webPage]);
|
|
238
245
|
}
|
|
239
246
|
if (!interactive)
|
|
240
247
|
say('Signed in.\n');
|
|
248
|
+
// The summary with before and after in each pull request's description, asked with the other consents up front, so the test drive's has it too (#402).
|
|
249
|
+
const reviewSettings = `${setupApi}/repos/${encodeURIComponent(token.repository.name)}/review-settings`;
|
|
250
|
+
const prSummary = await askPrSummary(() => requestJson(reviewSettings, { token: token.access_token }, log), askUpFront, (body) => requestJson(reviewSettings, { method: 'PUT', token: token.access_token, body }, log));
|
|
251
|
+
log.write('pr summary', prSummary);
|
|
241
252
|
// Beside the transcript, named after the run as it is, so a run never writes over another's in the temp folder.
|
|
242
253
|
const scans = scanRecorder(log, flags.scanReport ? join(dirname(transcriptPaths(run.run_id, flags.transcript).markdown), `scan-report-${run.run_id.replace(/[^\w.-]/g, '_')}.json`) : undefined);
|
|
243
254
|
atExit(() => scans.report());
|
|
@@ -261,37 +272,45 @@ export async function main(argv) {
|
|
|
261
272
|
const branch = backToStart();
|
|
262
273
|
if (branch)
|
|
263
274
|
log.write('checkout left on the run\'s branch', branch);
|
|
264
|
-
//
|
|
275
|
+
// A test drive pushed with no pull request opened from it stays on GitHub, where the host built it (#324): one, or an app's of several.
|
|
265
276
|
const retry = previewRetryState(log);
|
|
266
|
-
const pushed = retry.
|
|
267
|
-
const left = [...keysCreated, ...(branch ? [branch] : [])];
|
|
268
|
-
// Said only where gh confirms no pull request is open from it, a draft counted:
|
|
269
|
-
const unopened = async (recorded) => (pushed
|
|
277
|
+
const pushed = [...retry.drives.keys()].filter((drive) => onOrigin(checkout.root, drive));
|
|
278
|
+
const left = [...keysCreated.values(), ...(branch ? [branch] : [])];
|
|
279
|
+
// Said only where gh confirms no pull request is open from it, a draft counted: with every pushed one recorded, or gh unable to say, nothing is.
|
|
280
|
+
const unopened = async (recorded) => (pushed.length > recorded
|
|
281
|
+
? (await Promise.all(pushed.map(async (drive) => ((await openFrom(slug, drive)) === false ? [`${drive} is on GitHub with no pull request: delete the branch there, or open a pull request from it.`] : [])))).flat()
|
|
282
|
+
: []);
|
|
270
283
|
if (report) {
|
|
271
284
|
const saved = await settle(liveSync.flush(), 5000);
|
|
272
285
|
const { testPr } = report;
|
|
286
|
+
// A run that set up several apps: each app's own test drive, named, however many opened (impeccable-reviews#397).
|
|
287
|
+
const drives = report.testPrs?.length ? report.testPrs : undefined;
|
|
273
288
|
const setupPr = shownSetupPr(report.setupPr);
|
|
274
289
|
// On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286). A run short of that says what is not, and exits 1 (#321).
|
|
275
290
|
const apps = appsSummary(report.apps ?? []);
|
|
276
291
|
const end = ending(report);
|
|
277
|
-
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
|
|
292
|
+
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive (each app's, on a repository with several), then the setup pull request.
|
|
278
293
|
const hand = { open: openBrowser, copy: copyText };
|
|
279
|
-
if (
|
|
294
|
+
if (drives)
|
|
295
|
+
for (const drive of drives)
|
|
296
|
+
await showPullRequest(drive.url, hand, drive.name);
|
|
297
|
+
else if (testPr && setupPr)
|
|
280
298
|
await showPullRequest(testPr.url, hand);
|
|
281
299
|
await showOutro({
|
|
282
300
|
outcome: end.outcome,
|
|
283
301
|
title: end.title,
|
|
284
302
|
result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
285
|
-
|
|
303
|
+
// Each app's test drive had its own screen: the last one holds the setup pull request, or lists them all.
|
|
304
|
+
link: (setupPr ?? (drives ? undefined : testPr))?.url,
|
|
286
305
|
links: [
|
|
287
|
-
...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
306
|
+
...(drives ? drives.map((drive) => ({ label: `${drive.name}'s test drive, watch the review there`, url: drive.url })) : testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
288
307
|
// Without a current test drive the setup pull request is not ready to merge; the report says why.
|
|
289
308
|
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
290
309
|
{ label: 'Web page', url: page },
|
|
291
310
|
],
|
|
292
311
|
todo: end.todo,
|
|
293
312
|
...(report.hosts?.length ? { used: report.hosts } : {}),
|
|
294
|
-
warnings: [...removed, ...left, ...(await unopened(
|
|
313
|
+
warnings: [...removed, ...left, ...(await unopened(drives ? drives.length : testPr ? 1 : 0)), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : []), ...(prSummary === 'off' ? [PR_SUMMARY_OFF] : [])],
|
|
295
314
|
}, hand);
|
|
296
315
|
// The web page has the report; when it missed it, or the run fell short of all set up, whose why it holds, the report stays here as well.
|
|
297
316
|
if (!saved?.ok || end.code)
|
|
@@ -310,13 +329,15 @@ export async function main(argv) {
|
|
|
310
329
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
|
311
330
|
const missed = saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`];
|
|
312
331
|
const drive = liveSync.payload.test_pr, setup = shownSetupPr(liveSync.payload.setup_pr);
|
|
332
|
+
const drives = Object.entries(liveSync.payload.test_prs ?? {});
|
|
333
|
+
const driveLinks = drives.length ? drives.map(([key, pr]) => ({ label: `Test drive of ${key}`, url: pr.url })) : drive ? [{ label: 'Test drive', url: drive.url }] : [];
|
|
313
334
|
await showOutro({
|
|
314
335
|
outcome: 'stopped',
|
|
315
336
|
title: cancelled && !fatal ? 'Setup cancelled' : 'Setup failed',
|
|
316
337
|
result: why,
|
|
317
|
-
links: [...
|
|
338
|
+
links: [...driveLinks, ...(setup ? [{ label: 'Setup pull request', url: setup.url }] : []), { label: 'Web page', url: page }],
|
|
318
339
|
todo: ['Run impeccable-reviews again to carry on. What was saved stays saved; the web page has the details.'],
|
|
319
|
-
warnings: [...removed, ...left, ...(await unopened(
|
|
340
|
+
warnings: [...removed, ...left, ...(await unopened(drives.length || (drive ? 1 : 0))), ...missed],
|
|
320
341
|
});
|
|
321
342
|
await reporter.done();
|
|
322
343
|
process.exit(1);
|
|
@@ -341,7 +362,7 @@ export async function main(argv) {
|
|
|
341
362
|
sync: liveSync,
|
|
342
363
|
viewPullRequest: (n) => viewPullRequest(`${token.repository.owner}/${token.repository.name}`, n),
|
|
343
364
|
onReport: (r) => void (report = r),
|
|
344
|
-
onKeyCreated: (line) => void keysCreated.
|
|
365
|
+
onKeyCreated: (line, about) => void keysCreated.set(about ?? line, line),
|
|
345
366
|
log,
|
|
346
367
|
previewWait: previewWait(),
|
|
347
368
|
sandbox,
|
|
@@ -385,7 +406,7 @@ export async function main(argv) {
|
|
|
385
406
|
console.error(message);
|
|
386
407
|
// A run that stopped on an error goes back to its starting branch too, or says where the checkout is.
|
|
387
408
|
const branch = backToStart?.();
|
|
388
|
-
for (const line of [...keysCreated, ...(branch ? [branch] : [])])
|
|
409
|
+
for (const line of [...keysCreated.values(), ...(branch ? [branch] : [])])
|
|
389
410
|
console.error(line);
|
|
390
411
|
await Promise.all([letTabsHear(), reporter.done()]);
|
|
391
412
|
process.exit(1);
|