@impeccable/reviews 0.1.0 → 0.1.1

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/tools.js CHANGED
@@ -7,13 +7,16 @@ import { request } from 'node:http';
7
7
  import { createServer } from 'node:net';
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { defineTool } from '@earendil-works/pi-coding-agent';
10
- import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
- import { focusCheckup, focusState } from './focus.js';
12
+ import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
13
  import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
- import { previewHostName, provePreview, readHostedPreviews } from './hosted.js';
14
+ import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
+ import { pagesProject, readCloudflarePreviews } from './cloudflare.js';
16
+ import { passCloudflareAccess } from './cloudflare-access.js';
15
17
  import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
16
- import { connectVercelProject, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
18
+ import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
19
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
17
20
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
18
21
  import { fitsTask, stagesOf } from './stages.js';
19
22
  import { redact } from './log.js';
@@ -196,12 +199,20 @@ const NETLIFY_PREVIEWS = {
196
199
  pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
197
200
  },
198
201
  };
202
+ const CLOUDFLARE_PREVIEWS = {
203
+ type: 'object',
204
+ additionalProperties: false,
205
+ properties: {
206
+ pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
207
+ branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the build of its head only, and waits up to five minutes for it to finish.' },
208
+ },
209
+ };
199
210
  const PROVE_PREVIEW = {
200
211
  type: 'object',
201
212
  additionalProperties: false,
202
213
  required: ['url', 'expect'],
203
214
  properties: {
204
- url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews or impeccable_netlify_previews listed, or a page on it.' },
215
+ url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or a page on it.' },
205
216
  expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
206
217
  },
207
218
  };
@@ -222,7 +233,7 @@ const ASK = {
222
233
  options: {
223
234
  type: 'array',
224
235
  description: 'single and multi: the choices, at least two.',
225
- items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 } } },
236
+ items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 }, selected: { type: 'boolean', description: 'multi only: picked when the question opens; the person can unpick it.' } } },
226
237
  },
227
238
  prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
228
239
  confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
@@ -252,6 +263,8 @@ function askError(q) {
252
263
  return q.options ? 'input.options is for single and multi only' : null;
253
264
  if (values.length < 2)
254
265
  return `input.options needs at least two choices for ${q.kind}`;
266
+ if (q.kind === 'single' && q.options.some((o) => o.selected !== undefined))
267
+ return 'input.options[].selected is for multi only';
255
268
  return new Set(values).size === values.length ? null : 'input.options has a value twice';
256
269
  }
257
270
  /** Why `url` is not pull request `number` of owner/repo on GitHub; null when it is. */
@@ -1388,9 +1401,12 @@ export function createTools(ctx) {
1388
1401
  }
1389
1402
  /** What the person typed into sensitive questions, by the reference the model holds instead. */
1390
1403
  const typed = new Map();
1391
- /** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built deploy previews Netlify's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify proof answered. */
1404
+ /** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built previews Netlify's and Cloudflare's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify or Cloudflare proof answered. */
1392
1405
  const listedPreviews = new Set();
1393
1406
  const netlifyListed = new Set();
1407
+ /** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
1408
+ const publicProduction = new Set();
1409
+ const cloudflareListed = new Set();
1394
1410
  let hostedPreviewProven = false;
1395
1411
  let provenPattern;
1396
1412
  /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
@@ -1424,7 +1440,7 @@ export function createTools(ctx) {
1424
1440
  };
1425
1441
  /** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
1426
1442
  const vercelMade = new Set();
1427
- /** The address impeccable_prove_preview met Vercel's login at, and the words it looked for there. */
1443
+ /** The address impeccable_prove_preview met a host's lock at, the words it looked for there, and whose lock it was. */
1428
1444
  let gated;
1429
1445
  /** The bypass secret saved in this run, for the proof of a Vercel address to send; never for the model. It is one project's, so it goes only to the origins impeccable_vercel_bypass proved it on, never to another project's deployment of this repository. */
1430
1446
  let bypassSecret;
@@ -1432,6 +1448,24 @@ export function createTools(ctx) {
1432
1448
  /** What impeccable_vercel_bypass answered, less the secret: a skip is asked once, so that answer stands; the saved one is what an address the secret opens later gets. */
1433
1449
  let bypassOutcome;
1434
1450
  let bypassSaved;
1451
+ /** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
1452
+ let sitePassword;
1453
+ const passwordSites = new Set();
1454
+ /** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, and the team login stays. Another site of the repository is read on its own. */
1455
+ const passwordStands = new Map();
1456
+ /** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
1457
+ const passwordConsented = new Set();
1458
+ /** The saved answer, what an address the password opens later gets. */
1459
+ let passwordSaved;
1460
+ /** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
1461
+ let accessGated;
1462
+ /** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
1463
+ let accessToken;
1464
+ let accessMade = {};
1465
+ const accessProjects = new Set();
1466
+ let accessStored = false;
1467
+ /** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
1468
+ let accessOutcome;
1435
1469
  // Keep ownership of files for this one run; never recreate it per call.
1436
1470
  const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1437
1471
  const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
@@ -1607,14 +1641,29 @@ export function createTools(ctx) {
1607
1641
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1608
1642
  ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
1609
1643
  }
1610
- /** The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked when Vercel's GitHub app cannot reach the repository. */
1644
+ /**
1645
+ * The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked
1646
+ * when Vercel's GitHub app cannot reach the repository; then, connected, the project locked behind Vercel's login at
1647
+ * every address while it has no production deployment, before anything is pushed: Vercel makes a new project's first
1648
+ * deployment Production whatever branch it builds, and serves it at the project's own address.
1649
+ */
1611
1650
  async function vercelConnect(pi, signal) {
1612
1651
  if (!hostSetupAgreed(ctx.log, 'vercel'))
1613
1652
  return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
1614
1653
  const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
1615
- const result = await connectVercelProject(vercel);
1616
1654
  // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
1617
- return hostAccess('vercel', result, () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1655
+ const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1656
+ // The fence holds pushes and deploys from the sign-in until the linked project's first deployment is settled: Vercel's app cannot reach the repository (a push builds nothing), production is deployed already, or the project is locked at every address.
1657
+ const link = await readLink(pi.cwd);
1658
+ if (!result.connected) {
1659
+ if (result.access !== undefined)
1660
+ settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
1661
+ return result;
1662
+ }
1663
+ const first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1664
+ if (link && (first.production === 'exists' || first.locked === true))
1665
+ settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
1666
+ return { ...result, ...first };
1618
1667
  }
1619
1668
  async function syncWorkflowTool(pi, signal) {
1620
1669
  try {
@@ -1658,9 +1707,36 @@ export function createTools(ctx) {
1658
1707
  const res = await requestJson(url, { ...init, token: await ctx.auth.token(pi) }, ctx.log);
1659
1708
  return res.status === 401 ? requestJson(url, { ...init, token: await ctx.auth.renew(pi) }, ctx.log) : res;
1660
1709
  };
1661
- /** A read of the setup API, trimmed to this setup before the model sees it. */
1710
+ /**
1711
+ * Saves a host's key for reviews: the key as Impeccable's credential for the lock, then the setting that sends it in
1712
+ * `header`, both through the setup API. `stored` says the first write took; `failed` what stopped it, else null.
1713
+ */
1714
+ const saveLock = async (pi, what, header, value) => {
1715
+ const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1716
+ if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value } })).ok)
1717
+ return { stored: false, failed: `The setup API did not store the ${what}.` };
1718
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header } })).ok)
1719
+ return { stored: true, failed: `The setup API stored the ${what}, but not the setting that sends it: it is turned on under Deployment protection on the setup page.` };
1720
+ return { stored: true, failed: null };
1721
+ };
1722
+ /** The instructions the writes are checked against: the first state may have come before the scan proposed the apps, so a later read that shows them brings the apps write in. */
1723
+ let instructions = ctx.instructions;
1724
+ /** Whether the run signed in again for apps the scan proposed after the sign-in. */
1725
+ let renewedForApps = false;
1726
+ /**
1727
+ * A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
1728
+ * there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
1729
+ * that covers every app there is now, and reads again. Once it has, a card still left out goes into the report.
1730
+ */
1662
1731
  const get = async (pi, path, focus) => {
1663
- const res = await call(pi, `${ctx.setupApi}${path}`);
1732
+ let res = await call(pi, `${ctx.setupApi}${path}`);
1733
+ if (res.ok && !renewedForApps && appsLeftOut(res.data, ctx.repo)) {
1734
+ await ctx.auth.renew(pi, 'apps');
1735
+ renewedForApps = true;
1736
+ res = await call(pi, `${ctx.setupApi}${path}`);
1737
+ }
1738
+ if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
1739
+ instructions = focusInstructions(ctx.allInstructions, { apps: true });
1664
1740
  return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
1665
1741
  };
1666
1742
  return [
@@ -1735,20 +1811,20 @@ export function createTools(ctx) {
1735
1811
  reportTasks(tasks);
1736
1812
  return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
1737
1813
  }),
1738
- tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
1814
+ tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, its apps item where the scan proposed several apps, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
1739
1815
  tool('impeccable_checkup', 'Checkup', 'Run the checkup for this setup\'s items: it reads GitHub again and answers status (complete, waiting or incomplete), next (what to do, in order), forPerson (what only the person can do) and waitingFor (what needs time).', NO_INPUT, (_p, pi) => get(pi, '/checkup', focusCheckup)),
1740
1816
  tool('impeccable_write', 'Write a setting', 'Write a setting through the setup API, at an endpoint the instructions list and with the method they give.', WRITE, async (p, pi, signal) => {
1741
- const target = resolveWrite(ctx.instructions, ctx.setupApi, ctx.repo, p.path);
1817
+ const target = resolveWrite(instructions, ctx.setupApi, ctx.repo, p.path);
1742
1818
  if ('error' in target)
1743
1819
  return `Refused: ${target.error}`;
1744
1820
  if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
1745
- return 'Refused: a Vercel bypass secret is saved by impeccable_vercel_bypass, which opens the preview with it first, and never through this tool. Nothing was saved.';
1821
+ return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
1746
1822
  if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
1747
1823
  return "Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app. Nothing was saved.";
1748
1824
  }
1749
1825
  const saved = object(p.body);
1750
1826
  if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
1751
- return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify preview. Nothing was saved.';
1827
+ return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
1752
1828
  }
1753
1829
  const testAccounts = target.url.endsWith('/test-accounts');
1754
1830
  let body = p.body;
@@ -1901,24 +1977,39 @@ export function createTools(ctx) {
1901
1977
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
1902
1978
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
1903
1979
  }),
1904
- tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer (a project\'s first deployment is called Production, whatever branch it builds, and counts). It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, _pi, signal) => {
1980
+ tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
1905
1981
  const branch = p.branch;
1906
1982
  const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
1907
- // With `branch` the evidence holds only the deployments of its head, and Vercel's is its preview whatever environment it is called: the first deployment of a
1983
+ // With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
1908
1984
  // project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
1909
- const preview = (d) => (branch !== undefined ? d.vercel : /preview/i.test(d.environment ?? ''));
1910
- // A head's preview is still building until it has a final status.
1911
- const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => preview(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1985
+ const isPreview = (d) => /preview/i.test(d.environment ?? '');
1986
+ const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
1987
+ // A head's build is still building until it has a final status.
1988
+ const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1912
1989
  const evidence = branch !== undefined ? await awaitBuild(`Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
1913
1990
  listedPreviews.clear();
1914
1991
  vercelMade.clear();
1992
+ publicProduction.clear();
1993
+ /** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
1994
+ let locked;
1915
1995
  for (const d of evidence.deployments) {
1916
- if (d.state === 'success' && d.url && preview(d) && (d.pullRequests.length || branch !== undefined)) {
1917
- const { origin } = new URL(d.url);
1918
- listedPreviews.add(origin);
1919
- if (d.vercel)
1920
- vercelMade.add(origin);
1996
+ if (d.state !== 'success' || !d.url || !ofBranch(d) || !(d.pullRequests.length || branch !== undefined))
1997
+ continue;
1998
+ const url = new URL(d.url);
1999
+ // A pushed branch's Production deployment is its preview only as the locked project's own: under Standard Protection a project's own address serves it to anyone, and another project of this repository may have made it.
2000
+ if (branch !== undefined && !isPreview(d)) {
2001
+ locked ??= await (async () => {
2002
+ const link = await readLink(pi.cwd);
2003
+ return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link) : new Set();
2004
+ })();
2005
+ if (!locked.has(url.host)) {
2006
+ publicProduction.add(url.origin);
2007
+ continue;
2008
+ }
1921
2009
  }
2010
+ listedPreviews.add(url.origin);
2011
+ if (d.vercel)
2012
+ vercelMade.add(url.origin);
1922
2013
  }
1923
2014
  return JSON.stringify(evidence);
1924
2015
  }),
@@ -1935,18 +2026,52 @@ export function createTools(ctx) {
1935
2026
  netlifyListed.add(new URL(p.url).origin);
1936
2027
  return JSON.stringify(evidence);
1937
2028
  }),
1938
- tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch (a successful Preview deployment, or a deploy preview that is ready), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify it answers the address pattern to save beside it. gate "vercel-protection" or "netlify-protection" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
2029
+ tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or wrangler on this machine when it is signed in (read only: it never signs in). cli says whether wrangler is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or wrangler. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2030
+ const pull = p.pull;
2031
+ const branch = p.branch;
2032
+ if (pull !== undefined && branch !== undefined)
2033
+ return 'Invalid input: give pull or branch, not both.';
2034
+ const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
2035
+ // Cloudflare posts its check run once the build ended: until then the head lists nothing, or wrangler's row says it is building.
2036
+ // GitHub's record unread ends the wait; wrangler's alone does not, since the check run still says when the build ends.
2037
+ const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2038
+ const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2039
+ const evidence = subject ? await awaitBuild(`Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2040
+ cloudflareListed.clear();
2041
+ for (const preview of evidence.previews) {
2042
+ if (preview.state !== 'success')
2043
+ continue;
2044
+ cloudflareListed.add(new URL(preview.url).origin);
2045
+ if (preview.alias)
2046
+ cloudflareListed.add(new URL(preview.alias).origin);
2047
+ }
2048
+ return JSON.stringify(evidence);
2049
+ }),
2050
+ tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
1939
2051
  const url = p.url;
1940
- if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin)))
1941
- return 'Refused: open the address of a preview that impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch, or a page on it.';
1942
- const proof = await provePreview(url, p.expect, pi.cwd, { fetch: ctx.previewFetch, signal, ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}) });
2052
+ if (URL.canParse(url) && publicProduction.has(new URL(url).origin))
2053
+ return "Refused: Vercel called this deployment Production, as it calls a project's first whatever branch it builds, and no project of this folder's that is locked at every address made it, so it is served at a project's own address without a login: it is no preview, and nothing was opened. No test drive, as the vercel-setup case says.";
2054
+ if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin) || cloudflareListed.has(new URL(url).origin)))
2055
+ return 'Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch, or a page on it.';
2056
+ const site = netlifySiteOf(url);
2057
+ // A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
2058
+ const project = pagesProject(new URL(url).origin);
2059
+ const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
2060
+ const proof = await provePreview(url, p.expect, pi.cwd, {
2061
+ fetch: ctx.previewFetch, signal,
2062
+ ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}),
2063
+ ...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
2064
+ ...(token ? { access: token } : {}),
2065
+ });
1943
2066
  hostedPreviewProven ||= proof.proven;
1944
2067
  if (proof.proven)
1945
2068
  provenHost = previewHostName(url) ?? provenHost;
1946
2069
  if (proof.proven && proof.pattern)
1947
2070
  provenPattern = proof.pattern;
1948
- if (proof.gate === 'vercel-protection')
1949
- gated = { url, expect: p.expect };
2071
+ if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
2072
+ gated = { url, expect: p.expect, gate: proof.gate };
2073
+ if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
2074
+ accessGated = { url, expect: p.expect };
1950
2075
  return JSON.stringify(proof);
1951
2076
  }),
1952
2077
  tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
@@ -1958,9 +2083,9 @@ export function createTools(ctx) {
1958
2083
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
1959
2084
  return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
1960
2085
  }),
1961
- tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
2086
+ tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet: Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
1962
2087
  tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
1963
- if (!gated || !vercelMade.has(new URL(gated.url).origin))
2088
+ if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
1964
2089
  return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
1965
2090
  if (bypassOutcome?.status === 'skipped')
1966
2091
  return JSON.stringify(bypassOutcome);
@@ -1983,13 +2108,9 @@ export function createTools(ctx) {
1983
2108
  },
1984
2109
  prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
1985
2110
  save: async (secret) => {
1986
- const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1987
- if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value: secret } })).ok)
1988
- return 'The setup API did not store the secret.';
1989
- stored = true;
1990
- if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: BYPASS_HEADER } })).ok)
1991
- return 'The setup API stored the secret, but not the setting that sends it: it is turned on under Deployment protection on the setup page.';
1992
- return null;
2111
+ const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret);
2112
+ stored = saved.stored;
2113
+ return saved.failed;
1993
2114
  },
1994
2115
  remember: noteSensitiveAnswer,
1995
2116
  });
@@ -2011,5 +2132,138 @@ export function createTools(ctx) {
2011
2132
  }
2012
2133
  return JSON.stringify(bypassOutcome);
2013
2134
  }),
2135
+ tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), login (Netlify\'s team login stands in front, which no tool gets past: protection says so, and reason why), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2136
+ const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2137
+ if (!gated || !site)
2138
+ return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
2139
+ const stands = passwordStands.get(site);
2140
+ if (stands)
2141
+ return JSON.stringify(stands);
2142
+ const at = gated;
2143
+ /** The address pattern the proof with the password answers, saved as the method once the password is. */
2144
+ let pattern;
2145
+ const prove = async (password) => {
2146
+ const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password });
2147
+ if (proof.proven && proof.pattern)
2148
+ pattern = proof.pattern;
2149
+ return proof;
2150
+ };
2151
+ // A password in hand already: another deploy preview of the same site opens with it.
2152
+ if (passwordSaved && sitePassword && (await prove(sitePassword)).proven) {
2153
+ passwordSites.add(site);
2154
+ provenPattern = pattern ?? provenPattern;
2155
+ return JSON.stringify(passwordSaved);
2156
+ }
2157
+ /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
2158
+ let stored = false;
2159
+ const { value, ...told } = await passNetlifyLock({
2160
+ run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
2161
+ site,
2162
+ consent: async () => {
2163
+ if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
2164
+ return passwordConsented.add(site), true;
2165
+ return false;
2166
+ },
2167
+ paste: async () => {
2168
+ const answer = await ask(HOST_SCREENS['netlify-password-key']);
2169
+ return answer.skipped ? undefined : answer.value?.trim();
2170
+ },
2171
+ prove,
2172
+ save: async (password) => {
2173
+ const saved = await saveLock(pi, 'password', PASSWORD_HEADER, basicCredentials(password));
2174
+ stored = saved.stored;
2175
+ return saved.failed;
2176
+ },
2177
+ remember: noteSensitiveAnswer,
2178
+ });
2179
+ const passwordOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
2180
+ if (told.status === 'skipped' || told.status === 'login')
2181
+ passwordStands.set(site, passwordOutcome);
2182
+ if (value) {
2183
+ // What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
2184
+ sitePassword = value;
2185
+ passwordSites.clear();
2186
+ passwordSites.add(site);
2187
+ hostedPreviewProven = true;
2188
+ provenHost = 'Netlify';
2189
+ provenPattern = pattern ?? provenPattern;
2190
+ passwordSaved = passwordOutcome;
2191
+ }
2192
+ else if (told.status === 'save-failed' && stored) {
2193
+ // The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through.
2194
+ sitePassword = undefined;
2195
+ passwordSites.clear();
2196
+ passwordSaved = undefined;
2197
+ }
2198
+ return JSON.stringify(passwordOutcome);
2199
+ }),
2200
+ tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token and one taking the Cloudflare API token that creating it takes, then the token named "Impeccable reviews" created and allowed in the policy of the application in front of the preview; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2201
+ if (!accessGated)
2202
+ return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
2203
+ if (accessOutcome?.status === 'skipped')
2204
+ return JSON.stringify(accessOutcome);
2205
+ const at = accessGated;
2206
+ let proof;
2207
+ const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
2208
+ const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
2209
+ const save = async (token) => {
2210
+ if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret } })).ok)
2211
+ return 'The setup API did not store the service token.';
2212
+ // An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
2213
+ const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
2214
+ const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
2215
+ if (lock?.authMethod === 'access')
2216
+ return null;
2217
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access' } })).ok)
2218
+ return 'The setup API stored the service token, but not the setting that sends it: it is turned on under Preview protection on the setup page.';
2219
+ return null;
2220
+ };
2221
+ let answer;
2222
+ if (accessToken && (await prove(accessToken)).proven) {
2223
+ // The token in hand opens this address too: stored already, or stored now.
2224
+ const failed = accessStored ? null : await save(accessToken);
2225
+ answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
2226
+ }
2227
+ else {
2228
+ answer = await passCloudflareAccess({
2229
+ url: at.url,
2230
+ consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
2231
+ apiToken: async () => {
2232
+ const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
2233
+ return typed.skipped ? undefined : typed.value;
2234
+ },
2235
+ paste: async () => {
2236
+ const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
2237
+ return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
2238
+ },
2239
+ prove,
2240
+ save,
2241
+ remember: noteSensitiveAnswer,
2242
+ signal,
2243
+ ...(ctx.hostWait?.policy ? { wait: ctx.hostWait.policy } : {}),
2244
+ });
2245
+ }
2246
+ const { value, ...told } = answer;
2247
+ if (value) {
2248
+ // What is in hand follows the store, which holds one token: a new one is the one saved now, and the projects the last one opened no longer stand.
2249
+ if (value !== accessToken)
2250
+ accessProjects.clear();
2251
+ accessToken = value;
2252
+ accessStored = told.status === 'saved';
2253
+ accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}) };
2254
+ const project = pagesProject(new URL(at.url).origin);
2255
+ if (project)
2256
+ accessProjects.add(project);
2257
+ }
2258
+ const pattern = told.status === 'saved' ? proof?.pattern : undefined;
2259
+ accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
2260
+ if (told.status === 'saved') {
2261
+ hostedPreviewProven = true;
2262
+ provenHost = 'Cloudflare Pages';
2263
+ if (pattern)
2264
+ provenPattern = pattern;
2265
+ }
2266
+ return JSON.stringify(accessOutcome);
2267
+ }),
2014
2268
  ];
2015
2269
  }
package/dist/ui/bridge.js CHANGED
@@ -120,17 +120,20 @@ async function askOnStdin(q) {
120
120
  }
121
121
  if (q.kind === 'account') {
122
122
  // An empty username keeps the one the field would hold; an empty password skips the account.
123
- out.write(`\n${q.title ? `${q.title}. ` : ''}${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}Username or email${q.prefill ? ` (Enter for ${q.prefill})` : ''}: `);
123
+ const [first, second] = q.fields ?? ['Username or email', 'Password'];
124
+ out.write(`\n${q.title ? `${q.title}. ` : ''}${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}${first}${q.prefill ? ` (Enter for ${q.prefill})` : ''}: `);
124
125
  const username = (await nextLine())?.trim() || q.prefill;
125
126
  if (!username)
126
127
  return { skipped: true };
127
- out.write('Password, or Enter to skip: ');
128
+ out.write(`${second}, or Enter to skip: `);
128
129
  const password = await (process.stdin.isTTY ? hiddenLine : nextLine)();
129
130
  return password ? { skipped: false, username, password } : { skipped: true };
130
131
  }
131
132
  const how = q.kind === 'text' ? (q.skippable ? '(Enter to skip)' : '') : q.kind === 'single' ? `Type a number${q.skippable ? ', or Enter to skip' : ''}:` : `Type the numbers, separated by commas${q.skippable ? ', or Enter to skip' : ''}:`;
132
133
  out.write(`\n${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}`);
133
- (q.options ?? []).forEach((o, i) => out.write(` ${i + 1}. ${o.label}\n`));
134
+ // A multi choice that opens with picks shows them; on a pipe the numbers typed are the whole answer.
135
+ const marked = q.kind === 'multi' && (q.options ?? []).some((o) => o.selected);
136
+ (q.options ?? []).forEach((o, i) => out.write(` ${i + 1}. ${marked ? `[${o.selected ? 'x' : ' '}] ` : ''}${o.label}\n`));
134
137
  // A sensitive answer typed at a terminal is not echoed.
135
138
  const read = q.sensitive && process.stdin.isTTY ? hiddenLine : nextLine;
136
139
  out.write(how ? `${how} ` : '');
package/dist/ui/look.js CHANGED
@@ -20,6 +20,11 @@ const FIELD_WIDTH = 48;
20
20
  /** The running task's spinner: one glyph every third frame. */
21
21
  const SPINNER = ['◐', '◓', '◑', '◒'];
22
22
  const START = { heading: "Let's set up your app", text: 'This wizard detects your framework and wires up all the essentials.' };
23
+ /** The sign-in over a run, by why it signs in again. */
24
+ const AGAIN = {
25
+ 'ran-out': 'Your sign-in ran out. Approve again in your browser to carry on.',
26
+ apps: 'Impeccable found your apps after you signed in. Approve again in your browser to set them up.',
27
+ };
23
28
  /** The invitation to the test drive, on whichever end screen shows it; the words "test drive" stay off it. */
24
29
  export const SEE_THE_REVIEW = 'See Impeccable review a change: we opened a small pull request just for that.';
25
30
  const WORDMARK = 'I M P E C C A B L E';
@@ -162,7 +167,8 @@ function questionPage(qv, inner, st) {
162
167
  const width = Math.min(FIELD_WIDTH, inner);
163
168
  const label = (text) => ` ${st.soft(text)}`;
164
169
  const hint = q.hint ? [...wrapTextWithAnsi(q.hint, width - 2).map(label), ''] : [];
165
- const fields = [...fieldBox('Username or email', qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox('Password', qv.password?.() ?? '', width, qv.focus === 1, st)];
170
+ const [first, second] = q.fields ?? ['Username or email', 'Password'];
171
+ const fields = [...fieldBox(first, qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox(second, qv.password?.() ?? '', width, qv.focus === 1, st)];
166
172
  return {
167
173
  heading: q.title ?? 'A test account',
168
174
  text: said(q.prompt, inner, st),
@@ -279,7 +285,7 @@ function pageOf(f, inner) {
279
285
  const link = block(urlRows(scene.link, ruleWidth(inner), st.soft, st), inner);
280
286
  return {
281
287
  heading: scene.again ? 'Sign in again' : START.heading,
282
- text: said(scene.again ? 'Your sign-in ran out. Approve again in your browser to carry on.' : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
288
+ text: said(scene.again ? AGAIN[scene.again] : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
283
289
  rule: divider(inner, st),
284
290
  action: [...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied === false ? ['', copyWay(inner, st)] : [])],
285
291
  };
@@ -155,7 +155,9 @@ class Wizard {
155
155
  this.state.touch();
156
156
  resolve(answer);
157
157
  };
158
- this.questions.push({ q, resolve: answered, field, password: q.kind === 'account' ? new Input() : undefined, focus: 0, cursor: 0, picked: new Set() });
158
+ // A multi choice opens with the options the question marks selected picked.
159
+ const picked = new Set(q.kind === 'multi' ? (q.options ?? []).flatMap((o, i) => (o.selected ? [i] : [])) : []);
160
+ this.questions.push({ q, resolve: answered, field, password: q.kind === 'account' ? new Input() : undefined, focus: 0, cursor: 0, picked });
159
161
  this.redraw();
160
162
  });
161
163
  }
@@ -289,14 +291,19 @@ class Wizard {
289
291
  else if (matchesKey(data, 'enter')) {
290
292
  if (choices && q.kind === 'single')
291
293
  return done({ skipped: false, value: choices[question.cursor].value });
292
- if (choices)
294
+ // A multi choice answers what is picked; with nothing picked, Skip is the way to say none.
295
+ if (choices && question.picked.size)
293
296
  return done({ skipped: false, values: choices.filter((_, i) => question.picked.has(i)).map((o) => o.value) });
294
- const value = question.field.getValue().trim();
295
- if (value)
296
- return done({ skipped: false, value });
297
- if (q.skippable)
298
- return done({ skipped: true });
299
- question.note = 'Type an answer first.';
297
+ if (choices)
298
+ question.note = 'Pick at least one, or skip.';
299
+ else {
300
+ const value = question.field.getValue().trim();
301
+ if (value)
302
+ return done({ skipped: false, value });
303
+ if (q.skippable)
304
+ return done({ skipped: true });
305
+ question.note = 'Type an answer first.';
306
+ }
300
307
  }
301
308
  else if (!choices) {
302
309
  question.field.handleInput(data);
@@ -443,7 +450,7 @@ export function showSignIn(link, opts) {
443
450
  console.log(link);
444
451
  return { close() { } };
445
452
  }
446
- const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') };
453
+ const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') && (opts.why ?? 'ran-out') };
447
454
  screen.push(view);
448
455
  screen.copy(view);
449
456
  return { close: () => screen.remove(view) };