@impeccable/reviews 0.1.9 → 0.1.11

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
@@ -10,17 +10,18 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
10
10
  import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
- import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn } from './host-questions.js';
13
+ import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
14
14
  import { enableNetlifyPreviews, folderOf } from './host-previews.js';
15
15
  import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
16
16
  import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
17
17
  import { passCloudflareAccess } from './cloudflare-access.js';
18
18
  import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
19
19
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
20
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
21
21
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
22
22
  import { fitsTask, stagesOf } from './stages.js';
23
23
  import { redact } from './log.js';
24
+ import { scan } from './scan.js';
24
25
  import { gitEnvironment } from './sandbox.js';
25
26
  import { openBrowser } from './login.js';
26
27
  import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
@@ -170,6 +171,11 @@ const REPORT = {
170
171
  description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
171
172
  items: { type: 'string', minLength: 1 },
172
173
  },
174
+ apps: {
175
+ type: 'array',
176
+ description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". Names only, never an address or a key.',
177
+ items: { type: 'object', additionalProperties: false, required: ['appKey', 'reason'], properties: { appKey: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } } },
178
+ },
173
179
  },
174
180
  };
175
181
  const WRITE = {
@@ -1687,6 +1693,17 @@ export function createTools(ctx) {
1687
1693
  const vercelDeployed = new Map();
1688
1694
  /** Each Vercel project's production address, read from Vercel, by origin, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
1689
1695
  const vercelProductions = new Map();
1696
+ /** The person's Vercel teams as the CLI listed them this run, once: a project's dashboard page is named by its own team, never the CLI's current scope or Vercel's picker, which open whatever project they like. */
1697
+ let vercelTeamList;
1698
+ /** A page of a Vercel project's dashboard, under the team the project's own ids name (the link file's or Vercel's list's); the team this run chose where the CLI cannot list the teams, and Vercel's picker last. */
1699
+ const vercelPageFor = async (vercel, link, page, project) => {
1700
+ let scope;
1701
+ if (link && project) {
1702
+ vercelTeamList ??= vercelTeams(vercel);
1703
+ scope = await vercelDashboardScope(vercel, link, await vercelTeamList);
1704
+ }
1705
+ return vercelProjectPage(page, scope ?? chosenHostTeam(ctx.log, 'vercel'), project);
1706
+ };
1690
1707
  /** The bypass secret a proof sends to `origin`: the one proved there, or, for a live address, the one of the project whose production it is. */
1691
1708
  const bypassFor = (origin, live) => {
1692
1709
  const store = bypassOrigins.get(origin);
@@ -1713,6 +1730,7 @@ export function createTools(ctx) {
1713
1730
  // The CLI's current scope is the team; one team is the team; several with none current are the person's pick.
1714
1731
  team: async () => {
1715
1732
  const teams = await vercelTeams(cli);
1733
+ vercelTeamList = Promise.resolve(teams);
1716
1734
  const one = teams.find((t) => t.current) ?? (teams.length === 1 ? teams[0] : undefined);
1717
1735
  return one ? { team: one.value, choices: [] } : { choices: teams.map(({ label, value }) => ({ label, value })) };
1718
1736
  },
@@ -1863,6 +1881,8 @@ export function createTools(ctx) {
1863
1881
  if (appsRepo) {
1864
1882
  const folders = new Map(await Promise.all([...(pickedApps ?? [])].map(async (key) => [await appFolder(pi, key), key])));
1865
1883
  connectedApp = project.rootDirectory !== undefined ? folders.get(project.rootDirectory) : undefined;
1884
+ if (project.name)
1885
+ noteApp(connectedApp, { host_project: project.name });
1866
1886
  if (connectedApp === undefined) {
1867
1887
  // A project not connected to this repository builds no push of it, so the push goes; one connected already would build it, so it stays held.
1868
1888
  if (project.repository !== repository.toLowerCase())
@@ -1891,7 +1911,7 @@ export function createTools(ctx) {
1891
1911
  vercelProject = first.project ?? vercelProject;
1892
1912
  // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
1893
1913
  if (first.production === 'none' && !first.locked) {
1894
- const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
1914
+ const page = await vercelPageFor(vercel, link, 'settings/deployment-protection', vercelProject);
1895
1915
  first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, repository, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
1896
1916
  waiting: "Waiting for Vercel's login on the project",
1897
1917
  skipped: 'The person skipped locking the Vercel project.',
@@ -1955,8 +1975,10 @@ export function createTools(ctx) {
1955
1975
  const app = appKey !== undefined ? { appKey } : {};
1956
1976
  if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value, ...app } })).ok)
1957
1977
  return { stored: false, failed: `The setup API did not store the ${what}.` };
1978
+ noteApp(appKey, { key_placed: true });
1958
1979
  if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: header, ...app } })).ok)
1959
1980
  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.` };
1981
+ noteApp(appKey, { method_saved: true });
1960
1982
  return { stored: true, failed: null };
1961
1983
  };
1962
1984
  /** 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. */
@@ -1973,6 +1995,35 @@ export function createTools(ctx) {
1973
1995
  let pickedApps;
1974
1996
  /** This run confirmed the scan's proposal, so nothing was answered before: the question opens with every app picked. */
1975
1997
  let confirmedApps = false;
1998
+ /** What the run did for each picked app, as its own tool calls found it, sent with the session whenever it changes (#2307). */
1999
+ const appSummaries = new Map();
2000
+ const sendApps = () => ctx.sync.update({ apps: [...appSummaries.values()].slice(0, 50) });
2001
+ const pick = (keys) => {
2002
+ pickedApps = keys;
2003
+ const before = new Map(appSummaries);
2004
+ appSummaries.clear();
2005
+ for (const key of keys)
2006
+ appSummaries.set(key, before.get(key) ?? { app_key: key, host_project: null, preview_proven: false, preview_host: null, key_placed: false, method_saved: false });
2007
+ sendApps();
2008
+ };
2009
+ const noteApp = (key, patch) => {
2010
+ const was = key !== undefined ? appSummaries.get(key) : undefined;
2011
+ if (!was || Object.entries(patch).every(([field, value]) => was[field] === value))
2012
+ return;
2013
+ appSummaries.set(key, { ...was, ...patch });
2014
+ sendApps();
2015
+ };
2016
+ /** The command saw the app's preview open at `url`, whether or not a key for it was saved after: only its hostname is kept. */
2017
+ const sawApp = (key, url, proof) => {
2018
+ if (proof.proven)
2019
+ noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
2020
+ return proof;
2021
+ };
2022
+ /** A proof opened the app's preview at `url`, so its method may be saved. */
2023
+ const proveApp = (key, url) => {
2024
+ provenApps.add(key);
2025
+ noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
2026
+ };
1976
2027
  /** Each app's folder, by key, as the state names it: what its host project or site must build. */
1977
2028
  const appFolders = new Map();
1978
2029
  /** The apps as the state last listed them. */
@@ -2103,7 +2154,7 @@ export function createTools(ctx) {
2103
2154
  // 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).
2104
2155
  const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
2105
2156
  if (named && !(await ask(namedAppQuestion(named))).skipped) {
2106
- pickedApps = new Set([named.key]);
2157
+ pick(new Set([named.key]));
2107
2158
  return JSON.stringify({ values: [named.key], named: true });
2108
2159
  }
2109
2160
  // 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.
@@ -2112,11 +2163,11 @@ export function createTools(ctx) {
2112
2163
  const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
2113
2164
  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 })) });
2114
2165
  if (answer.skipped) {
2115
- pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
2166
+ pick(new Set(apps.filter((a) => a.enabled).map((a) => a.key)));
2116
2167
  return JSON.stringify({ skipped: true, on: [...pickedApps] });
2117
2168
  }
2118
2169
  const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
2119
- pickedApps = new Set(values);
2170
+ pick(new Set(values));
2120
2171
  return JSON.stringify({ values });
2121
2172
  }
2122
2173
  return [
@@ -2253,6 +2304,8 @@ export function createTools(ctx) {
2253
2304
  savedHost = provenHost;
2254
2305
  if (saved?.appKey !== undefined)
2255
2306
  perApp = true;
2307
+ if (saved && 'intendedMethod' in saved && typeof saved.appKey === 'string')
2308
+ noteApp(saved.appKey, { method_saved: true });
2256
2309
  if (saved?.appKey !== undefined && typeof saved.intendedMethod === 'string')
2257
2310
  appMethods.set(String(saved.appKey), saved.intendedMethod);
2258
2311
  if (saved && 'intendedMethod' in saved)
@@ -2434,12 +2487,26 @@ export function createTools(ctx) {
2434
2487
  const covered = test_pr && recordedBranches?.setup
2435
2488
  ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
2436
2489
  const drive = covered === false ? undefined : test_pr;
2490
+ // Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
2491
+ const appsBefore = new Map(appSummaries);
2492
+ for (const { appKey, reason } of p.apps ?? []) {
2493
+ const plain = reason.trim();
2494
+ const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
2495
+ if (plain.length <= 200 && plainText(plain) === plain && !address && !scan('output', plain).length)
2496
+ noteApp(appKey, { reason: plain });
2497
+ }
2437
2498
  // The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
2438
2499
  ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
2439
2500
  const res = await ctx.sync.flush();
2440
2501
  if (res && res.status >= 400 && res.status < 500) {
2441
2502
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
2442
2503
  ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
2504
+ // Its reasons go with it: the report sent again gives its own.
2505
+ if (appsBefore.size) {
2506
+ for (const [key, was] of appsBefore)
2507
+ appSummaries.set(key, was);
2508
+ sendApps();
2509
+ }
2443
2510
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
2444
2511
  }
2445
2512
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
@@ -2494,7 +2561,9 @@ export function createTools(ctx) {
2494
2561
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
2495
2562
  // A head's build is still building until it has a final status.
2496
2563
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
2497
- const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => vercelProjectPage('settings/git', chosenHostTeam(ctx.log, 'vercel'), app?.name ?? vercelProject), signal) : await read();
2564
+ // The project's Git settings, for the screen a build that never comes opens: the app's project's own page, else the linked project's.
2565
+ const gitPage = branch !== undefined ? await vercelPageFor(vercel, app ? app.link : await readLink(pi.cwd), 'settings/git', app?.name ?? vercelProject) : '';
2566
+ const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
2498
2567
  /** The project the evidence is of: the app's, else the one this folder is linked to. */
2499
2568
  const link = async () => (app ? app.link : readLink(pi.cwd));
2500
2569
  /** 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. */
@@ -2546,6 +2615,8 @@ export function createTools(ctx) {
2546
2615
  vercelProductions.set(new URL(production).origin, linked.projectId);
2547
2616
  noteProduction(production, 'vercel', undefined, appKey);
2548
2617
  }
2618
+ if (app?.name)
2619
+ noteApp(appKey, { host_project: app.name });
2549
2620
  return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
2550
2621
  }),
2551
2622
  tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
@@ -2581,6 +2652,9 @@ export function createTools(ctx) {
2581
2652
  if (evidence.pullRequestPreviews)
2582
2653
  previewFacts.set('netlify', evidence.pullRequestPreviews);
2583
2654
  noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
2655
+ const site = evidence.unmatched ? undefined : liveSite(evidence);
2656
+ if (site)
2657
+ noteApp(appKey, { host_project: site });
2584
2658
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2585
2659
  }),
2586
2660
  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 cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf 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 cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. 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, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
@@ -2616,8 +2690,10 @@ export function createTools(ctx) {
2616
2690
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2617
2691
  if (!subject)
2618
2692
  cloudflareEvidence = evidence;
2619
- if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project)
2693
+ if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
2620
2694
  cloudflareApps.set(appKey, { project: evidence.project, label: (evidence.production && pagesProject(evidence.production)) || evidence.project });
2695
+ noteApp(appKey, { host_project: evidence.project });
2696
+ }
2621
2697
  for (const origin of cloudflareListed)
2622
2698
  listedApps.delete(origin);
2623
2699
  cloudflareListed.clear();
@@ -2670,7 +2746,7 @@ export function createTools(ctx) {
2670
2746
  if (proof.proven)
2671
2747
  provenHost = previewHostName(url) ?? provenHost;
2672
2748
  if (proof.proven && app)
2673
- provenApps.add(app.appKey);
2749
+ proveApp(app.appKey, url);
2674
2750
  if (proof.proven && app)
2675
2751
  lockPending.delete(app.appKey);
2676
2752
  // The lock in front of an app's preview is that app's own: its key tool is the next call for it, and every other app's steps wait for that (#286). An earlier proof of the app (another preview, or one the key opened before) says nothing about this lock: the app is proven again only once something opens it.
@@ -2852,7 +2928,7 @@ export function createTools(ctx) {
2852
2928
  hostedPreviewProven = true;
2853
2929
  provenHost = 'Vercel';
2854
2930
  if (at.app)
2855
- provenApps.add(at.app.appKey);
2931
+ proveApp(at.app.appKey, at.url);
2856
2932
  };
2857
2933
  // A secret in hand already for the same store and project: another address of the project opens with it.
2858
2934
  const held = bypassHeld.get(store);
@@ -2868,13 +2944,15 @@ export function createTools(ctx) {
2868
2944
  // A project the folder is not linked to is named on the CLI's reads and makes; the reads are held to its id all the same.
2869
2945
  ...(at.app?.name ? { project: at.app.name } : {}),
2870
2946
  consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
2871
- paste: async () => {
2872
- // The project's own settings page, in its team where the run knows it; else Vercel's picker.
2873
- (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
2874
- const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
2947
+ paste: async (stopped) => {
2948
+ // This project's own settings page, under its own team (the one its ids name, as the CLI lists the person's teams): never another project of the account's.
2949
+ (ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
2950
+ const answer = await ask(sentTo(vercelBypassPaste(stopped), at.url));
2875
2951
  return answer.skipped ? undefined : answer.value?.trim();
2876
2952
  },
2877
- prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
2953
+ ...(ctx.hostWait?.bypass ? { wait: ctx.hostWait.bypass } : {}),
2954
+ ...(signal ? { signal } : {}),
2955
+ prove: async (secret) => sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret })),
2878
2956
  save: async (secret) => {
2879
2957
  const saved = await saveLock(pi, 'secret', BYPASS_HEADER, secret, at.app?.appKey);
2880
2958
  stored = saved.stored;
@@ -2911,7 +2989,7 @@ export function createTools(ctx) {
2911
2989
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2912
2990
  let pattern;
2913
2991
  const prove = async (password, form = false) => {
2914
- const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
2992
+ const proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form }));
2915
2993
  if (proof.proven && proof.pattern)
2916
2994
  pattern = proof.pattern;
2917
2995
  return proof;
@@ -2922,7 +3000,7 @@ export function createTools(ctx) {
2922
3000
  if ((await prove(undefined)).proven) {
2923
3001
  provenPattern = pattern ?? provenPattern;
2924
3002
  if (at.app)
2925
- provenApps.add(at.app.appKey);
3003
+ proveApp(at.app.appKey, at.url);
2926
3004
  return JSON.stringify(stands);
2927
3005
  }
2928
3006
  passwordStands.delete(site);
@@ -2933,7 +3011,7 @@ export function createTools(ctx) {
2933
3011
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2934
3012
  provenPattern = pattern ?? provenPattern;
2935
3013
  if (at.app)
2936
- provenApps.add(at.app.appKey);
3014
+ proveApp(at.app.appKey, at.url);
2937
3015
  return JSON.stringify(passwordSaved);
2938
3016
  }
2939
3017
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
@@ -2981,7 +3059,7 @@ export function createTools(ctx) {
2981
3059
  hostedPreviewProven = true;
2982
3060
  provenHost = 'Netlify';
2983
3061
  if (at.app)
2984
- provenApps.add(at.app.appKey);
3062
+ proveApp(at.app.appKey, at.url);
2985
3063
  provenPattern = pattern ?? provenPattern;
2986
3064
  }
2987
3065
  if (value) {
@@ -2992,7 +3070,7 @@ export function createTools(ctx) {
2992
3070
  hostedPreviewProven = true;
2993
3071
  provenHost = 'Netlify';
2994
3072
  if (at.app)
2995
- provenApps.add(at.app.appKey);
3073
+ proveApp(at.app.appKey, at.url);
2996
3074
  provenPattern = pattern ?? provenPattern;
2997
3075
  passwordSaved = passwordOutcome;
2998
3076
  }
@@ -3014,12 +3092,13 @@ export function createTools(ctx) {
3014
3092
  if (accessOutcome?.status === 'skipped')
3015
3093
  return JSON.stringify(accessOutcome);
3016
3094
  let proof;
3017
- const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
3095
+ const prove = async (token) => (proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token })));
3018
3096
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
3019
3097
  const save = async (token) => {
3020
3098
  const app = at.app ? { appKey: at.app.appKey } : {};
3021
3099
  if (!(await call(pi, `${base}/preview-auth/access`, { method: 'PUT', body: { clientId: token.id, clientSecret: token.secret, ...app } })).ok)
3022
3100
  return 'The setup API did not store the service token.';
3101
+ noteApp(at.app?.appKey, { key_placed: true });
3023
3102
  // An app that signs in with Access sends this token already: the setting would make it a second credential, which the setup API refuses.
3024
3103
  const repos = object((await call(pi, `${ctx.setupApi}/state`)).data)?.repos;
3025
3104
  const lock = Array.isArray(repos) ? object(object(repos.map(object).find((r) => r?.name === ctx.repo)?.items)?.['preview-protection']) : undefined;
@@ -3027,6 +3106,7 @@ export function createTools(ctx) {
3027
3106
  return null;
3028
3107
  if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'access', ...app } })).ok)
3029
3108
  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.';
3109
+ noteApp(at.app?.appKey, { method_saved: true });
3030
3110
  return null;
3031
3111
  };
3032
3112
  let answer;
@@ -3077,7 +3157,7 @@ export function createTools(ctx) {
3077
3157
  hostedPreviewProven = true;
3078
3158
  provenHost = 'Cloudflare Pages';
3079
3159
  if (at.app)
3080
- provenApps.add(at.app.appKey);
3160
+ proveApp(at.app.appKey, at.url);
3081
3161
  if (pattern)
3082
3162
  provenPattern = pattern;
3083
3163
  }
package/dist/ui/look.js CHANGED
@@ -63,7 +63,7 @@ function block(lines, width, size = Math.max(0, ...lines.map((line) => visibleWi
63
63
  const left = ' '.repeat(Math.max(0, Math.floor((width - Math.min(size, width)) / 2)));
64
64
  return lines.map((line) => left + truncateToWidth(line, width - left.length, '…'));
65
65
  }
66
- /** Plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
66
+ /** 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. */
67
67
  function balanced(text, max) {
68
68
  const sentences = text.split(/(?<=[.!?])\s+/);
69
69
  if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
@@ -76,8 +76,8 @@ function balanced(text, max) {
76
76
  }
77
77
  return lines;
78
78
  }
79
- /** Supporting text: wrapped, soft, each line centered. */
80
- const said = (text, inner, st) => balanced(text, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner));
79
+ /** Supporting text: wrapped, soft, each line centered; a blank line in it parts paragraphs, each wrapped on its own and an empty row between. */
80
+ const said = (text, inner, st) => text.split('\n\n').flatMap((part, i) => [...(i ? [''] : []), ...balanced(part, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner))]);
81
81
  /** Wrapped text whose first line starts with `first` and the rest line up under it. */
82
82
  const hang = (text, width, first) => wrapTextWithAnsi(text, Math.max(10, width - first.length)).map((line, i) => (i ? ' '.repeat(first.length) : first) + line);
83
83
  /** An address cut into rows no wider than `width`, after a slash where one falls in the row's last two thirds, each linking to the whole of it, so any row opens it. */
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { redactShapes } from './scan.js';
3
5
  /**
4
6
  * Getting past Vercel's Deployment Protection the way Vercel documents it for tools: Protection Bypass for Automation, a
5
7
  * secret of the project that a request sends in the x-vercel-protection-bypass header (every plan has it; a team member
@@ -67,24 +69,71 @@ function pick(secrets, before) {
67
69
  const newest = (list) => [...list].sort((a, b) => b.createdAt - a.createdAt)[0];
68
70
  return newest(secrets.filter((s) => s.note === SECRET_NOTE)) ?? (before && newest(secrets.filter((s) => !before.has(s.value))));
69
71
  }
70
- /** Makes the secret with the label through `vercel api`; a CLI without `api` (or one Vercel refuses) falls back to its own command, which the same role check stops. */
72
+ /**
73
+ * What a failed CLI run printed, as one line for a reason the screen and the model read: Vercel's own error message
74
+ * where the output is its JSON, else the first line that says "Error"; nothing shaped like a secret, which a CLI can
75
+ * echo back from its arguments, since the reason goes to the model and the transcript.
76
+ */
77
+ export function cliSaid(err) {
78
+ const printed = `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`;
79
+ const json = printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1);
80
+ let said = '';
81
+ try {
82
+ const error = record(record(JSON.parse(json))?.error);
83
+ if (typeof error?.message === 'string' && error.message)
84
+ said = error.message.trim();
85
+ }
86
+ catch {
87
+ // Not JSON: the lines below.
88
+ }
89
+ if (!said) {
90
+ const line = printed.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).find((l) => /error/i.test(l));
91
+ said = (line ?? '').replace(/^error[:!]?\s*/i, '');
92
+ }
93
+ return redactShapes(said).slice(0, 200);
94
+ }
95
+ /** Vercel's answer to a role that may not make the secret: 403, or its words for it. */
96
+ const REFUSED = /forbidden|not authorized|not_authorized|permission|\b403\b/i;
97
+ /**
98
+ * Makes the secret with the label through `vercel api`; a CLI without `api` (or one Vercel refuses) falls back to its own
99
+ * command, which the same role check stops. Once made, which kind the project now holds (labelled through `api`, or the
100
+ * CLI's own without a label); else why not, in Vercel's words where it gave any, the role named only where Vercel refused.
101
+ */
71
102
  async function make(run, link, name) {
103
+ const said = [];
72
104
  if (link) {
73
105
  try {
74
106
  await run(makeApi(link));
75
- return true;
107
+ return { made: 'labelled' };
76
108
  }
77
- catch {
109
+ catch (err) {
78
110
  // The CLI's own command below fails the same way when the role is missing, and works when `api` is the one missing.
111
+ said.push(cliSaid(err));
79
112
  }
80
113
  }
81
114
  try {
82
115
  await run(makeArgs(name));
83
- return true;
116
+ return { made: 'unlabelled' };
84
117
  }
85
- catch {
86
- return false;
118
+ catch (err) {
119
+ said.push(cliSaid(err));
87
120
  }
121
+ const words = said.filter(Boolean).at(-1);
122
+ const detail = words ? ` (Vercel said: ${words})` : '';
123
+ return {
124
+ reason: REFUSED.test(said.join('\n'))
125
+ ? `Vercel refused to make a bypass secret on the project for the Vercel CLI here${detail}: that takes the Member role, or Project Administrator.`
126
+ : `The Vercel CLI here could not make a bypass secret on the project${detail}.`,
127
+ };
128
+ }
129
+ /** The proof again, while Vercel's login still stands in front of the address, until the edge takes a secret made moments ago or the wait runs out; any other answer stands at once. */
130
+ async function admitted(secret, deps) {
131
+ const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
132
+ let proof = await deps.prove(secret);
133
+ for (const end = Date.now() + wait.totalMs; !proof.proven && proof.gate === 'vercel-protection' && Date.now() < end && !deps.signal?.aborted; proof = await deps.prove(secret)) {
134
+ await sleep(wait.everyMs, undefined, { signal: deps.signal });
135
+ }
136
+ return proof;
88
137
  }
89
138
  /**
90
139
  * Gets a bypass secret in place for the address Vercel's login stands in front of, and saves only one that opens it. The
@@ -96,46 +145,62 @@ export async function passVercelLogin(deps) {
96
145
  const link = await deps.link();
97
146
  const project = await readProject(deps.run, link, deps.project);
98
147
  const from = project?.name ? { project: project.name } : {};
148
+ const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
99
149
  const finish = async (secret, how) => {
100
150
  deps.remember(secret);
101
- const proof = await deps.prove(secret);
151
+ // A secret made moments ago may take the edge a few seconds: its proof is tried again while the login stands.
152
+ const proof = how === 'created' ? await admitted(secret, { ...deps, wait }) : await deps.prove(secret);
102
153
  if (!proof.proven)
103
154
  return { reason: proof.reason ?? 'The address did not open with the secret.' };
104
155
  const failed = await deps.save(secret);
105
156
  return failed ? { status: 'save-failed', reason: failed, ...from } : { status: 'saved', secret: how, ...from, value: secret };
106
157
  };
107
- /** Why the CLI's way ended without a secret that opens the preview, for the paste screen's skip. */
158
+ /** Why the CLI's way ended without a secret that opens the preview, for the paste screen and its skip. */
108
159
  let stopped;
160
+ /** What the project holds for the person to copy rather than make another: a secret labelled for Impeccable (its own, or one this run made), or one the CLI's own command made without a label. */
161
+ let held = 'none';
109
162
  if (!project)
110
163
  stopped = 'The Vercel CLI could not read the project: it is signed out, this folder is not linked to the project, or the role does not allow it.';
111
164
  else {
112
165
  const own = pick(project.secrets);
113
166
  if (own) {
167
+ held = 'labelled';
114
168
  const done = await finish(own.value, 'existing');
115
169
  if ('status' in done)
116
170
  return done;
117
- stopped = done.reason;
171
+ stopped = `The project's secret labelled "${SECRET_NOTE}" did not open the preview: ${done.reason}`;
118
172
  }
119
173
  else {
120
174
  if (!(await deps.consent()))
121
175
  return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
122
176
  const before = new Set(project.secrets.map((s) => s.value));
123
- if (!(await make(deps.run, link, deps.project)))
124
- stopped = 'The Vercel CLI could not make a bypass secret on the project: that takes the Member role or Project Administrator.';
177
+ const made = await make(deps.run, link, deps.project);
178
+ if ('reason' in made)
179
+ stopped = made.reason;
125
180
  else {
126
- const ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
181
+ // The project holds the secret from here on, whatever the reads below say: the screen says to copy it, never to make another.
182
+ held = made.made;
183
+ // Read back by its label, again for a few seconds while the settings do not list it yet.
184
+ let ours;
185
+ for (const end = Date.now() + wait.totalMs;;) {
186
+ ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
187
+ if (ours || Date.now() >= end || deps.signal?.aborted)
188
+ break;
189
+ await sleep(wait.everyMs, undefined, { signal: deps.signal });
190
+ }
127
191
  if (!ours)
128
- stopped = 'The secret the Vercel CLI made could not be read back.';
192
+ stopped = `The Vercel CLI made a bypass secret${held === 'labelled' ? ` labelled "${SECRET_NOTE}"` : ' (without a label)'}, but could not read it back from the project's settings within ${Math.round(wait.totalMs / 1000)} s.`;
129
193
  else {
194
+ held = ours.note === SECRET_NOTE ? 'labelled' : 'unlabelled';
130
195
  const done = await finish(ours.value, 'created');
131
196
  if ('status' in done)
132
197
  return done;
133
- stopped = done.reason;
198
+ stopped = `The Vercel CLI made a bypass secret${held === 'labelled' ? ` labelled "${SECRET_NOTE}"` : ' (without a label)'}, but the preview did not open with it within ${Math.round(wait.totalMs / 1000)} s: ${done.reason}`;
134
199
  }
135
200
  }
136
201
  }
137
202
  }
138
- const typed = await deps.paste();
203
+ const typed = await deps.paste({ reason: stopped, held });
139
204
  if (typed === undefined)
140
205
  return { status: 'skipped', reason: stopped, ...from };
141
206
  if (!isBypassSecret(typed))
package/dist/vercel.js CHANGED
@@ -17,20 +17,31 @@ export async function vercelStatus(vercel) {
17
17
  }
18
18
  }
19
19
  /**
20
- * `vercel teams ls --json`: the teams the person is a member of, each by its slug (what `--team` takes) and name, and
21
- * which one is the CLI's current scope. The JSON is on stdout; the table the CLI prints otherwise goes to stderr.
20
+ * `vercel teams ls --json`: the teams the person is a member of, each by its slug (what `--team` takes), its name and
21
+ * its id, and which one is the CLI's current scope. The JSON is on stdout; the table the CLI prints otherwise goes to
22
+ * stderr.
22
23
  */
23
24
  export async function vercelTeams(vercel) {
24
25
  try {
25
26
  const listed = JSON.parse(await vercel(['teams', 'ls', '--json'])).teams;
26
27
  return (Array.isArray(listed) ? listed : []).flatMap((team) => typeof team.slug === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(team.slug)
27
- ? [{ value: team.slug, label: typeof team.name === 'string' && team.name ? team.name.slice(0, 100) : team.slug, current: team.current === true }]
28
+ ? [{ value: team.slug, label: typeof team.name === 'string' && team.name ? team.name.slice(0, 100) : team.slug, current: team.current === true, ...(typeof team.id === 'string' && /^team_[A-Za-z0-9]+$/.test(team.id) ? { id: team.id } : {}) }]
28
29
  : []);
29
30
  }
30
31
  catch {
31
32
  return [];
32
33
  }
33
34
  }
35
+ /**
36
+ * The scope a project's dashboard pages sit under, `https://vercel.com/<scope>/<project>/…`, for the project `link`
37
+ * names: its team's slug, as the CLI lists the person's teams, or for a project of the personal scope the username the
38
+ * CLI is signed in as. Undefined when the CLI cannot say, where the caller falls back to Vercel's picker.
39
+ */
40
+ export async function vercelDashboardScope(vercel, link, teams) {
41
+ if (link.orgId.startsWith('team_'))
42
+ return (teams ?? (await vercelTeams(vercel))).find((team) => team.id === link.orgId)?.value;
43
+ return (await vercelStatus(vercel)).user;
44
+ }
34
45
  /** Where Vercel's GitHub app is installed or given more repositories. */
35
46
  export const VERCEL_APP_PAGE = 'https://github.com/apps/vercel/installations/new';
36
47
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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,