@impeccable/reviews 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/login.js CHANGED
@@ -130,7 +130,7 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
130
130
  },
131
131
  };
132
132
  }
133
- throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again.`);
133
+ throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again.`, { event: 'setup_command_sign_in_ended', outcome: 'ports_busy' });
134
134
  }
135
135
  /** The code from what the person pasted: the whole callback address, or the code alone. Null when it is neither, or from another sign-in. */
136
136
  export function parsePasted(text, state) {
@@ -215,18 +215,20 @@ export async function exchangeCode(host, input, log) {
215
215
  }
216
216
  /**
217
217
  * The browser sign-in: callback server, PKCE and state, the paste fallback,
218
- * then the token exchange, once per code. `check` stops a token the command
218
+ * then the token exchange, once per code. `repo` is the checkout's owner/name:
219
+ * the consent page names it, so the person approves and picks nothing (an
220
+ * older Impeccable ignores it and asks). `check` stops a token the command
219
221
  * cannot use; otherwise the tab that brought the code goes on to the page
220
222
  * `landing` resolves to (the web page that mirrors the run, once the command
221
223
  * has what it needs to show a run there), or says why it will not.
222
224
  */
223
- export async function signIn(host, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
225
+ export async function signIn(host, repo, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
224
226
  const state = randomBytes(24).toString('base64url');
225
227
  const { verifier, challenge } = pkcePair();
226
228
  log.secret(verifier);
227
229
  const server = await startCallbackServer(state);
228
230
  const authorize = new URL(`${host}/app/cli/authorize`);
229
- authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
231
+ authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256', repo }).toString();
230
232
  log.write(`callback server at ${server.redirectUri}`);
231
233
  prompt.show(authorize.href);
232
234
  openBrowser(authorize.href);
@@ -235,8 +237,8 @@ export async function signIn(host, log, prompt, check = (t) => t, landing = asyn
235
237
  try {
236
238
  const code = await new Promise((resolve, reject) => {
237
239
  server.code.then(resolve, reject);
238
- stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.')));
239
- timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.')), LOGIN_TIMEOUT_MS);
240
+ stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.', { event: 'setup_command_sign_in_ended', outcome: 'cancelled' })));
241
+ timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.', { event: 'setup_command_sign_in_ended', outcome: 'timed_out' })), LOGIN_TIMEOUT_MS);
240
242
  });
241
243
  log.secret(code);
242
244
  const token = check(await exchangeCode(host, { code, verifier, redirectUri: server.redirectUri }, log));
package/dist/main.js CHANGED
@@ -4,18 +4,20 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { runAgent } from './agent.js';
7
- import { fenceExtension } from './fence.js';
7
+ import { fenceExtension, hostsAgreed, placeOf } from './fence.js';
8
8
  import { focusInstructions, focusState, severalApps } from './focus.js';
9
9
  import { requestJson } from './http.js';
10
10
  import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
11
11
  import { createLog, UserError } from './log.js';
12
12
  import { firstMessage, playbook, setupContext } from './playbook.js';
13
13
  import { checkoutFrontEnd, parseFlags, readCheckout, stateSaysNoFrontEnd, USAGE } from './preflight.js';
14
+ import { startSandbox } from './sandbox.js';
14
15
  import { scanRecorder } from './scan.js';
16
+ import { createReporter, reportingOff } from './report.js';
15
17
  import { createSessionSync, syncExtension } from './sync.js';
16
18
  import { createTools, resultLine } from './tools.js';
17
19
  import { transcriptPaths } from './transcript.js';
18
- import { atExit, closeScreens, helpMark, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
20
+ import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
19
21
  const say = (line = '') => console.log(line);
20
22
  const execFileAsync = promisify(execFile);
21
23
  /** How a run on a repository with no web front end ends, here and on the web page. */
@@ -41,9 +43,11 @@ function previewWait() {
41
43
  return Number.isFinite(totalMs) && totalMs > 0 ? { totalMs, everyMs: Math.min(15_000, Math.max(50, Math.floor(totalMs / 5))) } : undefined;
42
44
  }
43
45
  /** The end of a run with nothing to set up, before the agent: the web page says why too, and the command exits 2. */
44
- async function nothingToSetUp(sync, title, result, links) {
45
- sync.update({ phase: 'error', status: result, question: null });
46
+ async function nothingToSetUp(sync, reporter, why, title, result, links) {
47
+ sync.update({ phase: 'error', status: result, question: null, stop_reason: why });
46
48
  const saved = await settle(sync.flush(), 5000);
49
+ if (!saved?.ok)
50
+ reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
47
51
  await showOutro({
48
52
  outcome: 'stopped',
49
53
  title,
@@ -52,12 +56,14 @@ async function nothingToSetUp(sync, title, result, links) {
52
56
  todo: [],
53
57
  warnings: saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`],
54
58
  });
55
- await letTabsHear();
59
+ await Promise.all([letTabsHear(), reporter.done()]);
56
60
  process.exit(2);
57
61
  }
58
62
  export async function main(argv) {
59
63
  let log = createLog(false);
60
64
  let sync;
65
+ // Nothing is reported until the flags say where to and whether to.
66
+ let reporter = createReporter({ host: '', off: true });
61
67
  // The tab the person approved in goes on to the web page once the run is minted, so the page has a run to show; a stop before then is what it says instead.
62
68
  let land = () => { };
63
69
  const landing = new Promise((resolve, reject) => (land = (outcome) => (typeof outcome === 'string' ? resolve(outcome) : reject(outcome))));
@@ -67,33 +73,64 @@ export async function main(argv) {
67
73
  if (flags.help)
68
74
  return say(`${helpMark().join('\n').trimEnd()}\n\n${USAGE}`);
69
75
  log = createLog(flags.debug);
76
+ reporter = createReporter({ host: flags.host, off: reportingOff(process.env, flags.noTelemetry) });
77
+ reporter.step({ event: 'setup_command_launched', outcome: 'launched' });
78
+ // A screen that ends the command at once (Ctrl+C before a run, a signal), and anything nobody caught, still say so first.
79
+ onScreenExit(async (why) => {
80
+ reporter.step({ event: 'setup_command_stopped', outcome: why });
81
+ await reporter.done();
82
+ });
83
+ const crashed = (err) => {
84
+ console.error(err);
85
+ reporter.step({ event: 'setup_command_stopped', outcome: 'crashed' });
86
+ void reporter.done().finally(() => process.exit(1));
87
+ };
88
+ process.once('uncaughtException', crashed);
89
+ process.once('unhandledRejection', crashed);
70
90
  // Printed as the command exits, after the screen has gone, so it stays in the scrollback.
71
91
  if (log.path)
72
92
  atExit(() => [`Debug log: ${log.path}`]);
73
93
  log.write('start', { host: flags.host, model: flags.model, effort: flags.effort, yes: flags.yes, node: process.versions.node });
74
94
  if (flags.frames)
75
95
  recordFrames(flags.frames);
76
- const checkout = readCheckout(process.cwd());
96
+ const checkout = readCheckout(flags.installDir?.path ?? process.cwd(), flags.installDir?.from);
77
97
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
78
98
  const slug = `${checkout.owner}/${checkout.repo}`;
79
- if (!(await showIntro()))
80
- return;
99
+ // From the checkout's top, where the sandbox lays its own protections of a checkout.
100
+ process.chdir(checkout.root);
101
+ // Scratch output (a server's log, a build's output) goes here and never into the checkout.
102
+ const scratch = mkdtempSync(join(tmpdir(), 'impeccable-reviews-scratch-'));
103
+ process.on('exit', () => rmSync(scratch, { recursive: true, force: true }));
104
+ const sandbox = await startSandbox({
105
+ root: checkout.root,
106
+ scratch,
107
+ host: flags.host,
108
+ log,
109
+ place: placeOf,
110
+ hosts: () => hostsAgreed(log),
111
+ });
112
+ if (!(await showIntro(slug, sandbox.warning))) {
113
+ reporter.step({ event: 'setup_command_sign_in_ended', outcome: 'cancelled' });
114
+ return void (await reporter.done());
115
+ }
81
116
  const onHost = (url) => URL.canParse(url) && new URL(url).origin === flags.host;
82
117
  /** The sign-in's answer, when it is for this checkout and points at --host only. */
83
118
  const accept = (t) => {
84
119
  log.secret(t.access_token);
85
120
  const approved = `${t.repository.owner}/${t.repository.name}`;
86
121
  if (approved.toLowerCase() !== slug.toLowerCase()) {
87
- throw new UserError(`You approved ${approved}, but this checkout is ${slug}. Run it again and pick ${slug} on the consent page.`);
122
+ throw new UserError(`You approved ${approved}, but this checkout is ${slug}. Run it again and approve ${slug} on the consent page.`, { event: 'setup_command_sign_in_ended', outcome: 'repo_mismatch' });
88
123
  }
89
124
  if (!onHost(t.setup_api) || !onHost(t.cli_api)) {
90
125
  throw new UserError(`Impeccable answered with addresses off ${flags.host}, so the command stopped before sending anything there.`);
91
126
  }
92
127
  return t;
93
128
  };
94
- const token = await signIn(flags.host, log, screenPrompt(showSignIn), accept, () => landing);
129
+ const token = await signIn(flags.host, slug, log, screenPrompt(showSignIn), accept, () => landing);
95
130
  const setupApi = token.setup_api.replace(/\/$/, '');
96
131
  const cliApi = token.cli_api.replace(/\/$/, '');
132
+ // Signed in: what the command held goes now, on the person who approved.
133
+ reporter.signedIn(token.access_token, cliApi);
97
134
  const page = progressPage(flags.host, token);
98
135
  // The run screen opens at once, with its placeholder, while the run starts. Stopping before the agent takes over
99
136
  // stops the command, and a run already started shows as stopped on the web page.
@@ -104,13 +141,14 @@ export async function main(argv) {
104
141
  stopping = true;
105
142
  const why = 'Impeccable stopped before the setup started.';
106
143
  if (sync) {
107
- sync.update({ phase: 'error', status: why, question: null });
144
+ sync.update({ phase: 'error', status: why, question: null, stop_reason: 'stopped' });
108
145
  await settle(sync.flush(), 5000);
109
146
  }
110
147
  land(new UserError(why));
111
148
  await closeScreens();
112
149
  console.error(why);
113
- await letTabsHear();
150
+ reporter.step({ event: 'setup_command_stopped', outcome: 'ctrl_c' });
151
+ await Promise.all([letTabsHear(), reporter.done()]);
114
152
  process.exit(130);
115
153
  };
116
154
  showRun({ onCancel: () => void stopEarly() });
@@ -126,6 +164,8 @@ export async function main(argv) {
126
164
  throw new UserError(`Impeccable answered with a model gateway off ${flags.host}, so the command stopped before sending anything there.`);
127
165
  land(page);
128
166
  const runToken = run.run_token;
167
+ // From here the run's own token, so each step names the run too.
168
+ reporter.signedIn(runToken, cliApi);
129
169
  const sessionUrl = `${cliApi}/sessions/${encodeURIComponent(run.run_id)}`;
130
170
  const liveSync = createSessionSync((payload) => requestJson(sessionUrl, { method: 'PUT', token: runToken, body: payload }, log));
131
171
  sync = liveSync;
@@ -135,7 +175,7 @@ export async function main(argv) {
135
175
  log.write('front end', frontEnd);
136
176
  const webPage = { label: 'Web page', url: page };
137
177
  if (frontEnd === 'none')
138
- await nothingToSetUp(liveSync, 'No web front end', NO_FRONT_END, [webPage]);
178
+ await nothingToSetUp(liveSync, reporter, 'nothing_to_set_up', 'No web front end', NO_FRONT_END, [webPage]);
139
179
  const [instructions, state] = await Promise.all([
140
180
  requestJson(`${setupApi}/instructions`, { token: token.access_token }, log),
141
181
  requestJson(`${setupApi}/state`, { token: token.access_token }, log),
@@ -145,15 +185,12 @@ export async function main(argv) {
145
185
  // When the checkout cannot tell, the setup state's own verdict decides. When it has a web front end Impeccable skipped, the person adds it first.
146
186
  if (stateSaysNoFrontEnd(state.data, token.repository.name)) {
147
187
  if (frontEnd === 'unknown')
148
- await nothingToSetUp(liveSync, 'No web front end', NO_FRONT_END, [webPage]);
188
+ await nothingToSetUp(liveSync, reporter, 'nothing_to_set_up', 'No web front end', NO_FRONT_END, [webPage]);
149
189
  const setupPage = state.data.account?.setupPage;
150
- await nothingToSetUp(liveSync, 'Repository skipped', SKIPPED, [...(typeof setupPage === 'string' && onHost(setupPage) ? [{ label: 'Setup page', url: setupPage }] : []), webPage]);
190
+ await nothingToSetUp(liveSync, reporter, 'skipped_repo', 'Repository skipped', SKIPPED, [...(typeof setupPage === 'string' && onHost(setupPage) ? [{ label: 'Setup page', url: setupPage }] : []), webPage]);
151
191
  }
152
192
  if (!interactive)
153
193
  say('Signed in.\n');
154
- // Scratch output (a server's log, a build's output) goes here and never into the checkout.
155
- const scratch = mkdtempSync(join(tmpdir(), 'impeccable-reviews-scratch-'));
156
- process.on('exit', () => rmSync(scratch, { recursive: true, force: true }));
157
194
  // Beside the transcript, named after the run as it is, so a run never writes over another's in the temp folder.
158
195
  const scans = scanRecorder(log, flags.scanReport ? join(dirname(transcriptPaths(run.run_id, flags.transcript).markdown), `scan-report-${run.run_id.replace(/[^\w.-]/g, '_')}.json`) : undefined);
159
196
  atExit(() => scans.report());
@@ -187,12 +224,18 @@ export async function main(argv) {
187
224
  // The web page has the report; when it missed it, the report stays here instead.
188
225
  if (!saved?.ok)
189
226
  say(`\n${report.markdown.trim()}`);
227
+ if (!saved?.ok)
228
+ reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
229
+ await reporter.done();
190
230
  process.exit(0);
191
231
  }
192
232
  const waiting = liveSync.payload.phase === 'waiting' ? liveSync.payload.question?.prompt : undefined;
193
233
  const why = fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.';
194
- liveSync.update({ phase: 'error', status: why, question: null });
234
+ const stopped = fatal ? 'model_error' : waiting ? 'unanswered' : cancelled ? 'stopped' : 'failed';
235
+ liveSync.update({ phase: 'error', status: why, question: null, stop_reason: stopped });
195
236
  const saved = await settle(liveSync.flush(), 5000);
237
+ if (!saved?.ok)
238
+ reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
196
239
  const missed = saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`];
197
240
  const { test_pr: drive, setup_pr: setup } = liveSync.payload;
198
241
  await showOutro({
@@ -203,6 +246,7 @@ export async function main(argv) {
203
246
  todo: ['Run impeccable-reviews again to carry on. What was saved stays saved; the web page has the details.'],
204
247
  warnings: missed,
205
248
  });
249
+ await reporter.done();
206
250
  process.exit(1);
207
251
  };
208
252
  await runAgent({
@@ -212,12 +256,12 @@ export async function main(argv) {
212
256
  effort: flags.effort,
213
257
  gatewayUrl: run.gateway_url,
214
258
  runToken,
215
- systemPrompt: playbook({ owner: checkout.owner, name: token.repository.name, scratch }),
259
+ systemPrompt: playbook({ owner: checkout.owner, name: token.repository.name, scratch, sandboxed: !sandbox.warning }),
216
260
  firstMessage: firstMessage({ owner: checkout.owner, name: token.repository.name }),
217
261
  firstContext: setupContext(focused, focusState(state.data)),
218
262
  tools: createTools({
219
263
  setupApi,
220
- auth: setupAuth(token, (_pi, why) => signIn(flags.host, log, screenPrompt((link, on) => showSignIn(link, { ...on, why })), accept)),
264
+ auth: setupAuth(token, (_pi, why) => signIn(flags.host, slug, log, screenPrompt((link, on) => showSignIn(link, { ...on, why })), accept)),
221
265
  owner: token.repository.owner,
222
266
  repo: token.repository.name,
223
267
  instructions: focused,
@@ -227,11 +271,13 @@ export async function main(argv) {
227
271
  onReport: (r) => void (report = r),
228
272
  log,
229
273
  previewWait: previewWait(),
274
+ sandbox,
230
275
  }),
231
276
  extensions: [
232
277
  fenceExtension(checkout.root, log, checkout.protectedBranches, scratch, scans),
233
278
  syncExtension(liveSync, { onFatal: (message) => void (fatal = message), onQuit: finish }),
234
279
  ],
280
+ bash: sandbox.shell,
235
281
  interactive,
236
282
  runId: run.run_id,
237
283
  transcript: flags.transcript,
@@ -247,12 +293,19 @@ export async function main(argv) {
247
293
  land(new UserError(message));
248
294
  log.write('fatal', err instanceof Error ? (err.stack ?? err.message) : String(err));
249
295
  if (sync) {
250
- sync.update({ phase: 'error', status: message, question: null });
296
+ // A sign-in the person cancelled mid-run (a renewed one) is a stop they chose, not a failure.
297
+ const chose = err instanceof UserError && err.step?.event === 'setup_command_sign_in_ended' && err.step.outcome === 'cancelled';
298
+ sync.update({ phase: 'error', status: message, question: null, stop_reason: chose ? 'stopped' : 'failed' });
251
299
  await settle(sync.flush(), 5000);
252
300
  }
301
+ // A stop that ends a step reports it, and anything the command did not expect is a crash; the rest the Worker saw itself.
302
+ if (!(err instanceof UserError))
303
+ reporter.step({ event: 'setup_command_stopped', outcome: 'crashed' });
304
+ else if (err.step)
305
+ reporter.step(err.step);
253
306
  await closeScreens();
254
307
  console.error(message);
255
- await letTabsHear();
308
+ await Promise.all([letTabsHear(), reporter.done()]);
256
309
  process.exit(1);
257
310
  }
258
311
  }
@@ -47,7 +47,8 @@ export async function passNetlifyLock(deps) {
47
47
  const finish = async (password, how) => {
48
48
  deps.remember(password);
49
49
  deps.remember(basicCredentials(password));
50
- const proof = await deps.prove(password);
50
+ // Netlify's form, which an app's own password form looks like, gets the password only on a site whose settings show one.
51
+ const proof = await deps.prove(password, site !== null);
51
52
  // locked: Netlify's lock stood all the same, and its form did not take the password either. Any other failure is the proof's to say: the form took it and the header did not, or the page past the lock is another, or did not answer.
52
53
  if (!proof.proven)
53
54
  return { reason: proof.reason ?? 'The address did not open with the password.', locked: proof.gate !== undefined && !proof.formOpened };
package/dist/netlify.js CHANGED
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { setTimeout as sleep } from 'node:timers/promises';
5
5
  import { promisify } from 'node:util';
6
+ import { gitEnvironment } from './sandbox.js';
6
7
  /**
7
8
  * Netlify: the deploy previews this repository's pull requests get (from
8
9
  * GitHub's record and from the Netlify CLI on this machine when it is signed
@@ -28,11 +29,13 @@ const previewAddress = (pullRequest, site) => (`deploy-preview-${pullRequest}--$
28
29
  /**
29
30
  * A runner for gh or netlify here: its input ends at once, so nothing waits on it, and no browser opens (a sign-in is the
30
31
  * person's) unless `browser` says so, for `netlify login`, which opens one and waits for the person's approval there.
32
+ * `env` adds settings the command reads, such as the account cf acts in.
31
33
  */
32
34
  export function commandRunner(name, cwd, opts = {}) {
33
35
  return async (args) => {
34
36
  try {
35
- const env = { ...process.env, ...(opts.browser ? {} : { BROWSER: 'none', CI: 'true' }), GH_PROMPT_DISABLED: '1' };
37
+ // The CLIs run git in the checkout, outside the sandbox.
38
+ const env = gitEnvironment({ ...process.env, ...(opts.browser ? {} : { BROWSER: 'none', CI: 'true' }), GH_PROMPT_DISABLED: '1', ...opts.env });
36
39
  const run = execFileAsync(name, args, { cwd, encoding: 'utf8', timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024, signal: opts.signal, env });
37
40
  run.child.stdin?.end();
38
41
  return (await run).stdout;
@@ -1,6 +1,6 @@
1
1
  ### When this applies
2
2
 
3
- Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and wrangler on this machine lists the project's deployments when it is signed in. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
3
+ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
4
4
 
5
5
  ### Steps
6
6
 
@@ -13,5 +13,5 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
13
13
  - **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as ci-tunnel or static-site says instead, and say under "What is not verified" what the Cloudflare address answered.
14
14
  - **Nothing listed**: Cloudflare's previews are not on record, so set the preview up as ci-tunnel or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
15
15
  - **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
16
- - The evidence is read only: link, deploy and change nothing on Cloudflare, and never sign wrangler in. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
16
+ - The evidence is read only: link, deploy and change nothing on Cloudflare. The one Cloudflare sign-in is impeccable_cloudflare_access's, behind Access, on the person's yes. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
17
17
  - The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
@@ -17,7 +17,7 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
17
17
 
18
18
  - **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the site not created, the repository not connected): the Netlify setup ends there. Set the preview up as ci-tunnel or static-site says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Netlify's GitHub app access to the repository", with the `access` page in the report.
19
19
  - **No preview for the draft**: save no method, set up no other preview, and leave the draft as it is, unrecorded, as pull-requests' "No preview, no test drive" says; the site and the connection stay, and the report names them.
20
- - **The build failed** (`state` is `error` or `failure`): `netlify deploy --build --context deploy-preview` builds this linked checkout as Netlify does, uploads a draft, and prints why it failed, so the report can name the cause.
20
+ - **The build failed** (`state` is `error` or `failure`): say so under "What is not verified", with the command that builds this linked checkout as Netlify does and prints why, for the person to run: `netlify deploy --build --context deploy-preview`. It never runs here, since it builds with their Netlify sign-in.
21
21
  - **No deploy preview within five minutes**: the build had not finished, or Netlify builds no deploy preview for pull requests here: Deploy Previews may be off in the site's settings, so put "Turn Deploy Previews on in the site's Netlify settings" in `left`.
22
22
  - **Behind a password or Netlify's login** (`gate: "netlify-protection"`): the preview-protection case's, whose steps come before the save in step 5. A team that keeps new sites private puts Netlify's team login in front of the new site's previews, which no tool gets past: put "Make the site public, or password-protected, in Project configuration → General → Visitor access" in `left`.
23
23
  - **The report** names the Netlify site under "What was configured, and by whom", whichever way the run ended, since it stays in the person's Netlify account, with how the repository was connected and the CLI's install when there was one. The site's environment variables are the person's: when the app needs some (`.env.example`), list their names, never their values, under "Before you merge".
@@ -4,8 +4,8 @@ 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, or to create a service token (then one taking the Cloudflare API token that creating it takes, and it allows the token in the Access application's policy), or, when it cannot make or read one, one screen taking a pasted one; opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
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. Step 2.
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, 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; opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
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
  - `login` (Netlify): the site is behind Netlify's team login, which no tool gets past; `reason` says so. Step 3.
10
10
  - `refused` whose `reason` says to prove a page a visitor opens: Netlify's own site password opened neither the header nor Netlify's form, so the page you proved asks for the app's own sign-in past the lock, or Netlify did not answer. Prove a page a visitor opens without signing in, as netlify-previews' step 1 says, and call the tool again; the consent is not asked a second time. Refused again, step 3.
11
11
  - Any other `skipped` or `refused`: nothing was saved; `reason` says what stopped it. Step 3.
@@ -15,7 +15,7 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
15
15
 
16
16
  ### Reference
17
17
 
18
- - **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or the person typed it, and where it is changed: the site's Project configuration → General → Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens, with the Cloudflare API token the person made for the run theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until this setup is run again.
18
+ - **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or the person typed it, and where it is changed: the site's Project configuration → General → Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens; with `apiToken`, the Cloudflare API token the person made for the run is theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until this setup is run again.
19
19
  - **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it in the host's header (`x-vercel-protection-bypass`, `Authorization`, or `CF-Access-Client-Id` and `CF-Access-Client-Secret`), both through the setup API, so impeccable_write refuses those two. The state's `preview-protection` item words the same calls, and shows `keySaved` once they are done.
20
20
  - **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails.
21
21
  - **The key is never yours**: it goes from the command's own process to the host and to Impeccable, and into no message, status line, file, command or report. `vercel project protection`, `vercel api`, `vercel curl` and `netlify api` print it, and the fence refuses them.
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const LIMIT = 20;
5
+ const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
6
+ const FAILURE = 'GitHub preview evidence could not be read completely.';
7
+ const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
8
+ function record(value) {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : undefined;
12
+ }
13
+ function positiveInteger(value) {
14
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
15
+ }
16
+ function sha(value) {
17
+ return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
18
+ }
19
+ function previewUrl(value) {
20
+ if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
21
+ return null;
22
+ try {
23
+ const url = new URL(value);
24
+ if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
25
+ return null;
26
+ return url.href;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
33
+ export async function readHostedPreviews(owner, repo, dependencies = {}) {
34
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
35
+ || !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
36
+ return { coverage: COVERAGE, deployments: [], error: FAILURE };
37
+ }
38
+ const runGh = dependencies.runGh ?? (async (args) => {
39
+ const { stdout } = await execFileAsync('gh', args, {
40
+ encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
41
+ });
42
+ return stdout;
43
+ });
44
+ const base = `/repos/${owner}/${repo}`;
45
+ async function get(route) {
46
+ if (dependencies.signal?.aborted)
47
+ throw new Error(FAILURE);
48
+ const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
49
+ if (dependencies.signal?.aborted)
50
+ throw new Error(FAILURE);
51
+ const data = JSON.parse(stdout);
52
+ if (!Array.isArray(data))
53
+ throw new Error(FAILURE);
54
+ return data.slice(0, LIMIT);
55
+ }
56
+ try {
57
+ const [deployments, pulls] = await Promise.all([
58
+ get(`${base}/deployments?per_page=${LIMIT}`),
59
+ get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
60
+ ]);
61
+ let incomplete = false;
62
+ const evidence = [];
63
+ for (const entry of deployments) {
64
+ if (dependencies.signal?.aborted) {
65
+ incomplete = true;
66
+ break;
67
+ }
68
+ const deployment = record(entry);
69
+ if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
70
+ continue;
71
+ let latest;
72
+ try {
73
+ latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
74
+ }
75
+ catch {
76
+ incomplete = true;
77
+ if (dependencies.signal?.aborted)
78
+ break;
79
+ }
80
+ const status = STATES.find((state) => state === latest?.state) ?? null;
81
+ const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
82
+ && !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
83
+ const pullRequests = [];
84
+ for (const item of pulls) {
85
+ const pull = record(item);
86
+ if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
87
+ && record(pull.head)?.sha === deployment.sha) {
88
+ pullRequests.push({ number: pull.number, state: pull.state });
89
+ }
90
+ }
91
+ evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
92
+ previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
93
+ }
94
+ incomplete ||= dependencies.signal?.aborted ?? false;
95
+ return { coverage: COVERAGE, deployments: evidence,
96
+ ...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
97
+ }
98
+ catch {
99
+ return { coverage: COVERAGE, deployments: [], error: FAILURE };
100
+ }
101
+ }
@@ -0,0 +1,24 @@
1
+ /** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
2
+ export function netlifyPatternError(body, observed) {
3
+ const pattern = body.previewUrlPattern;
4
+ if (typeof pattern !== 'string')
5
+ return null;
6
+ let netlify = /netlify\.app|<site>/i.test(pattern);
7
+ try {
8
+ const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
9
+ netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
10
+ }
11
+ catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
12
+ if (!netlify)
13
+ return null;
14
+ const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
15
+ if (!match || match[1].length > 63)
16
+ return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
17
+ if (Object.hasOwn(body, 'appKey'))
18
+ return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
19
+ if (body.intendedMethod !== 'preview-deploy')
20
+ return 'Save a Netlify pattern with intendedMethod preview-deploy.';
21
+ if (!observed.has(pattern))
22
+ return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
23
+ return null;
24
+ }