@impeccable/reviews 0.1.0 → 0.1.2

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/bridge.js CHANGED
@@ -120,17 +120,20 @@ async function askOnStdin(q) {
120
120
  }
121
121
  if (q.kind === 'account') {
122
122
  // An empty username keeps the one the field would hold; an empty password skips the account.
123
- out.write(`\n${q.title ? `${q.title}. ` : ''}${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}Username or email${q.prefill ? ` (Enter for ${q.prefill})` : ''}: `);
123
+ const [first, second] = q.fields ?? ['Username or email', 'Password'];
124
+ out.write(`\n${q.title ? `${q.title}. ` : ''}${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}${first}${q.prefill ? ` (Enter for ${q.prefill})` : ''}: `);
124
125
  const username = (await nextLine())?.trim() || q.prefill;
125
126
  if (!username)
126
127
  return { skipped: true };
127
- out.write('Password, or Enter to skip: ');
128
+ out.write(`${second}, or Enter to skip: `);
128
129
  const password = await (process.stdin.isTTY ? hiddenLine : nextLine)();
129
130
  return password ? { skipped: false, username, password } : { skipped: true };
130
131
  }
131
132
  const how = q.kind === 'text' ? (q.skippable ? '(Enter to skip)' : '') : q.kind === 'single' ? `Type a number${q.skippable ? ', or Enter to skip' : ''}:` : `Type the numbers, separated by commas${q.skippable ? ', or Enter to skip' : ''}:`;
132
133
  out.write(`\n${q.prompt}\n${q.hint ? `${q.hint}\n` : ''}`);
133
- (q.options ?? []).forEach((o, i) => out.write(` ${i + 1}. ${o.label}\n`));
134
+ // A multi choice that opens with picks shows them; on a pipe the numbers typed are the whole answer.
135
+ const marked = q.kind === 'multi' && (q.options ?? []).some((o) => o.selected);
136
+ (q.options ?? []).forEach((o, i) => out.write(` ${i + 1}. ${marked ? `[${o.selected ? 'x' : ' '}] ` : ''}${o.label}\n`));
134
137
  // A sensitive answer typed at a terminal is not echoed.
135
138
  const read = q.sensitive && process.stdin.isTTY ? hiddenLine : nextLine;
136
139
  out.write(how ? `${how} ` : '');
package/dist/ui/look.js CHANGED
@@ -20,6 +20,11 @@ const FIELD_WIDTH = 48;
20
20
  /** The running task's spinner: one glyph every third frame. */
21
21
  const SPINNER = ['◐', '◓', '◑', '◒'];
22
22
  const START = { heading: "Let's set up your app", text: 'This wizard detects your framework and wires up all the essentials.' };
23
+ /** The sign-in over a run, by why it signs in again. */
24
+ const AGAIN = {
25
+ 'ran-out': 'Your sign-in ran out. Approve again in your browser to carry on.',
26
+ apps: 'Impeccable found your apps after you signed in. Approve again in your browser to set them up.',
27
+ };
23
28
  /** The invitation to the test drive, on whichever end screen shows it; the words "test drive" stay off it. */
24
29
  export const SEE_THE_REVIEW = 'See Impeccable review a change: we opened a small pull request just for that.';
25
30
  const WORDMARK = 'I M P E C C A B L E';
@@ -162,7 +167,8 @@ function questionPage(qv, inner, st) {
162
167
  const width = Math.min(FIELD_WIDTH, inner);
163
168
  const label = (text) => ` ${st.soft(text)}`;
164
169
  const hint = q.hint ? [...wrapTextWithAnsi(q.hint, width - 2).map(label), ''] : [];
165
- const fields = [...fieldBox('Username or email', qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox('Password', qv.password?.() ?? '', width, qv.focus === 1, st)];
170
+ const [first, second] = q.fields ?? ['Username or email', 'Password'];
171
+ const fields = [...fieldBox(first, qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox(second, qv.password?.() ?? '', width, qv.focus === 1, st)];
166
172
  return {
167
173
  heading: q.title ?? 'A test account',
168
174
  text: said(q.prompt, inner, st),
@@ -224,9 +230,15 @@ function runPage(scene, f, inner, st) {
224
230
  : t.status === 'skipped' ? '–'
225
231
  : '○';
226
232
  const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
227
- // The task in progress carries the wait's timer, so a screen waiting on a host's build is never still.
228
- const detail = t.status === 'skipped' || t.status === 'failed' ? t.status : i === running && scene.wait ? scene.wait : undefined;
229
- return ink(`${mark} ${t.title}${detail ? ` · ${detail}` : ''}`);
233
+ const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
234
+ if (t.status === 'skipped' || t.status === 'failed')
235
+ return ink(row(t.status));
236
+ if (i !== running || !scene.wait)
237
+ return ink(row());
238
+ // The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
239
+ // waiting on a host's build is never still; the time alone where the words would push it off the row.
240
+ const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
241
+ return ink(visibleWidth(waiting) <= inner ? waiting : row(scene.wait.timer));
230
242
  });
231
243
  const counted = scene.tasks.filter((t) => t.status !== 'skipped');
232
244
  const skipped = scene.tasks.length - counted.length;
@@ -271,15 +283,17 @@ function pageOf(f, inner) {
271
283
  switch (scene.kind) {
272
284
  case 'wait':
273
285
  return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.faint('One moment…'), inner)] };
274
- case 'intro':
275
- return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner)], ways: [['Continue', 'Enter']] };
286
+ case 'intro': {
287
+ const note = scene.note ? ['', ...block(hang(scene.note, Math.min(TEXT_WIDTH, inner), '! ').map(st.soft), inner)] : [];
288
+ return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner), ...note], ways: [['Continue', 'Enter']] };
289
+ }
276
290
  case 'signin': {
277
291
  // P still takes a pasted code on a remote shell; --help says so, the screen does not.
278
292
  const under = scene.note ?? (scene.copied ? 'Copied to clipboard!' : '');
279
293
  const link = block(urlRows(scene.link, ruleWidth(inner), st.soft, st), inner);
280
294
  return {
281
295
  heading: scene.again ? 'Sign in again' : START.heading,
282
- text: said(scene.again ? 'Your sign-in ran out. Approve again in your browser to carry on.' : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
296
+ text: said(scene.again ? AGAIN[scene.again] : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
283
297
  rule: divider(inner, st),
284
298
  action: [...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied === false ? ['', copyWay(inner, st)] : [])],
285
299
  };
@@ -24,7 +24,7 @@ export class RunState {
24
24
  tasks = [];
25
25
  previous;
26
26
  current;
27
- /** A bounded wait under way: the current line carries its timer, counting up to the limit. */
27
+ /** A bounded wait under way: the current line carries its timer, counting up. */
28
28
  waiting;
29
29
  /** When something last changed on screen: a status, the task list, an answered question. */
30
30
  changedAt;
@@ -51,12 +51,12 @@ export class RunState {
51
51
  this.touch();
52
52
  }
53
53
  /**
54
- * The wait's timer, "1:05 of 5:00", while one is under way: the task in progress carries it on the tasks screen.
55
- * `live` false gives the wait's start, "0:00 of 5:00", the same on every tick: a drawing that changes only when
56
- * a wait begins or ends, so the frames record the wait once rather than once a second.
54
+ * The wait under way, which the task in progress carries on the tasks screen: the host whose preview it is and the
55
+ * time so far, "1:05". `live` false gives the wait's start, "0:00", the same on every tick: a drawing that changes
56
+ * only when a wait begins or ends, so the frames record the wait once rather than once a second.
57
57
  */
58
58
  timer(live = true) {
59
- return this.waiting ? `${timer(live ? this.now() - this.waiting.since : 0)} of ${timer(this.waiting.limitMs)}` : undefined;
59
+ return this.waiting && { host: this.waiting.host, timer: timer(live ? this.now() - this.waiting.since : 0) };
60
60
  }
61
61
  setTasks(tasks) {
62
62
  this.tasks = tasks.map((t) => ({ ...t }));
@@ -70,14 +70,14 @@ export class RunState {
70
70
  * The band's two lines as they read now: after a minute with nothing new the
71
71
  * last line moves up, dimmed, under "Still working…" and the time since.
72
72
  * `quiet` is false while the person is being asked. During a bounded wait
73
- * the current line carries the timer, "1:05 of 5:00", so the screen never
73
+ * the current line carries the timer, "1:05", so the screen never
74
74
  * looks stalled; `live` false holds the timer at the wait's start, for a
75
75
  * drawing that changes only when the words do.
76
76
  */
77
77
  band(quiet = true, live = true) {
78
- if (this.waiting && this.current) {
79
- return { previous: this.previous, current: `${this.current} · ${this.timer(live)}` };
80
- }
78
+ const wait = this.timer(live);
79
+ if (wait && this.current)
80
+ return { previous: this.previous, current: `${this.current} · ${wait.timer}` };
81
81
  const silence = this.now() - this.changedAt;
82
82
  if (quiet && silence >= SILENCE_MS)
83
83
  return { previous: this.current, current: `Still working… ${elapsed(silence)}` };
@@ -1,13 +1,17 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { format } from 'node:util';
3
3
  import { CURSOR_MARKER, getCapabilities, Input, KeybindingsManager, matchesKey, ProcessTerminal, setKeybindings, TUI_KEYBINDINGS, TuiAltScreen } from '@earendil-works/pi-tui';
4
- import { readCheckout } from '../preflight.js';
5
4
  import { copyText } from '../login.js';
6
5
  import { workStarted } from '../stages.js';
7
6
  import { mountUi } from './bridge.js';
8
7
  import { createFrameWriter, frameState } from './frames.js';
9
8
  import { drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
10
9
  import { RunState } from './run-state.js';
10
+ /** What the command does before a screen ends it at once (Ctrl+C before a run, or a signal): report the stop (main.ts), briefly. */
11
+ let beforeExit = async () => { };
12
+ export function onScreenExit(fn) {
13
+ beforeExit = fn;
14
+ }
11
15
  /** How often what moves on screen moves: the loader, the spinner. */
12
16
  const TICK_MS = 80;
13
17
  /** The run screen's first line, before the agent has said anything. */
@@ -44,7 +48,7 @@ class Wizard {
44
48
  onExit = () => this.stop();
45
49
  onSignal = (signal) => {
46
50
  this.stop();
47
- process.kill(process.pid, signal);
51
+ void beforeExit('terminated').finally(() => process.kill(process.pid, signal));
48
52
  };
49
53
  constructor() {
50
54
  // The alternate screen's own scrolling and search keys stay off: the wizard draws every row, and a typed answer keeps Home and End.
@@ -155,7 +159,9 @@ class Wizard {
155
159
  this.state.touch();
156
160
  resolve(answer);
157
161
  };
158
- this.questions.push({ q, resolve: answered, field, password: q.kind === 'account' ? new Input() : undefined, focus: 0, cursor: 0, picked: new Set() });
162
+ // A multi choice opens with the options the question marks selected picked.
163
+ const picked = new Set(q.kind === 'multi' ? (q.options ?? []).flatMap((o, i) => (o.selected ? [i] : [])) : []);
164
+ this.questions.push({ q, resolve: answered, field, password: q.kind === 'account' ? new Input() : undefined, focus: 0, cursor: 0, picked });
159
165
  this.redraw();
160
166
  });
161
167
  }
@@ -226,7 +232,8 @@ class Wizard {
226
232
  const run = this.views.find((v) => v.kind === 'run');
227
233
  if (!run) {
228
234
  this.stop();
229
- process.exit(130);
235
+ void beforeExit('ctrl_c').finally(() => process.exit(130));
236
+ return;
230
237
  }
231
238
  this.stopAsked = 0;
232
239
  for (const question of this.questions.splice(0))
@@ -289,14 +296,19 @@ class Wizard {
289
296
  else if (matchesKey(data, 'enter')) {
290
297
  if (choices && q.kind === 'single')
291
298
  return done({ skipped: false, value: choices[question.cursor].value });
292
- if (choices)
299
+ // A multi choice answers what is picked; with nothing picked, Skip is the way to say none.
300
+ if (choices && question.picked.size)
293
301
  return done({ skipped: false, values: choices.filter((_, i) => question.picked.has(i)).map((o) => o.value) });
294
- const value = question.field.getValue().trim();
295
- if (value)
296
- return done({ skipped: false, value });
297
- if (q.skippable)
298
- return done({ skipped: true });
299
- question.note = 'Type an answer first.';
302
+ if (choices)
303
+ question.note = 'Pick at least one, or skip.';
304
+ else {
305
+ const value = question.field.getValue().trim();
306
+ if (value)
307
+ return done({ skipped: false, value });
308
+ if (q.skippable)
309
+ return done({ skipped: true });
310
+ question.note = 'Type an answer first.';
311
+ }
300
312
  }
301
313
  else if (!choices) {
302
314
  question.field.handleInput(data);
@@ -323,7 +335,7 @@ class Wizard {
323
335
  if (!view)
324
336
  return { kind: 'wait' };
325
337
  if (view.kind === 'intro')
326
- return { kind: 'intro' };
338
+ return { kind: 'intro', note: view.note };
327
339
  if (view.kind === 'signin')
328
340
  return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note };
329
341
  if (view.kind === 'pull')
@@ -394,30 +406,24 @@ function host() {
394
406
  wizard ??= new Wizard();
395
407
  return wizard;
396
408
  }
397
- function repository() {
398
- try {
399
- const checkout = readCheckout(process.cwd());
400
- return `${checkout.owner}/${checkout.repo}`;
401
- }
402
- catch {
403
- return undefined;
404
- }
405
- }
406
409
  /** Writes every screen into `dir` as it changes (--frames), for the eval. */
407
410
  export function recordFrames(dir) {
408
411
  frames = createFrameWriter(dir);
409
412
  }
410
- /** The first screen: what the command does, and the way on. Resolves true to go on, false to cancel. */
411
- export function showIntro() {
413
+ /** The first screen: what the command does, a warning to read before anything runs (`note`), and the way on; without a terminal, a line naming `repository`, the owner/name being set up. Resolves true to go on, false to cancel. */
414
+ export function showIntro(repository, note) {
412
415
  const screen = host();
413
416
  if (!screen) {
414
- console.log(`${PRODUCT}: setting up ${repository() ?? 'this repository'}.`);
417
+ console.log(`${PRODUCT}: setting up ${repository}.`);
418
+ if (note)
419
+ console.log(`! ${note}`);
415
420
  console.log();
416
421
  return Promise.resolve(true);
417
422
  }
418
423
  return new Promise((resolve) => {
419
424
  const view = {
420
425
  kind: 'intro',
426
+ note,
421
427
  done: (ok) => {
422
428
  screen.remove(view);
423
429
  if (ok)
@@ -443,7 +449,7 @@ export function showSignIn(link, opts) {
443
449
  console.log(link);
444
450
  return { close() { } };
445
451
  }
446
- const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') };
452
+ const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') && (opts.why ?? 'ran-out') };
447
453
  screen.push(view);
448
454
  screen.copy(view);
449
455
  return { close: () => screen.remove(view) };
package/dist/vercel.js CHANGED
@@ -68,3 +68,80 @@ export async function waitForVercelAccess(vercel, deps) {
68
68
  }
69
69
  return result;
70
70
  }
71
+ /**
72
+ * `vercel api` on the project this folder is linked to: GET /v9/projects/{id} reads it, and PATCH changes a setting
73
+ * through the update-project endpoint (https://vercel.com/docs/rest-api/projects/update-an-existing-project); a team's
74
+ * project needs its team on the request.
75
+ */
76
+ const projectApi = (link, change) => [
77
+ 'api',
78
+ `/v9/projects/${encodeURIComponent(link.projectId)}${link.orgId.startsWith('team_') ? `?teamId=${encodeURIComponent(link.orgId)}` : ''}`,
79
+ ...(change ? ['--method', 'PATCH', ...change] : []),
80
+ '--raw', '--non-interactive',
81
+ ];
82
+ export const readProjectApi = (link) => projectApi(link);
83
+ /**
84
+ * Vercel Authentication on All Deployments: Vercel's login in front of every address of the project, its production
85
+ * domains included (https://vercel.com/docs/deployment-protection#all-deployments), as the endpoint takes it
86
+ * (https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication#how-to-manage-vercel-authentication-with-the-api).
87
+ */
88
+ export const lockProjectApi = (link) => projectApi(link, ['--field', 'ssoProtection={"deploymentType":"all"}']);
89
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
90
+ const printedProject = async (vercel, args) => {
91
+ const printed = await vercel(args);
92
+ return record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
93
+ };
94
+ const scope = (project) => record(project?.ssoProtection)?.deploymentType;
95
+ /** The linked project as `vercel api` prints it: its production deployment (the production target, or one among the latest) unless every one failed or was cancelled, its Git link's production branch, its protection and its deployments' hosts. Null when the CLI could not read it. */
96
+ export async function readVercelProject(vercel, link) {
97
+ let project;
98
+ try {
99
+ project = await printedProject(vercel, readProjectApi(link));
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ if (project?.id !== link.projectId)
105
+ return null;
106
+ const target = record(record(project.targets)?.production);
107
+ const latest = (Array.isArray(project.latestDeployments) ? project.latestDeployments : []).map(record).filter((d) => d !== undefined);
108
+ const failed = (d) => d.readyState === 'ERROR' || d.readyState === 'CANCELED';
109
+ const exists = (target !== undefined && !failed(target)) || latest.some((d) => d.target === 'production' && !failed(d));
110
+ const branch = record(project.link)?.productionBranch;
111
+ const protection = scope(project);
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) };
114
+ }
115
+ /**
116
+ * The linked project before its first push: read through `vercel api`, and, with no production deployment, locked
117
+ * behind Vercel's login at every address, since Vercel makes a new project's first deployment Production whatever
118
+ * branch it builds and Standard Protection serves production domains to anyone. A project that deploys production
119
+ * already (live or building), or is locked already, is left as it is.
120
+ */
121
+ export async function prepareFirstDeployment(vercel, link) {
122
+ const project = await readVercelProject(vercel, link);
123
+ if (!project)
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 } : {};
126
+ if (project.production === 'exists')
127
+ return { production: 'exists', ...branch };
128
+ if (project.protection === 'all')
129
+ return { production: 'none', locked: true, ...branch };
130
+ try {
131
+ if (scope(await printedProject(vercel, lockProjectApi(link))) === 'all')
132
+ return { production: 'none', locked: true, ...branch };
133
+ }
134
+ catch {
135
+ // What the answer below says.
136
+ }
137
+ 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
+ }
139
+ /**
140
+ * The hosts of the linked project's deployments while Vercel's login stands in front of every address of it, read
141
+ * now: a Production deployment GitHub lists for a pushed branch is its preview only when it is one of these. Empty
142
+ * when the project is not locked at every address, or could not be read.
143
+ */
144
+ export async function lockedDeploymentHosts(vercel, link) {
145
+ const project = await readVercelProject(vercel, link);
146
+ return project?.protection === 'all' ? project.deploymentHosts : new Set();
147
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
- "license": "UNLICENSED",
5
+ "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "eval": "npm run build && node dist/eval/main.js"
25
25
  },
26
26
  "dependencies": {
27
+ "@anthropic-ai/sandbox-runtime": "0.0.78",
27
28
  "@earendil-works/pi-coding-agent": "~0.87.1",
28
29
  "@earendil-works/pi-tui": "~0.87.1"
29
30
  },