@impeccable/reviews 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -7,12 +7,12 @@ import { request } from 'node:http';
7
7
  import { createServer } from 'node:net';
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { defineTool } from '@earendil-works/pi-coding-agent';
10
- import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
13
  import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
14
  import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
- import { pagesProject, readCloudflarePreviews } from './cloudflare.js';
15
+ import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
16
16
  import { passCloudflareAccess } from './cloudflare-access.js';
17
17
  import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
18
18
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
@@ -20,6 +20,7 @@ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, ve
20
20
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
21
21
  import { fitsTask, stagesOf } from './stages.js';
22
22
  import { redact } from './log.js';
23
+ import { gitEnvironment } from './sandbox.js';
23
24
  import { openBrowser } from './login.js';
24
25
  import { TASK_STATUSES } from './sync.js';
25
26
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
@@ -409,7 +410,7 @@ async function stopPreviewChild(child) {
409
410
  catch { /* already stopped */ }
410
411
  }
411
412
  /** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
412
- export async function verifyLocalPreview(input, root, signal) {
413
+ export async function verifyLocalPreview(input, root, signal, sandbox) {
413
414
  const probes = [];
414
415
  const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
415
416
  if (process.platform === 'win32')
@@ -506,8 +507,11 @@ export async function verifyLocalPreview(input, root, signal) {
506
507
  scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
507
508
  const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
508
509
  await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
510
+ // The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it.
511
+ const inSandbox = sandbox?.preview(scratch);
512
+ const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
509
513
  env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
510
- const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true);
514
+ const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
511
515
  if (!listing.ok)
512
516
  return fail(stage, 'Could not list checkout files.');
513
517
  const files = [...new Set(listing.output.split('\0').filter(Boolean))];
@@ -547,7 +551,7 @@ export async function verifyLocalPreview(input, root, signal) {
547
551
  await copyFile(source, target);
548
552
  await chmod(target, info.mode & 0o777);
549
553
  if (file.split('/').at(-1) === 'package.json') {
550
- const baseline = await run('git', ['show', `HEAD:${file}`], root, true);
554
+ const baseline = await run('git', ['show', `HEAD:${file}`], root, true, 120_000, signal ?? null, gitEnvironment(env));
551
555
  const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
552
556
  scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
553
557
  }
@@ -629,8 +633,9 @@ export async function verifyLocalPreview(input, root, signal) {
629
633
  }
630
634
  if (!executable)
631
635
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
632
- // Precreate even for explicit bootstrap steps so every command in a compound step uses it.
633
- if (!(await run(executable, ['-m', 'venv', '.venv'], directory)).ok)
636
+ // Precreate even for explicit bootstrap steps so every command in a compound step uses it. `-m` imports from the folder, the checkout's code, so it runs in the sandbox too.
637
+ const venv = confined(`'${executable.replace(/'/g, `'\\''`)}' -m venv .venv`, env);
638
+ if (!(await run(venv.file, venv.args, directory, false, 120_000, signal ?? null, venv.env)).ok)
634
639
  return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
635
640
  }
636
641
  const environmentFor = (directory) => {
@@ -666,7 +671,8 @@ export async function verifyLocalPreview(input, root, signal) {
666
671
  }
667
672
  for (const [index, step] of input.steps.entries()) {
668
673
  stage = step.phase;
669
- if (!(await run('/bin/sh', ['-c', step.command], directories[index], false, 120_000, signal ?? null, environmentFor(directories[index]))).ok)
674
+ const command = confined(step.command, environmentFor(directories[index]));
675
+ if (!(await run(command.file, command.args, directories[index], false, 120_000, signal ?? null, command.env)).ok)
670
676
  return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
671
677
  }
672
678
  if (staticInput) {
@@ -682,7 +688,8 @@ export async function verifyLocalPreview(input, root, signal) {
682
688
  const free = await new Promise((done) => { const check = createServer(); check.once('error', () => done(false)); check.listen(serverInput.port, '127.0.0.1', () => check.close(() => done(true))); });
683
689
  if (!free)
684
690
  return fail(stage, 'The preview port is already in use; an existing service was not probed.');
685
- server = spawn('/bin/sh', ['-c', serverInput.start.command], { cwd: directories.at(-1), env: environmentFor(directories.at(-1)), detached: true, stdio: 'ignore' });
691
+ const start = confined(serverInput.start.command, environmentFor(directories.at(-1)));
692
+ server = spawn(start.file, start.args, { cwd: directories.at(-1), env: start.env, detached: true, stdio: 'ignore' });
686
693
  let exited = false;
687
694
  server.once('error', () => { exited = true; });
688
695
  server.once('exit', () => { exited = true; });
@@ -746,7 +753,7 @@ const execFileAsync = promisify(execFile);
746
753
  */
747
754
  export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
748
755
  try {
749
- await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000 });
756
+ await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000, env: gitEnvironment() });
750
757
  return true;
751
758
  }
752
759
  catch (err) {
@@ -981,11 +988,11 @@ export function workflowGitEnvironment() {
981
988
  'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
982
989
  'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
983
990
  delete env[key];
984
- return Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
991
+ return gitEnvironment(Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
985
992
  GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
986
993
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
987
994
  // Removing the variables alone still permits globally configured trace2 targets.
988
- GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
995
+ GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' }));
989
996
  }
990
997
  const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
991
998
  export function workflowDefaultBranch(value) {
@@ -1413,16 +1420,16 @@ export function createTools(ctx) {
1413
1420
  let provenHost;
1414
1421
  let savedHost;
1415
1422
  /**
1416
- * A host's build, waited for on the screen as the task in progress with its timer: `read` again every so often until
1417
- * `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1423
+ * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1424
+ * 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.
1418
1425
  */
1419
- async function awaitBuild(status, read, built, signal) {
1426
+ async function awaitBuild(host, status, read, built, signal) {
1420
1427
  let evidence = await read();
1421
1428
  if (built(evidence))
1422
1429
  return evidence;
1423
1430
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1424
1431
  said(status);
1425
- reportWait({ since: Date.now(), limitMs: wait.totalMs });
1432
+ reportWait({ since: Date.now(), host });
1426
1433
  try {
1427
1434
  for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1428
1435
  await sleep(wait.everyMs, undefined, { signal });
@@ -1528,6 +1535,12 @@ export function createTools(ctx) {
1528
1535
  const hostSignIns = new Map();
1529
1536
  /** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
1530
1537
  const teamChoices = new Map();
1538
+ /** A host's setup ended before anything was set up on the host: nothing more is asked for it, and the fence lets its CLI and its hold on pushes go. */
1539
+ const endSetup = (host) => {
1540
+ hostSignIns.set(host, 'skipped');
1541
+ teamChoices.delete(host);
1542
+ endHostSetup(ctx.log, host);
1543
+ };
1531
1544
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1532
1545
  const hostCli = (host, pi, signal) => {
1533
1546
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
@@ -1577,7 +1590,7 @@ export function createTools(ctx) {
1577
1590
  if (!hostSignIns.has(host)) {
1578
1591
  const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
1579
1592
  if (answer.skipped) {
1580
- hostSignIns.set(host, 'skipped');
1593
+ endSetup(host);
1581
1594
  return { skipped: true };
1582
1595
  }
1583
1596
  hostSignIns.set(host, 'confirmed');
@@ -1599,11 +1612,11 @@ export function createTools(ctx) {
1599
1612
  if (status.cli === 'signed-in')
1600
1613
  return signedIn();
1601
1614
  if (attempt === 2) {
1602
- hostSignIns.set(host, 'skipped');
1615
+ endSetup(host);
1603
1616
  return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
1604
1617
  }
1605
1618
  if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
1606
- hostSignIns.set(host, 'skipped');
1619
+ endSetup(host);
1607
1620
  return { skipped: true };
1608
1621
  }
1609
1622
  }
@@ -1742,7 +1755,7 @@ export function createTools(ctx) {
1742
1755
  return [
1743
1756
  tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. Only the push itself proves Git may push.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
1744
1757
  tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
1745
- tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal))),
1758
+ tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server already on Impeccable\'s tunnel give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
1746
1759
  tool('impeccable_preview_run', 'Read the preview attempt', 'On a rerun, once impeccable_test_pr recorded the setup pull request: read its preview workflow\'s runs. Answers each attempt with its failed stage and a fixed reason (never log text), the three-attempt budget (used, remaining, historyKnown), and adds the attempts to the task list. Reading is free.', PREVIEW_RUN, async (p, pi, signal) => {
1747
1760
  if (!retry.pr)
1748
1761
  return 'Refused: record the setup pull request with impeccable_test_pr first.';
@@ -1819,6 +1832,9 @@ export function createTools(ctx) {
1819
1832
  return `Refused: ${target.error}`;
1820
1833
  if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
1821
1834
  return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
1835
+ if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'ci-tunnel') {
1836
+ return "Refused: Impeccable no longer runs a server in CI for a new setup, so ci-tunnel is never saved. Pages that need a server get their host's pull request previews, as the server-previews case says. Nothing was saved.";
1837
+ }
1822
1838
  if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
1823
1839
  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. Nothing was saved.";
1824
1840
  }
@@ -1842,7 +1858,7 @@ export function createTools(ctx) {
1842
1858
  const response = await call(pi, target.url, { method: target.method, body });
1843
1859
  if (target.url.endsWith('/preview') && response.ok) {
1844
1860
  const keys = object(object(response.data)?.keys);
1845
- if (keys?.intendedMethod === 'stored' && ['ci-tunnel', 'static-upload'].includes(String(object(p.body)?.intendedMethod)))
1861
+ if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
1846
1862
  retry.requireHistory = true;
1847
1863
  if (object(p.body)?.intendedMethod === 'preview-deploy')
1848
1864
  savedHost = provenHost;
@@ -1884,8 +1900,12 @@ export function createTools(ctx) {
1884
1900
  if (error)
1885
1901
  return `Invalid input: ${error}.`;
1886
1902
  const answer = await askPerson(q);
1887
- if (answer.skipped)
1903
+ if (answer.skipped) {
1904
+ // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
1905
+ if (host?.[1] === 'vercel')
1906
+ endSetup('vercel');
1888
1907
  return JSON.stringify({ skipped: true });
1908
+ }
1889
1909
  if (q.kind === 'multi')
1890
1910
  return JSON.stringify({ values: answer.values ?? [] });
1891
1911
  if (q.kind === 'confirm') {
@@ -1986,7 +2006,7 @@ export function createTools(ctx) {
1986
2006
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
1987
2007
  // A head's build is still building until it has a final status.
1988
2008
  const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1989
- const evidence = branch !== undefined ? await awaitBuild(`Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
2009
+ const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
1990
2010
  listedPreviews.clear();
1991
2011
  vercelMade.clear();
1992
2012
  publicProduction.clear();
@@ -2019,24 +2039,24 @@ export function createTools(ctx) {
2019
2039
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
2020
2040
  const states = ['success', 'ready', 'failure', 'error'];
2021
2041
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
2022
- const evidence = pull !== undefined ? await awaitBuild(`Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2042
+ const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
2023
2043
  netlifyListed.clear();
2024
2044
  for (const p of evidence.previews)
2025
2045
  if (p.state === 'success' || p.state === 'ready')
2026
2046
  netlifyListed.add(new URL(p.url).origin);
2027
2047
  return JSON.stringify(evidence);
2028
2048
  }),
2029
- tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or wrangler on this machine when it is signed in (read only: it never signs in). cli says whether wrangler is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or wrangler. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2049
+ tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2030
2050
  const pull = p.pull;
2031
2051
  const branch = p.branch;
2032
2052
  if (pull !== undefined && branch !== undefined)
2033
2053
  return 'Invalid input: give pull or branch, not both.';
2034
2054
  const read = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch });
2035
- // Cloudflare posts its check run once the build ended: until then the head lists nothing, or wrangler's row says it is building.
2036
- // GitHub's record unread ends the wait; wrangler's alone does not, since the check run still says when the build ends.
2055
+ // Cloudflare posts its check run once the build ended: until then the head lists nothing, or cf's row says it is building.
2056
+ // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2037
2057
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2038
2058
  const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2039
- const evidence = subject ? await awaitBuild(`Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2059
+ const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, signal) : await read();
2040
2060
  cloudflareListed.clear();
2041
2061
  for (const preview of evidence.previews) {
2042
2062
  if (preview.state !== 'success')
@@ -2142,15 +2162,14 @@ export function createTools(ctx) {
2142
2162
  const at = gated;
2143
2163
  /** The address pattern the proof with the password answers, saved as the method once the password is. */
2144
2164
  let pattern;
2145
- const prove = async (password) => {
2146
- const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password });
2165
+ const prove = async (password, form = false) => {
2166
+ const proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, password, form });
2147
2167
  if (proof.proven && proof.pattern)
2148
2168
  pattern = proof.pattern;
2149
2169
  return proof;
2150
2170
  };
2151
- // A password in hand already: another deploy preview of the same site opens with it.
2152
- if (passwordSaved && sitePassword && (await prove(sitePassword)).proven) {
2153
- passwordSites.add(site);
2171
+ // A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
2172
+ if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
2154
2173
  provenPattern = pattern ?? provenPattern;
2155
2174
  return JSON.stringify(passwordSaved);
2156
2175
  }
@@ -2197,7 +2216,7 @@ export function createTools(ctx) {
2197
2216
  }
2198
2217
  return JSON.stringify(passwordOutcome);
2199
2218
  }),
2200
- tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token and one taking the Cloudflare API token that creating it takes, then the token named "Impeccable reviews" created and allowed in the policy of the application in front of the preview; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open 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 token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2219
+ tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open 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 token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2201
2220
  if (!accessGated)
2202
2221
  return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
2203
2222
  if (accessOutcome?.status === 'skipped')
@@ -2228,6 +2247,10 @@ export function createTools(ctx) {
2228
2247
  answer = await passCloudflareAccess({
2229
2248
  url: at.url,
2230
2249
  consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
2250
+ cf: cfRunner({}, ctx.hostRunner),
2251
+ login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
2252
+ retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
2253
+ status: said,
2231
2254
  apiToken: async () => {
2232
2255
  const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
2233
2256
  return typed.skipped ? undefined : typed.value;
@@ -2250,7 +2273,7 @@ export function createTools(ctx) {
2250
2273
  accessProjects.clear();
2251
2274
  accessToken = value;
2252
2275
  accessStored = told.status === 'saved';
2253
- accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}) };
2276
+ accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
2254
2277
  const project = pagesProject(new URL(at.url).origin);
2255
2278
  if (project)
2256
2279
  accessProjects.add(project);
package/dist/ui/look.js CHANGED
@@ -230,9 +230,15 @@ function runPage(scene, f, inner, st) {
230
230
  : t.status === 'skipped' ? '–'
231
231
  : '○';
232
232
  const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
233
- // The task in progress carries the wait's timer, so a screen waiting on a host's build is never still.
234
- const detail = t.status === 'skipped' || t.status === 'failed' ? t.status : i === running && scene.wait ? scene.wait : undefined;
235
- return ink(`${mark} ${t.title}${detail ? ` · ${detail}` : ''}`);
233
+ const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
234
+ if (t.status === 'skipped' || t.status === 'failed')
235
+ return ink(row(t.status));
236
+ if (i !== running || !scene.wait)
237
+ return ink(row());
238
+ // The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
239
+ // waiting on a host's build is never still; the time alone where the words would push it off the row.
240
+ const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
241
+ return ink(visibleWidth(waiting) <= inner ? waiting : row(scene.wait.timer));
236
242
  });
237
243
  const counted = scene.tasks.filter((t) => t.status !== 'skipped');
238
244
  const skipped = scene.tasks.length - counted.length;
@@ -277,8 +283,10 @@ function pageOf(f, inner) {
277
283
  switch (scene.kind) {
278
284
  case 'wait':
279
285
  return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.faint('One moment…'), inner)] };
280
- case 'intro':
281
- return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner)], ways: [['Continue', 'Enter']] };
286
+ case 'intro': {
287
+ const note = scene.note ? ['', ...block(hang(scene.note, Math.min(TEXT_WIDTH, inner), '! ').map(st.soft), inner)] : [];
288
+ return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner), ...note], ways: [['Continue', 'Enter']] };
289
+ }
282
290
  case 'signin': {
283
291
  // P still takes a pasted code on a remote shell; --help says so, the screen does not.
284
292
  const under = scene.note ?? (scene.copied ? 'Copied to clipboard!' : '');
@@ -24,7 +24,7 @@ export class RunState {
24
24
  tasks = [];
25
25
  previous;
26
26
  current;
27
- /** A bounded wait under way: the current line carries its timer, counting up to the limit. */
27
+ /** A bounded wait under way: the current line carries its timer, counting up. */
28
28
  waiting;
29
29
  /** When something last changed on screen: a status, the task list, an answered question. */
30
30
  changedAt;
@@ -51,12 +51,12 @@ export class RunState {
51
51
  this.touch();
52
52
  }
53
53
  /**
54
- * The wait's timer, "1:05 of 5:00", while one is under way: the task in progress carries it on the tasks screen.
55
- * `live` false gives the wait's start, "0:00 of 5:00", the same on every tick: a drawing that changes only when
56
- * a wait begins or ends, so the frames record the wait once rather than once a second.
54
+ * The wait under way, which the task in progress carries on the tasks screen: the host whose preview it is and the
55
+ * time so far, "1:05". `live` false gives the wait's start, "0:00", the same on every tick: a drawing that changes
56
+ * only when a wait begins or ends, so the frames record the wait once rather than once a second.
57
57
  */
58
58
  timer(live = true) {
59
- return this.waiting ? `${timer(live ? this.now() - this.waiting.since : 0)} of ${timer(this.waiting.limitMs)}` : undefined;
59
+ return this.waiting && { host: this.waiting.host, timer: timer(live ? this.now() - this.waiting.since : 0) };
60
60
  }
61
61
  setTasks(tasks) {
62
62
  this.tasks = tasks.map((t) => ({ ...t }));
@@ -70,14 +70,14 @@ export class RunState {
70
70
  * The band's two lines as they read now: after a minute with nothing new the
71
71
  * last line moves up, dimmed, under "Still working…" and the time since.
72
72
  * `quiet` is false while the person is being asked. During a bounded wait
73
- * the current line carries the timer, "1:05 of 5:00", so the screen never
73
+ * the current line carries the timer, "1:05", so the screen never
74
74
  * looks stalled; `live` false holds the timer at the wait's start, for a
75
75
  * drawing that changes only when the words do.
76
76
  */
77
77
  band(quiet = true, live = true) {
78
- if (this.waiting && this.current) {
79
- return { previous: this.previous, current: `${this.current} · ${this.timer(live)}` };
80
- }
78
+ const wait = this.timer(live);
79
+ if (wait && this.current)
80
+ return { previous: this.previous, current: `${this.current} · ${wait.timer}` };
81
81
  const silence = this.now() - this.changedAt;
82
82
  if (quiet && silence >= SILENCE_MS)
83
83
  return { previous: this.current, current: `Still working… ${elapsed(silence)}` };
@@ -1,13 +1,17 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { format } from 'node:util';
3
3
  import { CURSOR_MARKER, getCapabilities, Input, KeybindingsManager, matchesKey, ProcessTerminal, setKeybindings, TUI_KEYBINDINGS, TuiAltScreen } from '@earendil-works/pi-tui';
4
- import { readCheckout } from '../preflight.js';
5
4
  import { copyText } from '../login.js';
6
5
  import { workStarted } from '../stages.js';
7
6
  import { mountUi } from './bridge.js';
8
7
  import { createFrameWriter, frameState } from './frames.js';
9
8
  import { drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
10
9
  import { RunState } from './run-state.js';
10
+ /** What the command does before a screen ends it at once (Ctrl+C before a run, or a signal): report the stop (main.ts), briefly. */
11
+ let beforeExit = async () => { };
12
+ export function onScreenExit(fn) {
13
+ beforeExit = fn;
14
+ }
11
15
  /** How often what moves on screen moves: the loader, the spinner. */
12
16
  const TICK_MS = 80;
13
17
  /** The run screen's first line, before the agent has said anything. */
@@ -44,7 +48,7 @@ class Wizard {
44
48
  onExit = () => this.stop();
45
49
  onSignal = (signal) => {
46
50
  this.stop();
47
- process.kill(process.pid, signal);
51
+ void beforeExit('terminated').finally(() => process.kill(process.pid, signal));
48
52
  };
49
53
  constructor() {
50
54
  // The alternate screen's own scrolling and search keys stay off: the wizard draws every row, and a typed answer keeps Home and End.
@@ -228,7 +232,8 @@ class Wizard {
228
232
  const run = this.views.find((v) => v.kind === 'run');
229
233
  if (!run) {
230
234
  this.stop();
231
- process.exit(130);
235
+ void beforeExit('ctrl_c').finally(() => process.exit(130));
236
+ return;
232
237
  }
233
238
  this.stopAsked = 0;
234
239
  for (const question of this.questions.splice(0))
@@ -330,7 +335,7 @@ class Wizard {
330
335
  if (!view)
331
336
  return { kind: 'wait' };
332
337
  if (view.kind === 'intro')
333
- return { kind: 'intro' };
338
+ return { kind: 'intro', note: view.note };
334
339
  if (view.kind === 'signin')
335
340
  return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note };
336
341
  if (view.kind === 'pull')
@@ -401,30 +406,24 @@ function host() {
401
406
  wizard ??= new Wizard();
402
407
  return wizard;
403
408
  }
404
- function repository() {
405
- try {
406
- const checkout = readCheckout(process.cwd());
407
- return `${checkout.owner}/${checkout.repo}`;
408
- }
409
- catch {
410
- return undefined;
411
- }
412
- }
413
409
  /** Writes every screen into `dir` as it changes (--frames), for the eval. */
414
410
  export function recordFrames(dir) {
415
411
  frames = createFrameWriter(dir);
416
412
  }
417
- /** The first screen: what the command does, and the way on. Resolves true to go on, false to cancel. */
418
- export function showIntro() {
413
+ /** The first screen: what the command does, a warning to read before anything runs (`note`), and the way on; without a terminal, a line naming `repository`, the owner/name being set up. Resolves true to go on, false to cancel. */
414
+ export function showIntro(repository, note) {
419
415
  const screen = host();
420
416
  if (!screen) {
421
- console.log(`${PRODUCT}: setting up ${repository() ?? 'this repository'}.`);
417
+ console.log(`${PRODUCT}: setting up ${repository}.`);
418
+ if (note)
419
+ console.log(`! ${note}`);
422
420
  console.log();
423
421
  return Promise.resolve(true);
424
422
  }
425
423
  return new Promise((resolve) => {
426
424
  const view = {
427
425
  kind: 'intro',
426
+ note,
428
427
  done: (ok) => {
429
428
  screen.remove(view);
430
429
  if (ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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,
@@ -24,6 +24,7 @@
24
24
  "eval": "npm run build && node dist/eval/main.js"
25
25
  },
26
26
  "dependencies": {
27
+ "@anthropic-ai/sandbox-runtime": "0.0.78",
27
28
  "@earendil-works/pi-coding-agent": "~0.87.1",
28
29
  "@earendil-works/pi-tui": "~0.87.1"
29
30
  },
@@ -1,21 +0,0 @@
1
- ### When this applies
2
-
3
- The pages need a server from this repository: SSR, API routes, or a backend the front end calls. A front end whose bundle builds on its own still needs its backend here.
4
-
5
- ### Steps
6
-
7
- 1. **Work out the commands** in the workflow's order: install, prepare, build, seed and start, each with the folder it runs in. Done when each has a command, or none, and a folder.
8
- 2. **Prove them** with impeccable_verify_preview in server mode. Done when it answers `verified: true`; a failure names its `stage`, which the report explains, and pull-requests' "No preview, no test drive" says how the run goes on without a proof.
9
- 3. **Save them** with impeccable_write to `preview`: `intendedMethod: "ci-tunnel"` and the settings you proved, from the keys `buildSettings` lists. Done when the answer's `keys` show each key stored or unchanged.
10
-
11
- ### Reference
12
-
13
- - **Seed and migrate only a throwaway database**, never a shared or production one:
14
- - **SQLite**: a relative file URL in `env` (`DATABASE_URL=file:./preview.db`), and the repository's schema setup as the prepare or seed step.
15
- - **Postgres or MySQL**: `service`, which gives the steps their own `DATABASE_URL`.
16
- - **Cloudflare D1**: `--local` migrations and seeds.
17
- - **The tunnel host refused** (localhost answers, the tunnel host gets a 403): allow the preview host in the framework's host check (Rails `config.hosts`, Django `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS`, Vite `preview.allowedHosts`) for previews only, in the setup pull request; keep the check on.
18
- - **Readiness**: the job waits for `/` to answer. When only a base path answers, report it as a blocker: no setting points it elsewhere.
19
- - **Runtime**: compare `node --version` (or python, ruby, pnpm) with the repository's pins; the job sets its runtime from `runtimeVersion`, so a mismatch goes under "What is not verified".
20
- - **Env**: literal, non-secret values. A secret the app needs is `${{ secrets.NAME }}` in the saved `env`, and "Add the NAME repository secret" in `left`. A demo or test-login flag belongs in the preview's env only.
21
- - A verified result covers HTTP answers only: rendering and the CI runtime go under "What is not verified".