@impeccable/reviews 0.1.0
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 +68 -0
- package/dist/agent.js +183 -0
- package/dist/bin.js +11 -0
- package/dist/fence.js +1071 -0
- package/dist/focus.js +98 -0
- package/dist/host-questions.js +71 -0
- package/dist/hosted.js +195 -0
- package/dist/http.js +36 -0
- package/dist/log.js +38 -0
- package/dist/login.js +276 -0
- package/dist/main.js +252 -0
- package/dist/netlify.js +333 -0
- package/dist/playbook/cases/ci-tunnel.md +21 -0
- package/dist/playbook/cases/first-preview-failure.md +28 -0
- package/dist/playbook/cases/netlify-previews.md +17 -0
- package/dist/playbook/cases/netlify-setup.md +24 -0
- package/dist/playbook/cases/preview-protection.md +21 -0
- package/dist/playbook/cases/pull-requests.md +31 -0
- package/dist/playbook/cases/sign-in.md +21 -0
- package/dist/playbook/cases/static-site.md +16 -0
- package/dist/playbook/cases/vercel-previews.md +16 -0
- package/dist/playbook/cases/vercel-setup.md +27 -0
- package/dist/playbook/cases/workflow-as-person.md +23 -0
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +98 -0
- package/dist/preflight.js +211 -0
- package/dist/stages.js +34 -0
- package/dist/sync.js +142 -0
- package/dist/tools.js +2015 -0
- package/dist/transcript.js +139 -0
- package/dist/ui/bridge.js +145 -0
- package/dist/ui/frames.js +62 -0
- package/dist/ui/look.js +368 -0
- package/dist/ui/run-state.js +91 -0
- package/dist/ui/screens.js +544 -0
- package/dist/ui/sensitive.js +14 -0
- package/dist/vercel-bypass.js +136 -0
- package/dist/vercel.js +70 -0
- package/package.json +39 -0
package/dist/login.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { requestJson } from './http.js';
|
|
5
|
+
import { UserError } from './log.js';
|
|
6
|
+
import { askPerson } from './ui/bridge.js';
|
|
7
|
+
/** The callback ports, in the order the contract lists them; the Worker accepts no other. */
|
|
8
|
+
export const CALLBACK_PORTS = [8239, 8238, 8240, 8237, 8236, 8235];
|
|
9
|
+
const LOGIN_TIMEOUT_MS = 30 * 60_000;
|
|
10
|
+
export function challengeFor(verifier) {
|
|
11
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
12
|
+
}
|
|
13
|
+
export function pkcePair() {
|
|
14
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
15
|
+
return { verifier, challenge: challengeFor(verifier) };
|
|
16
|
+
}
|
|
17
|
+
/** The web page that mirrors the run: the token's `page` when it is on `host`, else the one for the repository on `host`. */
|
|
18
|
+
export function progressPage(host, t) {
|
|
19
|
+
if (typeof t.page === 'string' && URL.canParse(t.page) && new URL(t.page).origin === host)
|
|
20
|
+
return t.page;
|
|
21
|
+
return `${host}/app/${encodeURIComponent(t.repository.workspace || t.repository.owner)}/onboard?repo=${encodeURIComponent(t.repository.name)}`;
|
|
22
|
+
}
|
|
23
|
+
/** How long the server keeps answering a tab once it has somewhere to go: long enough for its next look, or a click on its link. */
|
|
24
|
+
const LANDED_MS = 60_000;
|
|
25
|
+
/** How long an exiting command waits for its waiting tabs to hear where to go. */
|
|
26
|
+
const HEAR_MS = 2000;
|
|
27
|
+
/** The waiting tabs that have not heard where to go yet, each settling once it has, or once its server stops. */
|
|
28
|
+
const untold = new Set();
|
|
29
|
+
/** Gives the tabs waiting on a sign-in a moment to hear where to go, since the command exiting takes their answer with it. */
|
|
30
|
+
export async function letTabsHear() {
|
|
31
|
+
await Promise.race([Promise.all(untold), new Promise((r) => setTimeout(r, HEAR_MS))]);
|
|
32
|
+
}
|
|
33
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
34
|
+
function page(res, status, message, more = '') {
|
|
35
|
+
const body = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Impeccable for PRs</title>
|
|
36
|
+
<style>body{font:17px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;color:#1a1a1a;background:#fafafa}a{color:inherit}@media (prefers-color-scheme:dark){body{color:#eee;background:#161616}}</style>
|
|
37
|
+
<main><p id="said" role="status" aria-live="polite">${escapeHtml(message)}</p>${more}</main></html>`;
|
|
38
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' }).end(body);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The page the tab shows once the code has come: it asks the command every
|
|
42
|
+
* half second where to go next, and goes there (the progress page), or says
|
|
43
|
+
* what went wrong; without scripts, a link does the asking.
|
|
44
|
+
*/
|
|
45
|
+
function waitingPage(res, state) {
|
|
46
|
+
const script = `<script>(function(){var said=document.getElementById('said'),next='/next?state='+encodeURIComponent(new URLSearchParams(location.search).get('state')||'');function ask(){fetch(next,{cache:'no-store'}).then(function(r){return r.json()}).then(function(a){if(a.page&&/^https?:/.test(a.page))location.replace(a.page);else if(a.error)said.textContent=a.error;else setTimeout(ask,500)},function(){said.textContent='Back to your terminal.'})}ask()})()</script>`;
|
|
47
|
+
page(res, 200, 'Signed in. One moment…', `<noscript><p><a href="/go?state=${escapeHtml(encodeURIComponent(state))}">Go on to the progress page</a></p></noscript>${script}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Listens on the first free port of `ports` for `/callback?code=&state=`. A
|
|
51
|
+
* wrong state gets a 400 and the wait goes on; the right one hands over the
|
|
52
|
+
* code and answers at once with a page that asks `/next` where to go, which
|
|
53
|
+
* says "not yet" until `land` knows (a JSON `wait`), then the page or what
|
|
54
|
+
* went wrong. `/go` is the same for a tab without scripts: a redirect, or
|
|
55
|
+
* the waiting page again.
|
|
56
|
+
*/
|
|
57
|
+
export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
58
|
+
let resolve;
|
|
59
|
+
let reject;
|
|
60
|
+
const code = new Promise((res, rej) => {
|
|
61
|
+
resolve = res;
|
|
62
|
+
reject = rej;
|
|
63
|
+
});
|
|
64
|
+
/** A tab brought the code, and waits to hear where to go. */
|
|
65
|
+
let visited = false;
|
|
66
|
+
let landing;
|
|
67
|
+
let heard = () => { };
|
|
68
|
+
const told = new Promise((r) => (heard = r)).finally(() => untold.delete(told));
|
|
69
|
+
const server = createServer((req, res) => {
|
|
70
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
71
|
+
const route = req.method === 'GET' ? url.pathname : '';
|
|
72
|
+
if (!['/callback', '/next', '/go'].includes(route))
|
|
73
|
+
return void res.writeHead(404, { connection: 'close' }).end();
|
|
74
|
+
if (url.searchParams.get('state') !== state)
|
|
75
|
+
return page(res, 400, 'This sign-in is not the one your terminal started. Run impeccable-reviews again.');
|
|
76
|
+
// Heard once the answer that says where to go has left for the tab.
|
|
77
|
+
if (route !== '/callback' && landing)
|
|
78
|
+
res.once('finish', heard);
|
|
79
|
+
if (route === '/next')
|
|
80
|
+
return void res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', connection: 'close' }).end(JSON.stringify(landing ?? { wait: true }));
|
|
81
|
+
if (route === '/go') {
|
|
82
|
+
if (landing && 'page' in landing)
|
|
83
|
+
return void res.writeHead(302, { location: landing.page, 'cache-control': 'no-store', connection: 'close' }).end();
|
|
84
|
+
return landing ? page(res, 400, landing.error) : waitingPage(res, state);
|
|
85
|
+
}
|
|
86
|
+
const got = url.searchParams.get('code');
|
|
87
|
+
const error = url.searchParams.get('error');
|
|
88
|
+
if (!got || error) {
|
|
89
|
+
page(res, 400, 'Sign-in did not finish. Back to your terminal.');
|
|
90
|
+
return reject(new UserError(`Sign-in did not finish${error ? ` (${error})` : ''}. Run impeccable-reviews again.`));
|
|
91
|
+
}
|
|
92
|
+
if (!visited)
|
|
93
|
+
untold.add(told);
|
|
94
|
+
visited = true;
|
|
95
|
+
resolve(got);
|
|
96
|
+
waitingPage(res, state);
|
|
97
|
+
});
|
|
98
|
+
const shut = () => {
|
|
99
|
+
heard();
|
|
100
|
+
server.close();
|
|
101
|
+
server.closeIdleConnections();
|
|
102
|
+
};
|
|
103
|
+
let closing = false;
|
|
104
|
+
const shutLater = () => void setTimeout(shut, LANDED_MS).unref();
|
|
105
|
+
for (const port of ports) {
|
|
106
|
+
const listening = await new Promise((done, fail) => {
|
|
107
|
+
const onError = (err) => (err.code === 'EADDRINUSE' || err.code === 'EACCES' ? done(false) : fail(err));
|
|
108
|
+
server.once('error', onError);
|
|
109
|
+
server.listen(port, '127.0.0.1', () => {
|
|
110
|
+
server.off('error', onError);
|
|
111
|
+
done(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
if (!listening)
|
|
115
|
+
continue;
|
|
116
|
+
const actual = server.address().port;
|
|
117
|
+
return {
|
|
118
|
+
redirectUri: `http://localhost:${actual}/callback`,
|
|
119
|
+
code,
|
|
120
|
+
land: (next) => void next.then((url) => void (landing ??= { page: url }), (err) => void (landing ??= { error: err instanceof UserError ? err.message : 'Sign-in did not finish. Back to your terminal.' })).then(() => closing && shutLater()),
|
|
121
|
+
close: () => {
|
|
122
|
+
// The tab asks again every half second; it is never a reason for the command to stay.
|
|
123
|
+
server.unref();
|
|
124
|
+
if (!visited)
|
|
125
|
+
return shut();
|
|
126
|
+
// It stops a while after the tab has somewhere to go, however long that takes to know.
|
|
127
|
+
closing = true;
|
|
128
|
+
if (landing)
|
|
129
|
+
shutLater();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
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.`);
|
|
134
|
+
}
|
|
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
|
+
export function parsePasted(text, state) {
|
|
137
|
+
const trimmed = text.trim();
|
|
138
|
+
if (trimmed.includes('code=')) {
|
|
139
|
+
let url;
|
|
140
|
+
try {
|
|
141
|
+
url = new URL(trimmed.includes('://') ? trimmed : `http://localhost/?${trimmed.split('?').pop()}`);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const pastedState = url.searchParams.get('state');
|
|
147
|
+
if (pastedState !== null && pastedState !== state)
|
|
148
|
+
return null;
|
|
149
|
+
return url.searchParams.get('code') || null;
|
|
150
|
+
}
|
|
151
|
+
return /^[A-Za-z0-9._~-]{8,}$/.test(trimmed) ? trimmed : null;
|
|
152
|
+
}
|
|
153
|
+
/** Opens the address in the default browser; a failure is fine because the address is printed too. BROWSER=none skips it. */
|
|
154
|
+
export function openBrowser(url) {
|
|
155
|
+
if (process.env.BROWSER === 'none')
|
|
156
|
+
return;
|
|
157
|
+
const [command, args, verbatim] = process.platform === 'darwin' ? ['open', [url], false]
|
|
158
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', `"${url}"`], true]
|
|
159
|
+
: ['xdg-open', [url], false];
|
|
160
|
+
try {
|
|
161
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: verbatim });
|
|
162
|
+
child.on('error', () => { });
|
|
163
|
+
child.unref();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// The address is on screen.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Puts text on the clipboard with the platform's own command, and says whether it got there; a failure is fine because the text is on screen. */
|
|
170
|
+
export function copyText(text) {
|
|
171
|
+
const [command, args] = process.platform === 'darwin' ? ['pbcopy', []] : process.platform === 'win32' ? ['clip', []] : ['xclip', ['-selection', 'clipboard']];
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
try {
|
|
174
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
175
|
+
child.on('error', () => resolve(false));
|
|
176
|
+
child.on('close', (code) => resolve(code === 0));
|
|
177
|
+
child.stdin.on('error', () => { });
|
|
178
|
+
child.stdin.end(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
resolve(false);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** On the UI's sign-in screen instead of printed lines: the pasted code comes through a question in its place. */
|
|
186
|
+
export function screenPrompt(showSignIn) {
|
|
187
|
+
let link = '';
|
|
188
|
+
return {
|
|
189
|
+
show: (url) => void (link = url),
|
|
190
|
+
listen(state, onCode, onCancel) {
|
|
191
|
+
const screen = showSignIn(link, {
|
|
192
|
+
onCancel,
|
|
193
|
+
onCopy: () => copyText(link),
|
|
194
|
+
async onPaste() {
|
|
195
|
+
const pasted = await askPerson({ id: 'sign-in-code', title: 'Sign-in code', prompt: 'Paste the code, or the whole address the browser ended on.', kind: 'text', skippable: true });
|
|
196
|
+
const code = pasted.skipped ? null : parsePasted(pasted.value ?? '', state);
|
|
197
|
+
if (code)
|
|
198
|
+
onCode(code);
|
|
199
|
+
return code;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return () => screen.close();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export async function exchangeCode(host, input, log) {
|
|
207
|
+
const res = await requestJson(`${host}/app/cli/token`, { method: 'POST', body: { code: input.code, code_verifier: input.verifier, redirect_uri: input.redirectUri } }, log);
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
throw new UserError(`Sign-in failed: ${res.error}. Run impeccable-reviews again.`);
|
|
210
|
+
const t = res.data;
|
|
211
|
+
if (!t || typeof t.access_token !== 'string' || !t.repository || typeof t.repository.name !== 'string' || typeof t.setup_api !== 'string' || typeof t.cli_api !== 'string') {
|
|
212
|
+
throw new UserError('Sign-in answered with something this version does not understand. Update impeccable-reviews and run it again.');
|
|
213
|
+
}
|
|
214
|
+
return t;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
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
|
|
219
|
+
* cannot use; otherwise the tab that brought the code goes on to the page
|
|
220
|
+
* `landing` resolves to (the web page that mirrors the run, once the command
|
|
221
|
+
* has what it needs to show a run there), or says why it will not.
|
|
222
|
+
*/
|
|
223
|
+
export async function signIn(host, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
|
|
224
|
+
const state = randomBytes(24).toString('base64url');
|
|
225
|
+
const { verifier, challenge } = pkcePair();
|
|
226
|
+
log.secret(verifier);
|
|
227
|
+
const server = await startCallbackServer(state);
|
|
228
|
+
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();
|
|
230
|
+
log.write(`callback server at ${server.redirectUri}`);
|
|
231
|
+
prompt.show(authorize.href);
|
|
232
|
+
openBrowser(authorize.href);
|
|
233
|
+
let stopPaste = () => { };
|
|
234
|
+
let timer;
|
|
235
|
+
try {
|
|
236
|
+
const code = await new Promise((resolve, reject) => {
|
|
237
|
+
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
|
+
});
|
|
241
|
+
log.secret(code);
|
|
242
|
+
const token = check(await exchangeCode(host, { code, verifier, redirectUri: server.redirectUri }, log));
|
|
243
|
+
server.land(landing(token));
|
|
244
|
+
return token;
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
server.land(Promise.reject(err));
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
stopPaste();
|
|
253
|
+
server.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The setup token lasts an hour and cannot be extended, so a run that outlives
|
|
258
|
+
* it signs the person in again, once for all the tools that need it.
|
|
259
|
+
*/
|
|
260
|
+
export function setupAuth(first, signInAgain, now = Date.now) {
|
|
261
|
+
const until = (t) => now() + (Number.isFinite(t.expires_in) ? t.expires_in : 3600) * 1000;
|
|
262
|
+
let current = { token: first.access_token, expiresAt: until(first) };
|
|
263
|
+
let pending;
|
|
264
|
+
const renew = (pi) => (pending ??= signInAgain(pi)
|
|
265
|
+
.then((t) => {
|
|
266
|
+
current = { token: t.access_token, expiresAt: until(t) };
|
|
267
|
+
return t.access_token;
|
|
268
|
+
})
|
|
269
|
+
.finally(() => {
|
|
270
|
+
pending = undefined;
|
|
271
|
+
}));
|
|
272
|
+
return {
|
|
273
|
+
token: (pi) => (now() < current.expiresAt - 60_000 ? Promise.resolve(current.token) : renew(pi)),
|
|
274
|
+
renew,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { runAgent } from './agent.js';
|
|
7
|
+
import { fenceExtension } from './fence.js';
|
|
8
|
+
import { focusInstructions, focusState } from './focus.js';
|
|
9
|
+
import { requestJson } from './http.js';
|
|
10
|
+
import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
|
|
11
|
+
import { createLog, UserError } from './log.js';
|
|
12
|
+
import { firstMessage, playbook, setupContext } from './playbook.js';
|
|
13
|
+
import { checkoutFrontEnd, parseFlags, readCheckout, stateSaysNoFrontEnd, USAGE } from './preflight.js';
|
|
14
|
+
import { createSessionSync, syncExtension } from './sync.js';
|
|
15
|
+
import { createTools, resultLine } from './tools.js';
|
|
16
|
+
import { atExit, closeScreens, helpMark, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
|
|
17
|
+
const say = (line = '') => console.log(line);
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
/** How a run on a repository with no web front end ends, here and on the web page. */
|
|
20
|
+
export const NO_FRONT_END = 'Impeccable reviews web front ends, and this repository does not have one. Nothing was set up.';
|
|
21
|
+
/** How a run on a repository Impeccable skipped ends when this checkout has a web front end: the setup state lists it with nothing to set up until the person adds it. */
|
|
22
|
+
const SKIPPED = 'Impeccable skipped this repository because its scan found no web front end, but this checkout has one. Choose Add anyway on the setup page, then run this again.';
|
|
23
|
+
/** A pull request of owner/repo as gh sees it: its branch, its state and whether it is still a draft, or why gh could not say. */
|
|
24
|
+
async function viewPullRequest(slug, number) {
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'view', String(number), '--repo', slug, '--json', 'headRefName,state,isDraft'], { timeout: 30_000 });
|
|
27
|
+
const pr = JSON.parse(stdout);
|
|
28
|
+
return typeof pr.headRefName === 'string' && typeof pr.state === 'string' ? { headRefName: pr.headRefName, state: pr.state, isDraft: pr.isDraft === true } : { error: 'gh answered something else' };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const { stderr, message } = err;
|
|
32
|
+
return { error: (stderr || message || 'gh failed').trim().split('\n')[0] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const settle = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(() => r(undefined), ms).unref())]);
|
|
36
|
+
/** How long a host's build of a pushed branch or a pull request is waited for: the tools' five minutes, unless IMPECCABLE_PREVIEW_WAIT_MS shortens it, which the smoke does, its fake host never building. */
|
|
37
|
+
function previewWait() {
|
|
38
|
+
const totalMs = Number(process.env.IMPECCABLE_PREVIEW_WAIT_MS);
|
|
39
|
+
return Number.isFinite(totalMs) && totalMs > 0 ? { totalMs, everyMs: Math.min(15_000, Math.max(50, Math.floor(totalMs / 5))) } : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** The end of a run with nothing to set up, before the agent: the web page says why too, and the command exits 2. */
|
|
42
|
+
async function nothingToSetUp(sync, title, result, links) {
|
|
43
|
+
sync.update({ phase: 'error', status: result, question: null });
|
|
44
|
+
const saved = await settle(sync.flush(), 5000);
|
|
45
|
+
await showOutro({
|
|
46
|
+
outcome: 'stopped',
|
|
47
|
+
title,
|
|
48
|
+
result,
|
|
49
|
+
links,
|
|
50
|
+
todo: [],
|
|
51
|
+
warnings: saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`],
|
|
52
|
+
});
|
|
53
|
+
await letTabsHear();
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
export async function main(argv) {
|
|
57
|
+
let log = createLog(false);
|
|
58
|
+
let sync;
|
|
59
|
+
// 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.
|
|
60
|
+
let land = () => { };
|
|
61
|
+
const landing = new Promise((resolve, reject) => (land = (outcome) => (typeof outcome === 'string' ? resolve(outcome) : reject(outcome))));
|
|
62
|
+
landing.catch(() => { });
|
|
63
|
+
try {
|
|
64
|
+
const flags = parseFlags(argv);
|
|
65
|
+
if (flags.help)
|
|
66
|
+
return say(`${helpMark().join('\n').trimEnd()}\n\n${USAGE}`);
|
|
67
|
+
log = createLog(flags.debug);
|
|
68
|
+
// Printed as the command exits, after the screen has gone, so it stays in the scrollback.
|
|
69
|
+
if (log.path)
|
|
70
|
+
atExit(() => [`Debug log: ${log.path}`]);
|
|
71
|
+
log.write('start', { host: flags.host, model: flags.model, effort: flags.effort, yes: flags.yes, node: process.versions.node });
|
|
72
|
+
if (flags.frames)
|
|
73
|
+
recordFrames(flags.frames);
|
|
74
|
+
const checkout = readCheckout(process.cwd());
|
|
75
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
76
|
+
const slug = `${checkout.owner}/${checkout.repo}`;
|
|
77
|
+
if (!(await showIntro()))
|
|
78
|
+
return;
|
|
79
|
+
const onHost = (url) => URL.canParse(url) && new URL(url).origin === flags.host;
|
|
80
|
+
/** The sign-in's answer, when it is for this checkout and points at --host only. */
|
|
81
|
+
const accept = (t) => {
|
|
82
|
+
log.secret(t.access_token);
|
|
83
|
+
const approved = `${t.repository.owner}/${t.repository.name}`;
|
|
84
|
+
if (approved.toLowerCase() !== slug.toLowerCase()) {
|
|
85
|
+
throw new UserError(`You approved ${approved}, but this checkout is ${slug}. Run it again and pick ${slug} on the consent page.`);
|
|
86
|
+
}
|
|
87
|
+
if (!onHost(t.setup_api) || !onHost(t.cli_api)) {
|
|
88
|
+
throw new UserError(`Impeccable answered with addresses off ${flags.host}, so the command stopped before sending anything there.`);
|
|
89
|
+
}
|
|
90
|
+
return t;
|
|
91
|
+
};
|
|
92
|
+
const token = await signIn(flags.host, log, screenPrompt(showSignIn), accept, () => landing);
|
|
93
|
+
const setupApi = token.setup_api.replace(/\/$/, '');
|
|
94
|
+
const cliApi = token.cli_api.replace(/\/$/, '');
|
|
95
|
+
const page = progressPage(flags.host, token);
|
|
96
|
+
// The run screen opens at once, with its placeholder, while the run starts. Stopping before the agent takes over
|
|
97
|
+
// stops the command, and a run already started shows as stopped on the web page.
|
|
98
|
+
let stopping = false;
|
|
99
|
+
const stopEarly = async () => {
|
|
100
|
+
if (stopping)
|
|
101
|
+
return;
|
|
102
|
+
stopping = true;
|
|
103
|
+
const why = 'Impeccable stopped before the setup started.';
|
|
104
|
+
if (sync) {
|
|
105
|
+
sync.update({ phase: 'error', status: why, question: null });
|
|
106
|
+
await settle(sync.flush(), 5000);
|
|
107
|
+
}
|
|
108
|
+
land(new UserError(why));
|
|
109
|
+
await closeScreens();
|
|
110
|
+
console.error(why);
|
|
111
|
+
await letTabsHear();
|
|
112
|
+
process.exit(130);
|
|
113
|
+
};
|
|
114
|
+
showRun({ onCancel: () => void stopEarly() });
|
|
115
|
+
const started = await requestJson(`${cliApi}/run`, { method: 'POST', token: token.access_token, body: {} }, log);
|
|
116
|
+
const run = started.data;
|
|
117
|
+
if (!started.ok)
|
|
118
|
+
throw new UserError(`Impeccable could not start a run: ${started.error}.`);
|
|
119
|
+
if (!run || typeof run.run_id !== 'string' || typeof run.run_token !== 'string' || typeof run.gateway_url !== 'string' || typeof run.model !== 'string') {
|
|
120
|
+
throw new UserError('Impeccable started a run this version does not understand. Update impeccable-reviews and run it again.');
|
|
121
|
+
}
|
|
122
|
+
log.secret(run.run_token);
|
|
123
|
+
if (!onHost(run.gateway_url))
|
|
124
|
+
throw new UserError(`Impeccable answered with a model gateway off ${flags.host}, so the command stopped before sending anything there.`);
|
|
125
|
+
land(page);
|
|
126
|
+
const runToken = run.run_token;
|
|
127
|
+
const sessionUrl = `${cliApi}/sessions/${encodeURIComponent(run.run_id)}`;
|
|
128
|
+
const liveSync = createSessionSync((payload) => requestJson(sessionUrl, { method: 'PUT', token: runToken, body: payload }, log));
|
|
129
|
+
sync = liveSync;
|
|
130
|
+
log.write('run started', { run_id: run.run_id, cap_usd: run.cap_usd, expires_at: run.expires_at });
|
|
131
|
+
// Impeccable reviews web front ends: a checkout without one stops here, before the state is read or anything runs.
|
|
132
|
+
const frontEnd = checkoutFrontEnd(checkout.root);
|
|
133
|
+
log.write('front end', frontEnd);
|
|
134
|
+
const webPage = { label: 'Web page', url: page };
|
|
135
|
+
if (frontEnd === 'none')
|
|
136
|
+
await nothingToSetUp(liveSync, 'No web front end', NO_FRONT_END, [webPage]);
|
|
137
|
+
const [instructions, state] = await Promise.all([
|
|
138
|
+
requestJson(`${setupApi}/instructions`, { token: token.access_token }, log),
|
|
139
|
+
requestJson(`${setupApi}/state`, { token: token.access_token }, log),
|
|
140
|
+
]);
|
|
141
|
+
if (!instructions.ok || !state.ok)
|
|
142
|
+
throw new UserError(`Impeccable's setup API did not answer: ${instructions.error ?? state.error}.`);
|
|
143
|
+
// 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.
|
|
144
|
+
if (stateSaysNoFrontEnd(state.data, token.repository.name)) {
|
|
145
|
+
if (frontEnd === 'unknown')
|
|
146
|
+
await nothingToSetUp(liveSync, 'No web front end', NO_FRONT_END, [webPage]);
|
|
147
|
+
const setupPage = state.data.account?.setupPage;
|
|
148
|
+
await nothingToSetUp(liveSync, 'Repository skipped', SKIPPED, [...(typeof setupPage === 'string' && onHost(setupPage) ? [{ label: 'Setup page', url: setupPage }] : []), webPage]);
|
|
149
|
+
}
|
|
150
|
+
if (!interactive)
|
|
151
|
+
say('Signed in.\n');
|
|
152
|
+
// Scratch output (a server's log, a build's output) goes here and never into the checkout.
|
|
153
|
+
const scratch = mkdtempSync(join(tmpdir(), 'impeccable-reviews-scratch-'));
|
|
154
|
+
process.on('exit', () => rmSync(scratch, { recursive: true, force: true }));
|
|
155
|
+
// The model reads the instructions and the state trimmed to this setup, and writes only what it read.
|
|
156
|
+
const focused = focusInstructions(instructions.data);
|
|
157
|
+
let report;
|
|
158
|
+
let fatal;
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
const finish = async () => {
|
|
161
|
+
if (report) {
|
|
162
|
+
const saved = await settle(liveSync.flush(), 5000);
|
|
163
|
+
const { testPr, setupPr } = report;
|
|
164
|
+
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
|
|
165
|
+
const hand = { open: openBrowser, copy: copyText };
|
|
166
|
+
if (testPr && setupPr)
|
|
167
|
+
await showPullRequest(testPr.url, hand);
|
|
168
|
+
await showOutro({
|
|
169
|
+
outcome: testPr ? 'done' : 'partial',
|
|
170
|
+
title: testPr ? "You're all set up" : 'Settings saved',
|
|
171
|
+
result: resultLine(report),
|
|
172
|
+
link: (setupPr ?? testPr)?.url,
|
|
173
|
+
links: [
|
|
174
|
+
...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
175
|
+
// Without a current test drive the setup pull request is not ready to merge; the report says why.
|
|
176
|
+
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
177
|
+
{ label: 'Web page', url: page },
|
|
178
|
+
],
|
|
179
|
+
todo: report.left,
|
|
180
|
+
warnings: saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [],
|
|
181
|
+
}, hand);
|
|
182
|
+
// The web page has the report; when it missed it, the report stays here instead.
|
|
183
|
+
if (!saved?.ok)
|
|
184
|
+
say(`\n${report.markdown.trim()}`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
const waiting = liveSync.payload.phase === 'waiting' ? liveSync.payload.question?.prompt : undefined;
|
|
188
|
+
const why = fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.';
|
|
189
|
+
liveSync.update({ phase: 'error', status: why, question: null });
|
|
190
|
+
const saved = await settle(liveSync.flush(), 5000);
|
|
191
|
+
const missed = saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`];
|
|
192
|
+
const { test_pr: drive, setup_pr: setup } = liveSync.payload;
|
|
193
|
+
await showOutro({
|
|
194
|
+
outcome: 'stopped',
|
|
195
|
+
title: cancelled && !fatal ? 'Setup cancelled' : 'Setup failed',
|
|
196
|
+
result: why,
|
|
197
|
+
links: [...(drive ? [{ label: 'Test drive', url: drive.url }] : []), ...(setup ? [{ label: 'Setup pull request', url: setup.url }] : []), { label: 'Web page', url: page }],
|
|
198
|
+
todo: ['Run impeccable-reviews again to carry on. What was saved stays saved; the web page has the details.'],
|
|
199
|
+
warnings: missed,
|
|
200
|
+
});
|
|
201
|
+
process.exit(1);
|
|
202
|
+
};
|
|
203
|
+
await runAgent({
|
|
204
|
+
cwd: checkout.root,
|
|
205
|
+
// The service hands out the model; --model overrides it.
|
|
206
|
+
model: flags.model ?? run.model,
|
|
207
|
+
effort: flags.effort,
|
|
208
|
+
gatewayUrl: run.gateway_url,
|
|
209
|
+
runToken,
|
|
210
|
+
systemPrompt: playbook({ owner: checkout.owner, name: token.repository.name, scratch }),
|
|
211
|
+
firstMessage: firstMessage({ owner: checkout.owner, name: token.repository.name }),
|
|
212
|
+
firstContext: setupContext(focused, focusState(state.data)),
|
|
213
|
+
tools: createTools({
|
|
214
|
+
setupApi,
|
|
215
|
+
auth: setupAuth(token, () => signIn(flags.host, log, screenPrompt(showSignIn), accept)),
|
|
216
|
+
owner: token.repository.owner,
|
|
217
|
+
repo: token.repository.name,
|
|
218
|
+
instructions: focused,
|
|
219
|
+
sync: liveSync,
|
|
220
|
+
viewPullRequest: (n) => viewPullRequest(`${token.repository.owner}/${token.repository.name}`, n),
|
|
221
|
+
onReport: (r) => void (report = r),
|
|
222
|
+
log,
|
|
223
|
+
previewWait: previewWait(),
|
|
224
|
+
}),
|
|
225
|
+
extensions: [
|
|
226
|
+
fenceExtension(checkout.root, log, checkout.protectedBranches, scratch),
|
|
227
|
+
syncExtension(liveSync, { onFatal: (message) => void (fatal = message), onQuit: finish }),
|
|
228
|
+
],
|
|
229
|
+
interactive,
|
|
230
|
+
runId: run.run_id,
|
|
231
|
+
transcript: flags.transcript,
|
|
232
|
+
print: say,
|
|
233
|
+
log,
|
|
234
|
+
onCancel: () => void (cancelled = true),
|
|
235
|
+
});
|
|
236
|
+
// Without a terminal the run ends here; session_shutdown has already finished it.
|
|
237
|
+
await finish();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const message = err instanceof UserError ? err.message : `Impeccable stopped: ${err instanceof Error ? err.message : String(err)}`;
|
|
241
|
+
land(new UserError(message));
|
|
242
|
+
log.write('fatal', err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
243
|
+
if (sync) {
|
|
244
|
+
sync.update({ phase: 'error', status: message, question: null });
|
|
245
|
+
await settle(sync.flush(), 5000);
|
|
246
|
+
}
|
|
247
|
+
await closeScreens();
|
|
248
|
+
console.error(message);
|
|
249
|
+
await letTabsHear();
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
}
|