@impeccable/reviews 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +68 -0
  2. package/dist/agent.js +183 -0
  3. package/dist/bin.js +11 -0
  4. package/dist/fence.js +1071 -0
  5. package/dist/focus.js +98 -0
  6. package/dist/host-questions.js +71 -0
  7. package/dist/hosted.js +195 -0
  8. package/dist/http.js +36 -0
  9. package/dist/log.js +38 -0
  10. package/dist/login.js +276 -0
  11. package/dist/main.js +252 -0
  12. package/dist/netlify.js +333 -0
  13. package/dist/playbook/cases/ci-tunnel.md +21 -0
  14. package/dist/playbook/cases/first-preview-failure.md +28 -0
  15. package/dist/playbook/cases/netlify-previews.md +17 -0
  16. package/dist/playbook/cases/netlify-setup.md +24 -0
  17. package/dist/playbook/cases/preview-protection.md +21 -0
  18. package/dist/playbook/cases/pull-requests.md +31 -0
  19. package/dist/playbook/cases/sign-in.md +21 -0
  20. package/dist/playbook/cases/static-site.md +16 -0
  21. package/dist/playbook/cases/vercel-previews.md +16 -0
  22. package/dist/playbook/cases/vercel-setup.md +27 -0
  23. package/dist/playbook/cases/workflow-as-person.md +23 -0
  24. package/dist/playbook/github-previews.js +101 -0
  25. package/dist/playbook/netlify-pattern.js +24 -0
  26. package/dist/playbook/netlify-previews.js +144 -0
  27. package/dist/playbook/probe-preview.js +67 -0
  28. package/dist/playbook/protection-secrets.js +15 -0
  29. package/dist/playbook/public-preview-request.js +96 -0
  30. package/dist/playbook/publication-wait.js +46 -0
  31. package/dist/playbook/vercel-protection.js +121 -0
  32. package/dist/playbook.js +98 -0
  33. package/dist/preflight.js +211 -0
  34. package/dist/stages.js +34 -0
  35. package/dist/sync.js +142 -0
  36. package/dist/tools.js +2015 -0
  37. package/dist/transcript.js +139 -0
  38. package/dist/ui/bridge.js +145 -0
  39. package/dist/ui/frames.js +62 -0
  40. package/dist/ui/look.js +368 -0
  41. package/dist/ui/run-state.js +91 -0
  42. package/dist/ui/screens.js +544 -0
  43. package/dist/ui/sensitive.js +14 -0
  44. package/dist/vercel-bypass.js +136 -0
  45. package/dist/vercel.js +70 -0
  46. package/package.json +39 -0
@@ -0,0 +1,139 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { redact, SECRET_FIELD } from './log.js';
5
+ const LIMIT = 2000;
6
+ /** `location` is a .md file (the .jsonl goes beside it) or a folder; the OS temp folder without one. */
7
+ export function transcriptPaths(runId, location) {
8
+ if (location?.endsWith('.md'))
9
+ return { markdown: location, events: `${location.slice(0, -3)}.jsonl` };
10
+ const name = `transcript-${runId.replace(/[^\w.-]/g, '_')}`;
11
+ const dir = location ?? tmpdir();
12
+ return { markdown: join(dir, `${name}.md`), events: join(dir, `${name}.jsonl`) };
13
+ }
14
+ const blocks = (content) => (Array.isArray(content) ? content : typeof content === 'string' ? [{ type: 'text', text: content }] : []);
15
+ const textOf = (content) => blocks(content)
16
+ .filter((b) => b.type === 'text')
17
+ .map((b) => b.text ?? '')
18
+ .join('\n')
19
+ .trim();
20
+ const cut = (text) => (text.length > LIMIT ? `${text.slice(0, LIMIT)}\n[cut: ${LIMIT} of ${text.length} characters shown]` : text);
21
+ const fenced = (text, lang = '') => {
22
+ const fence = text.includes('```') ? '````' : '```';
23
+ return `${fence}${lang}\n${text}\n${fence}`;
24
+ };
25
+ const count = (n) => (n ?? 0).toLocaleString('en-US');
26
+ /** A copy of an event with every string cleaned, a credential field's value and everything belonging to a sensitive tool call reading [redacted]. */
27
+ function mask(value, calls, clean) {
28
+ if (typeof value === 'string')
29
+ return clean(value);
30
+ if (Array.isArray(value))
31
+ return value.map((v) => mask(v, calls, clean));
32
+ if (!value || typeof value !== 'object')
33
+ return value;
34
+ const obj = value;
35
+ const hidden = typeof obj.toolCallId === 'string' && calls.has(obj.toolCallId);
36
+ const secret = (k, v) => (hidden && ['content', 'result', 'details'].includes(k)) || (typeof v === 'string' && SECRET_FIELD.test(k));
37
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, secret(k, v) ? '[redacted]' : mask(v, calls, clean)]));
38
+ }
39
+ export function createTranscript(opts) {
40
+ const paths = transcriptPaths(opts.runId, opts.location);
41
+ const secrets = new Set(opts.secrets?.filter(Boolean));
42
+ const sensitiveCalls = new Set();
43
+ const questions = new Set();
44
+ let turn = 0;
45
+ let failure;
46
+ /** Writes through, or remembers why it could not: a transcript is not worth stopping the run for. */
47
+ const attempt = (write) => {
48
+ if (failure)
49
+ return;
50
+ try {
51
+ write();
52
+ }
53
+ catch (err) {
54
+ failure = err instanceof Error ? err.message : String(err);
55
+ }
56
+ };
57
+ // Not even a token's prefix stays, so a search for pst_ or phr_ finds nothing.
58
+ // Each secret as typed and as JSON writes it, for a result that is JSON text.
59
+ const clean = (text) => redact(text, [...secrets].flatMap((s) => [s, JSON.stringify(s).slice(1, -1)])).replace(/\b(pst|phr)_\[redacted\]/g, '[redacted]');
60
+ const append = (path, text) => attempt(() => appendFileSync(path, clean(text), { mode: 0o600 }));
61
+ const md = (...parts) => append(paths.markdown, `${parts.join('\n\n')}\n\n`);
62
+ // Each run starts both files afresh, so a path used before holds this run alone.
63
+ attempt(() => {
64
+ mkdirSync(dirname(paths.markdown), { recursive: true });
65
+ writeFileSync(paths.markdown, clean(`# Impeccable setup transcript\n\n${opts.heading} · started ${new Date().toISOString()}\n\n`), { mode: 0o600 });
66
+ writeFileSync(paths.events, '', { mode: 0o600 });
67
+ // mode only applies to a new file; one that was there keeps its own until told.
68
+ for (const path of [paths.markdown, paths.events])
69
+ chmodSync(path, 0o600);
70
+ });
71
+ function assistant(message) {
72
+ const parts = [];
73
+ for (const block of blocks(message.content)) {
74
+ if (block.type === 'thinking')
75
+ parts.push(`**Thinking**`, block.redacted ? '_(redacted by the provider)_' : (block.thinking ?? '').trim().replace(/^/gm, '> '));
76
+ else if (block.type === 'text' && block.text?.trim())
77
+ parts.push(block.text.trim());
78
+ else if (block.type === 'toolCall' && block.id) {
79
+ const args = block.arguments ?? {};
80
+ if (block.name === 'impeccable_ask') {
81
+ questions.add(block.id);
82
+ if (args.sensitive === true)
83
+ sensitiveCalls.add(block.id);
84
+ const options = Array.isArray(args.options) ? args.options.map((o) => `- ${String(o.label)} (\`${String(o.value)}\`)`) : [];
85
+ parts.push(`**Question** \`${String(args.id)}\` (${String(args.kind)}${args.sensitive === true ? ', sensitive' : ''}${args.skippable === true ? ', skippable' : ''})`, String(args.prompt ?? ''), ...(options.length ? [options.join('\n')] : []));
86
+ }
87
+ else {
88
+ parts.push(`**Tool call** \`${block.name}\``, fenced(cut(JSON.stringify(args, null, 2)), 'json'));
89
+ }
90
+ }
91
+ }
92
+ if (message.stopReason === 'error')
93
+ parts.push(`**Error** ${message.errorMessage ?? 'the model call failed'}`);
94
+ const u = message.usage;
95
+ if (u)
96
+ parts.push(`_Usage: ${count(u.input)} in · ${count(u.output)} out · ${count(u.cacheRead)} cache read · ${count(u.cacheWrite)} cache write · $${(u.cost?.total ?? 0).toFixed(4)}_`);
97
+ if (parts.length)
98
+ md(...parts);
99
+ }
100
+ return {
101
+ paths,
102
+ get failure() {
103
+ return failure;
104
+ },
105
+ report() {
106
+ if (!failure)
107
+ return [`Transcript of the run: ${paths.markdown}`, `Its raw events: ${paths.events}`];
108
+ return [`The transcript stops early, because a write failed (${failure}). What was saved: ${paths.markdown} and ${paths.events}`];
109
+ },
110
+ secret(value) {
111
+ if (value)
112
+ secrets.add(value);
113
+ },
114
+ record(event) {
115
+ if (event.type === 'message_update' || event.type === 'tool_execution_update')
116
+ return;
117
+ // Cleaned string by string before it is serialized (#82): each line stays JSON, and a secret JSON escapes is still found.
118
+ const line = JSON.stringify({ at: new Date().toISOString(), ...mask(event, sensitiveCalls, clean) });
119
+ attempt(() => appendFileSync(paths.events, `${line}\n`, { mode: 0o600 }));
120
+ if (event.type === 'turn_start') {
121
+ md(`## Turn ${++turn} · ${new Date().toISOString().slice(11, 19)}Z`);
122
+ }
123
+ else if (event.type === 'message_end') {
124
+ const message = event.message;
125
+ if (message.role === 'assistant')
126
+ assistant(message);
127
+ else if (message.role === 'user')
128
+ md(`**Prompt**`, cut(textOf(message.content)).replace(/^/gm, '> '));
129
+ else if (message.role === 'custom')
130
+ md(`_Context sent to the model (${message.customType ?? 'custom'}, ${textOf(message.content).length} characters)._`);
131
+ }
132
+ else if (event.type === 'tool_execution_end') {
133
+ const answer = questions.has(event.toolCallId);
134
+ const text = sensitiveCalls.has(event.toolCallId) ? '[redacted]' : cut(textOf(event.result?.content));
135
+ md(`**${answer ? 'Answer' : 'Result'}** \`${event.toolName}\`${event.isError ? ' (error)' : ''}`, fenced(text || '(nothing)'));
136
+ }
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,145 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+ import { noteSensitiveAnswer } from './sensitive.js';
4
+ let mounted = null;
5
+ /** Asks the person: the question on the screen, or printed with one line read from stdin. */
6
+ export async function askPerson(q) {
7
+ const answer = await (mounted ? mounted.ask(q) : askOnStdin(q));
8
+ if (q.sensitive)
9
+ for (const value of [answer.value, ...(answer.values ?? [])])
10
+ if (value)
11
+ noteSensitiveAnswer(value);
12
+ if (answer.password)
13
+ noteSensitiveAnswer(answer.password);
14
+ return answer;
15
+ }
16
+ /** A status line for the status band. */
17
+ export function reportStatus(line) {
18
+ mounted?.status(line);
19
+ }
20
+ /** Replaces the task list. */
21
+ export function reportTasks(tasks) {
22
+ mounted?.tasks(tasks);
23
+ }
24
+ /** The run waits on something outside it (a host's build): the screen counts the time up beside the status line until `null` ends it. */
25
+ export function reportWait(wait) {
26
+ mounted?.wait?.(wait);
27
+ }
28
+ /** The screen registers itself; null = headless. */
29
+ export function mountUi(ui) {
30
+ mounted = ui;
31
+ }
32
+ /** What a typed line answers, or null when it answers nothing and the question goes again. */
33
+ function answerOf(q, line) {
34
+ const text = line.trim();
35
+ if (!text)
36
+ return q.skippable ? { skipped: true } : q.kind === 'multi' ? { skipped: false, values: [] } : null;
37
+ if (q.kind === 'text')
38
+ return { skipped: false, value: text };
39
+ const options = q.options ?? [];
40
+ const pick = (word) => options[Number(word) - 1] ?? options.find((o) => o.value === word || o.label.toLowerCase() === word.toLowerCase());
41
+ if (q.kind === 'single') {
42
+ const option = pick(text);
43
+ return option ? { skipped: false, value: option.value } : null;
44
+ }
45
+ const picked = text.split(',').map((word) => pick(word.trim()));
46
+ return picked.every(Boolean) ? { skipped: false, values: [...new Set(picked.map((o) => o.value))] } : null;
47
+ }
48
+ /** Lines typed or piped in, kept across questions so one read never swallows the next answer. */
49
+ const inputs = new WeakMap();
50
+ /** The next line on stdin, or null once it has ended. stdin is paused again in between, so it never holds the process open. */
51
+ function nextLine() {
52
+ const stdin = process.stdin;
53
+ const input = inputs.get(stdin) ?? { lines: [], partial: '', ended: false };
54
+ inputs.set(stdin, input);
55
+ const markEnded = () => {
56
+ if (input.ended)
57
+ return;
58
+ input.ended = true;
59
+ if (input.partial)
60
+ input.lines.push(input.partial);
61
+ input.partial = '';
62
+ };
63
+ return new Promise((resolve) => {
64
+ const settle = () => {
65
+ // stdin may have ended while nothing listened, between two questions.
66
+ if (stdin.readableEnded)
67
+ markEnded();
68
+ if (!input.lines.length && !input.ended)
69
+ return false;
70
+ stdin.off('data', onData).off('end', onEnd).pause();
71
+ resolve(input.lines.shift() ?? null);
72
+ return true;
73
+ };
74
+ const onData = (chunk) => {
75
+ const parts = (input.partial + String(chunk)).split(/\r?\n/);
76
+ input.partial = parts.pop();
77
+ input.lines.push(...parts);
78
+ settle();
79
+ };
80
+ const onEnd = () => {
81
+ markEnded();
82
+ settle();
83
+ };
84
+ if (!settle())
85
+ stdin.on('data', onData).on('end', onEnd).resume();
86
+ });
87
+ }
88
+ /** One line typed at a terminal without echoing it. */
89
+ async function hiddenLine() {
90
+ const rl = createInterface({ input: process.stdin, output: new Writable({ write: (_chunk, _encoding, done) => done() }), terminal: true });
91
+ try {
92
+ return await new Promise((resolve) => {
93
+ rl.once('line', resolve).once('close', () => resolve(null));
94
+ // In raw mode Ctrl+C reaches readline, not the process: pass it on so it still stops the command.
95
+ rl.once('SIGINT', () => {
96
+ rl.close();
97
+ process.kill(process.pid, 'SIGINT');
98
+ });
99
+ });
100
+ }
101
+ finally {
102
+ rl.close();
103
+ process.stdout.write('\n');
104
+ }
105
+ }
106
+ async function askOnStdin(q) {
107
+ const out = process.stdout;
108
+ if (q.kind === 'confirm') {
109
+ out.write(`\n${q.title ? `${q.title}\n` : ''}${q.hint ? `${q.hint}\n` : ''}${q.prompt}\n`);
110
+ const how = `Type y to ${(q.confirmLabel ?? 'continue').toLowerCase()}, or Enter to ${(q.skipLabel ?? 'skip').toLowerCase()}:`;
111
+ out.write(`${how} `);
112
+ for (let line = await nextLine(); line !== null; line = await nextLine()) {
113
+ if (/^y(es)?$/i.test(line.trim()))
114
+ return { skipped: false };
115
+ if (!line.trim())
116
+ return { skipped: true };
117
+ out.write(`That is not an answer to this one. ${how} `);
118
+ }
119
+ return { skipped: true };
120
+ }
121
+ if (q.kind === 'account') {
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})` : ''}: `);
124
+ const username = (await nextLine())?.trim() || q.prefill;
125
+ if (!username)
126
+ return { skipped: true };
127
+ out.write('Password, or Enter to skip: ');
128
+ const password = await (process.stdin.isTTY ? hiddenLine : nextLine)();
129
+ return password ? { skipped: false, username, password } : { skipped: true };
130
+ }
131
+ 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
+ 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 sensitive answer typed at a terminal is not echoed.
135
+ const read = q.sensitive && process.stdin.isTTY ? hiddenLine : nextLine;
136
+ out.write(how ? `${how} ` : '');
137
+ for (let line = await read(); line !== null; line = await read()) {
138
+ const answer = answerOf(q, line);
139
+ if (answer)
140
+ return answer;
141
+ out.write(`That is not an answer to this one. ${how} `);
142
+ }
143
+ // Nobody is there to answer.
144
+ return { skipped: true };
145
+ }
@@ -0,0 +1,62 @@
1
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { stripTerminalSequences } from '@earendil-works/pi-tui';
4
+ import { workStarted } from '../stages.js';
5
+ /** The state a screen shows; the blank moment between two screens keeps the state before it. */
6
+ export function frameState(scene, question, before = 'start') {
7
+ switch (scene.kind) {
8
+ case 'wait':
9
+ return before;
10
+ case 'intro':
11
+ return 'start';
12
+ case 'signin':
13
+ return 'sign-in';
14
+ case 'pull':
15
+ return 'test-drive';
16
+ case 'outro':
17
+ return scene.outro.outcome === 'stopped' ? 'stop' : 'done';
18
+ case 'run':
19
+ if (question)
20
+ return 'question';
21
+ return workStarted(scene.tasks) ? 'working' : 'understanding';
22
+ }
23
+ }
24
+ /** Readable only by the person, like the debug log; a file left by an earlier run into the same folder is made so too. */
25
+ const write = (path, text) => {
26
+ writeFileSync(path, text, { mode: 0o600 });
27
+ chmodSync(path, 0o600);
28
+ };
29
+ const plain = (lines) => lines.map((line) => stripTerminalSequences(line).trimEnd()).join('\n');
30
+ /** Writes frames into `dir`; one it cannot write is skipped, since frames are not worth stopping the run for. */
31
+ export function createFrameWriter(dir, now = () => new Date()) {
32
+ const index = [];
33
+ let seen;
34
+ return {
35
+ get last() {
36
+ return index.at(-1)?.state;
37
+ },
38
+ record(state, lines, { question, still } = {}) {
39
+ const asked = state === 'question' && question ? { question: { id: question.id, title: question.title, prompt: question.prompt, kind: question.kind, options: question.options, sensitive: question.sensitive, skippable: question.skippable } } : {};
40
+ // A new question is a change even when it reads like the one before.
41
+ const key = `${state}\n${asked.question?.id ?? ''}\n${plain(still ?? lines)}`;
42
+ if (key === seen)
43
+ return;
44
+ seen = key;
45
+ const text = plain(lines);
46
+ const n = index.length + 1;
47
+ const name = `${String(n).padStart(3, '0')}-${state}`;
48
+ index.push({ n, state, at: now().toISOString(), ...asked });
49
+ try {
50
+ mkdirSync(dir, { recursive: true });
51
+ write(join(dir, `${name}.ansi`), `${lines.join('\n')}\n`);
52
+ write(join(dir, `${name}.txt`), `${text}\n`);
53
+ // Replaced whole, so a reader never sees half of it.
54
+ write(join(dir, 'index.json.tmp'), JSON.stringify(index, null, 2));
55
+ renameSync(join(dir, 'index.json.tmp'), join(dir, 'index.json'));
56
+ }
57
+ catch {
58
+ // Skipped.
59
+ }
60
+ },
61
+ };
62
+ }