@impeccable/reviews 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host-questions.js +15 -4
- package/dist/preflight.js +44 -6
- package/dist/tools.js +7 -4
- package/dist/ui/look.js +9 -4
- package/package.json +1 -1
package/dist/host-questions.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
/** The ids the model may name, each worded here whatever else it writes for that id. */
|
|
2
3
|
export const HOST_QUESTIONS = {
|
|
3
4
|
'vercel-setup': {
|
|
@@ -46,13 +47,23 @@ export const HOST_SIGNED_IN = {
|
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
49
|
/**
|
|
49
|
-
* The Vercel setup screen with its CLI signed in already as `user`: it names that account
|
|
50
|
-
* the team the run takes (`team`), the one the person picks next (`pick`) or,
|
|
51
|
-
* is to that account and team (REN-1250).
|
|
50
|
+
* The Vercel setup screen with its CLI signed in already as `user`: it names that account, the new project (`project`,
|
|
51
|
+
* where its name is known) and where it goes, the team the run takes (`team`), the one the person picks next (`pick`) or,
|
|
52
|
+
* with no team, the account itself, so the yes is to that account, project and team (REN-1250).
|
|
52
53
|
*/
|
|
53
54
|
export function vercelSignedIn(user, where) {
|
|
54
55
|
const into = where.team ? `in the team ${where.team}` : where.pick ? 'in the team you pick next' : 'of that account';
|
|
55
|
-
|
|
56
|
+
const project = where.project ? `a new project, ${where.project}, ` : 'a new project ';
|
|
57
|
+
return { ...HOST_SIGNED_IN['vercel-setup'], hint: `Impeccable reviews each pull request on a preview. Vercel can host those previews: the run sets them up with the Vercel CLI on this machine, signed in as ${user}, in ${project}${into}. What is live stays as it is.` };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The project the Vercel setup makes, named as `vercel link --yes` finds it in the checkout's root (`root`): the folder's
|
|
61
|
+
* own name, which the playbook's `vercel project add` gives it. None where that name is not one Vercel takes as it is
|
|
62
|
+
* (lowercase letters, digits, `.`, `_` and `-`, at most 100, never `---`), since the CLI would change it (REN-1250).
|
|
63
|
+
*/
|
|
64
|
+
export function vercelProjectName(root) {
|
|
65
|
+
const name = basename(root);
|
|
66
|
+
return /^[a-z0-9._-]{1,100}$/.test(name) && !name.includes('---') ? name : undefined;
|
|
56
67
|
}
|
|
57
68
|
/**
|
|
58
69
|
* A host's CLI that is not on this machine (#396), asked before anything installs it: the package, the version and the
|
package/dist/preflight.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { folderOf } from './host-previews.js';
|
|
@@ -358,13 +358,43 @@ export function dropEmptyRunBranches(root, start, before) {
|
|
|
358
358
|
}
|
|
359
359
|
return lines;
|
|
360
360
|
}
|
|
361
|
+
/** What `vercel link` appends to the root .gitignore: each of its lines the file lacks, after a line break where the file ended without one. */
|
|
362
|
+
const LINK_LINES = /^(?:\r?\n)?(?:(?:\.vercel|\.env\*)\r?\n)+$/;
|
|
363
|
+
/**
|
|
364
|
+
* The root .gitignore taken back to the start branch's (`HEAD`, nothing staged) where all that changed is the lines
|
|
365
|
+
* `vercel link` appends, and only where git then shows no untracked file it hides now: a start branch that does not
|
|
366
|
+
* ignore the link's token file, .env.local, keeps the lines. Any other change is the person's, and stays (REN-1250).
|
|
367
|
+
* Whether it was taken back.
|
|
368
|
+
*/
|
|
369
|
+
function restoreLinkLines(root, git) {
|
|
370
|
+
const path = join(root, '.gitignore');
|
|
371
|
+
const now = readFileSync(path, 'utf8');
|
|
372
|
+
const before = git(['cat-file', 'blob', 'HEAD:.gitignore']);
|
|
373
|
+
if (git(['cat-file', 'blob', ':.gitignore']) !== before || !now.startsWith(before) || !LINK_LINES.test(now.slice(before.length)))
|
|
374
|
+
return false;
|
|
375
|
+
// File by file, so a file the lines hide inside a folder git shows already counts too.
|
|
376
|
+
const shown = () => git(['status', '--porcelain=v1', '-z', '--untracked-files=all']).split('\0').filter((line) => line.startsWith('?? '));
|
|
377
|
+
const visible = new Set(shown());
|
|
378
|
+
writeFileSync(path, before);
|
|
379
|
+
let restored = false;
|
|
380
|
+
try {
|
|
381
|
+
restored = shown().every((line) => visible.has(line));
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
// Whatever stopped the check, the link's lines go back, so nothing they hide is shown to git add.
|
|
385
|
+
if (!restored)
|
|
386
|
+
writeFileSync(path, now);
|
|
387
|
+
}
|
|
388
|
+
return restored;
|
|
389
|
+
}
|
|
361
390
|
/**
|
|
362
391
|
* The run makes its pull requests on branches of its own, and git leaves the checkout on the last it made: the end goes back
|
|
363
392
|
* to the branch the run started on (`start`), so what the person commits next does not land on the test drive. Only from one
|
|
364
393
|
* of the run's branches, and only with no change to a tracked file, which would go along, but to a .gitignore the two
|
|
365
|
-
* branches hold alike, which goes along named
|
|
366
|
-
*
|
|
367
|
-
* carried, or never left; else the line the done
|
|
394
|
+
* branches hold alike, which goes along named, or goes back where it holds only `vercel link`'s lines (restoreLinkLines);
|
|
395
|
+
* git refuses where an untracked file would be overwritten. The run's own files no commit carries are taken back before
|
|
396
|
+
* this (checkoutJournal). Null when the checkout is back with nothing carried, or never left; else the line the done
|
|
397
|
+
* screen shows.
|
|
368
398
|
*/
|
|
369
399
|
export function backToStartBranch(root, start) {
|
|
370
400
|
const now = currentBranch(root);
|
|
@@ -389,7 +419,7 @@ export function backToStartBranch(root, start) {
|
|
|
389
419
|
throw new Error('a content filter on the start branch');
|
|
390
420
|
// Named, so the done screen says which files keep the checkout here (#421).
|
|
391
421
|
const changed = git(['status', '--porcelain=v1', '-z', '--untracked-files=no', '--no-renames']).split('\0').filter(Boolean).map((line) => line.slice(3));
|
|
392
|
-
// A .gitignore both branches hold alike, which a host's link changed (vercel link adds .vercel and .env*, before the test drive's branch is made), goes along to the start branch as it is, named (REN-1250)
|
|
422
|
+
// A .gitignore both branches hold alike, which a host's link changed (vercel link adds .vercel and .env*, before the test drive's branch is made), goes along to the start branch, where the root's goes back as it was when the link's lines are all that changed and hide nothing the start branch's lines do not; else it stays as it is, named (REN-1250), since taking the lines back would show the link's token file, .env.local, to git add. Any other change keeps the checkout here.
|
|
393
423
|
const carried = changed.filter((path) => (path === '.gitignore' || path.endsWith('/.gitignore')) && !differ.includes(path));
|
|
394
424
|
const held = changed.filter((path) => !carried.includes(path));
|
|
395
425
|
if (held.length)
|
|
@@ -397,7 +427,15 @@ export function backToStartBranch(root, start) {
|
|
|
397
427
|
// The closing -- makes git read the name as the branch, never as a path.
|
|
398
428
|
// An ignored file of the person's where the start branch tracks a file of that name stops the checkout, rather than being overwritten.
|
|
399
429
|
git(['-c', 'submodule.recurse=false', 'checkout', '-q', '--no-overwrite-ignore', start, '--']);
|
|
400
|
-
|
|
430
|
+
let restored = false;
|
|
431
|
+
try {
|
|
432
|
+
restored = carried.includes('.gitignore') && restoreLinkLines(root, git);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Left as it is, and named.
|
|
436
|
+
}
|
|
437
|
+
const left = restored ? carried.filter((path) => path !== '.gitignore') : carried;
|
|
438
|
+
return left.length ? `${listPaths(left)} changed during the setup, from a command it ran or from you, and ${left.length === 1 ? 'stays' : 'stay'} uncommitted on ${start} as ${left.length === 1 ? 'it is' : 'they are'}.` : null;
|
|
401
439
|
}
|
|
402
440
|
catch {
|
|
403
441
|
return `Your checkout is still on ${now}: git checkout ${start} takes it back to the branch you started on.`;
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endAppSetup, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste, vercelSignedIn } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste, vercelProjectName, vercelSignedIn } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, renderSiteOf, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { closedPulls, deploymentsOf, hostingApp, hostingRead, liveHostOf, renderSitesOf } from './hosting.js';
|
|
@@ -453,6 +453,9 @@ const ASKS_ADDRESS = /\b(urls?|domains?|(web|site|live|production|preview|app's)
|
|
|
453
453
|
/** What schemaError cannot say about a question: options belong to single and multi, at least two, each value once, and each other field to its kinds. */
|
|
454
454
|
function askError(q) {
|
|
455
455
|
const values = (q.options ?? []).map((o) => o.value);
|
|
456
|
+
// A prompt the person cannot read as a question, such as "x", never reaches the screen, where Skip would read as their no (REN-1255).
|
|
457
|
+
if ((q.prompt.match(/\p{L}[\p{L}\p{N}'’-]*/gu) ?? []).length < 2)
|
|
458
|
+
return `input.prompt ${JSON.stringify(q.prompt.trim())} is not a question the person can answer, so nothing was shown. Ask only when the run needs the person's answer, with a prompt that says what they answer or agree to; with nothing to ask, go on`;
|
|
456
459
|
if (q.sensitive && q.kind !== 'text')
|
|
457
460
|
return 'input.sensitive is for text questions only';
|
|
458
461
|
if (q.prefill !== undefined && !(q.kind === 'account' || (q.kind === 'text' && !q.sensitive)))
|
|
@@ -2160,11 +2163,11 @@ export function createTools(ctx) {
|
|
|
2160
2163
|
return { skipped: true };
|
|
2161
2164
|
let status = await cli.status();
|
|
2162
2165
|
if (!hostSignIns.has(host)) {
|
|
2163
|
-
// Signed in already, Vercel's screen names the account and the team
|
|
2166
|
+
// Signed in already, Vercel's screen names the account, the new project and the team it goes into, which the yes is to (REN-1250).
|
|
2164
2167
|
let words = status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : undefined;
|
|
2165
2168
|
if (status.cli === 'signed-in' && host === 'vercel' && status.user) {
|
|
2166
2169
|
const { team, choices } = await (teamRead = cli.team());
|
|
2167
|
-
words = vercelSignedIn(status.user, { team, pick: choices.length > 1 });
|
|
2170
|
+
words = vercelSignedIn(status.user, { team, pick: choices.length > 1, project: vercelProjectName(pi.cwd) });
|
|
2168
2171
|
}
|
|
2169
2172
|
const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...words });
|
|
2170
2173
|
if (answer.skipped) {
|
|
@@ -2967,7 +2970,7 @@ export function createTools(ctx) {
|
|
|
2967
2970
|
// A test account never comes back to the model, even if the API echoed it.
|
|
2968
2971
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
2969
2972
|
}),
|
|
2970
|
-
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
2973
|
+
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Ask only when the run needs their answer, with a prompt that says what they answer or agree to: it refuses one of fewer than two words. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
2971
2974
|
// A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
|
|
2972
2975
|
const { appKey: forApp, ...asked } = p;
|
|
2973
2976
|
if (asked.id === APPS_QUESTION.id && appsRepo)
|
package/dist/ui/look.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
|
-
import { fitsTask, taskInProgress, workStarted } from '../stages.js';
|
|
2
|
+
import { fitsTask, stagesOf, taskInProgress, workStarted } from '../stages.js';
|
|
3
3
|
/**
|
|
4
4
|
* How the wizard's screens look, drawn as lines from what they show and the
|
|
5
5
|
* terminal's size, with no terminal behind them: the smoke renders every
|
|
@@ -270,12 +270,17 @@ function runPage(scene, f, inner, st) {
|
|
|
270
270
|
: '○';
|
|
271
271
|
const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
|
|
272
272
|
const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
|
|
273
|
-
|
|
273
|
+
// Preview method back to pending while Test drive runs waits for the test drive's preview, which proves the method, as the playbook's Tasks rule says: its row says so, so its work never reads as undone (REN-1255).
|
|
274
|
+
const proving = t.started && t.status === 'pending' && running >= 0 && stagesOf(t.title).includes('Preview method') && stagesOf(scene.tasks[running].title).includes('Pull requests');
|
|
275
|
+
return { ink, row, line: t.status === 'skipped' || t.status === 'failed' ? row(t.status) : row(), proving };
|
|
274
276
|
});
|
|
275
|
-
// The list's left edge is set by the rows without the wait's words, so it stays put while
|
|
277
|
+
// The list's left edge is set by the rows without the wait's words or the method's, so it stays put while they come and go (#390).
|
|
276
278
|
const widest = Math.max(...rows.map(({ line }) => visibleWidth(line)));
|
|
277
279
|
const room = inner - Math.max(0, Math.floor((inner - Math.min(widest, inner)) / 2));
|
|
278
|
-
const lines = rows.map(({ ink, row, line }, i) => {
|
|
280
|
+
const lines = rows.map(({ ink, row, line, proving }, i) => {
|
|
281
|
+
// The words that fit from the list's edge, shorter at a narrow width, and none where even those would be cut.
|
|
282
|
+
if (proving)
|
|
283
|
+
return ink(['finishes with the test drive', 'with the test drive'].map(row).find((words) => visibleWidth(words) <= room) ?? line);
|
|
279
284
|
if (i !== running || !scene.wait)
|
|
280
285
|
return ink(line);
|
|
281
286
|
// The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
|