@impeccable/reviews 0.1.3 → 0.1.5

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/ui/look.js CHANGED
@@ -10,6 +10,8 @@ import { fitsTask, taskInProgress, workStarted } from '../stages.js';
10
10
  */
11
11
  export const PRODUCT = 'Impeccable for PRs';
12
12
  export const FEEDBACK = 'support@renaissance-geek.ai';
13
+ /** How long the outro waits for a key before it closes itself (screens.ts), which its fine print says. */
14
+ export const CLOSES_AFTER_MINUTES = 20;
13
15
  /** The box fills the terminal up to this size, then sits in the middle of it. */
14
16
  const BOX_WIDTH = 100;
15
17
  const BOX_HEIGHT = 32;
@@ -189,6 +191,8 @@ function questionPage(qv, inner, st) {
189
191
  const field = Math.min(FIELD_WIDTH, inner);
190
192
  const edged = q.hint && visibleWidth(q.hint) <= field - 6 ? q.hint : '';
191
193
  const label = q.hint && !edged ? wrapTextWithAnsi(q.hint, field - 2).map((line) => ` ${st.soft(line)}`) : [];
194
+ // What never goes into the field stays under it, whatever is typed.
195
+ const caution = q.caution && !choices ? balanced(q.caution, field).map((line) => center(st.soft(line), inner)) : [];
192
196
  const lines = (choices ?? []).map((option, i) => {
193
197
  const here = i === qv.cursor;
194
198
  const mark = q.kind === 'single' ? (here ? '●' : '○') : qv.picked.has(i) ? '[x]' : '[ ]';
@@ -199,7 +203,7 @@ function questionPage(qv, inner, st) {
199
203
  heading: q.title ?? 'A question for you',
200
204
  text: said(q.prompt, inner, st),
201
205
  rule: divider(inner, st),
202
- action: choices ? [...hint, ...block(lines, inner, widest), ...after] : [...block([...label, ...fieldBox(edged, qv.field(field - 2), field, true, st)], inner, field), ...after],
206
+ action: choices ? [...hint, ...block(lines, inner, widest), ...after] : [...block([...label, ...fieldBox(edged, qv.field(field - 2), field, true, st)], inner, field), ...caution, ...after],
203
207
  at: choices ? hint.length + qv.cursor : label.length + 1,
204
208
  asks: true,
205
209
  ways: [['Continue', 'Enter'], ...(q.skippable ? [['Skip', 'Esc']] : [])],
@@ -261,6 +265,7 @@ function outroPage(scene, inner, st) {
261
265
  rule: divider(inner, st),
262
266
  action: pullAction(outro.link, scene.copied, inner, st),
263
267
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
268
+ foot: closesItself(scene, inner, st),
264
269
  };
265
270
  }
266
271
  const width = Math.min(TEXT_WIDTH, inner);
@@ -272,9 +277,11 @@ function outroPage(scene, inner, st) {
272
277
  rule: divider(inner, st),
273
278
  action: [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])],
274
279
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
275
- foot: outro.outcome === 'stopped' ? [center(st.faint(`Questions? Write to ${FEEDBACK}`), inner)] : undefined,
280
+ foot: outro.outcome === 'stopped' ? [center(st.faint(`Questions? Write to ${FEEDBACK}`), inner)] : closesItself(scene, inner, st),
276
281
  };
277
282
  }
283
+ /** The outro's fine print that it closes on its own, until it is closing; a stopped run's foot keeps the way to write to us instead, one line either way. */
284
+ const closesItself = (scene, inner, st) => (scene.leaving ? [] : [center(st.faint(`Closes on its own after ${CLOSES_AFTER_MINUTES} minutes.`), inner)]);
278
285
  function pageOf(f, inner) {
279
286
  const st = f.style;
280
287
  if (f.question)
@@ -291,11 +298,13 @@ function pageOf(f, inner) {
291
298
  // P still takes a pasted code on a remote shell; --help says so, the screen does not.
292
299
  const under = scene.note ?? (scene.copied ? 'Copied to clipboard!' : '');
293
300
  const link = block(urlRows(scene.link, ruleWidth(inner), st.soft, st), inner);
301
+ // In the action, which scrolls and keeps every line, so a short terminal still shows which checkout this approves.
302
+ const checkout = scene.checkout && !scene.again ? [...balanced(scene.checkout.repository, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.full(line), inner)), ...said(`in ${scene.checkout.folder}`, inner, st), ''] : [];
294
303
  return {
295
304
  heading: scene.again ? 'Sign in again' : START.heading,
296
305
  text: said(scene.again ? AGAIN[scene.again] : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
297
306
  rule: divider(inner, st),
298
- action: [...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied === false ? ['', copyWay(inner, st)] : [])],
307
+ action: [...checkout, ...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied === false ? ['', copyWay(inner, st)] : [])],
299
308
  };
300
309
  }
301
310
  case 'run':
@@ -5,7 +5,7 @@ import { copyText } from '../login.js';
5
5
  import { workStarted } from '../stages.js';
6
6
  import { mountUi } from './bridge.js';
7
7
  import { createFrameWriter, frameState } from './frames.js';
8
- import { drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
8
+ import { CLOSES_AFTER_MINUTES, drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
9
9
  import { RunState } from './run-state.js';
10
10
  /** What the command does before a screen ends it at once (Ctrl+C before a run, or a signal): report the stop (main.ts), briefly. */
11
11
  let beforeExit = async () => { };
@@ -18,6 +18,8 @@ const TICK_MS = 80;
18
18
  const FIRST_STATUS = 'Reading the repository';
19
19
  /** A first Esc on the run screen only asks for a second one within this long. */
20
20
  const STOP_WINDOW_MS = 3000;
21
+ /** The test drive and the outro close themselves after this long without a key, as Enter would: someone who walked away does not come back to a wizard still open. */
22
+ const CLOSE_AFTER_MS = CLOSES_AFTER_MINUTES * 60_000;
21
23
  /** This package's version, for the corner of the frame. */
22
24
  const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
23
25
  const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -337,7 +339,7 @@ class Wizard {
337
339
  if (view.kind === 'intro')
338
340
  return { kind: 'intro', note: view.note };
339
341
  if (view.kind === 'signin')
340
- return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note };
342
+ return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note, checkout: view.opts.checkout };
341
343
  if (view.kind === 'pull')
342
344
  return { kind: 'pull', url: view.url, copied: view.copied };
343
345
  if (view.kind === 'outro')
@@ -484,28 +486,25 @@ async function handOver(url, hand) {
484
486
  const copied = hand.copy(url).catch(() => false);
485
487
  return (await Promise.race([copied, new Promise((r) => setTimeout(() => r(false), 1000).unref())])) === true;
486
488
  }
487
- /** The test drive on a screen of its own once the run has ended, before the last screen shows the setup pull request, handed over as it appears, until a key other than ↑↓ PgUp PgDn is pressed. Without a terminal there is no such screen: the summary has it. */
489
+ /** The test drive on a screen of its own once the run has ended, before the last screen shows the setup pull request, handed over as it appears, until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes. Without a terminal there is no such screen: the summary has it. */
488
490
  export async function showPullRequest(url, hand) {
489
491
  const screen = host();
490
492
  if (!screen)
491
493
  return;
492
494
  const copied = await handOver(url, hand);
493
495
  await new Promise((resolve) => {
494
- const view = {
495
- kind: 'pull',
496
- url,
497
- copied,
498
- copy: () => hand.copy(url),
499
- done: () => {
500
- screen.remove(view);
501
- resolve();
502
- },
496
+ const done = () => {
497
+ clearTimeout(timer);
498
+ screen.remove(view);
499
+ resolve();
503
500
  };
501
+ const timer = setTimeout(done, CLOSE_AFTER_MS).unref();
502
+ const view = { kind: 'pull', url, copied, copy: () => hand.copy(url), done };
504
503
  screen.views = [];
505
504
  screen.push(view);
506
505
  });
507
506
  }
508
- /** The last screen: what happened, what is left, any warnings and its pull request (handed over as it appears) or the links, until a key other than ↑↓ PgUp PgDn is pressed. Its summary then stays in the scrollback. */
507
+ /** The last screen: what happened, what is left, any warnings and its pull request (handed over as it appears) or the links, until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes. Its summary then stays in the scrollback. */
509
508
  export async function showOutro(o, hand) {
510
509
  const screen = host();
511
510
  if (screen) {
@@ -513,17 +512,13 @@ export async function showOutro(o, hand) {
513
512
  // The link C copies: the pull request, or the one link a screen without it lists.
514
513
  const link = o.link ?? (o.links.length === 1 ? o.links[0].url : undefined);
515
514
  await new Promise((resolve) => {
516
- const view = {
517
- kind: 'outro',
518
- outro: o,
519
- leaving: false,
520
- copied,
521
- copy: link ? () => (hand?.copy ?? copyText)(link) : undefined,
522
- done: () => {
523
- view.leaving = true;
524
- void screen.close().then(resolve);
525
- },
515
+ const done = () => {
516
+ clearTimeout(timer);
517
+ view.leaving = true;
518
+ void screen.close().then(resolve);
526
519
  };
520
+ const timer = setTimeout(done, CLOSE_AFTER_MS).unref();
521
+ const view = { kind: 'outro', outro: o, leaving: false, copied, copy: link ? () => (hand?.copy ?? copyText)(link) : undefined, done };
527
522
  screen.views = [];
528
523
  screen.push(view);
529
524
  });
package/dist/vercel.js CHANGED
@@ -110,7 +110,7 @@ export async function readVercelProject(vercel, link) {
110
110
  const branch = record(project.link)?.productionBranch;
111
111
  const protection = scope(project);
112
112
  const hosts = [target, ...latest].flatMap((d) => (typeof d?.url === 'string' && d.url ? [d.url.replace(/^https?:\/\//, '').toLowerCase()] : []));
113
- return { production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), protection: typeof protection === 'string' ? protection : null, deploymentHosts: new Set(hosts) };
113
+ return { ...(typeof project.name === 'string' && project.name ? { name: project.name } : {}), production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), protection: typeof protection === 'string' ? protection : null, deploymentHosts: new Set(hosts) };
114
114
  }
115
115
  /**
116
116
  * The linked project before its first push: read through `vercel api`, and, with no production deployment, locked
@@ -118,15 +118,17 @@ export async function readVercelProject(vercel, link) {
118
118
  * branch it builds and Standard Protection serves production domains to anyone. A project that deploys production
119
119
  * already (live or building), or is locked already, is left as it is.
120
120
  */
121
- export async function prepareFirstDeployment(vercel, link) {
121
+ export async function prepareFirstDeployment(vercel, link, lock = true) {
122
122
  const project = await readVercelProject(vercel, link);
123
123
  if (!project)
124
124
  return { production: 'unknown', locked: false, reason: 'The Vercel CLI could not read the project (vercel api): it is signed out, or this CLI has no api command.' };
125
- const branch = project.productionBranch ? { productionBranch: project.productionBranch } : {};
125
+ const branch = { ...(project.productionBranch ? { productionBranch: project.productionBranch } : {}), ...(project.name ? { project: project.name } : {}) };
126
126
  if (project.production === 'exists')
127
127
  return { production: 'exists', ...branch };
128
128
  if (project.protection === 'all')
129
129
  return { production: 'none', locked: true, ...branch };
130
+ if (!lock)
131
+ return { production: 'none', locked: false, ...branch, reason: "Vercel's login is not in front of the project's addresses yet." };
130
132
  try {
131
133
  if (scope(await printedProject(vercel, lockProjectApi(link))) === 'all')
132
134
  return { production: 'none', locked: true, ...branch };
@@ -136,6 +138,28 @@ export async function prepareFirstDeployment(vercel, link) {
136
138
  }
137
139
  return { production: 'none', locked: false, ...branch, reason: "Vercel did not put its login in front of the project's addresses: that takes the Member role or Project Administrator." };
138
140
  }
141
+ /**
142
+ * A page of the project in Vercel's dashboard: by its team and name when the run knows both, else the dashboard's link
143
+ * that has the person pick them.
144
+ */
145
+ export function vercelProjectPage(page, team, project) {
146
+ return team && project ? `https://vercel.com/${encodeURIComponent(team)}/${encodeURIComponent(project)}/${page}` : `https://vercel.com/d?to=${encodeURIComponent(`/[team]/[project]/${page}`)}`;
147
+ }
148
+ /** Reads the project again every `wait.everyMs` until Vercel's login stands in front of every address of it, or it deploys production, or `wait.totalMs` has passed (a read that failed counts as neither): the person is locking it meanwhile. */
149
+ export async function waitForVercelLock(vercel, link, deps) {
150
+ for (const end = Date.now() + deps.wait.totalMs;;) {
151
+ const first = await prepareFirstDeployment(vercel, link, false);
152
+ // A read that failed says nothing yet, so the wait goes on.
153
+ if (first.production === 'exists' || first.locked || Date.now() >= end || deps.signal?.aborted)
154
+ return first;
155
+ try {
156
+ await (deps.sleep ?? ((ms) => sleep(ms, undefined, { signal: deps.signal })))(deps.wait.everyMs);
157
+ }
158
+ catch {
159
+ return first;
160
+ }
161
+ }
162
+ }
139
163
  /**
140
164
  * The hosts of the linked project's deployments while Vercel's login stands in front of every address of it, read
141
165
  * now: a Production deployment GitHub lists for a pushed branch is its preview only when it is one of these. Empty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,
@@ -25,8 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@anthropic-ai/sandbox-runtime": "0.0.78",
28
- "@earendil-works/pi-coding-agent": "~0.87.1",
29
- "@earendil-works/pi-tui": "~0.87.1"
28
+ "@earendil-works/pi-coding-agent": "0.87.1",
29
+ "@earendil-works/pi-tui": "0.87.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.4",