pixel-perfect-kit 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 (53) hide show
  1. package/CLONE-ALOYOGA-HEADER.md +76 -0
  2. package/CLONE-ANY-HEADER.md +106 -0
  3. package/CLONE-ANY-SITE.md +120 -0
  4. package/DEVELOP.md +144 -0
  5. package/LAUNCH-PROMPT.md +66 -0
  6. package/LEARNINGS.md +398 -0
  7. package/LICENSE +21 -0
  8. package/PLAYBOOK.md +260 -0
  9. package/README.md +219 -0
  10. package/WORKFLOW.md +257 -0
  11. package/bin/ppk +4 -0
  12. package/harness/adopt.js +52 -0
  13. package/harness/agent-setup.js +55 -0
  14. package/harness/behavior-selftest.js +243 -0
  15. package/harness/benchmarks/README.md +34 -0
  16. package/harness/benchmarks/battery.js +86 -0
  17. package/harness/benchmarks/detection-power.js +75 -0
  18. package/harness/capture-build-selftest.js +134 -0
  19. package/harness/capture-build.js +323 -0
  20. package/harness/doctor-selftest.js +58 -0
  21. package/harness/doctor.js +85 -0
  22. package/harness/fixtures/01-backdrop-color.js +51 -0
  23. package/harness/fixtures/02-line-strut.js +53 -0
  24. package/harness/fixtures/03-compat-mode.js +44 -0
  25. package/harness/fixtures/README.md +31 -0
  26. package/harness/human-qa-selftest.js +201 -0
  27. package/harness/human-qa.js +406 -0
  28. package/harness/merge-snapshot-selftest.js +56 -0
  29. package/harness/new-target.js +101 -0
  30. package/harness/regression.js +50 -0
  31. package/harness/score.js +58 -0
  32. package/harness/serve.js +149 -0
  33. package/harness/setup-selftest.js +93 -0
  34. package/harness/setup.js +158 -0
  35. package/harness/tunnel-selftest.js +76 -0
  36. package/harness/tunnel.js +241 -0
  37. package/harness/workflow-selftest.js +211 -0
  38. package/harness/workflow.js +739 -0
  39. package/package.json +40 -0
  40. package/skill/ditto-finish/SKILL.md +52 -0
  41. package/skill/pixel-perfect-clone/SKILL.md +49 -0
  42. package/tools/RUNBOOK.md +228 -0
  43. package/tools/behavior-capture.js +307 -0
  44. package/tools/behavior-worksheet.js +82 -0
  45. package/tools/browser-capture.js +240 -0
  46. package/tools/cli-selftest.js +136 -0
  47. package/tools/extract-fonts.js +133 -0
  48. package/tools/extract-icons.js +255 -0
  49. package/tools/merge-snapshot.js +56 -0
  50. package/tools/pixel-diff.js +845 -0
  51. package/tools/reassemble.js +63 -0
  52. package/tools/selftest.js +67 -0
  53. package/tools/sink.js +80 -0
@@ -0,0 +1,58 @@
1
+ // harness/score.js <name> — score targets/<name>/{live,clone}.json and compare to the
2
+ // previous run, so "is this iteration better?" is a number, not a vibe.
3
+ //
4
+ // Emits a scorecard, appends it to targets/<name>/scores.jsonl, and prints the delta
5
+ // vs the last recorded run (visual fails ↓ = better). Also lists the current failing
6
+ // --visual rows (the fix list) and the strict structural count (document or fix).
7
+ const fs = require("fs"), path = require("path");
8
+ const { diffSnapshots } = require("../tools/pixel-diff.js");
9
+
10
+ const name = process.argv[2];
11
+ if (!name) { console.error("usage: ppk score <target-name>"); process.exit(1); }
12
+ // targets/ live in the user's current directory (WORK), not inside the installed kit.
13
+ const dir = path.join(process.cwd(), "targets", name);
14
+ const readJson = (f) => {
15
+ const p = path.join(dir, f);
16
+ if (!fs.existsSync(p)) { console.error(`missing ${name}/${f} — capture it first (RUNBOOK)`); process.exit(1); }
17
+ const txt = fs.readFileSync(p, "utf8");
18
+ if (/^\s*\[BLOCKED/.test(txt)) { console.error(`${name}/${f} holds a "[BLOCKED…]" automation sentinel, not a snapshot — re-capture via the sink/stash path (RUNBOOK).`); process.exit(1); }
19
+ try { return JSON.parse(txt); } catch (e) { console.error(`${name}/${f} is not valid JSON: ${e.message}. Re-capture it (a truncated/partial paste is the usual cause).`); process.exit(1); }
20
+ };
21
+
22
+ const live = readJson("live.json"), clone = readJson("clone.json");
23
+ const v = diffSnapshots(live, clone, { visual: true });
24
+ const s = diffSnapshots(live, clone, {});
25
+ const targets = Object.keys(live.elements || {}).length;
26
+
27
+ const widthMismatch = live.viewport && clone.viewport && live.viewport.width !== clone.viewport.width;
28
+ const score = {
29
+ ts: new Date().toISOString(),
30
+ targets,
31
+ visualComparisons: v.summary.comparisons, visualFails: v.summary.failures, visualOk: v.ok,
32
+ strictComparisons: s.summary.comparisons, strictFails: s.summary.failures,
33
+ widthMismatch: !!widthMismatch,
34
+ };
35
+
36
+ // previous run (last non-empty line of scores.jsonl)
37
+ const logPath = path.join(dir, "scores.jsonl");
38
+ const prevLines = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean) : [];
39
+ const prev = prevLines.length ? JSON.parse(prevLines[prevLines.length - 1]) : null;
40
+
41
+ const arrow = (cur, was) => was == null ? "" : cur < was ? ` ↓ ${was}→${cur} better` : cur > was ? ` ↑ ${was}→${cur} WORSE` : ` = ${cur} same`;
42
+
43
+ console.log(`\nscorecard — ${name} (${targets} targets, width ${clone.viewport && clone.viewport.width})`);
44
+ if (widthMismatch) console.log(`⚠ viewport widths differ (${live.viewport.width} vs ${clone.viewport.width}) — x-positions not comparable. Re-measure both at the same width.`);
45
+ console.log(` --visual ${v.ok ? "PASS" : "FAIL"} fails ${score.visualFails}/${score.visualComparisons}${arrow(score.visualFails, prev && prev.visualFails)}`);
46
+ console.log(` strict fails ${score.strictFails}/${score.strictComparisons}${arrow(score.strictFails, prev && prev.strictFails)} (structural → fix or document)`);
47
+
48
+ // the fix list — current --visual failures
49
+ const vFails = v.rows.filter((r) => !r.pass);
50
+ if (vFails.length) {
51
+ console.log(`\n --visual fix list:`);
52
+ for (const r of vFails) console.log(` ${r.target.padEnd(14)} ${String(r.prop).padEnd(20)} live=${r.live} clone=${r.clone} Δ=${r.delta}`);
53
+ }
54
+
55
+ fs.appendFileSync(logPath, JSON.stringify(score) + "\n");
56
+ console.log(`\nrecorded to targets/${name}/scores.jsonl (${prevLines.length + 1} runs)`);
57
+ if (v.ok) console.log(`✓ --visual green. Next: close coverage (every painted leaf has a target), then read the strict table for colour/underline rows.`);
58
+ process.exit(v.ok ? 0 : 1);
@@ -0,0 +1,149 @@
1
+ // harness/serve.js <target-name> [port] — static server for one target's clone.
2
+ //
3
+ // Serves targets/<name>/clone/ at / AND the kit's tools/ at /tools/, so the clone
4
+ // page can `fetch('/tools/browser-capture.js')` (single source of truth — no copy to
5
+ // keep in sync). Zero deps. Pair with `node tools/sink.js` to receive snapshots.
6
+ const http = require("http"), fs = require("fs"), path = require("path");
7
+ const MIME = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript", ".json": "application/json", ".woff2": "font/woff2", ".woff": "font/woff", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" };
8
+
9
+ // Resolve a request URL to a real file path WITHIN one of the allowed roots, or null.
10
+ // The boundary check uses path.relative (not startsWith), so a sibling prefix like
11
+ // `<clone>-evil` or any `../` traversal is rejected — matching the canonical
12
+ // workspace-boundary guard the kit's diff tools rely on. Pure + exported so it's unit-tested
13
+ // without opening a socket.
14
+ function resolvePath(urlPath, { cloneDir, toolsDir }) {
15
+ // A malformed percent-encoding ("/%", "/%zz") throws URIError; uncaught inside the request
16
+ // handler it would kill the whole server mid-session — treat it as unresolvable instead.
17
+ let u;
18
+ try { u = decodeURIComponent(urlPath.split("?")[0]); } catch (e) { return null; }
19
+ if (u === "/") u = "/index.html";
20
+ const base = u.startsWith("/tools/") ? toolsDir : cloneDir;
21
+ const rel = u.startsWith("/tools/") ? u.slice(7) : u.slice(1);
22
+ const fp = path.resolve(base, rel);
23
+ const within = path.relative(base, fp);
24
+ if (within.startsWith("..") || path.isAbsolute(within)) return null; // traversal / prefix escape
25
+ return fp;
26
+ }
27
+
28
+ // ── LOCAL review mode (/__review) ─────────────────────────────────────────────
29
+ // The no-PingHumans path: the review page renders the latest LOCAL round's spec (same
30
+ // scope-pin/steps/changelog the marketplace test would carry), shows the clone in a
31
+ // same-origin iframe with click-to-pin, and enforces the SAME contract — a verdict
32
+ // button pick is mandatory; comments alone can't pass the gate. The submission is
33
+ // written into human-qa.json where `human-qa.js verify` reads it. Trust model: local
34
+ // verdicts are operator-trusted and recorded as provider:"local" — agents never open
35
+ // or submit this page themselves.
36
+ function latestLocalRound(hq) {
37
+ const r = (hq && hq.rounds || [])[Math.max(0, (hq.rounds || []).length - 1)];
38
+ return r && r.provider === "local" ? r : null;
39
+ }
40
+
41
+ // Pure: apply a review submission to the hq state. Returns {ok, message}.
42
+ function applySubmission(hq, body) {
43
+ const round = latestLocalRound(hq);
44
+ if (!round) return { ok: false, message: "no LOCAL round is latest — file one: ppk human <name> file --local" };
45
+ if (round.raw_response && round.raw_response.n_received) return { ok: false, message: "this round is already answered — file a new round for another review" };
46
+ if (!body || (body.choice == null && !(body.comments || []).length && !(body.free_text || "").trim())) return { ok: false, message: "empty submission" };
47
+ const comments = (body.comments || []).filter((c) => c && c.text);
48
+ const free = comments.length
49
+ ? `${comments.length} comment(s): ` + comments.map((c) => `<${c.selector || "?"}> — ${c.text}`).join(" | ") + ((body.free_text || "").trim() ? " | " + body.free_text.trim() : "")
50
+ : (body.free_text || "").trim() || null;
51
+ round.raw_response = { status: "complete", n_received: 1, n_target: 1, responses: [{ choice: body.choice != null ? body.choice : null, free_text: free }], comments };
52
+ round.answered_at = new Date().toISOString();
53
+ return { ok: true, message: `recorded — verdict: ${body.choice != null ? JSON.stringify(body.choice) : "NONE (comments only — the gate will refuse; pick a verdict button)"}` };
54
+ }
55
+
56
+ function renderReviewPage(hq, name) {
57
+ const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
58
+ const round = latestLocalRound(hq);
59
+ if (!round) return `<!doctype html><meta charset="utf-8"><body style="font:16px system-ui;padding:2rem">No local review round is pending for <b>${esc(name)}</b>.<br>File one: <code>ppk human ${esc(name)} file --local</code></body>`;
60
+ if (round.raw_response && round.raw_response.n_received) return `<!doctype html><meta charset="utf-8"><body style="font:16px system-ui;padding:2rem">This round is already answered (${esc((round.raw_response.responses[0] || {}).choice || "no verdict")}).<br>File a new round for another review.</body>`;
61
+ const spec = round.spec || { title: "Review", instructions: "", steps: [], verdict_options: round.approve_verdicts || ["Approve"] };
62
+ return `<!doctype html><meta charset="utf-8"><title>${esc(spec.title)}</title>
63
+ <body style="margin:0;font:14px/1.45 system-ui;display:grid;grid-template-columns:minmax(340px,28%) 1fr;height:100vh">
64
+ <div style="overflow:auto;padding:16px;border-right:1px solid #ddd;background:#fafafa">
65
+ <h2 style="margin:0 0 8px;font-size:16px">${esc(spec.title)} <span style="color:#888;font-weight:normal">(local review)</span></h2>
66
+ <p style="color:#444">${esc(spec.instructions)}</p>
67
+ <ol style="padding-left:18px;color:#333">${(spec.steps || []).map((s) => `<li style="margin:6px 0">${esc(s.text)}</li>`).join("")}</ol>
68
+ <p><button id="pin" style="padding:6px 10px">📌 Pin mode: OFF</button> <span style="color:#888">— toggle, then click anything in the clone that looks wrong</span></p>
69
+ <ul id="pins" style="padding-left:18px"></ul>
70
+ <p><textarea id="free" placeholder="anything else?" style="width:100%;height:56px"></textarea></p>
71
+ <p><b>Verdict (required):</b><br>${(spec.verdict_options || []).map((v) => `<label style="display:block;margin:4px 0"><input type="radio" name="v" value="${esc(v)}"> ${esc(v)}</label>`).join("")}</p>
72
+ <p><button id="go" style="padding:8px 14px;font-weight:600">Submit review</button> <span id="msg" style="color:#c00"></span></p>
73
+ </div>
74
+ <iframe id="clone" src="/" style="border:0;width:100%;height:100%"></iframe>
75
+ <script>
76
+ var pins=[],mode=false;
77
+ var pinBtn=document.getElementById('pin');
78
+ pinBtn.onclick=function(){mode=!mode;pinBtn.textContent='📌 Pin mode: '+(mode?'ON':'OFF')};
79
+ document.getElementById('clone').addEventListener('load',function(){
80
+ var doc=this.contentDocument;
81
+ doc.addEventListener('click',function(e){
82
+ if(!mode)return;e.preventDefault();e.stopPropagation();
83
+ var el=e.target;
84
+ var sel=el.id?('#'+el.id):(el.tagName.toLowerCase()+(el.className&&typeof el.className==='string'?'.'+el.className.trim().split(/\\s+/).slice(0,2).join('.'):''));
85
+ var txt=prompt('What looks wrong here? ('+sel+')');
86
+ if(txt){pins.push({selector:sel,text:txt});
87
+ var li=document.createElement('li');li.textContent=sel+' — '+txt;document.getElementById('pins').appendChild(li);}
88
+ },true);
89
+ });
90
+ document.getElementById('go').onclick=function(){
91
+ var v=document.querySelector('input[name=v]:checked');
92
+ if(!v){document.getElementById('msg').textContent='pick a verdict button — comments alone cannot pass the gate';return;}
93
+ fetch('/__review/submit',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({choice:v.value,free_text:document.getElementById('free').value,comments:pins})})
94
+ .then(r=>r.json()).then(function(r){document.body.innerHTML='<div style="font:16px system-ui;padding:2rem">'+(r.ok?'✓ recorded. You can close this tab — the agent picks it up via verify.':'❌ '+r.message)+'</div>'});
95
+ };
96
+ </script></body>`;
97
+ }
98
+
99
+ function serve(name, port) {
100
+ const PKG = path.resolve(__dirname, ".."); // the installed kit: /tools/* served from here
101
+ const WORK = process.cwd(); // the user's dir: targets/<name>/clone lives here
102
+ const cloneDir = path.join(WORK, "targets", name, "clone");
103
+ const toolsDir = path.join(PKG, "tools");
104
+ const hqPath = path.join(WORK, "targets", name, "human-qa.json");
105
+ const readHq = () => { try { return JSON.parse(fs.readFileSync(hqPath, "utf8")); } catch (e) { return { rounds: [] }; } };
106
+ if (!fs.existsSync(cloneDir)) { console.error(`no targets/${name}/clone — run: ppk new ${name} <url>`); process.exit(1); }
107
+ http.createServer((req, res) => {
108
+ const u = req.url.split("?")[0];
109
+ if (u === "/__review" && req.method === "GET") {
110
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
111
+ res.end(renderReviewPage(readHq(), name));
112
+ return;
113
+ }
114
+ if (u === "/__review/submit" && req.method === "POST") {
115
+ let body = "";
116
+ req.on("data", (d) => { body += d; if (body.length > 1e6) req.destroy(); });
117
+ req.on("end", () => {
118
+ let parsed = null;
119
+ try { parsed = JSON.parse(body); } catch (e) {}
120
+ const hq = readHq();
121
+ const r = applySubmission(hq, parsed);
122
+ if (r.ok) fs.writeFileSync(hqPath, JSON.stringify(hq, null, 2) + "\n");
123
+ console.log(`local review submission: ${r.message}`);
124
+ res.writeHead(r.ok ? 200 : 400, { "content-type": "application/json" });
125
+ res.end(JSON.stringify(r));
126
+ });
127
+ return;
128
+ }
129
+ const fp = resolvePath(req.url, { cloneDir, toolsDir });
130
+ if (!fp) { res.writeHead(403); res.end("403 forbidden"); return; }
131
+ fs.readFile(fp, (e, buf) => {
132
+ if (e) { res.writeHead(404); res.end("404 " + req.url); return; }
133
+ res.writeHead(200, { "content-type": MIME[path.extname(fp)] || "application/octet-stream", "access-control-allow-origin": "*" });
134
+ res.end(buf);
135
+ });
136
+ }).on("error", (e) => {
137
+ // self-describing, actionable — never a raw stack for a predictable failure
138
+ if (e.code === "EADDRINUSE") { console.error(`port ${port} is already in use — pass another: ppk serve ${name} <port> (e.g. ${port + 1})`); process.exit(1); }
139
+ console.error(`serve failed: ${e.message}`);
140
+ process.exit(1);
141
+ }).listen(port, () => console.log(`serving targets/${name}/clone → http://localhost:${port} (/tools/* → kit tools)`));
142
+ }
143
+
144
+ if (require.main === module) {
145
+ const name = process.argv[2], port = +(process.argv[3] || 8080);
146
+ if (!name) { console.error("usage: ppk serve <target-name> [port]"); process.exit(1); }
147
+ serve(name, port);
148
+ }
149
+ module.exports = { resolvePath, serve, applySubmission, renderReviewPage };
@@ -0,0 +1,93 @@
1
+ // harness/setup-selftest.js — guards the one-command onboarding (harness/setup.js).
2
+ // Fully offline: setup() takes an injectable io, so every prompt path is driven with
3
+ // scripted answers and a fake probe map; run() calls are recorded, never executed.
4
+ "use strict";
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const { setup, saidYes } = require("./setup.js");
9
+
10
+ let failed = 0;
11
+ const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
12
+
13
+ console.log("setup-selftest — one-command onboarding");
14
+
15
+ // consent semantics: Enter is yes ONLY on a real terminal; non-TTY silence is never consent
16
+ ok(saidYes("y", false) && saidYes("yes", true) && saidYes("", true), "y / yes / Enter-on-TTY are consent");
17
+ ok(!saidYes("", false) && !saidYes("n", true), "non-TTY silence and 'n' are NOT consent");
18
+
19
+ function fakeIO({ probes, answers, tty, paths }) {
20
+ const logs = [], runs = [];
21
+ let i = 0;
22
+ return {
23
+ io: {
24
+ isTTY: tty !== false,
25
+ log: (...a) => logs.push(a.join(" ")),
26
+ run: (cmd, args) => runs.push([cmd, ...args].join(" ")),
27
+ probe: (cmd) => !!probes[cmd],
28
+ which: (cmd) => (paths && paths[cmd]) || null,
29
+ ask: () => Promise.resolve(answers[i++] != null ? answers[i++ - 1] : ""),
30
+ },
31
+ logs, runs,
32
+ };
33
+ }
34
+
35
+ (async () => {
36
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-setup-"));
37
+
38
+ // ── fresh machine (true npx first-run), user consents to everything ──────────
39
+ // `which ppk` resolves to npx's EPHEMERAL bin — that must NOT count as installed
40
+ // (found live: the bare probe said "already installed" during the npx run itself)
41
+ {
42
+ const { io, logs, runs } = fakeIO({ probes: { brew: true }, answers: ["", "", ""], paths: { ppk: "/Users/x/.npm/_npx/abc123/node_modules/.bin/ppk" } });
43
+ const r = await setup(io, { home, sourceCheckout: false, resolveToken: () => null, dittoApiKey: false });
44
+ ok(r.ok, "fresh-machine run completes");
45
+ ok(runs.includes("npm i -g pixel-perfect-kit"), "npx's ephemeral bin doesn't count as installed — global install prompt fires and runs on consent");
46
+ ok(runs.includes("brew install cloudflared"), "installs cloudflared via brew on consent");
47
+ ok(runs.includes("npx cpyany setup"), "runs the PingHumans device login on consent");
48
+ ok(logs.some((l) => /ditto \(optional fast builder\): connect its MCP/.test(l)), "ditto guidance is MCP/API-key based — never a binary probe (macOS ships /usr/bin/ditto, a guaranteed false positive)");
49
+ ok(logs.some((l) => /taught your AI agent: .*pixel-perfect-clone/.test(l)), "installs the agent skills");
50
+ ok(fs.existsSync(path.join(home, ".claude", "skills", "pixel-perfect-clone", "SKILL.md")), "skills really land in the fake HOME");
51
+ ok(logs.some((l) => /Clone https:\/\/example\.com pixel-perfect/.test(l)) && logs.some((l) => /with ditto/.test(l)), "summary teaches both agent sentences");
52
+ }
53
+
54
+ // ── a real global install IS recognized; a DITTO_API_KEY is reported ─────────
55
+ {
56
+ const { io, logs, runs } = fakeIO({ probes: { cloudflared: true }, answers: [], paths: { ppk: "/usr/local/bin/ppk" } });
57
+ const r = await setup(io, { home, sourceCheckout: false, resolveToken: () => "tok", dittoApiKey: true });
58
+ ok(r.ok && runs.length === 0 && logs.some((l) => /already installed globally/.test(l)), "a persistent global bin counts as installed (no prompt)");
59
+ ok(logs.some((l) => /DITTO_API_KEY found/.test(l)), "a configured ditto API key is reported");
60
+ }
61
+
62
+ // ── skip-everything path still ends usable (local review mode) ────────────────
63
+ {
64
+ const { io, logs, runs } = fakeIO({ probes: {}, answers: ["n", "n", "n"] });
65
+ const r = await setup(io, { home: fs.mkdtempSync(path.join(os.tmpdir(), "ppk-setup2-")), sourceCheckout: false, resolveToken: () => null, dittoApiKey: false });
66
+ ok(r.ok && runs.length === 0, "declining every prompt runs nothing");
67
+ ok(logs.some((l) => /LOCAL review mode/.test(l)) && logs.some((l) => /file --local/.test(l)), "skip path points at local review mode explicitly");
68
+ }
69
+
70
+ // ── unattended (non-TTY): silence never installs or opens logins ─────────────
71
+ {
72
+ const { io, runs } = fakeIO({ probes: { brew: true }, answers: [], tty: false });
73
+ const r = await setup(io, { home: fs.mkdtempSync(path.join(os.tmpdir(), "ppk-setup3-")), sourceCheckout: false, resolveToken: () => null, dittoApiKey: false });
74
+ ok(r.ok && runs.length === 0, "non-TTY run never executes installers (silence is not consent)");
75
+ }
76
+
77
+ // ── source checkout + everything already present = pure no-op re-run ─────────
78
+ {
79
+ const { io, logs, runs } = fakeIO({ probes: { cloudflared: true }, answers: [] });
80
+ const r = await setup(io, { home, sourceCheckout: true, resolveToken: () => "tok", dittoApiKey: false });
81
+ ok(r.ok && runs.length === 0, "idempotent re-run: probes pass, nothing executes");
82
+ ok(logs.some((l) => /source checkout/.test(l)) && logs.some((l) => /login found/.test(l)), "re-run reports present state (checkout copy, login)");
83
+ ok(r.steps.includes("skills-present"), "already-installed skills are kept, not overwritten");
84
+ }
85
+
86
+ // ── old node fails fast with the fix ──────────────────────────────────────────
87
+ // (checkable only via the saidYes/steps contract — setup reads the REAL process
88
+ // version, so we assert the guard's presence in source rather than simulate it)
89
+ ok(/needs Node 18\+/.test(fs.readFileSync(path.join(__dirname, "setup.js"), "utf8")), "old-node guard exists with the fix text");
90
+
91
+ console.log(failed ? `\n❌ setup-selftest: ${failed} assertion(s) failed.` : "\n✓ setup-selftest: all assertions pass.");
92
+ process.exit(failed ? 1 : 0);
93
+ })();
@@ -0,0 +1,158 @@
1
+ // harness/setup.js — `npx pixel-perfect-kit setup` / `ppk setup`: the one-command onboarding.
2
+ //
3
+ // Everything a newcomer needs, in one interactive pass: global install (when run via
4
+ // npx), cloudflared (offered via brew), the PingHumans device login (skippable — LOCAL
5
+ // review mode needs no account), the optional ditto fast-builder check, and the agent
6
+ // skills. Interactive steps CANNOT live in npm postinstall (silenced, breaks CI), which
7
+ // is why this is an explicit command. Idempotent: every step probes before acting, so
8
+ // re-running it is always safe. `ppk doctor` remains the read-only re-check.
9
+ //
10
+ // USAGE: npx pixel-perfect-kit setup (first contact — nothing else installed)
11
+ // ppk setup (re-run anytime)
12
+ "use strict";
13
+
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const path = require("path");
17
+ const readline = require("readline");
18
+ const { spawnSync } = require("child_process");
19
+
20
+ const PKG = path.resolve(__dirname, "..");
21
+
22
+ // io is injectable so the selftest can drive every prompt path offline:
23
+ // probe(cmd,args) -> bool run(cmd,args) -> void (stdio inherit)
24
+ // ask(q) -> Promise<string> (lowercased answer; "" = Enter) isTTY, log
25
+ function defaultIO() {
26
+ return {
27
+ isTTY: !!process.stdin.isTTY,
28
+ log: (...a) => console.log(...a),
29
+ run: (cmd, args) => spawnSync(cmd, args, { stdio: "inherit" }),
30
+ probe: (cmd, args) => {
31
+ try {
32
+ const r = spawnSync(cmd, args, { stdio: "pipe", timeout: 10_000 });
33
+ return !r.error && (r.status === 0 || !!((r.stdout && r.stdout.length) || (r.stderr && r.stderr.length)));
34
+ } catch (e) { return false; }
35
+ },
36
+ // Resolve WHERE a command lives, not just whether something answers — found live in
37
+ // the fresh-machine test: npx injects the ephemeral package's own bin into PATH, so a
38
+ // bare probe says "ppk already installed" during the one run where the global install
39
+ // matters most (the npx cache evicts and ppk vanishes).
40
+ which: (cmd) => {
41
+ try {
42
+ const r = spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe", timeout: 10_000 });
43
+ const p = String(r.stdout || "").trim();
44
+ return r.status === 0 && p ? p : null;
45
+ } catch (e) { return null; }
46
+ },
47
+ ask: (q) =>
48
+ new Promise((res) => {
49
+ if (!process.stdin.isTTY) return res("");
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ rl.question(q, (a) => { rl.close(); res(a.trim().toLowerCase()); });
52
+ }),
53
+ };
54
+ }
55
+
56
+ // An npx-provided bin is EPHEMERAL, not an install (lives in the npx cache).
57
+ const isPersistentInstall = (binPath) => !!binPath && !/[\\/]_npx[\\/]/.test(binPath);
58
+
59
+ // "yes" = explicit y, or Enter on a real terminal (the [Y/n] default). A non-TTY ""
60
+ // is NOT consent — unattended runs must never install or open logins on their own.
61
+ const saidYes = (answer, isTTY) => answer === "y" || answer === "yes" || (answer === "" && isTTY);
62
+
63
+ async function setup(io, opts) {
64
+ const steps = [];
65
+ io.log("pixel-perfect-kit setup\n─────────────────────────");
66
+
67
+ // 1. node — the only hard requirement for anything at all
68
+ const major = parseInt(process.versions.node, 10);
69
+ if (major < 18) {
70
+ io.log(`❌ node ${process.versions.node} — the kit needs Node 18+ (https://nodejs.org). Fix that first, then re-run.`);
71
+ return { ok: false, steps: ["node-fail"] };
72
+ }
73
+ io.log(`✓ node ${process.versions.node}`);
74
+
75
+ // 2. ppk on PATH — when run via npx there is no PERSISTENT install yet (npx's own
76
+ // ephemeral bin must not count, or the prompt never fires in the npx first-run)
77
+ const ppkPath = io.which("ppk");
78
+ if (opts.sourceCheckout) {
79
+ io.log("✓ running from a source checkout — using this copy (skipping global install)");
80
+ steps.push("global-skip-checkout");
81
+ } else if (isPersistentInstall(ppkPath)) {
82
+ io.log("✓ ppk already installed globally");
83
+ steps.push("global-present");
84
+ } else if (saidYes(await io.ask("ppk isn't installed globally yet — install now? (npm i -g pixel-perfect-kit) [Y/n] "), io.isTTY)) {
85
+ io.run("npm", ["i", "-g", "pixel-perfect-kit"]);
86
+ io.log("✓ installed ppk globally");
87
+ steps.push("global-installed");
88
+ } else {
89
+ io.log("⚠ skipped — the commands below assume `ppk` is on PATH");
90
+ steps.push("global-skipped");
91
+ }
92
+
93
+ // 3. cloudflared — marketplace review tunnels; LOCAL mode needs none
94
+ if (io.probe("cloudflared", ["--version"])) {
95
+ io.log("✓ cloudflared");
96
+ steps.push("cloudflared-present");
97
+ } else if (io.probe("brew", ["--version"]) && saidYes(await io.ask("cloudflared not found (public tunnels for marketplace review) — install now? (brew install cloudflared) [Y/n] "), io.isTTY)) {
98
+ io.run("brew", ["install", "cloudflared"]);
99
+ steps.push("cloudflared-installed");
100
+ } else {
101
+ io.log("⚠ cloudflared not installed — marketplace review needs it; LOCAL review mode works without it.\n install later: brew install cloudflared (or developers.cloudflare.com/cloudflared)");
102
+ steps.push("cloudflared-skipped");
103
+ }
104
+
105
+ // 4. PingHumans login — marketplace reviewers; skippable by design
106
+ let token = null;
107
+ try { token = opts.resolveToken(); } catch (e) {}
108
+ if (token) {
109
+ io.log("✓ PingHumans login found");
110
+ steps.push("login-present");
111
+ } else if (saidYes(await io.ask("PingHumans login (marketplace review rounds, small credits) — log in now? [Y/n] "), io.isTTY)) {
112
+ io.run("npx", ["cpyany", "setup"]);
113
+ steps.push("login-run");
114
+ } else {
115
+ io.log("⚠ skipped — LOCAL review mode needs no account: your agent files rounds with\n `ppk human <name> file --local` and you review at http://localhost:8080/__review");
116
+ steps.push("login-skipped");
117
+ }
118
+
119
+ // 5. ditto — optional fast builder. NOT a binary probe: macOS ships /usr/bin/ditto
120
+ // (Apple's file copier — a guaranteed false positive), and ditto.site is reached via
121
+ // its MCP server or REST API (DITTO_API_KEY) anyway, per the ditto-finish skill.
122
+ if (opts.dittoApiKey) {
123
+ io.log("✓ DITTO_API_KEY found (ditto fast-builder path available)");
124
+ steps.push("ditto-key-present");
125
+ } else {
126
+ io.log("ℹ ditto (optional fast builder): connect its MCP server in your agent or set DITTO_API_KEY — the full ppk pipeline works without it");
127
+ steps.push("ditto-unconfigured");
128
+ }
129
+
130
+ // 6. teach the agent — install every skill the kit ships
131
+ const r = require("./agent-setup.js").install(opts.home, false);
132
+ io.log(r.ok ? `✓ taught your AI agent: ${r.installed.join(", ")}` : `✓ agent skills already installed (${r.message.split("\n")[0].replace(/^already installed \(|\).*$/g, "")})`);
133
+ steps.push(r.ok ? "skills-installed" : "skills-present");
134
+
135
+ io.log(`
136
+ ─────────────────────────
137
+ Done. Open your AI agent and say:
138
+
139
+ "Clone https://example.com pixel-perfect."
140
+ or "Clone https://example.com with ditto and polish it."
141
+
142
+ You'll review the results: pin what looks wrong, pick a verdict button.
143
+ (re-check anytime: ppk doctor)`);
144
+ return { ok: true, steps };
145
+ }
146
+
147
+ function main() {
148
+ const { resolveToken } = require("./human-qa.js");
149
+ setup(defaultIO(), {
150
+ home: os.homedir(),
151
+ sourceCheckout: fs.existsSync(path.join(PKG, ".git")),
152
+ resolveToken,
153
+ dittoApiKey: !!process.env.DITTO_API_KEY,
154
+ }).then((r) => process.exit(r.ok ? 0 : 1));
155
+ }
156
+
157
+ if (require.main === module) main();
158
+ module.exports = { setup, saidYes };
@@ -0,0 +1,76 @@
1
+ // harness/tunnel-selftest.js — guards the verified-tunnel tool (harness/tunnel.js).
2
+ // The lesson it locks in: a human test filed against a dead or wrong tunnel burns a whole
3
+ // QA round (hn round 1: "clone unreachable — tunnel died"), so "the tunnel serves the
4
+ // clone" must be a CHECKED fact. Tests the pure halves offline (file:// — socket-free):
5
+ // - parseTunnelUrl finds the quick-tunnel url in cloudflared's log noise
6
+ // - verifyServes: byte-identical → ok; different bytes → named mismatch; missing → unreachable
7
+ // - human-qa file-time integration: no tunnel.json + no --draft → refusal names tunnel.js
8
+ // Run: node harness/tunnel-selftest.js (regression.js runs it too)
9
+ "use strict";
10
+
11
+ const fs = require("fs");
12
+ const os = require("os");
13
+ const path = require("path");
14
+ const { execFileSync } = require("child_process");
15
+ const { pathToFileURL } = require("url");
16
+ const { parseTunnelUrl, verifyServes, looksLikeSink } = require("./tunnel.js");
17
+
18
+ let failed = 0;
19
+ const check = (label, ok, detail) => {
20
+ console.log(`${ok ? "✓" : "✗"} ${label}${ok || !detail ? "" : ` — ${detail}`}`);
21
+ if (!ok) failed++;
22
+ };
23
+
24
+ // ── parseTunnelUrl ────────────────────────────────────────────────────────────
25
+ const LOG = `2026-07-02T20:00:01Z INF Thank you for trying Cloudflare Tunnel.
26
+ 2026-07-02T20:00:02Z INF +--------------------------------------------------------------+
27
+ 2026-07-02T20:00:02Z INF | Your quick Tunnel has been created! Visit it at: |
28
+ 2026-07-02T20:00:02Z INF | https://engines-pad-firewire-investing.trycloudflare.com |
29
+ 2026-07-02T20:00:02Z INF +--------------------------------------------------------------+`;
30
+ check("parses the quick-tunnel url out of log noise", parseTunnelUrl(LOG) === "https://engines-pad-firewire-investing.trycloudflare.com");
31
+ check("no url in log → null (keeps waiting, no garbage match)", parseTunnelUrl("INF Starting tunnel connection...") === null);
32
+
33
+ // ── the sink signature (--sink mode verifies delivery through the tunnel by provoking the
34
+ // sink's distinctive empty-POST reply — not by GET/byte-compare, the sink serves nothing) ──
35
+ check("400 + 'empty body' IS the sink", looksLikeSink(400, "empty body for pxprobe.json — capture returned nothing (the finder/injection likely failed)."));
36
+ check("200 from some other server is NOT the sink", !looksLikeSink(200, "ok"));
37
+ check("a 400 with different text is NOT the sink", !looksLikeSink(400, "Bad Request"));
38
+
39
+ // ── verifyServes (file:// — offline) ─────────────────────────────────────────
40
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-tunnel-"));
41
+ const idx = path.join(work, "index.html");
42
+ fs.writeFileSync(idx, "<html><body>the clone</body></html>");
43
+ const twin = path.join(work, "twin.html");
44
+ fs.writeFileSync(twin, "<html><body>the clone</body></html>");
45
+ const other = path.join(work, "other.html");
46
+ fs.writeFileSync(other, "<html><body>something else entirely</body></html>");
47
+
48
+ (async () => {
49
+ const same = await verifyServes(pathToFileURL(twin).href, idx);
50
+ check("byte-identical content verifies ok (+sha recorded)", same.ok && /^[0-9a-f]{16}$/.test(same.sha256));
51
+ const diff = await verifyServes(pathToFileURL(other).href, idx);
52
+ check("different bytes → NOT ok, mismatch is named", !diff.ok && /NOT clone\/index\.html/.test(diff.reason));
53
+ const dead = await verifyServes(pathToFileURL(path.join(work, "missing.html")).href, idx);
54
+ check("missing/dead url → NOT ok, reported unreachable", !dead.ok && /unreachable/.test(dead.reason));
55
+
56
+ // ── human-qa integration: refusal path names the tunnel tool ───────────────
57
+ const tdir = path.join(work, "targets", "t1");
58
+ fs.mkdirSync(path.join(tdir, "clone"), { recursive: true });
59
+ fs.writeFileSync(path.join(tdir, "target.json"), JSON.stringify({ name: "t1", url: "https://example.com/", width: 1280 }));
60
+ fs.writeFileSync(path.join(tdir, "clone", "index.html"), "<html></html>");
61
+ let out = "", status = 0;
62
+ try { out = execFileSync("node", [path.join(__dirname, "human-qa.js"), "file", "t1"], { cwd: work, stdio: "pipe" }).toString(); }
63
+ catch (e) { status = e.status; out = (e.stdout || "").toString() + (e.stderr || "").toString(); }
64
+ check("human-qa file with no tunnel.json and no --draft refuses, pointing at tunnel.js", status === 1 && /tunnel\.js/.test(out));
65
+
66
+ // with a recorded tunnel.json pointing at DEAD content, file-time re-verify refuses
67
+ fs.writeFileSync(path.join(tdir, "tunnel.json"), JSON.stringify({ url: pathToFileURL(path.join(work, "gone.html")).href, port: 8080 }));
68
+ status = 0; out = "";
69
+ try { out = execFileSync("node", [path.join(__dirname, "human-qa.js"), "file", "t1"], { cwd: work, stdio: "pipe" }).toString(); }
70
+ catch (e) { status = e.status; out = (e.stdout || "").toString() + (e.stderr || "").toString(); }
71
+ check("human-qa file refuses when the recorded tunnel no longer serves (hn round 1)", status === 1 && /refusing to file/.test(out));
72
+
73
+ fs.rmSync(work, { recursive: true, force: true });
74
+ console.log(failed ? `\n❌ tunnel-selftest: ${failed} check(s) failed.` : "\n✓ tunnel-selftest: all checks pass.");
75
+ process.exit(failed ? 1 : 0);
76
+ })();