@impeccable/reviews 0.1.21 → 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.
@@ -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')
@@ -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, ownNpmCache } from './sandbox.js';
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: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
241
- * when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
242
- * checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox, and from where cf
243
- * itself would read the checkout's .env into its token and account and evaluate its cloudflare.config.ts: every cf
244
- * command names its project or its account, so none needs the checkout. Only the sign-in cf picks by folder does: with
245
- * `checkout`, each command runs as the profile cf binds to it (`cf auth activate`), which cf names from its own folder
246
- * too, so the account is the one the person's cf uses there; where reading the bindings failed, the command fails as well,
247
- * never falling back to another sign-in. It fetches cf through a cache of the command's own, never ~/.npm, where a
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 npx = (args, signal, env = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...run, signal, env: { NO_COLOR: '1', ...env } })(['--yes', '--cache', ownNpmCache(), '--prefer-online', '--ignore-scripts', CF_PACKAGE, ...args]);
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 = npx(['auth', 'list'], signal).then((listed) => boundProfile(listed, checkout)).catch((err) => { profile = undefined; throw err; });
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 npx([...(bound ? ['--profile', bound] : []), ...args], signal, account ? { CLOUDFLARE_ACCOUNT_ID: account } : {});
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
- const unread = () => ({ previews: [], cli: 'missing', error: "Cloudflare's previews could not be read.", unread: ['GitHub'] });
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
- // cf installs itself through npx on a machine without it, which takes a while the first time. The project's settings are read with the plain evidence, not while a build is waited for.
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 that runs, and only once the person said yes to setting that host up. */
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
- /** The one form of a host CLI's install that runs: npm's, global, of the pinned version. */
636
- export const hostCliInstallCommand = (pkg) => `npm install -g ${pkg}@${HOST_CLI_VERSIONS[pkg]}`;
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.hosts.has(add.host))
948
- return pinned(cmd, add) ? allow('installs the CLI of the host the person said yes to') : deny(`the run installs ${add.pkg} with npm, pinned to the version it knows: ${hostCliInstallCommand(add.pkg)}`);
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, drivePushes: 0, driveCreated: false, requireHistory: false, historyKnown: false, observed: new Map() };
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
- const driveBranch = (branch) => /^impeccable\/test-drive(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
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
- let drive = state.driveBranch, drivePushes = state.drivePushes, driveCreated = state.driveCreated;
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
- if (drive && drivePush !== drive)
1723
- return deny('keep the test drive on one branch');
1724
- if (++drivePushes > 3)
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
- if (driveCreated)
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
- if (drive && parsed.head !== drive)
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 (!driveCreated || parsed.branch !== drive)
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.driveBranch = drive;
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') {
@@ -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, and the paste when it cannot read it.
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
- /** On a second Netlify site behind a password, the offer to use the one the person typed for another site of this run (#384); on Skip, the paste screen. On a repository with several apps, each site is named after its app first, as every other screen names them (#390). */
232
- export function netlifySamePassword(site, other, apps = {}) {
233
- const whose = apps.app ? `${apps.app}'s previews, built by the Netlify site ${site},` : `The previews of the Netlify site ${site}`;
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-same',
258
+ id: 'netlify-password-set',
236
259
  kind: 'confirm',
237
260
  title: 'Your Netlify previews are protected',
238
- hint: `${apps.app ? whose[0].toUpperCase() + whose.slice(1) : whose} are behind its site password too. The run tries the password you typed for ${apps.other ?? other} on ${site}'s preview first, and saves it for ${apps.app ?? site} only if it opens.`,
239
- prompt: `Use the same password for ${apps.app ? `${apps.app} (site ${site})` : site}?`,
240
- confirmLabel: 'Use it',
241
- skipLabel: 'Type another',
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). */
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
- const keysCreated = new Set();
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
- // The test drive pushed with no pull request opened from it stays on GitHub, where the host built it (#324).
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.driveBranch && onOrigin(checkout.root, retry.driveBranch) ? retry.driveBranch : undefined;
267
- const left = [...keysCreated, ...(branch ? [branch] : [])];
268
- // Said only where gh confirms no pull request is open from it, a draft counted: a recorded test drive, or gh unable to say, says nothing.
269
- const unopened = async (recorded) => (pushed && !recorded && (await openFrom(slug, pushed)) === false ? [`${pushed} is on GitHub with no pull request: delete the branch there, or open a pull request from it.`] : []);
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 (testPr && setupPr)
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
- link: (setupPr ?? testPr)?.url,
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(Boolean(testPr))), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
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: [...(drive ? [{ label: 'Test drive', url: drive.url }] : []), ...(setup ? [{ label: 'Setup pull request', url: setup.url }] : []), { label: 'Web page', url: page }],
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(Boolean(drive))), ...missed],
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.add(line),
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);