@impeccable/reviews 0.1.6 → 0.1.8
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 +22 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +347 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +123 -0
- package/dist/host-questions.js +101 -21
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +37 -14
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +58 -16
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +8 -8
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +7 -6
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/pull-requests.md +3 -3
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +3 -1
- package/dist/playbook/cases/vercel-setup.md +9 -5
- package/dist/playbook.js +3 -3
- package/dist/preflight.js +43 -8
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +682 -140
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +21 -12
- package/dist/vercel.js +147 -17
- package/package.json +1 -1
package/dist/login.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
+
import { win32 } from 'node:path';
|
|
4
5
|
import { requestJson } from './http.js';
|
|
5
6
|
import { UserError } from './log.js';
|
|
7
|
+
import { LOOPBACK } from './preflight.js';
|
|
6
8
|
import { askPerson } from './ui/bridge.js';
|
|
7
9
|
/** The callback ports, in the order the contract lists them; the Worker accepts no other. */
|
|
8
10
|
export const CALLBACK_PORTS = [8239, 8238, 8240, 8237, 8236, 8235];
|
|
@@ -40,7 +42,8 @@ function page(res, status, message, more = '') {
|
|
|
40
42
|
/**
|
|
41
43
|
* The page the tab shows once the code has come: it asks the command every
|
|
42
44
|
* 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.
|
|
45
|
+
* what went wrong; without scripts, a link does the asking. It hears the
|
|
46
|
+
* answer once.
|
|
44
47
|
*/
|
|
45
48
|
function waitingPage(res, state) {
|
|
46
49
|
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>`;
|
|
@@ -53,8 +56,11 @@ function waitingPage(res, state) {
|
|
|
53
56
|
* the callback. A wrong state gets a 400 and the wait goes on; the right one
|
|
54
57
|
* hands over the code and answers at once with a page that asks `/next` where
|
|
55
58
|
* to go, which says "not yet" until `land` knows (a JSON `wait`), then the
|
|
56
|
-
* page or what went wrong. `/go` is the same for a tab without scripts:
|
|
57
|
-
* redirect, or the waiting page again.
|
|
59
|
+
* page or what went wrong, once. `/go` is the same for a tab without scripts:
|
|
60
|
+
* a redirect, or the waiting page again. Only the first callback with the
|
|
61
|
+
* right state counts: a later one gets a 400 and changes nothing, unless it
|
|
62
|
+
* is the same tab reloading, which waits again. `/next` and `/go` answer only
|
|
63
|
+
* once that callback has brought a code, since the state is all they check.
|
|
58
64
|
*/
|
|
59
65
|
export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
60
66
|
let resolve;
|
|
@@ -65,7 +71,11 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
65
71
|
});
|
|
66
72
|
/** A tab brought the code, and waits to hear where to go. */
|
|
67
73
|
let visited = false;
|
|
74
|
+
/** What the one callback that counts brought: its code, or '' when it said the sign-in did not finish. */
|
|
75
|
+
let brought;
|
|
68
76
|
let landing;
|
|
77
|
+
/** The tab has heard where to go, so nothing more is said. */
|
|
78
|
+
let said = false;
|
|
69
79
|
let heard = () => { };
|
|
70
80
|
const told = new Promise((r) => (heard = r)).finally(() => untold.delete(told));
|
|
71
81
|
const answer = (req, res) => {
|
|
@@ -75,9 +85,15 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
75
85
|
return void res.writeHead(404, { connection: 'close' }).end();
|
|
76
86
|
if (url.searchParams.get('state') !== state)
|
|
77
87
|
return page(res, 400, 'This sign-in is not the one your terminal started. Run impeccable-reviews again.');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (route !== '/callback') {
|
|
89
|
+
if (!visited || said)
|
|
90
|
+
return page(res, 400, 'Back to your terminal.');
|
|
91
|
+
// Heard once the answer that says where to go has left for the tab.
|
|
92
|
+
if (landing) {
|
|
93
|
+
said = true;
|
|
94
|
+
res.once('finish', heard);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
81
97
|
if (route === '/next')
|
|
82
98
|
return void res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', connection: 'close' }).end(JSON.stringify(landing ?? { wait: true }));
|
|
83
99
|
if (route === '/go') {
|
|
@@ -87,12 +103,14 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
87
103
|
}
|
|
88
104
|
const got = url.searchParams.get('code');
|
|
89
105
|
const error = url.searchParams.get('error');
|
|
106
|
+
if (brought !== undefined)
|
|
107
|
+
return got && !error && got === brought && !said ? waitingPage(res, state) : page(res, 400, 'This sign-in already had its answer. Back to your terminal.');
|
|
108
|
+
brought = !got || error ? '' : got;
|
|
90
109
|
if (!got || error) {
|
|
91
110
|
page(res, 400, 'Sign-in did not finish. Back to your terminal.');
|
|
92
111
|
return reject(new UserError(`Sign-in did not finish${error ? ` (${error})` : ''}. Run impeccable-reviews again.`));
|
|
93
112
|
}
|
|
94
|
-
|
|
95
|
-
untold.add(told);
|
|
113
|
+
untold.add(told);
|
|
96
114
|
visited = true;
|
|
97
115
|
resolve(got);
|
|
98
116
|
waitingPage(res, state);
|
|
@@ -178,15 +196,29 @@ export function parsePasted(text, state) {
|
|
|
178
196
|
}
|
|
179
197
|
return /^[A-Za-z0-9._~-]{8,}$/.test(trimmed) ? trimmed : null;
|
|
180
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The platform's command that opens `url` in the default browser, with no shell between. Null for anything but an https
|
|
201
|
+
* address, or plain http to this machine (localhost, 127.0.0.1, [::1] or a .localhost name), free of quotes, spaces,
|
|
202
|
+
* control characters and what a shell would read (^ | < >): Windows' opener runs a local file or any other protocol it is
|
|
203
|
+
* handed, and the address may come from the model or the setup API.
|
|
204
|
+
*/
|
|
205
|
+
export function browserCommand(url, platform = process.platform) {
|
|
206
|
+
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
207
|
+
const web = parsed?.protocol === 'https:' || (parsed?.protocol === 'http:' && (LOOPBACK.has(parsed.hostname) || /^(?:[a-z0-9-]+\.)+localhost$/.test(parsed.hostname)));
|
|
208
|
+
if (!web || /["^|<>\s\x00-\x1f\x7f]/.test(url))
|
|
209
|
+
return null;
|
|
210
|
+
// rundll32 by its full path: the run's folder is the checkout, which Windows would otherwise look in first.
|
|
211
|
+
return platform === 'darwin' ? ['open', [url]]
|
|
212
|
+
: platform === 'win32' ? [win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'rundll32.exe'), ['url.dll,FileProtocolHandler', url]]
|
|
213
|
+
: ['xdg-open', [url]];
|
|
214
|
+
}
|
|
181
215
|
/** Opens the address in the default browser; a failure is fine because the address is printed too. BROWSER=none skips it. */
|
|
182
216
|
export function openBrowser(url) {
|
|
183
|
-
|
|
217
|
+
const opener = browserCommand(url);
|
|
218
|
+
if (process.env.BROWSER === 'none' || !opener)
|
|
184
219
|
return;
|
|
185
|
-
const [command, args, verbatim] = process.platform === 'darwin' ? ['open', [url], false]
|
|
186
|
-
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', `"${url}"`], true]
|
|
187
|
-
: ['xdg-open', [url], false];
|
|
188
220
|
try {
|
|
189
|
-
const child = spawn(
|
|
221
|
+
const child = spawn(opener[0], opener[1], { stdio: 'ignore', detached: true });
|
|
190
222
|
child.on('error', () => { });
|
|
191
223
|
child.unref();
|
|
192
224
|
}
|
package/dist/main.js
CHANGED
|
@@ -4,19 +4,19 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { runAgent } from './agent.js';
|
|
7
|
-
import { fenceExtension, hostsAgreed, placeOf } from './fence.js';
|
|
7
|
+
import { fenceExtension, guardedFileOperations, hostsAgreed, placeOf } from './fence.js';
|
|
8
8
|
import { focusInstructions, focusState, severalApps } from './focus.js';
|
|
9
9
|
import { requestJson } from './http.js';
|
|
10
10
|
import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
|
|
11
11
|
import { createLog, UserError } from './log.js';
|
|
12
12
|
import { firstMessage, playbook, setupContext } from './playbook.js';
|
|
13
|
-
import { checkoutFrontEnd, parseFlags, readCheckout, stateSaysNoFrontEnd, USAGE } from './preflight.js';
|
|
13
|
+
import { checkoutFrontEnd, parseFlags, readCheckout, skipWorkingFolderForCommands, stateSaysNoFrontEnd, USAGE } from './preflight.js';
|
|
14
14
|
import { startSandbox } from './sandbox.js';
|
|
15
15
|
import { scanRecorder } from './scan.js';
|
|
16
16
|
import { createReporter, reportingOff } from './report.js';
|
|
17
|
-
import { createSessionSync, syncExtension } from './sync.js';
|
|
17
|
+
import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
|
|
18
18
|
import { checkPushAccess, readPushAccess } from './push-access.js';
|
|
19
|
-
import { createTools, resultLine } from './tools.js';
|
|
19
|
+
import { createTools, ownPullRequest, resultLine } from './tools.js';
|
|
20
20
|
import { transcriptPaths } from './transcript.js';
|
|
21
21
|
import { askPerson } from './ui/bridge.js';
|
|
22
22
|
import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
|
|
@@ -62,6 +62,7 @@ async function nothingToSetUp(sync, reporter, why, title, result, links) {
|
|
|
62
62
|
process.exit(2);
|
|
63
63
|
}
|
|
64
64
|
export async function main(argv) {
|
|
65
|
+
skipWorkingFolderForCommands();
|
|
65
66
|
let log = createLog(false);
|
|
66
67
|
let sync;
|
|
67
68
|
// Nothing is reported until the flags say where to and whether to.
|
|
@@ -110,8 +111,9 @@ export async function main(argv) {
|
|
|
110
111
|
log,
|
|
111
112
|
place: placeOf,
|
|
112
113
|
hosts: () => hostsAgreed(log),
|
|
114
|
+
noSandbox: flags.noSandbox,
|
|
113
115
|
});
|
|
114
|
-
if (!(await showIntro(slug, sandbox.warning))) {
|
|
116
|
+
if (!(await showIntro(slug, [sandbox.warning, sandbox.noPreview].filter(Boolean).join(' ') || undefined))) {
|
|
115
117
|
reporter.step({ event: 'setup_command_sign_in_ended', outcome: 'cancelled' });
|
|
116
118
|
return void (await reporter.done());
|
|
117
119
|
}
|
|
@@ -173,7 +175,7 @@ export async function main(argv) {
|
|
|
173
175
|
sync = liveSync;
|
|
174
176
|
log.write('run started', { run_id: run.run_id, cap_usd: run.cap_usd, expires_at: run.expires_at });
|
|
175
177
|
// Impeccable reviews web front ends: a checkout without one stops here, before the state is read or anything runs.
|
|
176
|
-
const frontEnd = checkoutFrontEnd(checkout.root);
|
|
178
|
+
const frontEnd = checkoutFrontEnd(checkout.root, log);
|
|
177
179
|
log.write('front end', frontEnd);
|
|
178
180
|
const webPage = { label: 'Web page', url: page };
|
|
179
181
|
if (frontEnd === 'none')
|
|
@@ -191,6 +193,11 @@ export async function main(argv) {
|
|
|
191
193
|
const setupPage = state.data.account?.setupPage;
|
|
192
194
|
await nothingToSetUp(liveSync, reporter, 'skipped_repo', 'Repository skipped', SKIPPED, [...(typeof setupPage === 'string' && onHost(setupPage) ? [{ label: 'Setup page', url: setupPage }] : []), webPage]);
|
|
193
195
|
}
|
|
196
|
+
// --app names one app of a repository with several: a key that is none of them stops here, with the keys there are, rather than asking about apps it did not mean.
|
|
197
|
+
const appKeys = (state.data?.repos ?? []).find((r) => r?.name === token.repository.name)?.apps?.flatMap((a) => (typeof a?.key === 'string' ? [a.key] : [])) ?? [];
|
|
198
|
+
if (flags.app && severalApps(state.data) && !appKeys.includes(flags.app)) {
|
|
199
|
+
throw new UserError(`--app ${flags.app} is not an app of ${slug}. Its apps are ${appKeys.join(', ')}. Run it again with one of them, or without --app.`);
|
|
200
|
+
}
|
|
194
201
|
// The setup pushes as the person, so whether git may push is asked of GitHub now, before anything is created.
|
|
195
202
|
const push = await checkPushAccess(slug, () => readPushAccess(checkout.root), async (screen) => {
|
|
196
203
|
liveSync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
|
|
@@ -212,10 +219,22 @@ export async function main(argv) {
|
|
|
212
219
|
let report;
|
|
213
220
|
let fatal;
|
|
214
221
|
let cancelled = false;
|
|
222
|
+
/** The setup pull request the end screens open and copy, rebuilt from this checkout's owner and repo and its number; one that is not that is left out. */
|
|
223
|
+
const shownSetupPr = (pr) => {
|
|
224
|
+
const own = ownPullRequest(pr, checkout.owner, checkout.repo);
|
|
225
|
+
if (pr && !own)
|
|
226
|
+
log.write('setup pull request left out', { number: pr.number });
|
|
227
|
+
return own;
|
|
228
|
+
};
|
|
215
229
|
const finish = async () => {
|
|
230
|
+
// What a command left in the git folders goes before the summary, where the person reads it.
|
|
231
|
+
const removed = sandbox.finish();
|
|
232
|
+
if (removed.length)
|
|
233
|
+
log.write('git settings removed', removed);
|
|
216
234
|
if (report) {
|
|
217
235
|
const saved = await settle(liveSync.flush(), 5000);
|
|
218
|
-
const { testPr
|
|
236
|
+
const { testPr } = report;
|
|
237
|
+
const setupPr = shownSetupPr(report.setupPr);
|
|
219
238
|
// 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.
|
|
220
239
|
const hand = { open: openBrowser, copy: copyText };
|
|
221
240
|
if (testPr && setupPr)
|
|
@@ -223,7 +242,7 @@ export async function main(argv) {
|
|
|
223
242
|
await showOutro({
|
|
224
243
|
outcome: testPr ? 'done' : 'partial',
|
|
225
244
|
title: testPr ? "You're all set up" : 'Settings saved',
|
|
226
|
-
result: resultLine(report),
|
|
245
|
+
result: resultLine({ ...report, setupPr }),
|
|
227
246
|
link: (setupPr ?? testPr)?.url,
|
|
228
247
|
links: [
|
|
229
248
|
...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
@@ -232,32 +251,32 @@ export async function main(argv) {
|
|
|
232
251
|
{ label: 'Web page', url: page },
|
|
233
252
|
],
|
|
234
253
|
todo: report.left,
|
|
235
|
-
warnings: saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [],
|
|
254
|
+
warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
|
|
236
255
|
}, hand);
|
|
237
256
|
// The web page has the report; when it missed it, the report stays here instead.
|
|
238
257
|
if (!saved?.ok)
|
|
239
|
-
say(`\n${report.markdown
|
|
258
|
+
say(`\n${plainReport(report.markdown)}`);
|
|
240
259
|
if (!saved?.ok)
|
|
241
260
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
|
242
261
|
await reporter.done();
|
|
243
262
|
process.exit(0);
|
|
244
263
|
}
|
|
245
264
|
const waiting = liveSync.payload.phase === 'waiting' ? liveSync.payload.question?.prompt : undefined;
|
|
246
|
-
const why = fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.';
|
|
265
|
+
const why = pageStatus(fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.');
|
|
247
266
|
const stopped = fatal ? 'model_error' : waiting ? 'unanswered' : cancelled ? 'stopped' : 'failed';
|
|
248
267
|
liveSync.update({ phase: 'error', status: why, question: null, stop_reason: stopped });
|
|
249
268
|
const saved = await settle(liveSync.flush(), 5000);
|
|
250
269
|
if (!saved?.ok)
|
|
251
270
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
|
252
271
|
const missed = saved?.ok ? [] : [`The web page missed this (${saved?.error ?? 'no answer'}), so it may still show the run as going.`];
|
|
253
|
-
const
|
|
272
|
+
const drive = liveSync.payload.test_pr, setup = shownSetupPr(liveSync.payload.setup_pr);
|
|
254
273
|
await showOutro({
|
|
255
274
|
outcome: 'stopped',
|
|
256
275
|
title: cancelled && !fatal ? 'Setup cancelled' : 'Setup failed',
|
|
257
276
|
result: why,
|
|
258
277
|
links: [...(drive ? [{ label: 'Test drive', url: drive.url }] : []), ...(setup ? [{ label: 'Setup pull request', url: setup.url }] : []), { label: 'Web page', url: page }],
|
|
259
278
|
todo: ['Run impeccable-reviews again to carry on. What was saved stays saved; the web page has the details.'],
|
|
260
|
-
warnings: missed,
|
|
279
|
+
warnings: [...removed, ...missed],
|
|
261
280
|
});
|
|
262
281
|
await reporter.done();
|
|
263
282
|
process.exit(1);
|
|
@@ -286,12 +305,16 @@ export async function main(argv) {
|
|
|
286
305
|
previewWait: previewWait(),
|
|
287
306
|
sandbox,
|
|
288
307
|
pushAccess: push.outcome,
|
|
308
|
+
severalApps: severalApps(state.data),
|
|
309
|
+
...(flags.app ? { app: flags.app } : {}),
|
|
310
|
+
start: checkout.start,
|
|
289
311
|
}),
|
|
290
312
|
extensions: [
|
|
291
313
|
fenceExtension(checkout.root, log, checkout.protectedBranches, scratch, scans),
|
|
292
|
-
syncExtension(liveSync, { onFatal: (message) => void (fatal = message), onQuit: finish }),
|
|
314
|
+
syncExtension(liveSync, { onFatal: (message) => void (fatal = message), onQuit: finish }, scans),
|
|
293
315
|
],
|
|
294
316
|
bash: sandbox.shell,
|
|
317
|
+
files: guardedFileOperations(checkout.root, scratch),
|
|
295
318
|
interactive,
|
|
296
319
|
runId: run.run_id,
|
|
297
320
|
transcript: flags.transcript,
|
package/dist/netlify-password.js
CHANGED
|
@@ -29,7 +29,7 @@ const NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
|
29
29
|
const ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
30
30
|
/** A password as the header can carry it: one line, of a length a person would type. */
|
|
31
31
|
const password = (value) => (typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value) ? value : null);
|
|
32
|
-
/** The site's protection, read through the CLI; null when it could not say: signed out,
|
|
32
|
+
/** The site's protection, read through the CLI, which is what makes the site the person's; null when it could not say: signed out, no site of that name in a team of the person's, or a site read back under another name. */
|
|
33
33
|
export async function readSite(run, site) {
|
|
34
34
|
let read;
|
|
35
35
|
try {
|
|
@@ -38,7 +38,7 @@ export async function readSite(run, site) {
|
|
|
38
38
|
catch {
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
|
-
if (typeof read?.id !== 'string' || !ID.test(read.id) ||
|
|
41
|
+
if (typeof read?.id !== 'string' || !ID.test(read.id) || read.name !== site || !NAME.test(site))
|
|
42
42
|
return null;
|
|
43
43
|
const given = password(read.password);
|
|
44
44
|
return { id: read.id, name: read.name, hasPassword: read.has_password === true || given !== null, password: given, teamLogin: read.sso_login === true };
|
|
@@ -47,7 +47,8 @@ export async function readSite(run, site) {
|
|
|
47
47
|
* Gets the site password in place for the address Netlify's lock stands in front of, and saves only one that opens it.
|
|
48
48
|
* The site's settings say what the lock is: a password, which the person's yes has the run store when Netlify gives it
|
|
49
49
|
* back; the team login, which the person's yes has the run lift by making the site public; or neither. When the CLI
|
|
50
|
-
* cannot read the site,
|
|
50
|
+
* cannot read the site, no password goes anywhere, since the site's lock may be another's; when Netlify gives none
|
|
51
|
+
* back for a site it read, the person may paste the password.
|
|
51
52
|
*/
|
|
52
53
|
export async function passNetlifyLock(deps) {
|
|
53
54
|
let site = await readSite(deps.run, deps.site);
|
|
@@ -71,11 +72,12 @@ export async function passNetlifyLock(deps) {
|
|
|
71
72
|
const failed = await deps.save(password);
|
|
72
73
|
return failed ? { status: 'save-failed', reason: failed, protection: 'password', ...from } : { status: 'saved', password: how, protection: 'password', ...from, value: password };
|
|
73
74
|
};
|
|
75
|
+
// A site the CLI cannot read as the person is not shown to be theirs: a password typed for it could go to anyone's deploy preview that asks for one.
|
|
76
|
+
if (!site)
|
|
77
|
+
return { status: 'refused', reason: `The Netlify CLI could not read the site ${deps.site} as the person: it is signed out, or the site is in no team of theirs. A password goes only to a site of the person's, so none was asked for or sent.` };
|
|
74
78
|
/** Why the run has no password to open the preview with, for the paste screen's skip. */
|
|
75
79
|
let stopped;
|
|
76
|
-
if (
|
|
77
|
-
stopped = 'The Netlify CLI could not read the site: it is signed out, or the site is in no team of the person\'s.';
|
|
78
|
-
else if (site.hasPassword) {
|
|
80
|
+
if (site.hasPassword) {
|
|
79
81
|
if (!site.password)
|
|
80
82
|
stopped = 'Netlify does not give the site password back.';
|
|
81
83
|
else if (!(await deps.consent()))
|
package/dist/netlify.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { folderOf, netlifyPreviewsFact, pickPreviewsFact } from './host-previews.js';
|
|
6
7
|
import { gitEnvironment } from './sandbox.js';
|
|
7
8
|
/**
|
|
8
9
|
* Netlify: the deploy previews this repository's pull requests get (from
|
|
9
10
|
* GitHub's record and from the Netlify CLI on this machine when it is signed
|
|
10
|
-
* in), whether that CLI is signed in,
|
|
11
|
-
* repository
|
|
11
|
+
* in), whether that CLI is signed in, whether a site built from this
|
|
12
|
+
* repository builds Deploy Previews at all (host-previews.ts), and the
|
|
13
|
+
* connection of a site to this repository through Netlify's site settings and
|
|
14
|
+
* its GitHub app. Nothing here
|
|
12
15
|
* reads a token, and nothing comes back beyond the fields below.
|
|
13
16
|
*/
|
|
14
17
|
const execFileAsync = promisify(execFile);
|
|
@@ -121,13 +124,15 @@ async function fromGitHub(owner, repo, gh, only) {
|
|
|
121
124
|
return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
|
|
122
125
|
}
|
|
123
126
|
/** What the Netlify CLI on this machine knows, when it is signed in: the sites built from this repository, and their deploy previews. */
|
|
124
|
-
async function fromCli(owner, repo, netlify, only) {
|
|
127
|
+
async function fromCli(owner, repo, netlify, only, directory) {
|
|
125
128
|
const { cli } = await netlifyStatus(netlify);
|
|
129
|
+
const addresses = new Map();
|
|
126
130
|
if (cli !== 'signed-in')
|
|
127
|
-
return { cli, sites: [], previews: [], failed: cli === 'unknown' };
|
|
131
|
+
return { cli, sites: [], addresses, previews: [], failed: cli === 'unknown' };
|
|
128
132
|
const slug = `${owner}/${repo}`.toLowerCase();
|
|
129
133
|
const sites = [];
|
|
130
134
|
const previews = [];
|
|
135
|
+
const facts = [];
|
|
131
136
|
let failed = false;
|
|
132
137
|
try {
|
|
133
138
|
for (const site of list(JSON.parse(await netlify(['sites:list', '--json'])))) {
|
|
@@ -135,7 +140,16 @@ async function fromCli(owner, repo, netlify, only) {
|
|
|
135
140
|
// A site is this repository's only when it is built from it: the folder's own link to a site says nothing of that.
|
|
136
141
|
if (typeof site.name !== 'string' || typeof site.id !== 'string' || !new RegExp(`^${SITE}$`).test(site.name) || typeof path !== 'string' || path.toLowerCase() !== slug)
|
|
137
142
|
continue;
|
|
143
|
+
// For one app, only the site whose base directory is the app's folder.
|
|
144
|
+
if (directory !== undefined && folderOf(record(site.build_settings)?.base) !== directory)
|
|
145
|
+
continue;
|
|
138
146
|
sites.push(site.name);
|
|
147
|
+
const fact = netlifyPreviewsFact(site);
|
|
148
|
+
if (fact)
|
|
149
|
+
facts.push(fact);
|
|
150
|
+
// The site's own address, its custom domain when it has one: what Netlify prints as ssl_url.
|
|
151
|
+
if (typeof site.ssl_url === 'string' && /^https:\/\/[a-z0-9.-]{1,253}$/i.test(site.ssl_url))
|
|
152
|
+
addresses.set(site.name, site.ssl_url.toLowerCase());
|
|
139
153
|
try {
|
|
140
154
|
// The newest 100 deploy previews, newest first: the first deploy of a pull request is its current one.
|
|
141
155
|
for (const deploy of list(JSON.parse(await netlify(['api', 'listSiteDeploys', '--data', JSON.stringify({ site_id: site.id, 'deploy-previews': true, per_page: 100 })])))) {
|
|
@@ -153,14 +167,26 @@ async function fromCli(owner, repo, netlify, only) {
|
|
|
153
167
|
catch {
|
|
154
168
|
failed = true;
|
|
155
169
|
}
|
|
156
|
-
|
|
170
|
+
const pullRequestPreviews = pickPreviewsFact(facts);
|
|
171
|
+
return { cli: 'signed-in', sites, addresses, previews, failed, ...(pullRequestPreviews ? { pullRequestPreviews } : {}) };
|
|
157
172
|
}
|
|
173
|
+
/** The site whose address `production` is: the newest preview's, else the first the evidence names. */
|
|
174
|
+
export const liveSite = (evidence) => evidence.previews[0]?.site ?? evidence.sites[0];
|
|
158
175
|
/** Whether this repository's pull requests get Netlify deploy previews: the sites and the newest previews that GitHub and the signed-in Netlify CLI name; or, with `pull`, that pull request's only. */
|
|
159
176
|
export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
|
|
160
177
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
161
178
|
return { sites: [], previews: [], cli: 'missing', error: "Netlify's previews could not be read." };
|
|
162
179
|
}
|
|
163
|
-
const [
|
|
180
|
+
const [read, cli] = await Promise.all([fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull), fromCli(owner, repo, deps.netlify ?? commandRunner('netlify', cwd, { signal: deps.signal }), deps.pull, deps.directory)]);
|
|
181
|
+
// For one app's folder, GitHub's record counts only for the site the CLI read as building it: a site's name says nothing of its folder.
|
|
182
|
+
const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
|
|
183
|
+
const unmatched = deps.directory === undefined ? undefined
|
|
184
|
+
: cli.cli !== 'signed-in' ? `Which Netlify site builds ${where} cannot be told: the Netlify CLI here is not signed in, and only a site's settings name the folder it builds (its base directory).`
|
|
185
|
+
: cli.sites.length > 1 ? `Several Netlify sites build ${where} (${cli.sites.join(', ')}), so which one is this app's cannot be told.`
|
|
186
|
+
: !cli.sites.length && !cli.failed ? `No Netlify site of this repository builds ${where} (its base directory), as far as the Netlify CLI here can see.` : undefined;
|
|
187
|
+
const github = deps.directory === undefined ? read : { ...read, previews: unmatched ? [] : read.previews.filter((p) => cli.sites.includes(p.site)) };
|
|
188
|
+
if (unmatched)
|
|
189
|
+
Object.assign(cli, { sites: [], previews: [], addresses: new Map(), pullRequestPreviews: undefined });
|
|
164
190
|
// A deploy of an older commit than its pull request's head says nothing of the head's own preview: a commit status, which names the head, outranks it, and a wait for that pull request's preview ignores it.
|
|
165
191
|
const older = (p) => p.from === 'netlify cli' && Boolean(p.sha) && github.heads.has(p.pullRequest) && github.heads.get(p.pullRequest) !== p.sha;
|
|
166
192
|
// Of a pull request's status and the CLI's deploy of the same commit the later change is the truer, a status winning a tie; an older commit's deploy comes next, a bot's comment, which names no commit and changes no state, last.
|
|
@@ -172,11 +198,18 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
|
|
|
172
198
|
previews.push(p);
|
|
173
199
|
// The most recently updated first: an old pull request with a high number is not the newest preview.
|
|
174
200
|
previews.sort((a, b) => b.at - a.at);
|
|
201
|
+
const sites = [...new Set([...cli.sites, ...previews.map((p) => p.site)])];
|
|
202
|
+
// The live site, at its own domain when the CLI read one, else where Netlify serves every site.
|
|
203
|
+
const live = liveSite({ sites, previews });
|
|
175
204
|
return {
|
|
176
|
-
sites
|
|
205
|
+
sites,
|
|
206
|
+
...(live ? { production: cli.addresses.get(live) ?? `https://${live}.netlify.app` } : {}),
|
|
177
207
|
previews: previews.slice(0, NEWEST).map(({ at, sha, ...preview }) => preview),
|
|
178
208
|
cli: cli.cli,
|
|
179
209
|
...(github.failed || cli.failed ? { error: "Some of Netlify's previews could not be read: what is missing is unknown, not absent." } : {}),
|
|
210
|
+
// The site's settings ride with the plain evidence, not with a wait for one pull request's preview.
|
|
211
|
+
...(deps.pull === undefined && cli.pullRequestPreviews ? { pullRequestPreviews: cli.pullRequestPreviews } : {}),
|
|
212
|
+
...(unmatched ? { unmatched } : {}),
|
|
180
213
|
};
|
|
181
214
|
}
|
|
182
215
|
const CLONE_WAIT = { totalMs: 60_000, everyMs: 5_000 };
|
|
@@ -195,9 +228,9 @@ async function pause(ms, deps) {
|
|
|
195
228
|
}
|
|
196
229
|
}
|
|
197
230
|
/** The site this folder is linked to, as `netlify link` and `sites:create` leave it in .netlify/state.json. */
|
|
198
|
-
|
|
231
|
+
export function linkedNetlifySite(cwd) {
|
|
199
232
|
try {
|
|
200
|
-
const id = JSON.parse(
|
|
233
|
+
const id = JSON.parse(readFileSync(join(cwd, '.netlify', 'state.json'), 'utf8')).siteId;
|
|
201
234
|
return typeof id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(id) ? id : undefined;
|
|
202
235
|
}
|
|
203
236
|
catch {
|
|
@@ -252,11 +285,12 @@ export async function waitForNetlifyAccess(siteId, deps) {
|
|
|
252
285
|
* when a repository is linked: the repository, its default branch, and the installation of Netlify's GitHub app on the
|
|
253
286
|
* repository's GitHub account, which Netlify names on every site of the person's that the app already builds from that
|
|
254
287
|
* account. GitHub sends the app each push and pull request, so the pull requests build deploy previews. Then the first
|
|
255
|
-
* build says whether the app reads the repository.
|
|
288
|
+
* build says whether the app reads the repository. Only a site this run's own sites:create made takes the connection,
|
|
289
|
+
* and a site built from another repository keeps it: the person's sites are never pointed elsewhere.
|
|
256
290
|
*/
|
|
257
291
|
export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
258
292
|
const not = (reason, more = {}) => ({ connected: false, reason, ...more });
|
|
259
|
-
const siteId =
|
|
293
|
+
const siteId = linkedNetlifySite(cwd);
|
|
260
294
|
if (!siteId)
|
|
261
295
|
return not('This folder is linked to no Netlify site: link or create one first.');
|
|
262
296
|
let site;
|
|
@@ -284,10 +318,16 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
|
284
318
|
if (typeof settings?.repo_path === 'string' && settings.repo_path.toLowerCase() === slug) {
|
|
285
319
|
// Connected before this run: its newest build says whether the app still reads the repository.
|
|
286
320
|
const reads = await netlifyReadsRepository(siteId, 0, { ...deps, wait: { totalMs: 0, everyMs: 0 } });
|
|
321
|
+
const fact = netlifyPreviewsFact(site);
|
|
322
|
+
const previews = fact ? { pullRequestPreviews: fact } : {};
|
|
287
323
|
return reads === 'no' && positive(settings.installation_id)
|
|
288
|
-
? not("Netlify's GitHub app cannot read the repository.", { site: linked, how: 'already', access: accessPage(settings.installation_id) })
|
|
289
|
-
: { connected: true, site: linked, how: 'already' };
|
|
324
|
+
? not("Netlify's GitHub app cannot read the repository.", { site: linked, how: 'already', access: accessPage(settings.installation_id), ...previews })
|
|
325
|
+
: { connected: true, site: linked, how: 'already', ...previews };
|
|
290
326
|
}
|
|
327
|
+
if (typeof settings?.repo_path === 'string' && settings.repo_path)
|
|
328
|
+
return not('The linked site is built from another repository, and stays so: link a site built from this repository, or create one with netlify sites:create.', { site: linked });
|
|
329
|
+
if (!deps.linked.has(siteId))
|
|
330
|
+
return not('The linked site was not created in this run: only a site netlify sites:create made here, or one built from this repository already, takes the connection.', { site: linked });
|
|
291
331
|
const branch = github?.default_branch;
|
|
292
332
|
const fullName = typeof github?.full_name === 'string' && github.full_name.toLowerCase() === slug ? github.full_name : undefined;
|
|
293
333
|
if (!github || !positive(github.id) || typeof branch !== 'string' || !branch || !fullName)
|
|
@@ -330,7 +370,9 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
|
330
370
|
}
|
|
331
371
|
if (record(updated?.build_settings)?.repo_path !== fullName)
|
|
332
372
|
return not('Netlify did not keep the repository in the site settings.', { site: linked });
|
|
373
|
+
const fact = updated && netlifyPreviewsFact(updated);
|
|
374
|
+
const previews = fact ? { pullRequestPreviews: fact } : {};
|
|
333
375
|
return (await netlifyReadsRepository(siteId, since, deps)) === 'no'
|
|
334
|
-
? not("Netlify's GitHub app cannot read the repository.", { site: linked, how: 'github-app', access: accessPage(installation) })
|
|
335
|
-
: { connected: true, site: linked, how: 'github-app' };
|
|
376
|
+
? not("Netlify's GitHub app cannot read the repository.", { site: linked, how: 'github-app', access: accessPage(installation), ...previews })
|
|
377
|
+
: { connected: true, site: linked, how: 'github-app', ...previews };
|
|
336
378
|
}
|
package/dist/pi-paths.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* How pi's file tools turn the model's path string into the path they open, mirrored from @earendil-works/pi-coding-agent
|
|
7
|
+
* (normalizePath and resolvePath in dist/utils/paths.js, resolveToCwd and resolveReadPath in dist/core/tools/path-utils.js),
|
|
8
|
+
* which the package does not export. The fence judges the path pi opens, never the string the model typed, so each of
|
|
9
|
+
* these is held against pi's own in test/pi-paths.test.ts: a pi upgrade that changes them fails there.
|
|
10
|
+
*/
|
|
11
|
+
const UNICODE_SPACES = /[ - ]/g;
|
|
12
|
+
/** Git Bash, MSYS, Cygwin and WSL drive paths, as pi takes them on Windows. */
|
|
13
|
+
export function windowsShellPath(path) {
|
|
14
|
+
if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\'))
|
|
15
|
+
return path;
|
|
16
|
+
const match = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i.exec(path);
|
|
17
|
+
if (!match)
|
|
18
|
+
return path;
|
|
19
|
+
const suffix = match[2]?.replaceAll('/', '\\');
|
|
20
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ''}`;
|
|
21
|
+
}
|
|
22
|
+
function normalizePath(input, platform, tool) {
|
|
23
|
+
let normalized = tool ? input.replace(UNICODE_SPACES, ' ') : input;
|
|
24
|
+
if (tool && normalized.startsWith('@'))
|
|
25
|
+
normalized = normalized.slice(1);
|
|
26
|
+
if (platform === 'win32')
|
|
27
|
+
normalized = windowsShellPath(normalized);
|
|
28
|
+
const home = homedir();
|
|
29
|
+
if (normalized === '~')
|
|
30
|
+
return home;
|
|
31
|
+
if (normalized.startsWith('~/') || (platform === 'win32' && normalized.startsWith('~\\')))
|
|
32
|
+
return join(home, normalized.slice(2));
|
|
33
|
+
if (/^file:\/\//.test(normalized))
|
|
34
|
+
return fileURLToPath(normalized);
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
/** The path pi's write, edit, grep, find and ls tools open for the model's string. */
|
|
38
|
+
export function resolveToCwd(path, cwd, platform = process.platform) {
|
|
39
|
+
const normalized = normalizePath(path, platform, true);
|
|
40
|
+
return isAbsolute(normalized) ? resolve(normalized) : resolve(normalizePath(cwd, platform, false), normalized);
|
|
41
|
+
}
|
|
42
|
+
const exists = (path) => {
|
|
43
|
+
try {
|
|
44
|
+
accessSync(path, constants.F_OK);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** The path pi's read tool opens: the string's own, or, when that is not there, the first of its spellings that is (a narrow no-break space before AM/PM, NFD, a curly apostrophe, both). */
|
|
52
|
+
export function resolveReadPath(path, cwd, platform = process.platform) {
|
|
53
|
+
const resolved = resolveToCwd(path, cwd, platform);
|
|
54
|
+
if (exists(resolved))
|
|
55
|
+
return resolved;
|
|
56
|
+
const amPm = resolved.replace(/ (AM|PM)\./gi, ' $1.');
|
|
57
|
+
if (amPm !== resolved && exists(amPm))
|
|
58
|
+
return amPm;
|
|
59
|
+
const nfd = resolved.normalize('NFD');
|
|
60
|
+
if (nfd !== resolved && exists(nfd))
|
|
61
|
+
return nfd;
|
|
62
|
+
const curly = resolved.replace(/'/g, '’');
|
|
63
|
+
if (curly !== resolved && exists(curly))
|
|
64
|
+
return curly;
|
|
65
|
+
const nfdCurly = nfd.replace(/'/g, '’');
|
|
66
|
+
if (nfdCurly !== resolved && exists(nfdCurly))
|
|
67
|
+
return nfdCurly;
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
3
|
+
Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -9,9 +9,17 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
9
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
|
+
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
|
|
12
13
|
- **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
13
14
|
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Cloudflare address answered.
|
|
14
|
-
- **
|
|
15
|
+
- **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
|
|
16
|
+
1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
|
|
17
|
+
2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
|
|
18
|
+
3. **A Worker, not a Pages project** (`worker` in the answer, and no `pullRequestPreviews`): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), which gives pull requests no preview, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
|
|
19
|
+
4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
|
|
20
|
+
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews or static-site says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty.
|
|
21
|
+
- **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
|
|
22
|
+
- **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
15
23
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
|
|
16
|
-
- The evidence is read only: link, deploy and change nothing on Cloudflare. The
|
|
17
|
-
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
|
|
24
|
+
- The evidence is read only: link, deploy and change nothing on Cloudflare beyond what impeccable_enable_previews and impeccable_cloudflare_connect do on the person's yes. The Cloudflare sign-ins are those tools' own, on the person's yes. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
|
|
25
|
+
- The report names the address that answered with this app under "What we found" (and the Worker, where Cloudflare builds one), and the pattern saved under "What was configured, and by whom", with the pull-request previews turned on when they were (`by` says whether the run or the person did), the Pages project created or connected (`created` says which; name its `subdomain` address) and the access given to Cloudflare's GitHub app, since the account changed. The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
|
|
@@ -6,7 +6,7 @@ A rerun: the setup pull request an earlier run opened has a preview job that fai
|
|
|
6
6
|
|
|
7
7
|
1. **Record** the earlier pull requests with impeccable_test_pr: the open test drive, and the setup pull request in `setup_pr`. That binding cannot change. Done when it answers "Recorded".
|
|
8
8
|
2. **Read the attempts** with impeccable_preview_run. Done when you know the failed attempt's `stage` and `reason`, the `budget`, and that the state's `lastRun` for the app is the same run.
|
|
9
|
-
3. **Reproduce and correct**: run the failed step again with impeccable_verify_preview and find the smallest correction (see the table). Done when the correction passes locally, or no safe
|
|
9
|
+
3. **Reproduce and correct**: run the failed step again with impeccable_verify_preview and find the smallest correction (see the table). Where the check is skipped on this machine (its answer says it has no sandbox for it), reason from the attempt's `stage`, the table and the repository's files instead, and the next attempt is what proves the correction. Done when the correction passes locally, or, with the check skipped, is the smallest the table gives: then step 4. When no safe correction exists, push nothing more, and report.
|
|
10
10
|
4. **Push one correction**: save it with impeccable_write to `preview` (with `appKey`), impeccable_sync_workflow, commit on the setup branch, push once, then impeccable_preview_run again. Done when the new attempt shows in `budget`.
|
|
11
11
|
5. **Check it once** with impeccable_write to `latest-pull-check`: `verified: true` means this setup pull request's preview answered at the pushed head, which a passed job alone does not prove. A 409 `no-preview-running` or a job still starting is expected. Done when it answered; report the outcome you saw.
|
|
12
12
|
6. **Open a new test drive** from the corrected setup branch, with the default branch as its base (the earlier one ran on the old head), record it with impeccable_test_pr beside the same setup pull request, then impeccable_report. Done when impeccable_report answered.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
### When this applies
|
|
2
|
+
|
|
3
|
+
Every run that saves a preview method, for the state's `before` item: the address of the live app, which every review then shows beside the change. The item reads `optional` until an address is saved, so the checkup never waits on it; the run sets it all the same, from what the host or the repository already says, and asks nobody: the host's own address of the connected project changes nothing of the person's. Where Impeccable builds the previews itself (static-site), its build of the default branch is the before and the item reads done on its own: set nothing.
|
|
4
|
+
|
|
5
|
+
### Steps
|
|
6
|
+
|
|
7
|
+
1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
|
|
8
|
+
2. **Prove it** with impeccable_prove_preview, under the status line *"Opening the live app"*: that address, or a page on it a visitor opens without signing in, and words the page shows that the checkout holds. A key the run already got in place (Vercel's bypass secret, Netlify's site password, Cloudflare's service token) goes with it where the lock is the same project's or site's. Done when it answers `proven: true`. **An address no key opens is not set**: a `gate`, the app's own sign-in on every page, or no answer leaves the item as it is, and the report says what the address answered.
|
|
9
|
+
3. **Save it** with impeccable_write to `preview`: `beforeUrl` the proven address, in the same write as the method where the proof came before it (`{"intendedMethod": "preview-deploy", "beforeUrl": "https://…"}`), else in a write of its own; `appKey` the app's on a repository of several apps, one address per app. Done when the answer carries `beforeUrl`; its `beforeCheck.access` says how Impeccable's own check opened it: `ok`; `auth-wall`, the app's sign-in, which the saved test accounts get past; anything else goes under "What is not verified".
|
|
10
|
+
|
|
11
|
+
### Reference
|
|
12
|
+
|
|
13
|
+
- **The report** names the address under "What was configured, and by whom" (*The live app's address, https://…, as the before of every review, by the setup agent.*), or, with none set, says under "What was skipped, and why" what stopped it: no live app yet, no address known, or what the address answered.
|
|
14
|
+
- Never save a preview's address (one deployment's, with a hash or a branch in its name), a placeholder, or an address the proof did not answer with this app: impeccable_write refuses an unproven one. Clear nothing: an address the person saved stays.
|
|
15
|
+
- **Status lines** belong to the Preview method task.
|