@impeccable/reviews 0.1.20 → 0.1.22
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 +58 -26
- package/dist/host-questions.js +41 -17
- package/dist/main.js +26 -15
- package/dist/netlify-password.js +114 -37
- package/dist/playbook/cases/monorepo.md +3 -3
- 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 +1 -1
- package/dist/preflight.js +1 -1
- package/dist/sandbox.js +4 -4
- package/dist/tools.js +294 -91
- package/dist/ui/bridge.js +5 -3
- package/dist/ui/look.js +36 -10
- package/dist/ui/run-state.js +7 -1
- 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
|
@@ -365,6 +365,15 @@ function listsOrigin(args) {
|
|
|
365
365
|
const words = args.filter((a) => !a.startsWith('-'));
|
|
366
366
|
return options.every((a) => LS_REMOTE_FLAGS.test(a) || LS_REMOTE_LONG.has(a) || a.startsWith('--sort=')) && words[0] === 'origin';
|
|
367
367
|
}
|
|
368
|
+
/** git ls-files's options that pick which tracked or untracked files it names, and how it prints them; never --ignored, --stage, --format or one that reads a file (--exclude-from). */
|
|
369
|
+
const LS_FILES_OPTIONS = new Set(['-c', '--cached', '-o', '--others', '-m', '--modified', '-d', '--deleted', '--exclude-standard', '-z', '--full-name', '--directory', '--no-empty-directory']);
|
|
370
|
+
/** Whether `git ls-files` with these words only names the checkout's files: the options above, and paths inside the checkout (#390). */
|
|
371
|
+
function listsFiles(args, where) {
|
|
372
|
+
const end = args.indexOf('--');
|
|
373
|
+
const options = (end < 0 ? args : args.slice(0, end)).filter((a) => a.startsWith('-'));
|
|
374
|
+
const paths = end < 0 ? args.filter((a) => !a.startsWith('-')) : [...args.slice(0, end).filter((a) => !a.startsWith('-')), ...args.slice(end + 1)];
|
|
375
|
+
return options.every((a) => LS_FILES_OPTIONS.has(a)) && !paths.some((a) => outside(a, where));
|
|
376
|
+
}
|
|
368
377
|
/** The branch a push's refspec updates on the remote: its destination, else its source, without `+` and `refs/heads/` (or `heads/`, which git reads the same). */
|
|
369
378
|
const pushedBranch = (refspec) => refspec.replace(/^\+/, '').split(':').pop().replace(/^(?:refs\/)?heads\//, '');
|
|
370
379
|
const isFile = (path) => {
|
|
@@ -483,6 +492,8 @@ function git(args, where, protectedBranches, held = false) {
|
|
|
483
492
|
return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b or git switch -c makes a branch');
|
|
484
493
|
if (sub === 'remote')
|
|
485
494
|
return listsRemotes(rest) ? allow() : deny('git remote lists only: on its own, -v, or get-url <name>; nothing that adds, removes, renames, points or prunes a remote, and no show, which asks the remote');
|
|
495
|
+
if (sub === 'ls-files')
|
|
496
|
+
return listsFiles(rest, where) ? allow() : deny('git ls-files names files only: -c, -o, -m, -d, --exclude-standard, -z, --full-name, --directory and paths inside the checkout');
|
|
486
497
|
if (sub === 'ls-remote')
|
|
487
498
|
return listsOrigin(rest) ? allow() : deny('git ls-remote asks origin only, named: git ls-remote --heads origin, with --tags, --refs, --symref, --get-url and patterns after origin; no other remote, no URL, no --upload-pack, and not without origin, which asks the current branch\'s own remote');
|
|
488
499
|
if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
|
|
@@ -617,12 +628,14 @@ function reader(cmd, args, where) {
|
|
|
617
628
|
return deny('reads a .env file, or a pattern that can match one');
|
|
618
629
|
return allow();
|
|
619
630
|
}
|
|
620
|
-
/** 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). */
|
|
621
632
|
const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
|
|
622
|
-
/** The version of each the run installs, raised with a release of this command: never the registry's latest of the day. */
|
|
623
|
-
export const HOST_CLI_VERSIONS = { 'netlify-cli': '27.11.2', vercel: '62.7.0' };
|
|
624
|
-
/**
|
|
625
|
-
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(' ')}`;
|
|
626
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. */
|
|
627
640
|
function asPerson(words) {
|
|
628
641
|
const [cmd = '', ...args] = words;
|
|
@@ -933,8 +946,8 @@ function simpleCommand(seg, where, opts) {
|
|
|
933
946
|
if (!PACKAGE_MANAGERS.has(cmd))
|
|
934
947
|
return deny(`${cmd} is not on the allowlist`);
|
|
935
948
|
const add = hostCliAdd(cmd, args);
|
|
936
|
-
if (add && opts.
|
|
937
|
-
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)}`);
|
|
938
951
|
return packageManager(cmd, args, where, opts.scriptsChanged);
|
|
939
952
|
}
|
|
940
953
|
}
|
|
@@ -1189,7 +1202,7 @@ function unsyncedWorkflows(where, named, carried, synced) {
|
|
|
1189
1202
|
}
|
|
1190
1203
|
/** The fence's answer for one shell command run from the checkout's top folder. */
|
|
1191
1204
|
export function classifyCommand(command, root, options = {}) {
|
|
1192
|
-
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 };
|
|
1193
1206
|
const segments = parseCommand(command);
|
|
1194
1207
|
if (!segments) {
|
|
1195
1208
|
// A here-document is how a file or a message gets written in one command: the write tool, -m and --body do that.
|
|
@@ -1607,13 +1620,14 @@ const previewRetries = new WeakMap();
|
|
|
1607
1620
|
export function previewRetryState(log) {
|
|
1608
1621
|
let state = previewRetries.get(log);
|
|
1609
1622
|
if (!state) {
|
|
1610
|
-
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() };
|
|
1611
1624
|
previewRetries.set(log, state);
|
|
1612
1625
|
}
|
|
1613
1626
|
return state;
|
|
1614
1627
|
}
|
|
1615
1628
|
const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
|
|
1616
|
-
|
|
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');
|
|
1617
1631
|
/** A GitHub repository as `<owner>/<name>`. */
|
|
1618
1632
|
const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/;
|
|
1619
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. */
|
|
@@ -1696,7 +1710,13 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1696
1710
|
return deny('unsupported command syntax');
|
|
1697
1711
|
const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
|
|
1698
1712
|
let used = state.used, pushes = state.pushReservations, create = state.createBranch, reserved = state.creationReserved;
|
|
1699
|
-
|
|
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
|
+
};
|
|
1700
1720
|
for (const { words } of segments) {
|
|
1701
1721
|
if (words[0] === 'cd') {
|
|
1702
1722
|
where.cwd = resolve(where.cwd, words[1]);
|
|
@@ -1705,14 +1725,14 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1705
1725
|
const gitWords = words[0] === 'git' ? gitArgs(words.slice(1)) : [];
|
|
1706
1726
|
if (!gitWords)
|
|
1707
1727
|
return deny('a git option before the command');
|
|
1708
|
-
// 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.
|
|
1709
1729
|
const drivePush = pushBranch(gitWords, driveBranch);
|
|
1710
1730
|
if (drivePush) {
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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)
|
|
1714
1735
|
return deny('the test drive has been pushed three times already');
|
|
1715
|
-
drive = drivePush;
|
|
1716
1736
|
continue;
|
|
1717
1737
|
}
|
|
1718
1738
|
if (gitWords[0] === 'push') {
|
|
@@ -1741,12 +1761,12 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1741
1761
|
|| (!parsed.repository && realPath(where.cwd) !== where.root))
|
|
1742
1762
|
return deny('name this repository explicitly when creating a pull request');
|
|
1743
1763
|
if (parsed.head && driveBranch(parsed.head)) {
|
|
1744
|
-
|
|
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)
|
|
1745
1768
|
return deny('the test drive is already open');
|
|
1746
|
-
|
|
1747
|
-
return deny('open the test drive from the branch that was pushed');
|
|
1748
|
-
drive = parsed.head;
|
|
1749
|
-
driveCreated = true;
|
|
1769
|
+
drive.created = true;
|
|
1750
1770
|
continue;
|
|
1751
1771
|
}
|
|
1752
1772
|
if (state.recordedPr || state.pr || reserved)
|
|
@@ -1764,7 +1784,7 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1764
1784
|
if (!state.repository || (parsed.repository !== undefined && parsed.repository.toLowerCase() !== state.repository.toLowerCase())
|
|
1765
1785
|
|| (!parsed.repository && realPath(where.cwd) !== where.root))
|
|
1766
1786
|
return deny('name this repository explicitly when marking the test drive ready');
|
|
1767
|
-
if (!
|
|
1787
|
+
if (!drives.get(parsed.branch)?.created)
|
|
1768
1788
|
return deny('mark ready the test drive this run opened');
|
|
1769
1789
|
}
|
|
1770
1790
|
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
|
|
@@ -1783,9 +1803,7 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1783
1803
|
state.pushReservations = pushes;
|
|
1784
1804
|
state.createBranch = create;
|
|
1785
1805
|
state.creationReserved = reserved;
|
|
1786
|
-
state.
|
|
1787
|
-
state.drivePushes = drivePushes;
|
|
1788
|
-
state.driveCreated = driveCreated;
|
|
1806
|
+
state.drives = drives;
|
|
1789
1807
|
return allow();
|
|
1790
1808
|
}
|
|
1791
1809
|
function canonicalPreviewPr(state, pr) {
|
|
@@ -1848,6 +1866,19 @@ export function agreeToHostSetup(log, questionId) {
|
|
|
1848
1866
|
if (host && Object.hasOwn(HOST_CLIS, host))
|
|
1849
1867
|
hostsAgreed(log).add(host);
|
|
1850
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
|
+
}
|
|
1851
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. */
|
|
1852
1883
|
export function hostSetupAgreed(log, host) {
|
|
1853
1884
|
return hostsAgreed(log).has(host);
|
|
@@ -1860,6 +1891,7 @@ const linkedVercel = new WeakSet();
|
|
|
1860
1891
|
* Vercel project linked in this run keeps its hold until it is settled.
|
|
1861
1892
|
*/
|
|
1862
1893
|
export function endHostSetup(log, host) {
|
|
1894
|
+
installsAgreed(log).delete(host);
|
|
1863
1895
|
if (host !== 'vercel' || !linkedVercel.has(log))
|
|
1864
1896
|
hostsAgreed(log).delete(host);
|
|
1865
1897
|
}
|
|
@@ -2093,7 +2125,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
|
|
|
2093
2125
|
let shown;
|
|
2094
2126
|
if (event.toolName === 'bash') {
|
|
2095
2127
|
shown = String(input.command ?? '');
|
|
2096
|
-
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 });
|
|
2097
2129
|
if (decision.verdict === 'allow')
|
|
2098
2130
|
decision = reservePreviewCommand(retry, shown, root, scratch);
|
|
2099
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. */
|
|
@@ -82,7 +109,7 @@ const previewsOff = (id, name, where) => ({
|
|
|
82
109
|
},
|
|
83
110
|
});
|
|
84
111
|
/** The consent to a Vercel bypass secret, saying whose previews Vercel's login keeps and where the secret is revoked. */
|
|
85
|
-
const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection.`;
|
|
112
|
+
const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection. Vercel also hands it to the project's builds, as VERCEL_AUTOMATION_BYPASS_SECRET.`;
|
|
86
113
|
/** The consent to a Cloudflare service token, saying which Access application keeps whose previews, and whose policy allows the token. */
|
|
87
114
|
const accessHint = (keeps, policy) => `Impeccable reviews each pull request on its preview, and ${keeps} behind a login.\n\nCloudflare's way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in ${policy} policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.`;
|
|
88
115
|
/** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one, and to making a site behind Netlify's team login public. */
|
|
@@ -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,16 +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
|
-
|
|
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}`;
|
|
233
257
|
return {
|
|
234
|
-
id: 'netlify-password-
|
|
258
|
+
id: 'netlify-password-set',
|
|
235
259
|
kind: 'confirm',
|
|
236
260
|
title: 'Your Netlify previews are protected',
|
|
237
|
-
hint: `
|
|
238
|
-
prompt: `
|
|
239
|
-
confirmLabel: '
|
|
240
|
-
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',
|
|
241
265
|
};
|
|
242
266
|
}
|
|
243
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). */
|
package/dist/main.js
CHANGED
|
@@ -79,7 +79,8 @@ export async function main(argv) {
|
|
|
79
79
|
/** 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
80
|
let backToStart;
|
|
81
81
|
/** The keys the run created in the person's host accounts, each as the line the end screen shows, or an error's. */
|
|
82
|
-
|
|
82
|
+
/** 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. */
|
|
83
|
+
const keysCreated = new Map();
|
|
83
84
|
// Nothing is reported until the flags say where to and whether to.
|
|
84
85
|
let reporter = createReporter({ host: '', off: true });
|
|
85
86
|
// 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.
|
|
@@ -261,37 +262,45 @@ export async function main(argv) {
|
|
|
261
262
|
const branch = backToStart();
|
|
262
263
|
if (branch)
|
|
263
264
|
log.write('checkout left on the run\'s branch', branch);
|
|
264
|
-
//
|
|
265
|
+
// 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
266
|
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
|
|
267
|
+
const pushed = [...retry.drives.keys()].filter((drive) => onOrigin(checkout.root, drive));
|
|
268
|
+
const left = [...keysCreated.values(), ...(branch ? [branch] : [])];
|
|
269
|
+
// 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.
|
|
270
|
+
const unopened = async (recorded) => (pushed.length > recorded
|
|
271
|
+
? (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()
|
|
272
|
+
: []);
|
|
270
273
|
if (report) {
|
|
271
274
|
const saved = await settle(liveSync.flush(), 5000);
|
|
272
275
|
const { testPr } = report;
|
|
276
|
+
// A run that set up several apps: each app's own test drive, named, however many opened (impeccable-reviews#397).
|
|
277
|
+
const drives = report.testPrs?.length ? report.testPrs : undefined;
|
|
273
278
|
const setupPr = shownSetupPr(report.setupPr);
|
|
274
279
|
// 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
280
|
const apps = appsSummary(report.apps ?? []);
|
|
276
281
|
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.
|
|
282
|
+
// 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
283
|
const hand = { open: openBrowser, copy: copyText };
|
|
279
|
-
if (
|
|
284
|
+
if (drives)
|
|
285
|
+
for (const drive of drives)
|
|
286
|
+
await showPullRequest(drive.url, hand, drive.name);
|
|
287
|
+
else if (testPr && setupPr)
|
|
280
288
|
await showPullRequest(testPr.url, hand);
|
|
281
289
|
await showOutro({
|
|
282
290
|
outcome: end.outcome,
|
|
283
291
|
title: end.title,
|
|
284
292
|
result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
285
|
-
|
|
293
|
+
// Each app's test drive had its own screen: the last one holds the setup pull request, or lists them all.
|
|
294
|
+
link: (setupPr ?? (drives ? undefined : testPr))?.url,
|
|
286
295
|
links: [
|
|
287
|
-
...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
296
|
+
...(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
297
|
// Without a current test drive the setup pull request is not ready to merge; the report says why.
|
|
289
298
|
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
290
299
|
{ label: 'Web page', url: page },
|
|
291
300
|
],
|
|
292
301
|
todo: end.todo,
|
|
293
302
|
...(report.hosts?.length ? { used: report.hosts } : {}),
|
|
294
|
-
warnings: [...removed, ...left, ...(await unopened(
|
|
303
|
+
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.`] : [])],
|
|
295
304
|
}, hand);
|
|
296
305
|
// 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
306
|
if (!saved?.ok || end.code)
|
|
@@ -310,13 +319,15 @@ export async function main(argv) {
|
|
|
310
319
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
|
311
320
|
const missed = saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`];
|
|
312
321
|
const drive = liveSync.payload.test_pr, setup = shownSetupPr(liveSync.payload.setup_pr);
|
|
322
|
+
const drives = Object.entries(liveSync.payload.test_prs ?? {});
|
|
323
|
+
const driveLinks = drives.length ? drives.map(([key, pr]) => ({ label: `Test drive of ${key}`, url: pr.url })) : drive ? [{ label: 'Test drive', url: drive.url }] : [];
|
|
313
324
|
await showOutro({
|
|
314
325
|
outcome: 'stopped',
|
|
315
326
|
title: cancelled && !fatal ? 'Setup cancelled' : 'Setup failed',
|
|
316
327
|
result: why,
|
|
317
|
-
links: [...
|
|
328
|
+
links: [...driveLinks, ...(setup ? [{ label: 'Setup pull request', url: setup.url }] : []), { label: 'Web page', url: page }],
|
|
318
329
|
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(
|
|
330
|
+
warnings: [...removed, ...left, ...(await unopened(drives.length || (drive ? 1 : 0))), ...missed],
|
|
320
331
|
});
|
|
321
332
|
await reporter.done();
|
|
322
333
|
process.exit(1);
|
|
@@ -341,7 +352,7 @@ export async function main(argv) {
|
|
|
341
352
|
sync: liveSync,
|
|
342
353
|
viewPullRequest: (n) => viewPullRequest(`${token.repository.owner}/${token.repository.name}`, n),
|
|
343
354
|
onReport: (r) => void (report = r),
|
|
344
|
-
onKeyCreated: (line) => void keysCreated.
|
|
355
|
+
onKeyCreated: (line, about) => void keysCreated.set(about ?? line, line),
|
|
345
356
|
log,
|
|
346
357
|
previewWait: previewWait(),
|
|
347
358
|
sandbox,
|
|
@@ -385,7 +396,7 @@ export async function main(argv) {
|
|
|
385
396
|
console.error(message);
|
|
386
397
|
// A run that stopped on an error goes back to its starting branch too, or says where the checkout is.
|
|
387
398
|
const branch = backToStart?.();
|
|
388
|
-
for (const line of [...keysCreated, ...(branch ? [branch] : [])])
|
|
399
|
+
for (const line of [...keysCreated.values(), ...(branch ? [branch] : [])])
|
|
389
400
|
console.error(line);
|
|
390
401
|
await Promise.all([letTabsHear(), reporter.done()]);
|
|
391
402
|
process.exit(1);
|