@impeccable/reviews 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -44,7 +44,7 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
44
44
  4. **Two pull requests,** opened as you with `gh`, even when something is left for a person, which goes into the report, both into the default branch and in this order:
45
45
  - the test drive, safe to close (`impeccable/test-drive`, "Impeccable test drive (safe to close)"), always: a few real style or layout changes on a page the preview renders, on top of the setup commit when there is one, so it carries the workflow, whose job builds the preview on the pull request while Impeccable's review waits for it. Its pull request opens only once its preview is proven: the host's build of the pushed branch, or the local proof of Impeccable's own preview. No preview, no test drive: the run then says why in the report and what you can do, and still opens the setup pull request when the repository needs one;
46
46
  - the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed. The report lists the files it changes, so one the setup never meant to change stands out.
47
- 5. **The end.** The run ends once the pull requests are open and recorded. The report (what it found, configured, skipped and could not verify, and what to do before merging) goes to the web page, which follows Impeccable's review of the test drive; the terminal ends on the test drive's screen (when there is a setup pull request too), then "You're all set up" with the setup pull request, or the test drive when there is none, and what is left, and its summary keeps both pull requests and the web page; exit 0. If something stops it, it says why in a sentence and exits 1.
47
+ 5. **The end.** The run ends once the pull requests are open and recorded. The report (what it found, configured, skipped and could not verify, and what to do before merging) goes to the web page, which follows Impeccable's review of the test drive; the terminal ends on the test drive's screen (when there is a setup pull request too), then "You're all set up" with the setup pull request, or the test drive when there is none, and what is left, and its summary keeps both pull requests and the web page; exit 0. A run that opened no test drive, or left a picked app not set up, says so in its heading ("Not set up: the previews are behind Cloudflare Access") with the reason under it, prints the report, and exits 1. If something stops it, it says why in a sentence and exits 1.
48
48
 
49
49
  The sign-in's setup token lasts an hour. A run that goes longer asks you to sign in again, in the same terminal, and carries on.
50
50
 
package/dist/fence.js CHANGED
@@ -782,8 +782,9 @@ function vercel(args, where, agreed, team, settlement = null) {
782
782
  return allow();
783
783
  if (sub === 'login')
784
784
  return deny('the Vercel sign-in is impeccable_ask\'s, with the id vercel-setup: it opens the browser from the sign-in screen');
785
+ // The gate is the person's yes to setting Vercel up, not the CLI's sign-in: a project Vercel deploys already needs no link for its login, which impeccable_vercel_bypass gets past (#310).
785
786
  if (!agreed)
786
- return deny('sets Vercel up, and the person has not signed in to Vercel for that');
787
+ return deny("sets Vercel up, which waits for the person's yes to the Vercel setup (impeccable_ask vercel-setup), whether or not the CLI is signed in; a preview behind Vercel's login needs no vercel link, since impeccable_vercel_bypass finds the repository's project");
787
788
  const cwd = options.get('--cwd');
788
789
  if (cwd !== undefined && (typeof cwd !== 'string' || outside(cwd, where)))
789
790
  return deny('points the Vercel CLI outside the checkout');
package/dist/main.js CHANGED
@@ -16,7 +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 { appsSummary, createTools, ownPullRequest, resultLine } from './tools.js';
19
+ import { appsSummary, createTools, ending, ownPullRequest, resultLine } from './tools.js';
20
20
  import { transcriptPaths } from './transcript.js';
21
21
  import { askPerson } from './ui/bridge.js';
22
22
  import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
@@ -235,16 +235,16 @@ export async function main(argv) {
235
235
  const saved = await settle(liveSync.flush(), 5000);
236
236
  const { testPr } = report;
237
237
  const setupPr = shownSetupPr(report.setupPr);
238
- // On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286).
238
+ // On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286). A run short of that says what is not, and exits 1 (#321).
239
239
  const apps = appsSummary(report.apps ?? []);
240
- const allSetUp = Boolean(testPr) && !apps.notReady.length;
240
+ const end = ending(report);
241
241
  // One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
242
242
  const hand = { open: openBrowser, copy: copyText };
243
243
  if (testPr && setupPr)
244
244
  await showPullRequest(testPr.url, hand);
245
245
  await showOutro({
246
- outcome: allSetUp ? 'done' : 'partial',
247
- title: allSetUp ? "You're all set up" : apps.notReady.length ? `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up` : 'Settings saved',
246
+ outcome: end.outcome,
247
+ title: end.title,
248
248
  result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
249
249
  link: (setupPr ?? testPr)?.url,
250
250
  links: [
@@ -253,16 +253,16 @@ export async function main(argv) {
253
253
  ...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
254
254
  { label: 'Web page', url: page },
255
255
  ],
256
- todo: [...apps.todo, ...report.left],
256
+ todo: end.todo,
257
257
  warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
258
258
  }, hand);
259
- // The web page has the report; when it missed it, or there is no test drive, whose why it holds, the report stays here as well.
260
- if (!saved?.ok || !testPr)
259
+ // 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.
260
+ if (!saved?.ok || end.code)
261
261
  say(`\n${plainReport(report.markdown)}`);
262
262
  if (!saved?.ok)
263
263
  reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
264
264
  await reporter.done();
265
- process.exit(0);
265
+ process.exit(end.code);
266
266
  }
267
267
  const waiting = liveSync.payload.phase === 'waiting' ? liveSync.payload.question?.prompt : undefined;
268
268
  const why = pageStatus(fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.');
@@ -5,7 +5,7 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
5
5
  ### Steps
6
6
 
7
7
  1. **Prove the newest preview** with impeccable_prove_preview: the branch's alias (`alias`) of the newest listed preview whose state is success (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds, such as the app's name in its title. The alias is the address the pattern saves, so it is the one proven; a preview listed without one is proven on its own address (`url`). Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
8
- 2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
8
+ 2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`, with the app's `appKey` in a monorepo, since each app's project has a pattern of its own. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
9
9
 
10
10
  ### Reference
11
11
 
@@ -5,7 +5,7 @@ Netlify deploys previews of the pull requests: impeccable_netlify_previews lists
5
5
  ### Steps
6
6
 
7
7
  1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest listed preview whose state is success or ready (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
8
- 2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
8
+ 2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`, with the app's `appKey` in a monorepo, since each app's site has a pattern of its own. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
9
9
 
10
10
  ### Reference
11
11
 
@@ -4,7 +4,7 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
4
4
 
5
5
  ### Steps
6
6
 
7
- 1. **Get the key in place** (the Preview method task): impeccable_vercel_bypass for Vercel's login, under the status line *"Getting past Vercel's login"*; impeccable_netlify_password for Netlify's lock, under *"Getting past Netlify's password"*; impeccable_cloudflare_access for Cloudflare Access, under *"Getting past Cloudflare Access"*. The tool does the rest: it finds the project's secret labelled "Impeccable reviews", or reads what Netlify's settings say stands in front of the site; shows one screen asking to create the secret, to store the site password Netlify gives back, to make a site behind Netlify's team login public (signing the Netlify CLI in, in the browser, on that yes where it is signed out; where Netlify refuses, it opens Netlify's Visitor access page and asks the person to try again once they changed it there), or to create a service token (then Cloudflare's sign-in in the browser where cf here lacks it, and it creates the token as the person and allows it in the Access application's policy; a screen takes a Cloudflare API token only where that sign-in cannot), or, when it cannot make or read one, one screen taking a pasted one (Vercel's with the page where it is made opened); opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
7
+ 1. **Get the key in place** (the Preview method task): impeccable_vercel_bypass for Vercel's login, under the status line *"Getting past Vercel's login"*; impeccable_netlify_password for Netlify's lock, under *"Getting past Netlify's password"*; impeccable_cloudflare_access for Cloudflare Access, under *"Getting past Cloudflare Access"*. The tool does the rest: it finds Vercel's project with or without a `.vercel/` link (no `vercel link` is needed, and the tool signs the Vercel CLI in where it is signed out), then the project's secret labelled "Impeccable reviews", or reads what Netlify's settings say stands in front of the site; shows one screen asking to create the secret, to store the site password Netlify gives back, to make a site behind Netlify's team login public (signing the Netlify CLI in, in the browser, on that yes where it is signed out; where Netlify refuses, it opens Netlify's Visitor access page and asks the person to try again once they changed it there), or to create a service token (then Cloudflare's sign-in in the browser where cf here lacks it, and it creates the token as the person and allows it in the Access application's policy; a screen takes a Cloudflare API token only where that sign-in cannot), or, when it cannot make or read one, one screen taking a pasted one (Vercel's with the page where it is made opened); opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
8
8
  - `saved`: the preview is proven (`proven: true`); `secret`, `password` or `token` says whether it was created, existing or typed, and `project`, `site` or `application` which one holds or allows it; Cloudflare's `pattern` is the address pattern to save with the method, and `apiToken` says a Cloudflare API token the person made created the token. Step 2.
9
9
  - `opened` (Netlify): the site is public now and the preview proven (`proven: true`); `madePublic` says whether the run or the person made it public, and `site` which site. Step 2.
10
10
  - `login` (Netlify): Netlify kept its team login in front through the retries; `reason` says so. Step 3.
package/dist/playbook.js CHANGED
@@ -61,7 +61,7 @@ Keep secrets out of files, settings and messages: the workflow names a repositor
61
61
  ## Voice
62
62
  The person reads your [STATUS] lines, task titles and questions, here and on the web page: calm, specific, one line each.
63
63
  - **[STATUS] lines**: start each step's message with one, a present-tense verb phrase of at most 70 characters saying what is happening (*"Running the build"*, *"Opening the test drive"*), or a short finding (*"Next.js 16, pnpm, Prisma on SQLite. No hosting found."*). impeccable_progress refuses a line that narrates like an assistant: write *"Reading the deploy workflows"*, not *"I'm trying to work out how the app deploys."* Detail belongs in the report.
64
- - **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Apps (on a repository with several apps), Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
64
+ - **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Apps (on a repository with several apps), Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task completed only once its result is saved, and a task completed stays so: Preview method once a method is saved, so where the test drive's preview is what proves the method (a host's previews with no pull request yet), it waits as pending while Test drive is in progress and is completed with the save. Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
65
65
  - **Questions**: one sentence, at most one hint line, and a title that names the subject.
66
66
 
67
67
  ## The report
package/dist/tools.js CHANGED
@@ -17,7 +17,7 @@ import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enab
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, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, 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';
@@ -133,13 +133,35 @@ function stageError(status, tasks) {
133
133
  const task = running[0];
134
134
  if (!task || fitsTask(status, task.title))
135
135
  return null;
136
- return `Refused: the status "${status.trim()}" is about ${stagesOf(status).join(' and ')}, but the task in progress is "${task.title}". The status line is the detail of the task in progress: mark the task it is about in progress, or say what "${task.title}" is doing. ${again}`;
136
+ return `Refused: the status "${status.trim()}" is about ${stagesOf(status).join(' and ')}, but the task in progress is "${task.title}". The status line is the detail of the task in progress: mark the task it is about in progress, or say what "${task.title}" is doing. Mark "${task.title}" completed only once its result is saved, never to get past this. ${again}`;
137
137
  }
138
- /** The line under the last screen's heading ("You're all set up", or "Settings saved" without a test drive), about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. */
138
+ /** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
139
139
  export function resultLine(report) {
140
- const lead = !report.testPr ? 'No test drive: the report says why, printed here when this screen closes.' : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
140
+ const lead = !report.testPr ? `${noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
141
141
  return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
142
142
  }
143
+ /** Why a run opened no test drive, as far as what it saved and met tells. */
144
+ const noTestDrive = ({ methodSaved, lock }) => methodSaved ? 'A preview method is saved, but no test drive opened, so nothing shows Impeccable reviewing yet.'
145
+ : lock ? `The previews are behind ${LOCKS[lock].lock} and no key was saved, so no preview method was saved and no test drive opened.`
146
+ : 'No preview method was saved and no test drive opened, so Impeccable has nothing to review yet.';
147
+ /**
148
+ * How the last screen reads and the command exits (#321): "You're all set up" and 0 only with a test drive and every
149
+ * picked app set up; otherwise a heading that says what is not, the host's step first where its lock stopped the run,
150
+ * and 1, so a script tells a run that set nothing up from one that set everything up.
151
+ */
152
+ export function ending(report) {
153
+ const apps = appsSummary(report.apps ?? []);
154
+ // On a repository with several apps, each locked app's own line says it.
155
+ const step = !report.apps && !report.methodSaved && report.lock ? [LOCKS[report.lock].left] : [];
156
+ const todo = [...new Set([...step, ...apps.todo, ...report.left])];
157
+ if (report.testPr && !apps.notReady.length)
158
+ return { outcome: 'done', title: "You're all set up", todo, code: 0 };
159
+ const outcome = report.methodSaved ? 'partial' : 'stopped';
160
+ if (apps.notReady.length)
161
+ return { outcome, title: `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up`, todo, code: 1 };
162
+ const title = report.methodSaved ? 'No test drive opened' : `Not set up: ${report.lock ? `the previews are behind ${LOCKS[report.lock].lock}` : 'no preview method was saved'}`;
163
+ return { outcome, title, todo, code: 1 };
164
+ }
143
165
  /** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
144
166
  export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
145
167
  const PROGRESS = {
@@ -1513,13 +1535,28 @@ export function createTools(ctx) {
1513
1535
  const publicProduction = new Set();
1514
1536
  const cloudflareListed = new Set();
1515
1537
  let hostedPreviewProven = false;
1516
- let provenPattern;
1538
+ /**
1539
+ * The address pattern each proof answered, by the app it was proven for, and the last one under '' for a repository
1540
+ * of one app. A monorepo's apps may each be a project of their own on a host of their own, so one app's proof never
1541
+ * replaces another's pattern (#332).
1542
+ */
1543
+ const provenPatterns = new Map();
1544
+ const provePattern = (pattern, appKey) => {
1545
+ if (!pattern)
1546
+ return;
1547
+ provenPatterns.set('', pattern);
1548
+ if (appKey)
1549
+ provenPatterns.set(appKey, pattern);
1550
+ };
1517
1551
  /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
1518
1552
  let provenHost;
1519
1553
  let savedHost;
1520
1554
  /** Whether the repository's last saved method is a host's previews, and whether any write named an app: a monorepo's apps each go their own way, and only a run whose host builds them all opens no workflow and no setup pull request. */
1521
1555
  let hostMethod = false;
1522
1556
  let perApp = false;
1557
+ /** Whether any preview write with a method was taken this run, and the host's lock the last gated proof met: the last screen names it when no preview ever opened (#321), and Preview method is completed only once a method is saved (#322). */
1558
+ let methodSaved = false;
1559
+ let lastLock;
1523
1560
  /**
1524
1561
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1525
1562
  * every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
@@ -2265,8 +2302,8 @@ export function createTools(ctx) {
2265
2302
  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${appsRepo ? `, read for it with its appKey (${String(object(p.body)?.appKey)})` : ''}. Nothing was saved.`;
2266
2303
  }
2267
2304
  const saved = object(p.body);
2268
- if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
2269
- return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
2305
+ if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
2306
+ return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify or Cloudflare Pages preview. Nothing was saved.`;
2270
2307
  }
2271
2308
  // The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
2272
2309
  if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
@@ -2311,6 +2348,8 @@ export function createTools(ctx) {
2311
2348
  appMethods.set(String(saved.appKey), saved.intendedMethod);
2312
2349
  if (saved && 'intendedMethod' in saved)
2313
2350
  hostMethod = saved.intendedMethod === 'preview-deploy';
2351
+ if (typeof saved?.intendedMethod === 'string')
2352
+ methodSaved = true;
2314
2353
  for (const key of Object.keys(object(p.body) ?? {}))
2315
2354
  if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
2316
2355
  previews.changes.add(key);
@@ -2343,7 +2382,7 @@ export function createTools(ctx) {
2343
2382
  const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
2344
2383
  if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
2345
2384
  const projects = [...vercelDeployed.values()].filter(Boolean);
2346
- return `Refused: Vercel deploys this repository already${projects.length ? ` (${projects.join(', ')})` : ''}: it made deployments of it, production included, so its previews start with the first pull request, and there is no Vercel sign-in, project or connection to make. Go on as vercel-previews says: build and push the test drive as pull-requests' step 4 says, wait for Vercel's deployment of the pushed branch with impeccable_hosted_previews and branch, prove the test drive's page there (past Vercel's login as preview-protection says), and save the method.`;
2385
+ return `Refused: Vercel deploys this repository already${projects.length ? ` (${projects.join(', ')})` : ''}: it made deployments of it, production included, so its previews start with the first pull request, and there is no Vercel sign-in, project or connection to make. Go on as vercel-previews says: build and push the test drive as pull-requests' step 4 says, wait for Vercel's deployment of the pushed branch with impeccable_hosted_previews and branch, prove the test drive's page there (past Vercel's login as preview-protection says: impeccable_vercel_bypass finds the project with no vercel link, and signs the Vercel CLI in itself where it is signed out), and save the method.`;
2347
2386
  }
2348
2387
  if (host?.[2] === 'setup')
2349
2388
  return JSON.stringify(await hostSignIn(host[1], pi, signal));
@@ -2387,13 +2426,20 @@ export function createTools(ctx) {
2387
2426
  typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
2388
2427
  return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
2389
2428
  }),
2390
- tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
2429
+ tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, a task once completed that is no longer, Preview method completed before a method is saved, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
2391
2430
  const status = p.status.trim();
2392
2431
  const tasks = withAttempts(p.tasks);
2393
2432
  const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
2394
2433
  const voice = statusVoiceError(status) ?? stageError(status, own);
2395
2434
  if (voice)
2396
2435
  return voice;
2436
+ // The list only moves forward (#322): a task done stays done, and Preview method is done only once a method is saved.
2437
+ const undone = ctx.sync.payload.tasks.filter((t) => t.status === 'completed' && !t.id.startsWith('preview-attempt-') && own.find((n) => n.id === t.id)?.status !== 'completed');
2438
+ if (undone.length)
2439
+ return `Refused: ${undone.map((t) => `"${t.title}"`).join(' and ')} ${undone.length === 1 ? 'shows' : 'show'} as done, and a task done stays done and on the list, so the count never goes down. Keep ${undone.length === 1 ? 'it' : 'them'} completed, and mark what failed since on the task it belongs to. Nothing was saved: call impeccable_progress again.`;
2440
+ const early = methodSaved ? undefined : own.find((t) => t.status === 'completed' && stagesOf(t.title).includes('Preview method'));
2441
+ if (early)
2442
+ return `Refused: "${early.title}" is done only once a preview method is saved (impeccable_write to preview with its intendedMethod, taken by the setup API; one an earlier run saved is written again as it is), and none is yet. Keep it in progress while its work goes on, pending while another task is, and failed when the run saves none. Nothing was saved: call impeccable_progress again.`;
2397
2443
  const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
2398
2444
  if (unneeded.length)
2399
2445
  return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
@@ -2511,7 +2557,7 @@ export function createTools(ctx) {
2511
2557
  return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
2512
2558
  }
2513
2559
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2514
- ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
2560
+ ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}) });
2515
2561
  pi.shutdown();
2516
2562
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2517
2563
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
@@ -2756,8 +2802,10 @@ export function createTools(ctx) {
2756
2802
  lockPending.set(app.appKey, proof.gate);
2757
2803
  provenApps.delete(app.appKey);
2758
2804
  }
2759
- if (proof.proven && proof.pattern)
2760
- provenPattern = proof.pattern;
2805
+ if (proof.gate && Object.hasOwn(LOCKS, proof.gate))
2806
+ lastLock = proof.gate;
2807
+ if (proof.proven)
2808
+ provePattern(proof.pattern, app?.appKey);
2761
2809
  if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
2762
2810
  gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
2763
2811
  if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
@@ -2901,7 +2949,7 @@ export function createTools(ctx) {
2901
2949
  noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
2902
2950
  return JSON.stringify(answer);
2903
2951
  }),
2904
- 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 reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then 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) => {
2952
+ 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 reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then 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) => {
2905
2953
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2906
2954
  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.';
2907
2955
  const at = gated;
@@ -2914,26 +2962,81 @@ export function createTools(ctx) {
2914
2962
  if (asked?.status === 'skipped')
2915
2963
  return JSON.stringify(asked);
2916
2964
  // Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project has to have
2917
- // made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names.
2918
- const link = at.app?.link ?? (await readLink(pi.cwd));
2919
- if (!link)
2920
- return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
2965
+ // made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names,
2966
+ // else the one Vercel lists as connected to this repository (#310).
2967
+ const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
2968
+ const fileLink = at.app ? null : await readLink(pi.cwd);
2969
+ /** The consent screen's yes, given before Vercel's sign-in, stands for the secret: it is not asked again. */
2970
+ let consented = false;
2971
+ /**
2972
+ * Signed out, the CLI reads nothing, linked or not: the consent screen comes first, and its yes signs the CLI in, in the browser, rather than leaving the
2973
+ * run at a dead end. Null once it is signed in; else what the tool answers.
2974
+ */
2975
+ const signIn = async () => {
2976
+ /** A skip, of the consent or of the sign-in, stands for the run: it is asked once, as the consent alone is. */
2977
+ const skip = (reason) => {
2978
+ const outcome = { status: 'skipped', reason };
2979
+ bypassOutcomes.set(store, outcome);
2980
+ return JSON.stringify(outcome);
2981
+ };
2982
+ if ((await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped)
2983
+ return skip('The person skipped creating a bypass secret.');
2984
+ consented = true;
2985
+ const agreed = hostSetupAgreed(ctx.log, 'vercel');
2986
+ const had = hostSignIns.get('vercel');
2987
+ hostSignIns.set('vercel', 'confirmed');
2988
+ const signedIn = await hostSignIn('vercel', pi, signal);
2989
+ // The sign-in was for the secret alone: the Vercel setup stays as it was, so the fence neither opens the CLI's setup commands nor holds pushes for a connection nobody makes.
2990
+ if (!agreed) {
2991
+ endHostSetup(ctx.log, 'vercel');
2992
+ teamChoices.delete('vercel');
2993
+ }
2994
+ if (had)
2995
+ hostSignIns.set('vercel', had);
2996
+ else
2997
+ hostSignIns.delete('vercel');
2998
+ if (signedIn.signedIn === true)
2999
+ return null;
3000
+ return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.");
3001
+ };
3002
+ // A folder Vercel deploys from GitHub has no link file unless vercel link ran in it (the file is gitignored), so the project is found on Vercel by this repository,
3003
+ // with the CLI signed in as the person.
3004
+ let found;
3005
+ if (!at.app && !fileLink) {
3006
+ const cli = (await vercelStatus(vercel)).cli;
3007
+ const stop = cli === 'signed-out' ? await signIn() : null;
3008
+ if (stop)
3009
+ return stop;
3010
+ const listed = cli === 'missing' ? { reason: 'The Vercel CLI is not installed here.' } : await vercelRepoProject(vercel, repository);
3011
+ if ('reason' in listed)
3012
+ return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and Vercel named none for this repository: ${listed.reason} A bypass secret goes only to a preview the repository's project serves.`;
3013
+ found = listed;
3014
+ }
3015
+ const link = at.app?.link ?? fileLink ?? found.link;
2921
3016
  // Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
2922
- // An app's project is read from Vercel's list of this repository's projects, never from the file.
3017
+ // An app's project, and one found with no link file, are read from Vercel's list of this repository's projects, never from the file.
2923
3018
  const links = linkedByRun(ctx.log);
2924
3019
  const own = links.get(link.projectId);
2925
- if (!at.app && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
3020
+ if (fileLink && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
2926
3021
  return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
2927
- const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
2928
- const project = await readOwnProject(vercel, link, repository);
2929
- const which = at.app ? 'The Vercel project that builds this app' : 'The Vercel project this folder is linked to';
3022
+ let project = await readOwnProject(vercel, link, repository);
3023
+ // A linked project, or an app's, read nothing because the CLI is signed out: the same sign-in, then the read again.
3024
+ if ('reason' in project && !consented && (await vercelStatus(vercel)).cli === 'signed-out') {
3025
+ const stop = await signIn();
3026
+ if (stop)
3027
+ return stop;
3028
+ project = await readOwnProject(vercel, link, repository);
3029
+ }
3030
+ const which = at.app ? 'The Vercel project that builds this app' : found ? 'The Vercel project connected to this repository' : 'The Vercel project this folder is linked to';
2930
3031
  if ('reason' in project)
2931
3032
  return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
2932
3033
  // The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
3034
+ if (!project.previewsProtected && lastLock === 'vercel-protection')
3035
+ lastLock = undefined;
2933
3036
  if (!project.previewsProtected)
2934
3037
  return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
2935
3038
  if (!(await projectMadeDeployment(vercel, link, project, host)))
2936
- return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app ? 'that project' : 'the linked project'} serves.`;
3039
+ return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app || found ? 'that project' : 'the linked project'} serves.`;
2937
3040
  /** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
2938
3041
  const opened = () => {
2939
3042
  bypassOrigins.set(origin, store);
@@ -2953,9 +3056,9 @@ export function createTools(ctx) {
2953
3056
  const { value, ...told } = await passVercelLogin({
2954
3057
  run: vercel,
2955
3058
  link: async () => link,
2956
- // 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.
2957
- ...(at.app?.name ? { project: at.app.name } : {}),
2958
- consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
3059
+ // A project the folder is not linked to is named on the CLI's reads and makes, in its own team; the reads are held to its id all the same.
3060
+ ...(at.app?.name ? { project: at.app.name } : found ? { project: found.name, ...(found.scope ? { scope: found.scope } : {}) } : {}),
3061
+ consent: async () => consented || !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
2959
3062
  paste: async (stopped) => {
2960
3063
  // 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.
2961
3064
  (ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
@@ -3010,7 +3113,7 @@ export function createTools(ctx) {
3010
3113
  if (stands?.status === 'opened') {
3011
3114
  // A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
3012
3115
  if ((await prove(undefined)).proven) {
3013
- provenPattern = pattern ?? provenPattern;
3116
+ provePattern(pattern, at.app?.appKey);
3014
3117
  if (at.app)
3015
3118
  proveApp(at.app.appKey, at.url);
3016
3119
  return JSON.stringify(stands);
@@ -3021,7 +3124,7 @@ export function createTools(ctx) {
3021
3124
  return JSON.stringify(stands);
3022
3125
  // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
3023
3126
  if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
3024
- provenPattern = pattern ?? provenPattern;
3127
+ provePattern(pattern, at.app?.appKey);
3025
3128
  if (at.app)
3026
3129
  proveApp(at.app.appKey, at.url);
3027
3130
  return JSON.stringify(passwordSaved);
@@ -3064,6 +3167,9 @@ export function createTools(ctx) {
3064
3167
  remember: noteSensitiveAnswer,
3065
3168
  });
3066
3169
  const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
3170
+ // Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321).
3171
+ if (told.protection === 'none' && lastLock === 'netlify-protection')
3172
+ lastLock = undefined;
3067
3173
  if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
3068
3174
  passwordStands.set(site, passwordOutcome);
3069
3175
  if (told.status === 'opened') {
@@ -3072,7 +3178,7 @@ export function createTools(ctx) {
3072
3178
  provenHost = 'Netlify';
3073
3179
  if (at.app)
3074
3180
  proveApp(at.app.appKey, at.url);
3075
- provenPattern = pattern ?? provenPattern;
3181
+ provePattern(pattern, at.app?.appKey);
3076
3182
  }
3077
3183
  if (value) {
3078
3184
  // 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.
@@ -3083,7 +3189,7 @@ export function createTools(ctx) {
3083
3189
  provenHost = 'Netlify';
3084
3190
  if (at.app)
3085
3191
  proveApp(at.app.appKey, at.url);
3086
- provenPattern = pattern ?? provenPattern;
3192
+ provePattern(pattern, at.app?.appKey);
3087
3193
  passwordSaved = passwordOutcome;
3088
3194
  }
3089
3195
  else if (told.status === 'save-failed' && stored) {
@@ -3170,8 +3276,7 @@ export function createTools(ctx) {
3170
3276
  provenHost = 'Cloudflare Pages';
3171
3277
  if (at.app)
3172
3278
  proveApp(at.app.appKey, at.url);
3173
- if (pattern)
3174
- provenPattern = pattern;
3279
+ provePattern(pattern, at.app?.appKey);
3175
3280
  }
3176
3281
  return JSON.stringify(accessOutcome);
3177
3282
  }),
@@ -18,10 +18,12 @@ export const SECRET_NOTE = 'Impeccable reviews';
18
18
  export const isBypassSecret = (value) => typeof value === 'string' && /^[A-Za-z0-9]{32}$/.test(value);
19
19
  /** The CLI's read of the linked project's protection, or with `name` that project's: `protectionBypass` maps each secret to its note and creation time. */
20
20
  export const READ = ['project', 'protection', '--format', 'json', '--non-interactive'];
21
- const readArgs = (name) => (name ? ['project', 'protection', name, ...READ.slice(2)] : READ);
21
+ /** A team other than the CLI's current scope, which its own commands name with `--scope`. */
22
+ const inScope = (args, scope) => (scope ? [...args, '--scope', scope] : args);
23
+ const readArgs = (name, scope) => inScope(name ? ['project', 'protection', name, ...READ.slice(2)] : READ, scope);
22
24
  /** The CLI's own way to make a secret, which carries no label, for a CLI without `api`; with `name`, on that project. */
23
25
  export const MAKE_CLI = ['project', 'protection', 'enable', '--protection-bypass', '--non-interactive'];
24
- const makeArgs = (name) => (name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI);
26
+ const makeArgs = (name, scope) => inScope(name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI, scope);
25
27
  /** `vercel api` (beta) for PATCH /v1/projects/{id}/protection-bypass with `generate.note`, so the secret carries the label; a team's project needs its team on the request. */
26
28
  export const makeApi = (link) => [
27
29
  'api',
@@ -47,10 +49,10 @@ export async function readLink(cwd) {
47
49
  }
48
50
  }
49
51
  /** The linked project's name and automation secrets; null when the CLI could not say: signed out, not linked, or the role does not allow it; or when what it read is not the project `link` names. */
50
- async function readProject(run, link, name) {
52
+ async function readProject(run, link, name, scope) {
51
53
  let settings;
52
54
  try {
53
- const printed = await run(readArgs(name));
55
+ const printed = await run(readArgs(name, scope));
54
56
  settings = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
55
57
  }
56
58
  catch {
@@ -99,7 +101,7 @@ const REFUSED = /forbidden|not authorized|not_authorized|permission|\b403\b/i;
99
101
  * command, which the same role check stops. Once made, which kind the project now holds (labelled through `api`, or the
100
102
  * 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
103
  */
102
- async function make(run, link, name) {
104
+ async function make(run, link, name, scope) {
103
105
  const said = [];
104
106
  if (link) {
105
107
  try {
@@ -112,7 +114,7 @@ async function make(run, link, name) {
112
114
  }
113
115
  }
114
116
  try {
115
- await run(makeArgs(name));
117
+ await run(makeArgs(name, scope));
116
118
  return { made: 'unlabelled' };
117
119
  }
118
120
  catch (err) {
@@ -143,7 +145,7 @@ async function admitted(secret, deps) {
143
145
  */
144
146
  export async function passVercelLogin(deps) {
145
147
  const link = await deps.link();
146
- const project = await readProject(deps.run, link, deps.project);
148
+ const project = await readProject(deps.run, link, deps.project, deps.scope);
147
149
  const from = project?.name ? { project: project.name } : {};
148
150
  const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
149
151
  const finish = async (secret, how) => {
@@ -174,7 +176,7 @@ export async function passVercelLogin(deps) {
174
176
  if (!(await deps.consent()))
175
177
  return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
176
178
  const before = new Set(project.secrets.map((s) => s.value));
177
- const made = await make(deps.run, link, deps.project);
179
+ const made = await make(deps.run, link, deps.project, deps.scope);
178
180
  if ('reason' in made)
179
181
  stopped = made.reason;
180
182
  else {
@@ -183,7 +185,7 @@ export async function passVercelLogin(deps) {
183
185
  // Read back by its label, again for a few seconds while the settings do not list it yet.
184
186
  let ours;
185
187
  for (const end = Date.now() + wait.totalMs;;) {
186
- ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
188
+ ours = pick((await readProject(deps.run, link, deps.project, deps.scope))?.secrets ?? [], before);
187
189
  if (ours || Date.now() >= end || deps.signal?.aborted)
188
190
  break;
189
191
  await sleep(wait.everyMs, undefined, { signal: deps.signal });
package/dist/vercel.js CHANGED
@@ -167,11 +167,12 @@ const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]{0,99}$/;
167
167
  /**
168
168
  * The Vercel project that builds `directory` of `repository`, on a repository with several apps: of the projects Vercel
169
169
  * lists as connected to the repository (GET /v10/projects?repoUrl=), in the team `team` names (the root link's) or the
170
- * CLI's current scope, the one whose Root Directory is that folder. Never the newest, nor the one the root link names:
171
- * none, or several, is a `reason`, so one app never borrows another's project.
170
+ * CLI's current scope, the one whose Root Directory is that folder; with no `directory`, the one project connected to
171
+ * the repository, whatever folder it builds. Never the newest, nor the one the root link names: none (`none`), or
172
+ * several, is a `reason`, so one app never borrows another's project.
172
173
  */
173
174
  export async function vercelProjectFor(vercel, repository, directory, team) {
174
- const where = directory ? `the folder ${directory}` : "the repository's root";
175
+ const where = directory === undefined ? 'this repository' : directory ? `the folder ${directory}` : "the repository's root";
175
176
  let listed;
176
177
  try {
177
178
  const query = `repoUrl=${encodeURIComponent(`https://github.com/${repository}`)}&limit=100${team?.startsWith('team_') ? `&teamId=${encodeURIComponent(team)}` : ''}`;
@@ -185,7 +186,7 @@ export async function vercelProjectFor(vercel, repository, directory, team) {
185
186
  return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
186
187
  const builds = listed.map(record).filter((p) => {
187
188
  const git = record(p?.link);
188
- return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && folderOf(p.rootDirectory) === folderOf(directory);
189
+ return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && (directory === undefined || folderOf(p.rootDirectory) === folderOf(directory));
189
190
  });
190
191
  const found = builds.flatMap((p) => {
191
192
  const link = asLink(p.id, p.accountId);
@@ -194,9 +195,26 @@ export async function vercelProjectFor(vercel, repository, directory, team) {
194
195
  if (builds.length > 1)
195
196
  return { reason: `Several Vercel projects build ${where} (${found.map((p) => p.name).join(', ') || 'unnamed'}), so which one is this app's cannot be told.` };
196
197
  if (!found.length)
197
- return { reason: `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see.` };
198
+ return { reason: directory === undefined ? 'No Vercel project is connected to this repository, as far as the Vercel CLI here can see.' : `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see.`, none: true };
198
199
  return found[0];
199
200
  }
201
+ /**
202
+ * The Vercel project of a repository Vercel deploys from GitHub, for a folder `vercel link` never linked: the link file
203
+ * is gitignored, so a clone has none (#310). The one project connected to the repository in the CLI's current scope,
204
+ * else in another of the person's teams, with that team's slug (`scope`) for the CLI's own commands, which take the
205
+ * current scope otherwise.
206
+ */
207
+ export async function vercelRepoProject(vercel, repository) {
208
+ const here = await vercelProjectFor(vercel, repository);
209
+ if (!('none' in here))
210
+ return here;
211
+ for (const team of (await vercelTeams(vercel)).filter((t) => !t.current && t.id)) {
212
+ const there = await vercelProjectFor(vercel, repository, undefined, team.id);
213
+ if (!('reason' in there))
214
+ return { ...there, scope: team.value };
215
+ }
216
+ return here;
217
+ }
200
218
  /**
201
219
  * Whether the project made the deployment at `host`: one of its latest, as the project read lists them; else, since a
202
220
  * project lists its newest deployments alone while an older pull request's preview stays served, the deployment read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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,