@fringetools/cli 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -23,6 +23,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
23
23
  |---|---|
24
24
  | `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
25
25
  | `fringe scan <url> [--states file] [--stress] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--json]` | **Runtime check of a live page** (needs `npm i -D playwright-core axe-core`; uses your installed Chrome). Runs axe-core for real contrast, computed names and ARIA relations (`FR-400`; critical/serious = `error`, the rest `review`), then walks the page with Tab and reports keyboard traps (`FR-401`, error) and controls Tab never reaches (review). Accepts `http(s)://` and `file://` URLs. Exit `1` on errors. |
26
+ | `fringe login <url> [--out file]` | **Log in once, by hand, in a real browser window** and save the session (default `.fringe/auth.json`) for `scan --storage-state`. Works with MFA and SSO. The file holds live cookies: keep it out of git. |
26
27
  | `fringe rules [dir] [--json]` | **Per-rule health for this repo**: findings still open, accepted into the baseline, and silenced with `fringe-ignore`, plus the ledger trend when `--record` has been used. A rule that is mostly overridden here is flagged: it may not suit the repo, so retune, disable or report it. `fringe/rules.v1`. |
27
28
  | `fringe handoff [dir\|file] [--scan url]` | **Work order for another agent or CI.** Prints a self-contained prompt (rules, definition of done, the ordered plan) and saves `.fringe/handoff.json` (`fringe/handoff.v1`). `--json` prints the file; `--out` chooses where. |
28
29
  | `fringe verify [--handoff file] [--json]` | **Proof it was done.** Re-checks everything the hand-off covered and reports `complete` / `partial` / `regressed`: errors fixed, remaining, introduced, and how many findings were silenced with `fringe-ignore` since. Exit `0` only when `complete` (`fringe/verify.v1`). |
@@ -108,9 +109,54 @@ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a sub
108
109
 
109
110
  Each mode runs on a fresh page so one mode's emulation never leaks into the next, and only findings the plain page did not already have are kept. Findings are labelled `[dark mode]`, `[reduced motion]`, `[reflow]`. Modes apply to the initial page, not to named states.
110
111
 
111
- ### Pages behind a login, screenshots, and a baseline for scan findings
112
+ ### Pages behind a login
113
+
114
+ Most apps hide the interesting pages behind a sign-in, and a scan that gets bounced to the login screen audits the wrong page. Three ways to be logged in; pick the one that fits.
115
+
116
+ **1. Log in once by hand (works with MFA, SSO, captchas).**
117
+
118
+ ```sh
119
+ npx @fringetools/cli login http://localhost:3000/login # a Chrome window opens
120
+ # sign in as you normally would, then press Enter in the terminal (close the window afterwards)
121
+ npx @fringetools/cli scan http://localhost:3000/dashboard --storage-state .fringe/auth.json
122
+ ```
123
+
124
+ The session (cookies and local storage) is saved to `.fringe/auth.json`. It is a live login: **keep it out of git** (the command warns if `.gitignore` does not list `.fringe/`) and expect it to expire.
125
+
126
+ **2. A scripted login (for CI and agents).** Describe the steps in `fringe.scan.json`; credentials come from environment variables, so none are stored in the file:
127
+
128
+ ```json
129
+ {
130
+ "login": {
131
+ "url": "/login",
132
+ "steps": [
133
+ { "fill": "#email", "value": "${env:FRINGE_EMAIL}" },
134
+ { "fill": "#password", "value": "${env:FRINGE_PASSWORD}" },
135
+ { "click": "button:has-text(\"Sign in\")" },
136
+ { "waitFor": "[data-test=dashboard]" }
137
+ ]
138
+ },
139
+ "states": [ { "name": "settings", "steps": [ { "goto": "/settings" } ] } ]
140
+ }
141
+ ```
142
+
143
+ Use a **test account**, not a real user. A missing variable stops the scan before any browser opens, naming the variable and never its value; messages show the step with the `${env:...}` placeholder, not the secret. Prefer specific selectors: `text=Sign in` can match the page heading instead of the button.
144
+
145
+ **3. Several roles in one scan.** List sessions; each runs the whole scan (initial page, states, stress modes) and findings are labelled `[as admin]`, `[as member]`:
146
+
147
+ ```json
148
+ { "sessions": [
149
+ { "name": "member", "login": { "url": "/login", "steps": [ ... ] } },
150
+ { "name": "admin", "storageState": "admin-auth.json" }
151
+ ] }
152
+ ```
153
+
154
+ **What you will see when something is off**
155
+ - `FR-405` "the scan was sent to /login, which looks like a login page": the session is missing or expired. Everything else in that run describes the login screen, not your app, so refresh the session first.
156
+ - `FR-402` "login failed at step N": a selector did not match or the page did not reach the expected state. The step is shown; fix it in `fringe.scan.json`.
157
+
158
+ ### Screenshots and a baseline for scan findings
112
159
 
113
- - **Login:** save a browser session once, then point the scan at it: `npx playwright-core codegen --save-storage=auth.json http://localhost:3000` (log in by hand in the window that opens, then close it). Use it with `--storage-state auth.json`, or put `"storageState": "auth.json"` at the top of `fringe.scan.json` (relative to that file). Keep the file out of git: it contains session cookies.
114
160
  - **Screenshots:** `--screenshots shots/` saves a full-page PNG of the initial page, each named state and each stress mode, so a reviewer can see what was audited.
115
161
  - **Baseline:** `scan --update-baseline` writes `fringe.scan.baseline.json` (commit it); later scans hide those known findings and report `N baselined`. Matching is by rule, page/state and element, not axe's wording, so a contrast ratio that shifts slightly stays known.
116
162
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-ZTIMW7ON.js";
4
+ } from "./chunk-JJI65ERU.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "crypto";
3
3
  import { existsSync as existsSync5, readFileSync as readFileSync11, statSync } from "fs";
4
- import { dirname as dirname2, join as join11, resolve } from "path";
4
+ import { dirname as dirname3, join as join12, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.5.0";
7
+ var VERSION = "0.6.0";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -1641,31 +1641,61 @@ function tabWalkFindings(url, focusables, visits) {
1641
1641
  return out;
1642
1642
  }
1643
1643
  var STEP_KEYS = ["click", "fill", "press", "hover", "waitFor", "goto"];
1644
+ var bad = (m) => {
1645
+ throw new Error(`fringe.scan.json: ${m}`);
1646
+ };
1647
+ function checkSteps(steps, where2) {
1648
+ if (!Array.isArray(steps) || steps.length === 0) return bad(`${where2} needs a non-empty "steps" list`);
1649
+ for (const [k, step] of steps.entries()) {
1650
+ const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
1651
+ if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`${where2} step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
1652
+ if (keys[0] === "fill" && typeof step.value !== "string") bad(`${where2} step ${k + 1}: fill needs a "value"`);
1653
+ }
1654
+ return steps;
1655
+ }
1644
1656
  function parseStates(json) {
1645
- const bad = (m) => {
1646
- throw new Error(`fringe.scan.json: ${m}`);
1647
- };
1648
- const states = json?.states;
1649
- if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
1650
- return states.map((s, i) => {
1657
+ const o = json;
1658
+ if (o?.states === void 0 && (o?.login !== void 0 || o?.sessions !== void 0 || o?.storageState !== void 0)) return [];
1659
+ if (!Array.isArray(o?.states)) return bad('expected { "states": [ ... ] }');
1660
+ return o.states.map((s, i) => {
1651
1661
  if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
1652
- if (!Array.isArray(s.steps) || s.steps.length === 0) bad(`state "${s.name}" needs a non-empty "steps" list`);
1653
- for (const [k, step] of s.steps.entries()) {
1654
- const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
1655
- if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`state "${s.name}" step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
1656
- if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
1657
- }
1658
- return { name: s.name.trim(), steps: s.steps };
1662
+ return { name: s.name.trim(), steps: checkSteps(s.steps, `state "${s.name}"`) };
1659
1663
  });
1660
1664
  }
1665
+ function parseSessions(json, baseDir) {
1666
+ const o = json ?? {};
1667
+ const one = (x, where2, name) => {
1668
+ if (x?.storageState !== void 0 && typeof x.storageState !== "string") bad(`${where2}: storageState must be a file path`);
1669
+ if (x?.login !== void 0 && typeof x.login?.url !== "undefined" && typeof x.login.url !== "string") bad(`${where2}: login.url must be a string`);
1670
+ return { ...name ? { name } : {}, ...x?.storageState ? { storageState: join8(baseDir, x.storageState) } : {}, ...x?.login ? { login: { ...x.login.url ? { url: x.login.url } : {}, steps: checkSteps(x.login.steps, `${where2} login`) } } : {} };
1671
+ };
1672
+ const out = [];
1673
+ if (o.login !== void 0 || o.storageState !== void 0) out.push(one(o, "top level"));
1674
+ if (o.sessions !== void 0) {
1675
+ if (!Array.isArray(o.sessions) || o.sessions.length === 0) bad('"sessions" must be a non-empty list');
1676
+ for (const [i, s] of o.sessions.entries()) {
1677
+ if (typeof s?.name !== "string" || !s.name.trim()) bad(`sessions[${i}] needs a "name"`);
1678
+ if (!s.storageState && !s.login) bad(`session "${s.name}" needs a "storageState" or a "login"`);
1679
+ out.push(one(s, `session "${s.name}"`, s.name.trim()));
1680
+ }
1681
+ }
1682
+ return out;
1683
+ }
1684
+ var ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
1685
+ var expandEnv = (s) => s.replace(ENV_REF, (_m, n) => {
1686
+ const v = process.env[n];
1687
+ if (v === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
1688
+ return v;
1689
+ });
1690
+ var envNames = (steps) => steps.flatMap((st) => ("fill" in st ? [st.value] : "goto" in st ? [st.goto] : []).flatMap((v) => [...v.matchAll(ENV_REF)].map((m) => m[1])));
1661
1691
  async function runStep(page, step, base) {
1662
1692
  const t = { timeout: 1e4 };
1663
1693
  if ("click" in step) await page.click(step.click, t);
1664
- else if ("fill" in step) await page.fill(step.fill, step.value, t);
1694
+ else if ("fill" in step) await page.fill(step.fill, expandEnv(step.value), t);
1665
1695
  else if ("press" in step) await page.keyboard.press(step.press);
1666
1696
  else if ("hover" in step) await page.hover(step.hover, t);
1667
1697
  else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
1668
- else await page.goto(new URL(step.goto, base).href, { waitUntil: "load", timeout: 3e4 });
1698
+ else await page.goto(new URL(expandEnv(step.goto), base).href, { waitUntil: "load", timeout: 3e4 });
1669
1699
  await page.waitForTimeout(150);
1670
1700
  }
1671
1701
  async function audit(page, axePath, url) {
@@ -1683,12 +1713,12 @@ async function audit(page, axePath, url) {
1683
1713
  }
1684
1714
  return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1685
1715
  }
1686
- var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${url} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
1716
+ var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${f.loc.file === url ? url : f.loc.file} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
1687
1717
  var MODES = ["dark", "reduced", "reflow"];
1688
1718
  function parseModes(list) {
1689
1719
  const out = list.split(",").map((m) => m.trim()).filter(Boolean);
1690
- const bad = out.find((m) => !MODES.includes(m));
1691
- if (bad || out.length === 0) throw new Error(`unknown scan mode '${bad ?? ""}': use ${MODES.join(", ")}`);
1720
+ const bad2 = out.find((m) => !MODES.includes(m));
1721
+ if (bad2 || out.length === 0) throw new Error(`unknown scan mode '${bad2 ?? ""}': use ${MODES.join(", ")}`);
1692
1722
  return out;
1693
1723
  }
1694
1724
  function onlyNew(base, extra) {
@@ -1751,14 +1781,51 @@ function applyScanBaseline(cwd, findings) {
1751
1781
  return { fresh, known: findings.length - fresh.length };
1752
1782
  }
1753
1783
  var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "page";
1784
+ var LOGINISH = /log-?in|sign-?in|sso|auth/i;
1785
+ var firstLine = (e) => String(e.message).split("\n")[0].slice(0, 120);
1786
+ async function loginRedirect(page, requested, who) {
1787
+ let now, want;
1788
+ try {
1789
+ now = new URL(page.url());
1790
+ want = new URL(requested);
1791
+ } catch {
1792
+ return [];
1793
+ }
1794
+ if (now.pathname === want.pathname) return [];
1795
+ const hasPassword = await page.evaluate(() => !!document.querySelector("input[type=password]"));
1796
+ if (!LOGINISH.test(now.pathname) && !hasPassword) return [];
1797
+ return [{ rule: "FR-405", severity: "review", message: `the scan was sent to ${now.pathname}, which looks like a login page: ${who ? `the "${who}" session may have expired or the login failed` : "this page needs a login"}`, hint: 'save a session with `fringe login <url>` or add a "login" block to fringe.scan.json; findings for this page describe the login screen, not your app', loc: { file: requested, line: 1, col: 0 } }];
1798
+ }
1799
+ async function auditContext(context, axePath, url, opts, who, shot) {
1800
+ const { states = [], modes = [] } = opts;
1801
+ const page = await context.newPage();
1802
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1803
+ await shot(page, "initial");
1804
+ const all = [...await loginRedirect(page, url, who), ...await audit(page, axePath, url)];
1805
+ const plain = [...all];
1806
+ for (const st of states) {
1807
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1808
+ let step = 0;
1809
+ try {
1810
+ for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
1811
+ } catch (e) {
1812
+ all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${firstLine(e)})`, hint: "fix the step in fringe.scan.json or the page; nothing in this state was audited", loc: { file: `${url} [${st.name}]`, line: 1, col: 0 } });
1813
+ continue;
1814
+ }
1815
+ await shot(page, `state-${st.name}`);
1816
+ all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1817
+ }
1818
+ for (const m of modes) all.push(...await stress(context, axePath, url, m, plain, shot));
1819
+ return all;
1820
+ }
1754
1821
  async function scan(url, cwd = process.cwd(), opts = {}) {
1755
1822
  const { states = [], modes = [] } = opts;
1756
- const shot = async (page, name) => {
1757
- if (!opts.screenshots) return;
1758
- mkdirSync3(opts.screenshots, { recursive: true });
1759
- await page.screenshot({ path: join8(opts.screenshots, `${slug(name)}.png`), fullPage: true });
1760
- };
1761
1823
  if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1824
+ const sessions = opts.sessions?.length ? opts.sessions : [opts.storageState ? { storageState: opts.storageState } : {}];
1825
+ for (const n of /* @__PURE__ */ new Set([...sessions.flatMap((s) => envNames(s.login?.steps ?? [])), ...states.flatMap((s) => envNames(s.steps))])) {
1826
+ if (process.env[n] === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
1827
+ }
1828
+ for (const s of sessions) if (s.storageState && !existsSync2(s.storageState)) throw new Error(`storage state file not found: ${s.storageState} (create one with: fringe login ${url})`);
1762
1829
  const req = createRequire(join8(cwd, "noop.js"));
1763
1830
  let playwright, axePath;
1764
1831
  try {
@@ -1778,35 +1845,46 @@ async function scan(url, cwd = process.cwd(), opts = {}) {
1778
1845
  }
1779
1846
  }
1780
1847
  try {
1781
- if (opts.storageState && !existsSync2(opts.storageState)) throw new Error(`storage state file not found: ${opts.storageState} (create one with: npx playwright-core codegen --save-storage=auth.json ${url})`);
1782
- const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
1783
- const page = await context.newPage();
1784
- await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1785
- await shot(page, "initial");
1786
- const all = await audit(page, axePath, url);
1787
- const plain = [...all];
1788
- for (const st of states) {
1789
- await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1790
- let step = 0;
1848
+ const all = [];
1849
+ for (const s of sessions) {
1850
+ const who = s.name, label = who ? `as ${who}` : void 0;
1851
+ const shot = async (page, name) => {
1852
+ if (!opts.screenshots) return;
1853
+ mkdirSync3(opts.screenshots, { recursive: true });
1854
+ await page.screenshot({ path: join8(opts.screenshots, `${slug(who ? `${who}-${name}` : name)}.png`), fullPage: true });
1855
+ };
1856
+ const context = await browser.newContext(s.storageState ? { storageState: s.storageState } : {});
1791
1857
  try {
1792
- for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
1793
- } catch (e) {
1794
- all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${String(e.message).split("\n")[0].slice(0, 120)})`, hint: "fix the step in fringe.scan.json or the page; nothing in this state was audited", loc: { file: `${url} [${st.name}]`, line: 1, col: 0 } });
1795
- continue;
1858
+ let found;
1859
+ if (s.login) {
1860
+ const lp = await context.newPage();
1861
+ let step = 0;
1862
+ try {
1863
+ await lp.goto(new URL(s.login.url ?? url, url).href, { waitUntil: "load", timeout: 3e4 });
1864
+ for (; step < s.login.steps.length; step++) await runStep(lp, s.login.steps[step], url);
1865
+ found = await auditContext(context, axePath, url, opts, who, shot);
1866
+ } catch (e) {
1867
+ found = [{ rule: "FR-402", severity: "review", message: `login${who ? ` as ${who}` : ""} failed at step ${step + 1} ${JSON.stringify(s.login.steps[step] ?? {})} (${firstLine(e)})`, hint: "check the login steps and selectors in fringe.scan.json; nothing was scanned for this session", loc: { file: url, line: 1, col: 0 } }];
1868
+ } finally {
1869
+ await lp.close().catch(() => {
1870
+ });
1871
+ }
1872
+ } else found = await auditContext(context, axePath, url, opts, who, shot);
1873
+ all.push(...label ? found.map((f) => inState(label, url, f)) : found);
1874
+ } finally {
1875
+ await context.close();
1796
1876
  }
1797
- await shot(page, `state-${st.name}`);
1798
- all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1799
1877
  }
1800
- for (const m of modes) all.push(...await stress(context, axePath, url, m, plain, shot));
1878
+ const files = sessions.length * (1 + states.length + modes.length);
1801
1879
  if (opts.updateBaseline) {
1802
1880
  writeFileSync3(baselinePath(cwd), JSON.stringify(all.map(scanKey).sort(), null, 2) + "\n");
1803
- return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings: [], clusters: [], counts: { error: 0, review: 0, signal: 0 }, score: 100, baselined: all.length };
1881
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings: [], clusters: [], counts: { error: 0, review: 0, signal: 0 }, score: 100, baselined: all.length };
1804
1882
  }
1805
1883
  const { fresh, known } = applyScanBaseline(cwd, all);
1806
1884
  const { findings, clusters } = rankScan(url, fresh);
1807
1885
  const counts = { error: 0, review: 0, signal: 0 };
1808
1886
  for (const f of findings) counts[f.severity]++;
1809
- return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1), ...known ? { baselined: known } : {} };
1887
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1), ...known ? { baselined: known } : {} };
1810
1888
  } finally {
1811
1889
  await browser.close();
1812
1890
  }
@@ -1830,19 +1908,71 @@ function mergeScan(base, scanned, weights) {
1830
1908
  return { ...base, findings: [...base.findings, ...scanned.findings], clusters: [...base.clusters ?? [], ...scanned.clusters ?? []].sort((a, b) => b.priority - a.priority), counts, score: score(counts, weights, base.files), ...(base.baselined ?? 0) + (scanned.baselined ?? 0) ? { baselined: (base.baselined ?? 0) + (scanned.baselined ?? 0) } : {} };
1831
1909
  }
1832
1910
 
1833
- // src/init.ts
1834
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4, copyFileSync } from "fs";
1911
+ // src/login.ts
1912
+ import { createRequire as createRequire2 } from "module";
1913
+ import { mkdirSync as mkdirSync4 } from "fs";
1835
1914
  import { dirname, join as join9 } from "path";
1915
+ import { createInterface } from "readline";
1916
+ async function saveSession(url, cwd, out, o = {}) {
1917
+ if (!/^https?:\/\//i.test(url)) throw new Error(`fringe login takes an http(s):// URL, got '${url}'`);
1918
+ const req = createRequire2(join9(cwd, "noop.js"));
1919
+ let playwright;
1920
+ try {
1921
+ playwright = req("playwright-core");
1922
+ } catch {
1923
+ throw new Error(SCAN_HELP);
1924
+ }
1925
+ let browser;
1926
+ const headless = o.headless ?? false;
1927
+ try {
1928
+ browser = await playwright.chromium.launch({ channel: "chrome", headless });
1929
+ } catch {
1930
+ try {
1931
+ browser = await playwright.chromium.launch({ headless });
1932
+ } catch {
1933
+ throw new Error(SCAN_HELP);
1934
+ }
1935
+ }
1936
+ try {
1937
+ const context = await browser.newContext();
1938
+ const page = await context.newPage();
1939
+ await page.goto(url, { waitUntil: "load", timeout: 6e4 });
1940
+ if (o.wait) await o.wait();
1941
+ else {
1942
+ process.stderr.write("fringe: log in in the browser window, then come back here and press Enter. Close the window afterwards, not before.\n");
1943
+ await new Promise((ok, fail) => {
1944
+ const rl = createInterface({ input: process.stdin });
1945
+ rl.once("line", () => {
1946
+ rl.close();
1947
+ ok();
1948
+ });
1949
+ browser.once("disconnected", () => {
1950
+ rl.close();
1951
+ fail(new Error("the browser was closed before the session was saved: press Enter in this terminal first, then close the window"));
1952
+ });
1953
+ });
1954
+ }
1955
+ mkdirSync4(dirname(out), { recursive: true });
1956
+ await context.storageState({ path: out });
1957
+ } finally {
1958
+ await browser.close().catch(() => {
1959
+ });
1960
+ }
1961
+ }
1962
+
1963
+ // src/init.ts
1964
+ import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4, copyFileSync } from "fs";
1965
+ import { dirname as dirname2, join as join10 } from "path";
1836
1966
  import { fileURLToPath } from "url";
1837
1967
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1838
1968
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1839
1969
  function hookCommand(root) {
1840
- return existsSync3(join9(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1970
+ return existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1841
1971
  }
1842
1972
  function isWired(root, home) {
1843
1973
  for (const dir of [root, home]) {
1844
1974
  try {
1845
- if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join9(dir, ".claude", "settings.json"), "utf8"))) return true;
1975
+ if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join10(dir, ".claude", "settings.json"), "utf8"))) return true;
1846
1976
  } catch {
1847
1977
  }
1848
1978
  }
@@ -1850,7 +1980,7 @@ function isWired(root, home) {
1850
1980
  }
1851
1981
  function initProject(root) {
1852
1982
  const done = [];
1853
- const settingsPath = join9(root, ".claude", "settings.json");
1983
+ const settingsPath = join10(root, ".claude", "settings.json");
1854
1984
  let settings = {};
1855
1985
  if (existsSync3(settingsPath)) {
1856
1986
  try {
@@ -1863,16 +1993,16 @@ function initProject(root) {
1863
1993
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1864
1994
  else {
1865
1995
  post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
1866
- mkdirSync4(dirname(settingsPath), { recursive: true });
1996
+ mkdirSync5(dirname2(settingsPath), { recursive: true });
1867
1997
  writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1868
1998
  done.push(`hook added to ${settingsPath}`);
1869
1999
  }
1870
2000
  for (const name of readdirSync3(SKILL_DIR)) {
1871
- const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
2001
+ const skillPath = join10(root, ".claude", "skills", name, "SKILL.md");
1872
2002
  if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
1873
2003
  else {
1874
- mkdirSync4(dirname(skillPath), { recursive: true });
1875
- copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
2004
+ mkdirSync5(dirname2(skillPath), { recursive: true });
2005
+ copyFileSync(join10(SKILL_DIR, name, "SKILL.md"), skillPath);
1876
2006
  done.push(`skill ${name} installed`);
1877
2007
  }
1878
2008
  }
@@ -1905,7 +2035,7 @@ function initFor(root, agents) {
1905
2035
  for (const a of agents) {
1906
2036
  const spec = AGENT_FILES[a];
1907
2037
  if (!spec) throw new Error(`unknown agent '${a}': use ${AGENTS.join(", ")} (or 'claude' for the hook + skills)`);
1908
- const file2 = join9(root, spec.path);
2038
+ const file2 = join10(root, spec.path);
1909
2039
  const text = spec.wrap ? spec.wrap(body) : body;
1910
2040
  if (existsSync3(file2)) {
1911
2041
  const cur = readFileSync9(file2, "utf8");
@@ -1918,7 +2048,7 @@ ${MARK2}
1918
2048
  ${spec.wrap ? body : text}`);
1919
2049
  done.push(`rules appended to ${spec.path}`);
1920
2050
  } else {
1921
- mkdirSync4(dirname(file2), { recursive: true });
2051
+ mkdirSync5(dirname2(file2), { recursive: true });
1922
2052
  writeFileSync4(file2, text.replace("# Fringe: handle your edges", `${MARK2}
1923
2053
  # Fringe: handle your edges`));
1924
2054
  done.push(`${spec.path} created`);
@@ -1927,10 +2057,10 @@ ${spec.wrap ? body : text}`);
1927
2057
  return done;
1928
2058
  }
1929
2059
  function initGit(root) {
1930
- const dir = join9(root, ".git", "hooks");
1931
- if (!existsSync3(join9(root, ".git"))) throw new Error("not a git repository (no .git here)");
1932
- const file2 = join9(dir, "pre-commit");
1933
- const local = existsSync3(join9(root, "node_modules", "@fringetools", "cli", "package.json"));
2060
+ const dir = join10(root, ".git", "hooks");
2061
+ if (!existsSync3(join10(root, ".git"))) throw new Error("not a git repository (no .git here)");
2062
+ const file2 = join10(dir, "pre-commit");
2063
+ const local = existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json"));
1934
2064
  const cmd = local ? "npx --no-install fringe check" : "npx -y @fringetools/cli check";
1935
2065
  const script = `#!/bin/sh
1936
2066
  # fringe pre-commit: blocks the commit while error-level UX/accessibility problems remain
@@ -1946,7 +2076,7 @@ exit 0
1946
2076
  if (readFileSync9(file2, "utf8").includes("# fringe pre-commit")) return ["pre-commit hook already present"];
1947
2077
  throw new Error(`${file2} already exists and is not Fringe's: add 'npx -y @fringetools/cli check || exit 1' to it yourself`);
1948
2078
  }
1949
- mkdirSync4(dir, { recursive: true });
2079
+ mkdirSync5(dir, { recursive: true });
1950
2080
  writeFileSync4(file2, script, { mode: 493 });
1951
2081
  return [`pre-commit hook installed at ${file2}`];
1952
2082
  }
@@ -2079,12 +2209,12 @@ After each batch, verify: \`fringe check ${target}\``);
2079
2209
 
2080
2210
  // src/rulereport.ts
2081
2211
  import { existsSync as existsSync4, readFileSync as readFileSync10 } from "fs";
2082
- import { join as join10 } from "path";
2212
+ import { join as join11 } from "path";
2083
2213
  async function ruleReport(root) {
2084
2214
  const cfg = loadConfig(root);
2085
2215
  const result = await runCheck(root, cfg, false);
2086
2216
  const baselinedBy = /* @__PURE__ */ new Map();
2087
- const bl = join10(root, "fringe.baseline.json");
2217
+ const bl = join11(root, "fringe.baseline.json");
2088
2218
  if (existsSync4(bl)) {
2089
2219
  try {
2090
2220
  for (const k of JSON.parse(readFileSync10(bl, "utf8"))) baselinedBy.set(k.split("|")[0], (baselinedBy.get(k.split("|")[0]) ?? 0) + 1);
@@ -2207,7 +2337,7 @@ function verifyToMarkdown(v) {
2207
2337
  }
2208
2338
 
2209
2339
  // src/index.ts
2210
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2340
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
2211
2341
  var log = (o) => o.log ?? ((s) => console.log(s));
2212
2342
  var err = (o) => o.err ?? ((s) => process.stderr.write(s));
2213
2343
  function readManifest(path2, warn) {
@@ -2238,13 +2368,13 @@ function readManifest(path2, warn) {
2238
2368
  }
2239
2369
  function resolveTarget(arg) {
2240
2370
  if (!statSync(arg).isFile()) return { root: arg };
2241
- let dir = dirname2(arg);
2242
- for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
2243
- if (existsSync5(join11(d, "package.json")) || existsSync5(join11(d, "fringe.config.json"))) {
2371
+ let dir = dirname3(arg);
2372
+ for (let d = dir, i = 0; i < 12; i++, d = dirname3(d)) {
2373
+ if (existsSync5(join12(d, "package.json")) || existsSync5(join12(d, "fringe.config.json"))) {
2244
2374
  dir = d;
2245
2375
  break;
2246
2376
  }
2247
- if (dirname2(d) === d) break;
2377
+ if (dirname3(d) === d) break;
2248
2378
  }
2249
2379
  return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
2250
2380
  }
@@ -2254,9 +2384,10 @@ function loadScanOptions(cwd, rest) {
2254
2384
  return i === -1 ? void 0 : rest[i + 1];
2255
2385
  };
2256
2386
  const si = rest.indexOf("--states");
2257
- const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join11(cwd, "fringe.scan.json");
2387
+ const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join12(cwd, "fringe.scan.json");
2258
2388
  let states = [];
2259
- let storageState = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
2389
+ let sessions;
2390
+ const storageFlag = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
2260
2391
  if (si !== -1 || existsSync5(file2)) {
2261
2392
  let raw;
2262
2393
  try {
@@ -2265,10 +2396,11 @@ function loadScanOptions(cwd, rest) {
2265
2396
  throw new Error(`${file2}: ${e.message}`);
2266
2397
  }
2267
2398
  states = parseStates(raw);
2268
- if (!storageState && typeof raw.storageState === "string") storageState = resolve(dirname2(file2), raw.storageState);
2399
+ const parsed = parseSessions(raw, dirname3(file2));
2400
+ if (parsed.length) sessions = parsed;
2269
2401
  }
2270
2402
  const shots = flag("--screenshots");
2271
- return { states, modes: loadModes(rest), ...storageState ? { storageState } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
2403
+ return { states, modes: loadModes(rest), ...storageFlag ? { storageState: storageFlag } : sessions ? { sessions } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
2272
2404
  }
2273
2405
  function loadModes(rest) {
2274
2406
  const i = rest.indexOf("--modes");
@@ -2278,6 +2410,7 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
2278
2410
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
2279
2411
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
2280
2412
  fringe check --update-baseline [path] accept today's findings; only new ones gate
2413
+ fringe login <url> [--out auth.json] log in once in a browser window and save the session for scans
2281
2414
  fringe scan <url> [--states f] [--stress | --modes ...] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
2282
2415
  fringe rules [dir] [--json] per rule: open / baselined / silenced, to spot rules that don't suit this repo
2283
2416
  fringe handoff [dir|file] [--scan url] a work order for another agent or CI (saved to .fringe/handoff.json)
@@ -2405,6 +2538,27 @@ async function runCli(argv, opts = {}) {
2405
2538
  return result.counts.error > 0 ? 1 : 0;
2406
2539
  } catch (e) {
2407
2540
  err(opts)(`fringe: ${e.message}
2541
+ `);
2542
+ return 2;
2543
+ }
2544
+ }
2545
+ if (cmd === "login") {
2546
+ try {
2547
+ const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--out");
2548
+ if (!url) {
2549
+ err(opts)("fringe: login needs a URL, e.g. fringe login http://localhost:3000/login\n");
2550
+ return 2;
2551
+ }
2552
+ const cwd = opts.cwd ?? process.cwd();
2553
+ const oi = rest.indexOf("--out");
2554
+ const out = resolve(cwd, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/auth.json");
2555
+ await saveSession(url, cwd, out, { headless: rest.includes("--headless"), wait: opts.waitForUser });
2556
+ log(opts)(`fringe: session saved to ${out}
2557
+ It holds your logged-in cookies: keep it out of git. Use it with: fringe scan <url> --storage-state ${oi !== -1 ? rest[oi + 1] : ".fringe/auth.json"}`);
2558
+ if (oi === -1 && existsSync5(join12(cwd, ".gitignore")) && !/\.fringe/.test(readFileSync11(join12(cwd, ".gitignore"), "utf8"))) log(opts)("fringe: your .gitignore does not list .fringe/: add it, or the session file could be committed.");
2559
+ return 0;
2560
+ } catch (e) {
2561
+ err(opts)(`fringe: ${e.message}
2408
2562
  `);
2409
2563
  return 2;
2410
2564
  }
@@ -2431,7 +2585,7 @@ async function runCli(argv, opts = {}) {
2431
2585
  const h = buildHandoff(g.result, { root: g.root, target: g.target, ...g.only ? { only: g.only } : {}, ...g.scan ? { scan: g.scan } : {} }, g.limit);
2432
2586
  const oi = rest.indexOf("--out");
2433
2587
  const outFile = resolve(g.root, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/handoff.json");
2434
- mkdirSync5(dirname2(outFile), { recursive: true });
2588
+ mkdirSync6(dirname3(outFile), { recursive: true });
2435
2589
  writeFileSync5(outFile, JSON.stringify(h, null, 2) + "\n");
2436
2590
  log(opts)(rest.includes("--json") ? JSON.stringify(h, null, 2) : h.prompt);
2437
2591
  err(opts)(`fringe: hand-off saved to ${outFile}; the receiving agent proves it is done with: fringe verify${oi !== -1 ? ` --handoff ${outFile}` : ""}
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ interface CliOpts {
2
2
  log?: (s: string) => void;
3
3
  cwd?: string;
4
4
  err?: (s: string) => void; /** Home directory for `init --global` (tests override it). */
5
- home?: string;
5
+ home?: string; /** `fringe login` waits for this instead of Enter on the terminal (tests). */
6
+ waitForUser?: () => Promise<void>;
6
7
  }
7
8
  declare function runCli(argv: string[], opts?: CliOpts): Promise<number>;
8
9
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-ZTIMW7ON.js";
3
+ } from "./chunk-JJI65ERU.js";
4
4
  export {
5
5
  runCli
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fringetools/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
27
27
  | `check --update-baseline` | Accept today's findings; only new ones gate |
28
28
  | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk, and named states from `fringe.scan.json` (needs `playwright-core` + `axe-core`) |
29
29
  | `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
30
+ | `login <url>` | Log in once in a browser window and save the session, so `scan` can reach pages behind a sign-in |
30
31
  | `handoff` / `verify` | Work order for another agent or CI, and proof it was done (complete / partial / regressed) |
31
32
  | `rules` | Per rule: open / baselined / silenced, to spot rules that do not suit this repo |
32
33
  | `init --for <agents>` / `--git` | Rules file for Codex, Cursor, Copilot, Gemini…; commit gate for any agent |