@impeccable/reviews 0.1.16 → 0.1.18

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
@@ -15,7 +15,7 @@ npx @impeccable/reviews
15
15
 
16
16
  `npm install -g @impeccable/reviews` puts the `impeccable-reviews` command on your PATH instead.
17
17
 
18
- Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
18
+ Your browser opens Impeccable's consent page; after Approve it goes on to the run's web page. Keep that tab open: it shows the task list, a question waiting for you, the pull requests and, at the end, the report and the review. The terminal takes over its window while it works, one screen at a time in the same frame (the start; the sign-in, its link copied to the clipboard when you press C; understanding your app; a question when it needs you; the tasks; the test drive, then the setup pull request, each opened in your browser and copied to the clipboard as its screen appears), and gives it back with a short summary: the outcome, the account or team and the project or site it used on each host whose preview it opened, the test drive, the setup pull request, the web page and what is left for you. What the agent writes there (its status lines, the tasks, its questions, what is left and the report) shows as plain text: an escape sequence that would have the terminal write the clipboard, retitle the window, link words to another address or clear the screen is taken out, and the agent's status line or line of what is left that holds one is refused.
19
19
 
20
20
  Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (every question can be skipped); on the run screen, Esc twice (or Ctrl+C) stops the run. A screen taller than the terminal scrolls with ↑↓ and PgUp PgDn (↑↓ still move among a question's choices). The screens are black and white, with emphasis by intensity: greys on a terminal that takes true colour and reports its background, bold and dim elsewhere, and under `NO_COLOR`. The mark at the top is the logo's own image in a terminal that shows images (iTerm2, kitty, Ghostty, WezTerm; never under tmux), a square with its slash beside the letter-spaced wordmark elsewhere, and plain text on a dumb terminal.
21
21
 
package/dist/agent.js CHANGED
@@ -142,10 +142,14 @@ export async function runAgent(opts) {
142
142
  const session = runtime.session;
143
143
  /** The run asked to end: the report, a model error pi cannot get past, or the person pressing Ctrl+C. */
144
144
  let ending = false;
145
+ /** The run is over: the screen stays up until the end screen, and a stop pressed there stops nothing. */
146
+ let over = false;
145
147
  let lastStop;
146
148
  const screen = opts.interactive
147
149
  ? showRun({
148
150
  onCancel: () => {
151
+ if (over)
152
+ return;
149
153
  ending = true;
150
154
  opts.onCancel?.();
151
155
  void session.abort();
@@ -181,8 +185,10 @@ export async function runAgent(opts) {
181
185
  unsubscribe();
182
186
  }
183
187
  finally {
188
+ over = true;
184
189
  forgetSecrets();
185
- await screen?.close();
186
190
  }
191
+ // The run screen stays up, its last task ticked, until the end screen takes its place: closing it here put a new screen's
192
+ // start ("One moment…") up between the two (#312). An error leaves through the command's own close.
187
193
  await runtime.dispose();
188
194
  }
@@ -104,9 +104,9 @@ const granted = (s) => s.cli === 'signed-in' && (!s.scopes || ACCESS_SCOPES.ever
104
104
  * a retry, and two failed retries end it. The new sign-in asks for the scopes it held as well, so whatever else the
105
105
  * person uses cf for goes on working.
106
106
  */
107
- async function reach(deps) {
107
+ async function reach(deps, read) {
108
108
  const through = (s) => ({ way: throughCf(deps.cf, s.accounts, deps.signal) });
109
- let status = await cfStatus(deps.cf, deps.signal);
109
+ let status = read;
110
110
  if (granted(status))
111
111
  return through(status);
112
112
  if (status.cli === 'missing' || status.cli === 'unknown')
@@ -196,6 +196,8 @@ async function covering(way, url) {
196
196
  return unread ? stop(way, unread, '') : { reason: `No Access application ${way.who} reaches covers ${url.host}.` };
197
197
  return { apps };
198
198
  }
199
+ /** Why no token is made where several applications cover the address. */
200
+ const unclear = (count, host) => `${count} Access applications cover ${host}, so which one's policy should allow the token is not clear.`;
199
201
  /**
200
202
  * As the person, the way they reach Cloudflare's API: on the one application in front of the address, the service
201
203
  * token, then a Service Auth policy that allows it. An application on reusable policies (every one the dashboard makes
@@ -208,7 +210,7 @@ async function create(way, deps, found) {
208
210
  const api = way.call;
209
211
  const url = new URL(deps.url);
210
212
  if (found.length > 1)
211
- return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
213
+ return { reason: unclear(found.length, url.host) };
212
214
  const { account, app } = found[0];
213
215
  const base = `/accounts/${account}/access`;
214
216
  let links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
@@ -313,10 +315,6 @@ export async function passCloudflareAccess(deps) {
313
315
  covered ||= 'apps' in found;
314
316
  return found;
315
317
  };
316
- const build = async (way) => {
317
- const found = await find(way);
318
- return 'apps' in found ? create(way, deps, found.apps) : found;
319
- };
320
318
  /** A token that opened the address, saved for reviews. */
321
319
  const keep = async (token, how, made = {}) => {
322
320
  const failed = await deps.save(token);
@@ -325,16 +323,37 @@ export async function passCloudflareAccess(deps) {
325
323
  /** Why the run's own way ended without a token that opens the address, for the paste screen's skip, and how to make one by hand there. */
326
324
  let stopped;
327
325
  let manual;
328
- if (!(await deps.consent())) {
329
- stopped = 'The person skipped creating a service token.';
330
- // cf signed in here, with whatever scopes, says whether an application of the person's covers the address, since reading takes no write; a skip opens no sign-in for it.
331
- const status = await cfStatus(deps.cf, deps.signal);
332
- if (status.cli === 'signed-in')
333
- await find(throughCf(deps.cf, status.accounts, deps.signal));
334
- }
326
+ // cf signed in here, with whatever scopes, reads the Access application in front of the address before anything is asked, since reading takes no write
327
+ // (#357): none covering it asks nothing, and the consent names the one that does. Signed out, or refused the read, the consent comes first, as its yes opens
328
+ // the sign-in or the API token screen that finding it takes.
329
+ const status = await cfStatus(deps.cf, deps.signal);
330
+ const reads = status.cli === 'signed-in' && (!status.scopes || status.scopes.some((scope) => /^access\.(read|write)$/.test(scope)));
331
+ const early = reads ? await find(throughCf(deps.cf, status.accounts, deps.signal)) : undefined;
332
+ const apps = early && 'apps' in early ? early.apps : undefined;
333
+ const named = apps?.length === 1 ? apps[0] : undefined;
334
+ /** An application as the consent names it. */
335
+ const described = ({ account, app }) => ({ id: String(app.id), application: typeof app.name === 'string' && app.name ? app.name : url.host, account });
336
+ const SKIPPED = 'The person skipped creating a service token.';
337
+ if (early && 'reason' in early)
338
+ stopped = early.reason;
339
+ else if (apps && apps.length > 1)
340
+ stopped = unclear(apps.length, url.host);
341
+ else if (!(await deps.consent(named && described(named))))
342
+ stopped = SKIPPED;
335
343
  else {
336
- const reached = await reach(deps);
337
- let made = 'way' in reached ? await build(reached.way) : 'stopped' in reached ? { reason: reached.stopped } : reached;
344
+ /** Read anew, by a new sign-in or the API token: an application other than the one the yes named is asked about before anything is made on it. */
345
+ const rebuild = async (way) => {
346
+ const found = await find(way);
347
+ if (!('apps' in found))
348
+ return found;
349
+ const target = found.apps.length === 1 ? found.apps[0] : undefined;
350
+ if (named && target && (String(target.app.id) !== String(named.app.id) || target.account !== named.account) && !(await deps.consent(described(target))))
351
+ return { reason: SKIPPED };
352
+ return create(way, deps, found.apps);
353
+ };
354
+ const reached = await reach(deps, status);
355
+ // The sign-in that read the applications makes the token on the one it found, or answers the same refusal, without reading them again; a new sign-in reads its accounts.
356
+ let made = 'way' in reached ? (early && granted(status) ? ('apps' in early ? await create(reached.way, deps, early.apps) : early) : await rebuild(reached.way)) : 'stopped' in reached ? { reason: reached.stopped } : reached;
338
357
  let apiToken = false;
339
358
  if ('fallback' in made) {
340
359
  // Where cf's sign-in cannot make the token, an API token the person makes for the run can.
@@ -344,7 +363,7 @@ export async function passCloudflareAccess(deps) {
344
363
  else {
345
364
  deps.remember(key);
346
365
  apiToken = true;
347
- made = await build(withApiToken(key, deps));
366
+ made = await rebuild(withApiToken(key, deps));
348
367
  }
349
368
  }
350
369
  if (!('token' in made)) {
@@ -306,8 +306,9 @@ export async function cfStatus(cf, signal) {
306
306
  if (status?.authenticated !== true)
307
307
  return { cli: 'unknown', accounts: [] };
308
308
  const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
309
- const accounts = list(status.accounts).map((a) => a.id).filter((id) => typeof id === 'string' && /^[0-9a-f]{32}$/.test(id));
310
- return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts };
309
+ const listed = list(status.accounts).filter((a) => typeof a.id === 'string' && /^[0-9a-f]{32}$/.test(a.id));
310
+ const names = Object.fromEntries(listed.flatMap((a) => (typeof a.name === 'string' && a.name && a.name.length <= 100 ? [[a.id, a.name]] : [])));
311
+ return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts: listed.map((a) => a.id), ...(Object.keys(names).length ? { names } : {}) };
311
312
  }
312
313
  /** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
313
314
  const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
@@ -322,7 +323,7 @@ const PAGES = 4;
322
323
  * built from another repository is not this one's.
323
324
  */
324
325
  async function fromCf(named, cf, repository, settings, signal) {
325
- const { cli, scopes, accounts } = await cfStatus(cf, signal);
326
+ const { cli, scopes, accounts, names } = await cfStatus(cf, signal);
326
327
  const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
327
328
  if (cli !== 'signed-in' || !pages)
328
329
  return { cli, rows: [], failed: cli === 'unknown' };
@@ -361,7 +362,8 @@ async function fromCf(named, cf, repository, settings, signal) {
361
362
  subdomain = typeof ours.project.subdomain === 'string' && new RegExp(`^${LABEL}\\.pages\\.dev$`).test(ours.project.subdomain) ? ours.project.subdomain : undefined;
362
363
  }
363
364
  }
364
- const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(subdomain ? { subdomain } : {}) };
365
+ const accountName = account ? names?.[account] : undefined;
366
+ const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(accountName ? { accountName } : {}), ...(subdomain ? { subdomain } : {}) };
365
367
  if (!project)
366
368
  return { cli, rows: [], failed: false, ...settled };
367
369
  const ours = (d) => {
@@ -450,6 +452,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
450
452
  : cf.cli !== 'signed-in' ? `Which Cloudflare Pages project builds ${where} cannot be told: cf here is not signed in with Pages access, and only a project's settings name the folder it builds (its root directory).`
451
453
  : (cf.matching?.length ?? 0) > 1 ? `Several Cloudflare Pages projects build ${where} (${cf.matching.join(', ')}), so which one is this app's cannot be told.`
452
454
  : !cf.project ? `No Cloudflare Pages project of this repository builds ${where} (its root directory), as far as cf here can see.` : undefined;
455
+ // The name of the account the project was found in goes to the last screen alone, never into the evidence (#360).
456
+ if (cf.accountName && !unmatched)
457
+ deps.onAccountName?.(cf.accountName);
453
458
  if (unmatched)
454
459
  github.previews = [];
455
460
  // A preview's address names the project's own host, which a taken name suffixes (crewline-x1y.pages.dev), not always its name.
package/dist/fence.js CHANGED
@@ -467,10 +467,20 @@ function git(args, where, protectedBranches, held = false) {
467
467
  return deny('a path outside the checkout, or a pattern');
468
468
  return allow();
469
469
  }
470
- if (sub === 'checkout' && rest[0] === '-b' && rest.length >= 2 && rest.length <= 3)
471
- return allow();
470
+ if (sub === 'checkout' || sub === 'switch') {
471
+ // -q only quiets git. A branch's name and its start are words of their own, never an option (`-b -f x`).
472
+ const words = rest.filter((a) => a !== '-q' && a !== '--quiet');
473
+ const make = sub === 'checkout' ? '-b' : '-c';
474
+ if (words[0] === make && words.length >= 2 && words.length <= 3 && words.slice(1).every((a) => !a.startsWith('-')))
475
+ return allow();
476
+ // Back onto a branch the checkout has, by its name alone, which git takes as that branch and never as a path whose changes it throws away.
477
+ // show-ref takes the ref as written, where rev-parse would take `main~1` too and detach HEAD.
478
+ if (words.length === 1 && !words[0].startsWith('-') && gitRead(['show-ref', '--verify', '--quiet', `refs/heads/${words[0]}`], where.cwd) !== null)
479
+ return allow();
480
+ return deny(`git ${sub} makes a branch, git ${sub} ${make} <name> [<start>], or goes back to a branch the checkout has, by its name alone; -q may join either, and nothing else does`);
481
+ }
472
482
  if (sub === 'branch')
473
- return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b makes a branch');
483
+ return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b or git switch -c makes a branch');
474
484
  if (sub === 'remote')
475
485
  return listsRemotes(rest) ? allow() : deny('git remote lists only: on its own, -v, or get-url <name>; nothing that adds, removes, renames, points or prunes a remote, and no show, which asks the remote');
476
486
  if (sub === 'ls-remote')
@@ -592,9 +602,16 @@ function reader(cmd, args, where) {
592
602
  if (!args.some((a) => a === '-e' || a === '-f' || a.startsWith('--regexp') || a.startsWith('--file')))
593
603
  paths = paths.slice(1);
594
604
  }
605
+ // tail -f waits for more until the command's time runs out.
606
+ // Its long options only as these, since GNU tail takes any start of one's name (--fol is --follow).
607
+ if (cmd === 'tail' && args.some((a) => /^-[^-]*[fF]/.test(a) || (a.startsWith('--') && !/^--(?:lines|bytes)=|^--(?:quiet|silent|verbose)$/.test(a))))
608
+ return deny('a tail that follows a file, and never ends, or with a long option other than --lines= and --bytes=');
595
609
  // The scratch folder holds what this run wrote there, such as a build's log, so it may be read back.
596
610
  if (paths.some((p) => outside(p, where) && !inScratch(p, where)))
597
611
  return deny('reads outside the checkout');
612
+ // A diff of a folder reads the files in it nobody named, .env ones too, and a pattern can name a folder once the shell expands it.
613
+ if (cmd === 'diff' && (args.some((a) => /^-[^-]*r/.test(a) || a === '--recursive') || paths.some((p) => /[*?[]/.test(p) || isFolder(realPath(resolve(where.cwd, p))))))
614
+ return deny('a diff of a folder or a pattern reads every file it names, .env files too: name the files');
598
615
  // The shell expands `.e*` to .env too.
599
616
  if (cmd !== 'ls' && paths.some((p) => readsSecret(p, where) || /^\.[^/]*[*?[]/.test(basename(p))))
600
617
  return deny('reads a .env file, or a pattern that can match one');
@@ -877,6 +894,8 @@ function simpleCommand(seg, where, opts) {
877
894
  // gh runs as the person, who can read other repositories' pull requests: this run's own, by number or branch, in this repository.
878
895
  if (args[0] === 'pr' && args[1] === 'view')
879
896
  return ghView(args.slice(2)) ? allow() : deny("gh pr view takes a pull request's number or this run's branch, --repo for this repository, --json and --comments");
897
+ if (args[0] === 'pr' && args[1] === 'checks')
898
+ return ghView(args.slice(2), '--required') ? allow() : deny("gh pr checks takes a pull request's number or this run's branch, --repo for this repository, --json and --required");
880
899
  // The test drive opens as a draft where its preview needs the pull request first, and is marked ready once that preview is proven: the test drive's branch by name, nothing else.
881
900
  if (args[0] === 'pr' && args[1] === 'ready')
882
901
  return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
@@ -892,9 +911,18 @@ function simpleCommand(seg, where, opts) {
892
911
  case 'ls':
893
912
  case 'cat':
894
913
  case 'head':
914
+ case 'tail':
915
+ case 'diff':
895
916
  case 'grep':
896
917
  case 'find':
897
918
  return reader(cmd, args, where);
919
+ case 'mkdir':
920
+ // Folders in the checkout (the rule above stops one outside it), never in a .git, where git takes its settings and hooks.
921
+ if (args.some((a) => a.startsWith('-') && !['-p', '--parents', '-v', '--verbose'].includes(a)))
922
+ return deny('mkdir takes -p and the folders to make, nothing else');
923
+ return positional.some((a) => [a, relative(where.root, realPath(resolve(where.cwd, a)))].some((p) => p.split(/[\\/]/).includes('.git'))) ? never('makes a folder in .git, where hooks run') : allow();
924
+ case 'sleep':
925
+ return args.length === 1 && /^\d+(?:\.\d+)?$/.test(args[0]) && Number(args[0]) <= 300 ? allow() : deny('sleep takes a number of seconds, 300 at most, and nothing else');
898
926
  case 'netlify':
899
927
  return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'), opts.repository);
900
928
  case 'vercel':
@@ -973,7 +1001,7 @@ function pushBases(branch, defaultBranch, cwd) {
973
1001
  return [...bases, ...(known ?? '').split('\n').filter((line) => line.endsWith(' commit')).map((line) => line.split(' ')[0])];
974
1002
  }
975
1003
  const UNREAD_PUSH = 'the commits the push would carry past what the remote has could not be read here';
976
- const UNMADE_PUSH = 'a push in the same command as the git commit or git checkout -b that makes what it carries, which is not there yet when the fence reads what the push would carry: run git push as a command of its own, after the commit';
1004
+ const UNMADE_PUSH = 'a push in the same command as the git commit, git checkout -b or git switch -c that makes what it carries, which is not there yet when the fence reads what the push would carry: run git push as a command of its own, after the commit';
977
1005
  const UNREACHED_PUSH = 'the remote could not be reached, so what the push would carry could not be checked: try the push again once the network is back';
978
1006
  /** The paths every commit `head` has that none of `bases` has touches, from the checkout's top: a file a later commit removes again is still in what the push sends, and a merge's own changes too. Null when git cannot read them. */
979
1007
  function newPaths(head, bases, cwd) {
@@ -1163,8 +1191,12 @@ function unsyncedWorkflows(where, named, carried, synced) {
1163
1191
  export function classifyCommand(command, root, options = {}) {
1164
1192
  const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
1165
1193
  const segments = parseCommand(command);
1166
- if (!segments)
1167
- return deny('uses shell syntax the fence does not read');
1194
+ if (!segments) {
1195
+ // A here-document is how a file or a message gets written in one command: the write tool, -m and --body do that.
1196
+ if (/<<-?\s*['"]?\w/.test(command))
1197
+ return deny("a here-document, which the fence does not read: write a file with the write tool, a commit's message with -m and a pull request's body with --body");
1198
+ return deny('uses shell syntax the fence does not read: a variable, $( ), a subshell, braces, a here-document or a background job; run plain commands, joined with &&, ; or |');
1199
+ }
1168
1200
  if (!segments.length)
1169
1201
  return deny('an empty command');
1170
1202
  const where = { root: realPath(root), cwd: root, scratch: options.scratch && realPath(options.scratch) };
@@ -1177,14 +1209,14 @@ export function classifyCommand(command, root, options = {}) {
1177
1209
  return remote.get(branch);
1178
1210
  };
1179
1211
  let result = allow();
1180
- /** A git commit or checkout -b earlier in the command, whose commits or branch are not there yet when a push after it is judged. */
1212
+ /** A git commit, checkout -b or switch -c earlier in the command, whose commits or branch are not there yet when a push after it is judged. */
1181
1213
  let unmade = false;
1182
1214
  for (const seg of segments) {
1183
1215
  let decision = simpleCommand(seg, where, opts);
1184
1216
  const gitSub = seg.words[0] === 'git' ? gitArgs(seg.words.slice(1))?.[0] : undefined;
1185
1217
  if (decision.verdict === 'allow' && gitSub === 'push' && unmade)
1186
1218
  decision = deny(UNMADE_PUSH);
1187
- if (gitSub === 'commit' || gitSub === 'checkout')
1219
+ if (gitSub === 'commit' || gitSub === 'checkout' || gitSub === 'switch')
1188
1220
  unmade = true;
1189
1221
  if (decision.verdict === 'allow')
1190
1222
  decision = contentCheck(seg, where, opts.protectedBranches[0], basesOf) ?? decision;
@@ -1595,21 +1627,21 @@ function ghReady(args) {
1595
1627
  const repository = inline ?? (rest[0] === '--repo' || rest[0] === '-R' ? rest[1] : undefined);
1596
1628
  return rest.length === (inline !== undefined ? 1 : 2) && typeof repository === 'string' && REPOSITORY.test(repository) ? { branch, repository } : null;
1597
1629
  }
1598
- /** `gh pr view [<number> | this run's branch] [--repo <owner>/<name>] [--json <fields>] [--comments]`, each once: the repository named, or null. */
1599
- function ghView(args) {
1630
+ /** `gh pr view [<number> | this run's branch] [--repo <owner>/<name>] [--json <fields>] [--comments]`, each once, and `gh pr checks` the same with --required for --comments: the repository named, or null. */
1631
+ function ghView(args, flag = '--comments') {
1600
1632
  const seen = new Map();
1601
1633
  for (let i = 0; i < args.length; i++) {
1602
1634
  const arg = args[i];
1603
1635
  const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
1604
- const flag = at < 0 ? arg : arg.slice(0, at);
1605
- const name = flag === '-R' ? '--repo' : flag === '-c' ? '--comments' : flag;
1636
+ const given = at < 0 ? arg : arg.slice(0, at);
1637
+ const name = given === '-R' ? '--repo' : given === '-c' && flag === '--comments' ? '--comments' : given;
1606
1638
  let value = '';
1607
1639
  if (name === '--repo' || name === '--json') {
1608
1640
  value = (at < 0 ? args[++i] : arg.slice(at + 1)) ?? '';
1609
1641
  if (!value || value.startsWith('-'))
1610
1642
  return null;
1611
1643
  }
1612
- else if (name !== '--comments' || at >= 0) {
1644
+ else if (name !== flag || at >= 0) {
1613
1645
  if (arg.startsWith('-') || !(/^[1-9]\d*$/.test(arg) || setupBranch(arg) || driveBranch(arg)))
1614
1646
  return null;
1615
1647
  }
@@ -1735,8 +1767,8 @@ export function reservePreviewCommand(state, command, root, scratch) {
1735
1767
  if (!driveCreated || parsed.branch !== drive)
1736
1768
  return deny('mark ready the test drive this run opened');
1737
1769
  }
1738
- if (words[0] === 'gh' && words[1] === 'pr' && words[2] === 'view') {
1739
- const parsed = ghView(words.slice(3));
1770
+ if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
1771
+ const parsed = ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
1740
1772
  if (!parsed)
1741
1773
  return deny("view only this run's pull requests, by number or branch name");
1742
1774
  // Without --repo, gh picks a repository from the checkout's remotes, which may name another one.
@@ -81,6 +81,10 @@ const previewsOff = (id, name, where) => ({
81
81
  skipLabel: 'Skip',
82
82
  },
83
83
  });
84
+ /** The consent to a Vercel bypass secret, saying whose previews Vercel's login keeps and where the secret is revoked. */
85
+ const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection.`;
86
+ /** The consent to a Cloudflare service token, saying which Access application keeps whose previews, and whose policy allows the token. */
87
+ const accessHint = (keeps, policy) => `Impeccable reviews each pull request on its preview, and ${keeps} behind a login.\n\nCloudflare's way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in ${policy} policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.`;
84
88
  /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one, and to making a site behind Netlify's team login public. */
85
89
  export const HOST_SCREENS = {
86
90
  'netlify-signin-retry': { id: 'netlify-signin-retry', kind: 'confirm', title: 'Sign in to Netlify', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
@@ -138,7 +142,7 @@ export const HOST_SCREENS = {
138
142
  id: 'vercel-bypass',
139
143
  kind: 'confirm',
140
144
  title: 'Your Vercel previews are protected',
141
- hint: 'Impeccable reviews each pull request on its preview,\nand Vercel keeps yours behind a login.\n\nVercel\'s way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time.',
145
+ hint: bypassHint('yours', "the project's"),
142
146
  prompt: 'Create the secret for Impeccable?',
143
147
  confirmLabel: 'Create',
144
148
  skipLabel: 'Skip',
@@ -193,7 +197,7 @@ export const HOST_SCREENS = {
193
197
  id: 'cloudflare-access',
194
198
  kind: 'confirm',
195
199
  title: 'Your Cloudflare previews are protected',
196
- hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login.\n\nCloudflare\'s way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
200
+ hint: accessHint('Cloudflare Access keeps yours', "your Access application's"),
197
201
  prompt: 'Create the token for Impeccable?',
198
202
  confirmLabel: 'Create',
199
203
  skipLabel: 'Skip',
@@ -224,6 +228,17 @@ export function sentTo(screen, url) {
224
228
  const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
229
  return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
226
230
  }
231
+ /** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
232
+ export function vercelBypassConsent(project, app) {
233
+ const screen = HOST_SCREENS['vercel-bypass'];
234
+ if (!project)
235
+ return screen;
236
+ return { ...screen, hint: bypassHint(app ? `${app}'s previews, built by the Vercel project ${project},` : `the previews of the Vercel project ${project}`, `${project}'s`) };
237
+ }
238
+ /** The consent to a service token, asked once the Access application in front of the preview was found (#357): that application, its Cloudflare account, and whose previews at which host it keeps. */
239
+ export function cloudflareAccessConsent(found, host, app) {
240
+ return { ...HOST_SCREENS['cloudflare-access'], hint: accessHint(`the Access application "${found.application}", in the Cloudflare account ${found.account}, keeps ${app ? `${app}'s previews` : 'yours'} (${host})`, `"${found.application}"'s`) };
241
+ }
227
242
  /** The one consent to making the Access application's own policies reusable, which the run's way through cf needs to add the token's policy (#327): the application named. */
228
243
  export function cloudflareAccessReusable(application) {
229
244
  return {
package/dist/hosted.js CHANGED
@@ -80,10 +80,11 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
80
80
  return unread;
81
81
  }
82
82
  }
83
+ /** Each host's name, its lock's gate, the login's domain, and whose login that is: Cloudflare's is Access's, not Pages'. */
83
84
  const HOSTS = {
84
- vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/ },
85
- netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/ },
86
- cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/ },
85
+ vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/, lock: 'Vercel' },
86
+ netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/, lock: 'Netlify' },
87
+ cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/, lock: 'Cloudflare Access' },
87
88
  };
88
89
  const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
89
90
  const NETLIFY = new RegExp(`^(?=[^.]{1,63}\\.netlify\\.app$)deploy-preview-[1-9]\\d{0,6}--(${LABEL})\\.netlify\\.app$`);
@@ -137,6 +138,24 @@ export function netlifySiteOf(url) {
137
138
  const { hostname } = new URL(url);
138
139
  return NETLIFY.exec(hostname)?.[1] ?? LIVE.netlify.exec(hostname)?.[1] ?? null;
139
140
  }
141
+ /**
142
+ * The project and team a Vercel deployment's own address names, `<project>-<hash>-<team>.vercel.app` (Vercel's generated
143
+ * address, its hash nine letters and digits), for the last screen (#360): only where one split fits, and that split's
144
+ * project is `project` where a host tool named it; null otherwise, a project's own address included, and any address
145
+ * with `-git-` in it, which a branch's alias `<project>-git-<branch>-<team>` has, so a branch's word is never a hash.
146
+ */
147
+ export function vercelDeploymentOf(url, project) {
148
+ if (!URL.canParse(url))
149
+ return null;
150
+ const parsed = new URL(url);
151
+ if (!plainHttps(parsed) || !LIVE.vercel.test(parsed.hostname) || parsed.hostname.includes('-git-'))
152
+ return null;
153
+ const parts = parsed.hostname.slice(0, -'.vercel.app'.length).split('-');
154
+ const splits = parts
155
+ .flatMap((part, i) => (i > 0 && i < parts.length - 1 && /^[a-z0-9]{9}$/.test(part) ? [{ project: parts.slice(0, i).join('-'), team: parts.slice(i + 1).join('-') }] : []))
156
+ .filter((split) => project === undefined || split.project === project.toLowerCase());
157
+ return splits.length === 1 ? splits[0] : null;
158
+ }
140
159
  /** The address pattern a proven preview stands for, where the host reports none to GitHub: Netlify's by pull request, Cloudflare's by the branch's alias. A live address stands for none. */
141
160
  function patternOf(hostname) {
142
161
  const site = NETLIFY.exec(hostname)?.[1];
@@ -168,17 +187,56 @@ async function checkoutHolds(root, words) {
168
187
  return false;
169
188
  }
170
189
  }
171
- /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
190
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
191
+ /** The address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
192
+ const wholeOrigin = (origin) => `(^|[^A-Za-z0-9.-])${escapeRegExp(origin)}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`;
193
+ /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin, whole. */
172
194
  export async function checkoutNames(root, origin, folder = '') {
173
- const escaped = origin.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
174
195
  try {
175
- await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', `(^|[^A-Za-z0-9.-])${escaped}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`, '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
196
+ await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
176
197
  return true;
177
198
  }
178
199
  catch {
179
200
  return false;
180
201
  }
181
202
  }
203
+ /** Whether a line names an app's name or folder whole, not inside a longer name, a host name or a path, such as `apps/web` in `archived/apps/web`. */
204
+ const namesWhole = (line, words) => new RegExp(`(^|[^\\p{L}\\p{N}_./-])${escapeRegExp(words)}(?![\\p{L}\\p{N}_-]|[./][\\p{L}\\p{N}])`, 'iu').test(line);
205
+ /**
206
+ * Whether a line of the checkout's root README names the address's origin, whole, beside one app, by its name or its
207
+ * folder, and no other app: a row of a table of the apps' live addresses, or an item of such a list (#367). The
208
+ * addresses on the line are left out of the match, so a host name made of an app's name names no app.
209
+ */
210
+ export async function readmeNamesApp(root, origin, app, others) {
211
+ let lines;
212
+ try {
213
+ lines = (await execFileAsync('git', ['grep', '-h', '--ignore-case', '--extended-regexp', '-e', wholeOrigin(origin), '--', ':(glob,icase)README*'], { cwd: root, timeout: 30_000, env: gitEnvironment() })).stdout;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ // Each name or folder with every app it belongs to, so a name two apps share names both; the longest first, each taken
219
+ // out once found, so "Web" inside "Web admin" is not that app named.
220
+ const names = new Map();
221
+ for (const each of [app, ...others])
222
+ for (const words of [each.name, each.folder])
223
+ if (words)
224
+ names.set(words.toLowerCase(), [...(names.get(words.toLowerCase()) ?? []), each]);
225
+ const longestFirst = [...names].sort(([x], [y]) => y.length - x.length);
226
+ return lines.split('\n').some((line) => {
227
+ // The addresses end where a table cell or a tag does; a path's parentheses, and a link's closing one, go with them.
228
+ let text = line.replace(/https?:\/\/[^\s|<>[\]"'`]+/gi, ' ');
229
+ const named = new Set();
230
+ for (const [words, apps] of longestFirst) {
231
+ if (!namesWhole(text, words))
232
+ continue;
233
+ for (const each of apps)
234
+ named.add(each);
235
+ text = text.replace(new RegExp(escapeRegExp(words), 'giu'), ' ');
236
+ }
237
+ return named.size === 1 && named.has(app);
238
+ });
239
+ }
182
240
  /** What a visitor never sees of a page: comments, scripts, styles and templates. */
183
241
  const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
184
242
  /** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
@@ -264,7 +322,7 @@ export async function provePreview(url, words, root, deps = {}) {
264
322
  if (!host)
265
323
  return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, or a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, with no query, is opened.');
266
324
  // Another host's live address has a lock no key opens, so none is looked for: its login is another site, and its 401 the app's own sign-in.
267
- const { name, gate, login } = host === 'other' ? { name: 'the app', gate: undefined, login: /$^/ } : HOSTS[host];
325
+ const { gate, login, lock } = host === 'other' ? { gate: undefined, login: /$^/, lock: 'the app' } : HOSTS[host];
268
326
  const phrase = words.trim();
269
327
  if (phrase.length < 3 || phrase.length > 120 || /[\r\n]/.test(phrase))
270
328
  return unproven('The words are 3 to 120 characters on one line.');
@@ -292,7 +350,7 @@ export async function provePreview(url, words, root, deps = {}) {
292
350
  const location = res.headers.get('location') ?? '';
293
351
  const next = URL.canParse(location, target.href) ? new URL(location, target) : null;
294
352
  if (gate && next && login.test(next.hostname))
295
- return { ...unproven(`The address sent the browser to ${name}'s login${still}.`, res.status), gate };
353
+ return { ...unproven(`The address sent the browser to ${lock}'s login${still}.`, res.status), gate };
296
354
  if (next?.origin !== target.origin)
297
355
  return unproven('The address sent the browser to another site.', res.status);
298
356
  target = next;
@@ -310,7 +368,7 @@ export async function provePreview(url, words, root, deps = {}) {
310
368
  if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
311
369
  return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
312
370
  }
313
- return { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate, ...(host === 'netlify' && NETLIFY_TEAM_LOGIN.test(page) ? { teamLogin: true } : {}) };
371
+ return { ...unproven(`The address asked for ${lock}'s login or password${still}.`, res.status), gate, ...(host === 'netlify' && NETLIFY_TEAM_LOGIN.test(page) ? { teamLogin: true } : {}) };
314
372
  }
315
373
  if (res.status < 200 || res.status >= 300) {
316
374
  await res.body?.cancel().catch(() => { });