@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/README.md +25 -7
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +157 -49
- package/dist/cloudflare.js +93 -51
- package/dist/fence.js +107 -39
- package/dist/host-questions.js +4 -3
- package/dist/hosted.js +29 -8
- package/dist/log.js +6 -1
- package/dist/login.js +8 -6
- package/dist/main.js +76 -23
- package/dist/netlify-password.js +2 -1
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +4 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/monorepo.md +4 -3
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +4 -4
- package/dist/playbook/cases/preview-protection.md +3 -3
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +17 -0
- package/dist/playbook/cases/sign-in.md +0 -1
- package/dist/playbook/cases/static-site.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +3 -2
- package/dist/preflight.js +52 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/tools.js +58 -35
- package/dist/ui/look.js +13 -5
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +15 -16
- package/package.json +2 -1
- package/dist/playbook/cases/ci-tunnel.md +0 -21
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
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
|
|
2036
|
-
// GitHub's record unread ends the wait;
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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!' : '');
|
package/dist/ui/run-state.js
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
* `live` false gives the wait's start, "0:00
|
|
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
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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)}` };
|
package/dist/ui/screens.js
CHANGED
|
@@ -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
|
|
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.
|
|
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".
|