@impeccable/reviews 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -306,7 +306,7 @@ export function vercelBypassPaste(stopped) {
306
306
  : `Make one yourself: ${where} → Create, named "Impeccable reviews".`;
307
307
  return { ...HOST_SCREENS['vercel-bypass-key'], prompt: `Vercel keeps your previews behind a login. ${stopped.reason} ${next} It is stored encrypted and used only to open previews.` };
308
308
  }
309
- /** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. */
309
+ /** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. It has no Skip: at least one app is picked (#405). */
310
310
  export const APPS_QUESTION = {
311
311
  id: 'apps',
312
312
  kind: 'multi',
@@ -316,7 +316,7 @@ export const APPS_QUESTION = {
316
316
  };
317
317
  /** The apps question's hint when the command was started below the repository's top: `where` is an app's folder ("Tally Board's folder") or the folder itself. */
318
318
  export const startedIn = (where) => `You started in ${where}. Impeccable sets up from the top of the repository.`;
319
- /** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind Skip for someone who wants another app (#286). */
319
+ /** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind Pick other apps for someone who wants another app (#286); there is no Skip (#405). */
320
320
  export const namedAppQuestion = (app) => ({
321
321
  id: APPS_QUESTION.id,
322
322
  kind: 'confirm',
package/dist/main.js CHANGED
@@ -16,6 +16,7 @@ import { scanRecorder } from './scan.js';
16
16
  import { createReporter, reportingOff } from './report.js';
17
17
  import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
18
18
  import { checkPushAccess, readPushAccess } from './push-access.js';
19
+ import { askPrSummary, PR_SUMMARY_OFF } from './pr-summary.js';
19
20
  import { appsSummary, createTools, ending, ownPullRequest, resultLine } from './tools.js';
20
21
  import { transcriptPaths } from './transcript.js';
21
22
  import { askPerson } from './ui/bridge.js';
@@ -227,18 +228,27 @@ export async function main(argv) {
227
228
  throw new UserError(`--app ${flags.app} is not an app of ${slug}. Its apps are ${appKeys.join(', ')}. Run it again with one of them, or without --app.`);
228
229
  }
229
230
  // The setup pushes as the person, so whether git may push is asked of GitHub now, before anything is created.
230
- const push = await checkPushAccess(slug, () => readPushAccess(checkout.root), async (screen) => {
231
+ // A question asked before the agent starts. Ctrl+C there answers it as closed while stopEarly winds the run down and
232
+ // exits, so nothing after it runs: no update over the stop, and no agent started.
233
+ const askUpFront = async (screen) => {
231
234
  liveSync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
232
235
  const answer = await askPerson({ ...screen, skippable: true });
236
+ if (stopping)
237
+ return new Promise(() => { });
233
238
  liveSync.update({ phase: 'running', question: null });
234
239
  return answer;
235
- });
240
+ };
241
+ const push = await checkPushAccess(slug, () => readPushAccess(checkout.root), askUpFront);
236
242
  log.write('push access', push.outcome);
237
243
  if (push.outcome === 'refused' || push.outcome === 'signed-out') {
238
244
  await nothingToSetUp(liveSync, reporter, 'stopped', "Git can't push", `Git can't push to ${slug}${push.account ? ` as ${push.account}` : ''}, so nothing was set up. Sign git in as an account that can, then run this again.`, [webPage]);
239
245
  }
240
246
  if (!interactive)
241
247
  say('Signed in.\n');
248
+ // The summary with before and after in each pull request's description, asked with the other consents up front, so the test drive's has it too (#402).
249
+ const reviewSettings = `${setupApi}/repos/${encodeURIComponent(token.repository.name)}/review-settings`;
250
+ const prSummary = await askPrSummary(() => requestJson(reviewSettings, { token: token.access_token }, log), askUpFront, (body) => requestJson(reviewSettings, { method: 'PUT', token: token.access_token, body }, log));
251
+ log.write('pr summary', prSummary);
242
252
  // Beside the transcript, named after the run as it is, so a run never writes over another's in the temp folder.
243
253
  const scans = scanRecorder(log, flags.scanReport ? join(dirname(transcriptPaths(run.run_id, flags.transcript).markdown), `scan-report-${run.run_id.replace(/[^\w.-]/g, '_')}.json`) : undefined);
244
254
  atExit(() => scans.report());
@@ -300,7 +310,7 @@ export async function main(argv) {
300
310
  ],
301
311
  todo: end.todo,
302
312
  ...(report.hosts?.length ? { used: report.hosts } : {}),
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.`] : [])],
313
+ warnings: [...removed, ...left, ...(await unopened(drives ? drives.length : testPr ? 1 : 0)), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : []), ...(prSummary === 'off' ? [PR_SUMMARY_OFF] : [])],
304
314
  }, hand);
305
315
  // The web page has the report; when it missed it, or the run fell short of all set up, whose why it holds, the report stays here as well.
306
316
  if (!saved?.ok || end.code)
@@ -5,13 +5,13 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
5
5
  ### Steps
6
6
 
7
7
  1. **Confirm the proposal** as it stands, while the item's status is `needs-you`: impeccable_write to `apps` with the `confirm` action the item's instructions give. Its answer lists every app of the repository, on and off, with its `key`, `name`, `pathPrefixes` and `enabled`; the one the scan switched on is the repository's default. An item that is `done` or `skipped` was answered before (on the setup page, or by an earlier run): no confirm, and step 2 all the same. Done when it answered `apps`, or the item was answered before.
8
- 2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Set up monorepo apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there), or a skip, whose `on` lists the apps that are on: the ones this run sets up. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
9
- 3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. **After Skip**, write nothing more: the apps that are on stay on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app), or, after Skip, nothing was written.
8
+ 2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Set up monorepo apps task in progress first. Every write and host tool for an app is refused until it is answered. It has no Skip: the person picks at least one app. Done when it answered `values` or `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there). Where no answer came (the input ended), the tool wrote nothing and the run ends there: call nothing more. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
9
+ 3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app).
10
10
  4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
11
11
  5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Where a host's setup case (vercel-setup, netlify-setup) opens and records an app's test drive here, check that app's preview right after the recording, as pull-requests' step 6 says with its `appKey`, while its test drive is still the newest open pull request, before the next app is set up. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
12
12
  6. **The pull requests** as pull-requests says: the setup commit once for the repository, taking the state's one workflow `file` once every chosen app's method and build settings are saved, or its preview skipped as server-previews says. A run that sets up one app opens its test drive as pull-requests says, on `impeccable/test-drive`, its page in that app alone. A run that sets up more opens **a test drive of its own for each chosen app with a preview**, one app after the other, so each app's review shows on its own pull request; an app whose host's setup case (vercel-setup, netlify-setup) already opened, recorded and checked its test drive in step 5 has it, and gets none again. An app's test drive is branch `impeccable/test-drive-<appKey>` from the setup commit (from the default branch when there is none), with its page in that app alone, in that app's own framework and linked from that app's own public page, and no change to any other app; that case's step 3 builds that app as its host's case says; its step 4 pushes it and proves its page on that app's own preview, with that app's key where its lock stands in front; its step 5 opens it with the app's name in the title (*"Impeccable test drive: Storefront (safe to close)"*) and records it with impeccable_test_pr and that app's `appKey` (which the tool needs only when the run sets up more than one app); its step 6 checks that app with its `appKey` while its test drive is the newest open pull request, before the next app's test drive opens. An app whose preview was skipped gets no test drive, and goes under "What was skipped, and why". Step 7, when there is a setup commit, opens the setup pull request once, last, and records it with the last app's test drive and its `appKey` again. Done when every chosen app with a preview has its test drive recorded and checked, or the reason it has none in the report, and pull-requests' step 7 is done.
13
13
  - **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
14
- 7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`, on already after a skip), its method, its sign-in, what was verified; an app the question did not offer and the state shows off (`enabled` false) was turned off on the setup page, and its line says so, never that it was left off in the question; after `named`, an app still on that this run did not set up is outside this run, not turned off; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
14
+ 7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`), its method, its sign-in, what was verified; an app the question did not offer and the state shows off (`enabled` false) was turned off on the setup page, and its line says so, never that it was left off in the question; after `named`, an app still on that this run did not set up is outside this run, not turned off; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
15
15
 
16
16
  ### Reference
17
17
 
package/dist/playbook.js CHANGED
@@ -41,7 +41,7 @@ The state names the setup page's other cards in \`notPartOfThisSetup\`. They are
41
41
  - Ask through impeccable_ask, which shows a dialog and returns the answer; a question written in your message reaches nobody.
42
42
  - Ask only for what the repository cannot tell you. Running this command is the person's go-ahead for every step, so ask no approval questions.
43
43
  - Ask the questions that belong together back to back, one call each.
44
- - Every question offers Skip: after a skip, carry on without the answer, and say in the report what it left undone.
44
+ - Every question offers Skip but the apps question on a repository with several apps, which needs at least one app (the monorepo case): after a skip, carry on without the answer, and say in the report what it left undone.
45
45
  - **No run ends by telling the person to run the setup again**: what a step needs from them is asked in this run, on the tool's screen, and the run carries on. After a skip, or retries that ran out, the report says what stopped it, and no \`left\` line or question asks for a rerun.
46
46
 
47
47
  ## Commands
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The summary in each pull request's description, with the pages it changes before and after (#402): pristine's
3
+ * `modules.overview`, off by default because it writes into the author's description, so the person is asked once,
4
+ * worded here and never by the model. One question per repository, since pristine keeps the setting there.
5
+ */
6
+ export const PR_SUMMARY_SCREEN = {
7
+ id: 'pr-summary',
8
+ kind: 'confirm',
9
+ title: 'See every change, before and after',
10
+ hint: 'Each pull request gets side-by-side screenshots of the pages it changes, right in its description, so reviewers spot the difference at a glance. Turn it off any time in Settings.',
11
+ prompt: 'Show before and after on your pull requests?',
12
+ confirmLabel: 'Show it',
13
+ skipLabel: 'Not now',
14
+ };
15
+ /** The end screen's line when the summary is not on. */
16
+ export const PR_SUMMARY_OFF = 'Before and after is off: turn it on any time in Settings.';
17
+ const modulesOf = (data) => {
18
+ const modules = data?.effective?.modules;
19
+ return modules && typeof modules === 'object' && !Array.isArray(modules) ? modules : null;
20
+ };
21
+ /**
22
+ * Reads the repository's review settings, and unless the summary is on already asks the person; on their yes, reads
23
+ * them again, since Settings may have changed while the question was up, and saves `modules.overview: true` with every
24
+ * other module as it is in force then: the save replaces the whole `modules`, and the API has no conditional write.
25
+ * A Not now, an answer that never came (headless, closed input) or a settings read that failed writes nothing.
26
+ */
27
+ export async function askPrSummary(read, ask, save) {
28
+ const inForce = async () => {
29
+ const current = await read();
30
+ return current.ok ? modulesOf(current.data) : null;
31
+ };
32
+ const before = await inForce();
33
+ if (!before)
34
+ return 'off';
35
+ if (before.overview === true)
36
+ return 'on';
37
+ if ((await ask(PR_SUMMARY_SCREEN)).skipped)
38
+ return 'off';
39
+ const modules = await inForce();
40
+ if (!modules)
41
+ return 'off';
42
+ if (modules.overview === true)
43
+ return 'on';
44
+ return (await save({ modules: { ...modules, overview: true } })).ok ? 'on' : 'off';
45
+ }
package/dist/tools.js CHANGED
@@ -319,7 +319,7 @@ const ASK = {
319
319
  prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
320
320
  confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
321
321
  skipLabel: { type: 'string', minLength: 1, description: 'confirm only: the other way, such as "Skip for now".' },
322
- skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says.' },
322
+ skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says, but the apps question.' },
323
323
  sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed. A text question whose title, prompt or hint names a token, a password, a secret, a key or a credential is asked this way whatever this says.' },
324
324
  },
325
325
  };
@@ -2217,7 +2217,7 @@ export function createTools(ctx) {
2217
2217
  /** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
2218
2218
  let appsRepo = ctx.severalApps === true;
2219
2219
  /**
2220
- * The apps this run sets up, as the apps question answered (after a skip, the ones on): undefined until it was asked.
2220
+ * The apps this run sets up, as the apps question answered: undefined until it was answered.
2221
2221
  * On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
2222
2222
  * and host read for an app names one of these.
2223
2223
  */
@@ -2396,35 +2396,53 @@ export function createTools(ctx) {
2396
2396
  }
2397
2397
  /**
2398
2398
  * The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
2399
- * this run confirmed the scan's proposal); the command started for one app (--app) answers it without a screen. The
2400
- * answer is the apps this run sets up; after a skip, those that are on.
2399
+ * this run confirmed the scan's proposal); the command started for one app (--app) answers it on one confirm screen.
2400
+ * The answer is the apps this run sets up, at least one: the question has no Skip (#405).
2401
2401
  */
2402
- async function appsQuestion(pi) {
2402
+ async function appsQuestion(pi, signal) {
2403
2403
  const apps = await stateApps(pi);
2404
2404
  if (!apps || apps.length < 2)
2405
2405
  return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
2406
- // Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Skip opens the list for someone who wants another app (#286).
2406
+ // Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Pick other apps opens the list for someone who wants another app (#286).
2407
2407
  const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
2408
- if (named && !(await ask(namedAppQuestion(named))).skipped) {
2409
- pick(new Set([named.key]));
2410
- return JSON.stringify({ values: [named.key], named: true });
2408
+ if (named) {
2409
+ const screen = namedAppQuestion(named);
2410
+ const answer = await ask(screen);
2411
+ if (answer.closed)
2412
+ return unanswered(pi, screen, signal);
2413
+ if (!answer.skipped) {
2414
+ pick(new Set([named.key]));
2415
+ return JSON.stringify({ values: [named.key], named: true });
2416
+ }
2411
2417
  }
2412
2418
  // Started below the top: the app whose folder holds that folder (the deepest, never the root's) is ticked too, and the hint says so.
2413
2419
  const start = ctx.start ?? '';
2414
2420
  const here = start ? apps.filter((a) => a.folder && (start === a.folder || start.startsWith(`${a.folder}/`))).sort((a, b) => b.folder.length - a.folder.length)[0] : undefined;
2415
2421
  const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
2416
- // Reached by Esc on the named app's screen ("Pick other apps"), the list has no Skip on Esc: a second Esc to go back would skip the apps (#324).
2417
- const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) }, !named);
2418
- if (answer.skipped) {
2419
- pick(new Set(apps.filter((a) => a.enabled).map((a) => a.key)));
2420
- return JSON.stringify({ skipped: true, on: [...pickedApps] });
2422
+ const list = { ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) };
2423
+ // No Skip: Continue is the only way on, with at least one app picked (#405). A blank line in a piped run picks none, so the list goes again; on the list the named app's screen opened, the named app stands.
2424
+ for (;;) {
2425
+ const answer = await ask(list, false);
2426
+ if (answer.skipped)
2427
+ return unanswered(pi, list, signal);
2428
+ const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
2429
+ if (named && !values.length)
2430
+ values.push(named.key);
2431
+ if (!values.length)
2432
+ continue;
2433
+ pick(new Set(values));
2434
+ return JSON.stringify({ values });
2421
2435
  }
2422
- const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
2423
- // Nothing picked on the list the named app's screen opened, which has no Skip (a blank line in a piped run): the named app stands.
2424
- if (named && !values.length)
2425
- values.push(named.key);
2426
- pick(new Set(values));
2427
- return JSON.stringify({ values });
2436
+ }
2437
+ /**
2438
+ * No answer came on the apps question (the input ended, or the run stopped with it up), which has no Skip: nothing is
2439
+ * written, and the run ends here as stopped while waiting for that answer, which the end screen and the web page name.
2440
+ */
2441
+ function unanswered(pi, screen, signal) {
2442
+ signal?.throwIfAborted();
2443
+ ctx.sync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
2444
+ pi.shutdown();
2445
+ return { text: 'No answer came on the apps question, so no app was picked and nothing was written. The run ends here.', terminate: true };
2428
2446
  }
2429
2447
  return [
2430
2448
  tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
@@ -2614,11 +2632,11 @@ export function createTools(ctx) {
2614
2632
  // A test account never comes back to the model, even if the API echoed it.
2615
2633
  return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
2616
2634
  }),
2617
- tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there), or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2618
- // A skip never stops the run, so every question offers one.
2635
+ tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2636
+ // A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
2619
2637
  const asked = p;
2620
2638
  if (asked.id === APPS_QUESTION.id && appsRepo)
2621
- return appsQuestion(pi);
2639
+ return appsQuestion(pi, signal);
2622
2640
  const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
2623
2641
  // The Vercel setup is for a repository Vercel has never deployed: one it deploys already (every app this run sets up, on a repository with several), as impeccable_hosted_previews read it, gets its previews from the first pull request on (#286). An app with no project of its own still gets the screen, and a setup under way (asked again once the CLI was installed) is not stopped here.
2624
2642
  const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
@@ -2650,8 +2668,10 @@ export function createTools(ctx) {
2650
2668
  ctx.log.write('ask hidden (names a credential)', q.id);
2651
2669
  if (q.kind === 'text' && !q.sensitive)
2652
2670
  q.caution = NEVER_A_CREDENTIAL;
2653
- // The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
2654
- const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
2671
+ // The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here; it has no Skip (#405).
2672
+ const answer = q.id === APPS_QUESTION.id ? await ask(q, false) : await askPerson(q);
2673
+ if (q.id === APPS_QUESTION.id && answer.skipped)
2674
+ return unanswered(pi, q, signal);
2655
2675
  if (answer.skipped) {
2656
2676
  // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
2657
2677
  if (host?.[1] === 'vercel')
package/dist/ui/look.js CHANGED
@@ -80,7 +80,8 @@ function dotRows(word, max) {
80
80
  /** A paragraph of plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
81
81
  function balanced(given, max) {
82
82
  const text = given.split(' ').map((word) => dotRows(word, max).join('\n')).join(' ');
83
- const sentences = text.split(/(?<=[.!?])\s+/);
83
+ // A sentence ends on a mark after its last word: the "!" a warning opens with is no sentence of its own.
84
+ const sentences = text.split(/(?<=\S[.!?])\s+/);
84
85
  if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
85
86
  return sentences;
86
87
  const lines = wrapTextWithAnsi(text, Math.max(10, max));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,