@checkbox-site/cli 0.2.0 → 0.3.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 (2) hide show
  1. package/dist/checkbox.js +362 -222
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -3673,9 +3673,9 @@ var init_client = __esm({
3673
3673
  const p = globalThis.process;
3674
3674
  return p?.env?.[name] || void 0;
3675
3675
  };
3676
- sleep = (ms, signal) => new Promise((resolve8, reject) => {
3676
+ sleep = (ms, signal) => new Promise((resolve9, reject) => {
3677
3677
  if (signal?.aborted) return reject(signal.reason);
3678
- const t = setTimeout(resolve8, ms);
3678
+ const t = setTimeout(resolve9, ms);
3679
3679
  signal?.addEventListener("abort", () => (clearTimeout(t), reject(signal.reason)), { once: true });
3680
3680
  });
3681
3681
  Checkbox = class {
@@ -4113,11 +4113,11 @@ async function listCommands(dir = COMMANDS_DIR) {
4113
4113
  }
4114
4114
  function readAll(stream) {
4115
4115
  if (!stream || stream.isTTY) return Promise.resolve("");
4116
- return new Promise((resolve8, reject) => {
4116
+ return new Promise((resolve9, reject) => {
4117
4117
  let data = "";
4118
4118
  stream.setEncoding?.("utf8");
4119
4119
  stream.on("data", (c) => data += c.toString());
4120
- stream.on("end", () => resolve8(data));
4120
+ stream.on("end", () => resolve9(data));
4121
4121
  stream.on("error", reject);
4122
4122
  });
4123
4123
  }
@@ -10465,8 +10465,8 @@ function isNear(el, cta, viewport, device) {
10465
10465
  }
10466
10466
  return verticalGap(el, cta) <= maxVerticalGapPx(device, viewport) && horizontalGap(el, cta) <= maxHorizontalGapPx(device);
10467
10467
  }
10468
- function tastefulSmall(relative6, style2, opacity) {
10469
- if (relative6 < CONSPICUOUS_TASTEFUL_RELATIVE_FONT) return false;
10468
+ function tastefulSmall(relative7, style2, opacity) {
10469
+ if (relative7 < CONSPICUOUS_TASTEFUL_RELATIVE_FONT) return false;
10470
10470
  const contrast = textContrast({ color: style2.color, backgroundColor: style2.backgroundColor, opacity });
10471
10471
  if (contrast === null || contrast < CONSPICUOUS_MIN_CONTRAST) return false;
10472
10472
  return (style2.fontWeight ?? 400) >= 600 || contrast >= CONSPICUOUS_STRONG_CONTRAST;
@@ -10486,18 +10486,18 @@ function assessConspicuousness(el, opts = {}) {
10486
10486
  warnings.push("font size could not be measured");
10487
10487
  } else {
10488
10488
  const ref = opts.referenceFontPx && opts.referenceFontPx > 0 ? opts.referenceFontPx : void 0;
10489
- const relative6 = ref ? fontSizePx / ref : void 0;
10489
+ const relative7 = ref ? fontSizePx / ref : void 0;
10490
10490
  if (fontSizePx < CONSPICUOUS_MIN_FONT_PX) {
10491
- if (relative6 !== void 0 && relative6 >= CONSPICUOUS_MIN_RELATIVE_FONT) {
10491
+ if (relative7 !== void 0 && relative7 >= CONSPICUOUS_MIN_RELATIVE_FONT) {
10492
10492
  warnings.push(
10493
- `font is ${round2(fontSizePx)}px (below ${CONSPICUOUS_MIN_FONT_PX}px, but ${Math.round(relative6 * 100)}% of the surrounding ${round2(ref)}px text)`
10493
+ `font is ${round2(fontSizePx)}px (below ${CONSPICUOUS_MIN_FONT_PX}px, but ${Math.round(relative7 * 100)}% of the surrounding ${round2(ref)}px text)`
10494
10494
  );
10495
10495
  } else {
10496
10496
  failures.push(`font is ${round2(fontSizePx)}px (min ${CONSPICUOUS_MIN_FONT_PX}px)`);
10497
10497
  }
10498
- } else if (relative6 !== void 0 && relative6 < CONSPICUOUS_MIN_RELATIVE_FONT && !tastefulSmall(relative6, style2, opacity)) {
10498
+ } else if (relative7 !== void 0 && relative7 < CONSPICUOUS_MIN_RELATIVE_FONT && !tastefulSmall(relative7, style2, opacity)) {
10499
10499
  warnings.push(
10500
- `font is ${round2(fontSizePx)}px, ${Math.round(relative6 * 100)}% of the surrounding ${round2(ref)}px text \u2014 smaller than surrounding text must contrast or be set off to be conspicuous`
10500
+ `font is ${round2(fontSizePx)}px, ${Math.round(relative7 * 100)}% of the surrounding ${round2(ref)}px text \u2014 smaller than surrounding text must contrast or be set off to be conspicuous`
10501
10501
  );
10502
10502
  }
10503
10503
  }
@@ -17328,9 +17328,9 @@ function limiter(max) {
17328
17328
  run2();
17329
17329
  }
17330
17330
  };
17331
- return async (fn) => new Promise((resolve8, reject) => {
17331
+ return async (fn) => new Promise((resolve9, reject) => {
17332
17332
  queue.push(() => {
17333
- fn().then(resolve8, reject).finally(() => {
17333
+ fn().then(resolve9, reject).finally(() => {
17334
17334
  active--;
17335
17335
  next();
17336
17336
  });
@@ -22747,7 +22747,15 @@ function loadConfig(path) {
22747
22747
  function validateConfig(value) {
22748
22748
  const is = new Issues();
22749
22749
  if (!isObj2(value)) return ["must be a JSON object (see https://checkbox.site/docs/guides/ci#config)"];
22750
- is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "checks"]);
22750
+ is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "checks", "coverage"]);
22751
+ const cov = value.coverage;
22752
+ if (cov !== void 0 && cov !== false) {
22753
+ if (!isObj2(cov)) is.add("coverage", `must be false or an object like { "ignore": ["scripts/**"] } (got ${show(cov)})`);
22754
+ else {
22755
+ is.keys("coverage", cov, ["ignore"]);
22756
+ if (cov.ignore !== void 0 && (!Array.isArray(cov.ignore) || !cov.ignore.every((g) => typeof g === "string" && g.trim()))) is.add("coverage.ignore", `must be a list of file globs like ["scripts/**", "admin/*.ts"] (got ${show(cov.ignore)})`);
22757
+ }
22758
+ }
22751
22759
  if (value.$schema !== void 0 && typeof value.$schema !== "string") is.add("$schema", "must be a string");
22752
22760
  const m = value.merchant;
22753
22761
  if (m !== void 0) {
@@ -22820,13 +22828,16 @@ function validateConfig(value) {
22820
22828
  } else if (needsPlan === "always" && !planCount) {
22821
22829
  is.add(at, 'needs the plan it describes: add it to "plans" (name, price, interval, trial_days)');
22822
22830
  }
22831
+ if (e.covers !== void 0 && (!Array.isArray(e.covers) || !e.covers.every((c) => typeof c === "string" && /^[^#]+(?:#[1-9]\d*)?$/.test(c)))) {
22832
+ is.add(`${at}.covers`, `must be a list of files, or "file#n" for the n-th call (got ${show(e.covers)})`);
22833
+ }
22823
22834
  each(at, e);
22824
22835
  });
22825
22836
  };
22826
22837
  entries(
22827
22838
  "stripe_checkout",
22828
22839
  (at, e) => {
22829
- is.keys(at, e, ["name", "plan", "params", "file", "module", "args", "source", "call"]);
22840
+ is.keys(at, e, ["name", "plan", "params", "file", "module", "args", "source", "call", "covers"]);
22830
22841
  is.exactlyOne(at, e, ["params", "file", "module", "source"]);
22831
22842
  if (e.params !== void 0 && !isObj2(e.params)) is.add(`${at}.params`, `must be the params object (got ${show(e.params)})`);
22832
22843
  if (e.file !== void 0) is.path(`${at}.file`, e.file);
@@ -22842,7 +22853,7 @@ function validateConfig(value) {
22842
22853
  entries(
22843
22854
  "disclosures",
22844
22855
  (at, e) => {
22845
- is.keys(at, e, ["name", "plan", "text", "file", "button_text", "placement", "consent"]);
22856
+ is.keys(at, e, ["name", "plan", "text", "file", "button_text", "placement", "consent", "covers"]);
22846
22857
  is.exactlyOne(at, e, ["text", "file"]);
22847
22858
  if (e.text !== void 0) is.str(`${at}.text`, e.text, { max: 5e3 });
22848
22859
  if (e.file !== void 0) is.path(`${at}.file`, e.file);
@@ -22854,7 +22865,7 @@ function validateConfig(value) {
22854
22865
  entries(
22855
22866
  "pages",
22856
22867
  (at, e) => {
22857
- is.keys(at, e, ["name", "plan", "file", "url", "kind"]);
22868
+ is.keys(at, e, ["name", "plan", "file", "url", "kind", "covers"]);
22858
22869
  is.exactlyOne(at, e, ["file", "url"]);
22859
22870
  if (e.file !== void 0) is.path(`${at}.file`, e.file);
22860
22871
  if (e.url !== void 0) is.str(`${at}.url`, e.url, { pattern: /^https?:\/\/\S+$/, patternHint: "an http(s) URL" });
@@ -23524,15 +23535,299 @@ var init_detect = __esm({
23524
23535
  }
23525
23536
  });
23526
23537
 
23538
+ // src/ci/scaffold.ts
23539
+ import { readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
23540
+ import { basename, join as join5, relative as relative2 } from "node:path";
23541
+ function walk3(root, visit) {
23542
+ let n = 0;
23543
+ const go = (dir, depth) => {
23544
+ if (depth > 8 || n > MAX_FILES2) return;
23545
+ let names = [];
23546
+ try {
23547
+ names = readdirSync3(dir).sort();
23548
+ } catch {
23549
+ return;
23550
+ }
23551
+ for (const name of names) {
23552
+ if (IGNORE.has(name) || name.startsWith(".") && name !== ".well-known") continue;
23553
+ const abs = join5(dir, name);
23554
+ let st;
23555
+ try {
23556
+ st = statSync2(abs);
23557
+ } catch {
23558
+ continue;
23559
+ }
23560
+ if (st.isDirectory()) go(abs, depth + 1);
23561
+ else if (st.isFile() && st.size < 4e5) {
23562
+ n++;
23563
+ visit(relative2(root, abs).split("\\").join("/"));
23564
+ }
23565
+ }
23566
+ };
23567
+ go(root, 0);
23568
+ }
23569
+ function emailKindOf(file) {
23570
+ const name = basename(file).replace(/\.[^.]+$/, "");
23571
+ return EMAIL_NAMES.find(([re]) => re.test(name))?.[1];
23572
+ }
23573
+ function scaffoldConfig(root, env2 = {}) {
23574
+ const det = detectProject(root, env2);
23575
+ const todos = [];
23576
+ const found = [];
23577
+ const names = /* @__PURE__ */ new Set();
23578
+ const merchant = {};
23579
+ merchant.name = det.brand?.name ?? "TODO: the brand subscribers see";
23580
+ if (!det.brand) todos.push("merchant.name: the brand subscribers see");
23581
+ const cancelPath = det.policyPaths.cancellation;
23582
+ if (det.siteUrl && cancelPath) merchant.cancel_url = `${originOf(det.siteUrl)}${cancelPath}`;
23583
+ else {
23584
+ merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
23585
+ todos.push("merchant.cancel_url: the page where subscribers cancel online");
23586
+ }
23587
+ const plans = det.plans.map((p, i) => ({
23588
+ name: p.name ?? `Plan ${i + 1}`,
23589
+ amount: p.amount,
23590
+ currency: p.currency,
23591
+ interval: p.interval,
23592
+ ...p.interval_count ? { interval_count: p.interval_count } : {},
23593
+ ...p.trial_days ? { trial_days: p.trial_days } : {}
23594
+ }));
23595
+ for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
23596
+ if (!plans.length) {
23597
+ plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
23598
+ todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
23599
+ }
23600
+ const planNames = plans.map((p) => String(p.name));
23601
+ const planFor = () => plans.length > 1 ? { plan: `TODO: one of ${planNames.map((n) => JSON.stringify(n)).join(", ")}` } : {};
23602
+ if (plans.length > 1) todos.push("plan: pick the plan of each disclosure, page and email entry");
23603
+ const stripe = [];
23604
+ const perFile = /* @__PURE__ */ new Map();
23605
+ for (const s2 of det.checkoutSessions) {
23606
+ const call = (perFile.get(s2.file) ?? 0) + 1;
23607
+ perFile.set(s2.file, call);
23608
+ const entry = { name: unique(names, `Stripe Checkout (${basename(s2.file)}${call > 1 ? ` #${call}` : ""})`), source: s2.file, ...call > 1 ? { call } : {} };
23609
+ if (!s2.params) {
23610
+ todos.push(`${s2.file}:${s2.line}: the params are not an object literal; use "module" (an exported function returning the params) instead of "source"`);
23611
+ delete entry.source;
23612
+ delete entry.call;
23613
+ entry.module = `TODO: ${s2.file.replace(/\.[^.]+$/, "")}.ts#buildCheckoutParams`;
23614
+ } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
23615
+ else found.push(`${s2.file}:${s2.line}: Checkout Session`);
23616
+ stripe.push(entry);
23617
+ }
23618
+ const emails = [];
23619
+ const pages = [];
23620
+ const disclosures = [];
23621
+ walk3(root, (rel2) => {
23622
+ if (IN_EMAIL_DIR.test(rel2)) {
23623
+ const kind = emailKindOf(rel2);
23624
+ if (/\.(?:html?|txt|md)$/i.test(rel2) && kind) {
23625
+ emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...planFor() });
23626
+ found.push(`${rel2}: ${kind} email`);
23627
+ return;
23628
+ }
23629
+ if (/\.[jt]sx$|\.mjml$/i.test(rel2) && kind) {
23630
+ todos.push(`${rel2}: a ${kind} email component; add it with "module": "<file>#<export>" returning the rendered { subject, html } (React Email: render() with sample props)`);
23631
+ return;
23632
+ }
23633
+ }
23634
+ if (PAGE_FILE.test(rel2)) {
23635
+ const kind = /checkout|subscribe/i.test(rel2) ? "checkout" : "pricing";
23636
+ pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor() });
23637
+ found.push(`${rel2}: ${kind} page`);
23638
+ return;
23639
+ }
23640
+ if (/\.(?:txt|md)$/i.test(rel2) && !/(?:^|\/)(?:README|CHANGELOG|LICENSE|CONTRIBUTING|AGENTS|CLAUDE)[^/]*$/i.test(rel2) && !rel2.startsWith("docs/")) {
23641
+ let text = "";
23642
+ try {
23643
+ text = readFileSync6(join5(root, rel2), "utf8");
23644
+ } catch {
23645
+ return;
23646
+ }
23647
+ if (text.length < 1500 && DISCLOSURE_TEXT.test(text)) {
23648
+ disclosures.push({ name: unique(names, `Disclosure: ${basename(rel2)}`), file: rel2, ...planFor() });
23649
+ found.push(`${rel2}: disclosure text`);
23650
+ }
23651
+ }
23652
+ });
23653
+ if (!disclosures.length && det.payButtons.length) {
23654
+ todos.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
23655
+ }
23656
+ const checks = {};
23657
+ if (stripe.length) checks.stripe_checkout = stripe;
23658
+ if (disclosures.length) checks.disclosures = disclosures;
23659
+ if (pages.length) checks.pages = pages;
23660
+ if (emails.length) checks.emails = emails;
23661
+ checks.notice_schedule = true;
23662
+ if (!stripe.length && !disclosures.length && !pages.length && !emails.length) {
23663
+ todos.push('No checkout code, email templates or pages were found: add entries under "checks" (https://checkbox.site/docs/guides/ci#config)');
23664
+ }
23665
+ return {
23666
+ config: { $schema: SCHEMA_URL, merchant, jurisdictions: "all", plans, checks },
23667
+ todos,
23668
+ found
23669
+ };
23670
+ }
23671
+ var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique;
23672
+ var init_scaffold = __esm({
23673
+ "src/ci/scaffold.ts"() {
23674
+ "use strict";
23675
+ init_detect();
23676
+ init_config3();
23677
+ IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "coverage", ".astro", "vendor", ".venv", "__pycache__", ".github"]);
23678
+ MAX_FILES2 = 5e3;
23679
+ EMAIL_NAMES = [
23680
+ [/trial/i, "trial_reminder"],
23681
+ [/price[-_ ]?(?:change|increase|update)/i, "price_change"],
23682
+ [/annual|yearly[-_ ]?terms/i, "annual_terms"],
23683
+ [/renew/i, "renewal_reminder"],
23684
+ [/cancel/i, "cancellation_confirmation"],
23685
+ [/receipt|charge|invoice|payment[-_ ]?(?:succeeded|receipt)/i, "charge_notice"],
23686
+ [/welcome|confirm|acknowledg|subscribed|signup|sign[-_]up|order/i, "acknowledgment"]
23687
+ ];
23688
+ IN_EMAIL_DIR = /(?:^|\/)(?:e-?mails?|mail(?:ers?)?|notifications?)(?:\/|$)|(?:^|\/)templates\/(?:e-?mails?|mail)\//i;
23689
+ PAGE_FILE = /(?:^|\/)(?:public|dist|out|build|static|_site|www)\/(?:.*\/)?(pricing|checkout|subscribe|plans)(?:\/index)?\.html?$/i;
23690
+ DISCLOSURE_TEXT = /renews? automatically|automatically renews?|auto-?renew|until (?:you )?cancel/i;
23691
+ unique = (names, base) => {
23692
+ let n = base;
23693
+ for (let i = 2; names.has(n); i++) n = `${base} ${i}`;
23694
+ names.add(n);
23695
+ return n;
23696
+ };
23697
+ }
23698
+ });
23699
+
23700
+ // src/ci/coverage.ts
23701
+ import { readFileSync as readFileSync7 } from "node:fs";
23702
+ import { join as join6, relative as relative3, resolve as resolve3 } from "node:path";
23703
+ function callsIn(text, file) {
23704
+ const hits = [];
23705
+ for (const [re, kind] of PATTERNS) {
23706
+ for (const m of text.matchAll(re)) hits.push({ at: m.index, end: m.index + m[0].length, kind });
23707
+ }
23708
+ hits.sort((a, b) => a.at - b.at);
23709
+ const out = [];
23710
+ const count = { checkout_session: 0, payment_link: 0 };
23711
+ let last = -1;
23712
+ for (const h of hits) {
23713
+ if (h.at < last) continue;
23714
+ last = h.end;
23715
+ const lineStart2 = text.lastIndexOf("\n", h.at) + 1;
23716
+ const before = text.slice(lineStart2, h.at).trim();
23717
+ if (/^(?:\/\/|#|\*|\/\*)/.test(before)) continue;
23718
+ out.push({ file, line: text.slice(0, h.at).split("\n").length, kind: h.kind, index: ++count[h.kind], arg: text.slice(h.end, h.end + 200) });
23719
+ }
23720
+ return out;
23721
+ }
23722
+ function coverageOf(loaded, root, files) {
23723
+ const cfg = loaded.config;
23724
+ const toRoot = (p) => relative3(root, resolve3(loaded.dir, p)).split("\\").join("/");
23725
+ const calls = [];
23726
+ const visit = (rel2) => {
23727
+ if (!CODE.test(rel2) || TESTISH.test(rel2)) return;
23728
+ let text = "";
23729
+ try {
23730
+ text = readFileSync7(join6(root, rel2), "utf8");
23731
+ } catch {
23732
+ return;
23733
+ }
23734
+ if (!/create\s*\(/.test(text)) return;
23735
+ calls.push(...callsIn(text, rel2));
23736
+ };
23737
+ if (files) files.forEach(visit);
23738
+ else walk3(root, visit);
23739
+ const ignore = (cfg.coverage && typeof cfg.coverage === "object" ? cfg.coverage.ignore ?? [] : []).map((g) => glob(toRoot(g)));
23740
+ const wholeFiles = /* @__PURE__ */ new Set();
23741
+ const exactCalls = /* @__PURE__ */ new Set();
23742
+ const exportNames = [];
23743
+ const entries = [...cfg.checks.stripe_checkout ?? [], ...cfg.checks.disclosures ?? [], ...cfg.checks.pages ?? []];
23744
+ for (const e of cfg.checks.stripe_checkout ?? []) {
23745
+ if (e.source) exactCalls.add(`${toRoot(e.source)}#checkout_session#${e.call ?? 1}`);
23746
+ if (e.file) wholeFiles.add(toRoot(e.file));
23747
+ if (e.module) {
23748
+ const [path, name] = e.module.split("#");
23749
+ wholeFiles.add(toRoot(path));
23750
+ if (name && name !== "default") exportNames.push(name);
23751
+ }
23752
+ }
23753
+ for (const e of entries) {
23754
+ for (const c of e.covers ?? []) {
23755
+ const [path, n] = c.split("#");
23756
+ if (n) {
23757
+ exactCalls.add(`${toRoot(path)}#checkout_session#${n}`);
23758
+ exactCalls.add(`${toRoot(path)}#payment_link#${n}`);
23759
+ } else wholeFiles.add(toRoot(path));
23760
+ }
23761
+ }
23762
+ const uncovered = [];
23763
+ const ignored = [];
23764
+ for (const c of calls) {
23765
+ if (ignore.some((re) => re.test(c.file))) ignored.push(c);
23766
+ else if (wholeFiles.has(c.file) || exactCalls.has(`${c.file}#${c.kind}#${c.index}`)) continue;
23767
+ else if (exportNames.some((n) => new RegExp(`\\b${n.replace(/[$]/g, "\\$")}\\b`).test(c.arg))) continue;
23768
+ else uncovered.push(c);
23769
+ }
23770
+ return { calls, uncovered, ignored };
23771
+ }
23772
+ function coverageResults(cov) {
23773
+ return cov.uncovered.map((c) => {
23774
+ const fix = c.kind === "checkout_session" ? `Add it to checks.stripe_checkout: { "name": "\u2026", "source": "${c.file}"${c.index > 1 ? `, "call": ${c.index}` : ""} } (or "module" if the params come from a function). If it never reaches a subscriber (an admin tool, a script), add the file to "coverage": { "ignore": [...] }.` : `Payment Links are checked live from Stripe (connect Stripe in Checkbox). Say which check covers it with "covers": ["${c.file}${c.index > 1 ? `#${c.index}` : ""}"] on that entry, or add the file to "coverage": { "ignore": [...] } if it never reaches a subscriber.`;
23775
+ return {
23776
+ name: `${KIND_LABEL2[c.kind]} at ${c.file}:${c.line}`,
23777
+ type: "stripe_checkout",
23778
+ target: { file: c.file, line: c.line },
23779
+ status: "error",
23780
+ label: "Not in checkbox.json",
23781
+ message: `This ${KIND_LABEL2[c.kind]} call is not checked by any entry in checkbox.json, so a change to it would pass unnoticed.`,
23782
+ fix,
23783
+ fixes: [fix],
23784
+ warnings: []
23785
+ };
23786
+ });
23787
+ }
23788
+ var CODE, TESTISH, PATTERNS, glob, KIND_LABEL2;
23789
+ var init_coverage = __esm({
23790
+ "src/ci/coverage.ts"() {
23791
+ "use strict";
23792
+ init_scaffold();
23793
+ CODE = /\.(?:[cm]?[jt]sx?|py|rb|php)$/i;
23794
+ TESTISH = /(?:^|\/)(?:__tests__|__mocks__|tests?|spec|specs|fixtures?|mocks?|e2e|cypress|playwright|stories|storybook)(?:\/|$)|\.(?:test|spec|stories|e2e)\.[^/]+$|(?:^|\/)test_[^/]+\.py$|_test\.(?:py|rb)$|_spec\.rb$/i;
23795
+ PATTERNS = [
23796
+ [/\bcheckout\s*\.\s*sessions\s*\.\s*create\s*\(/g, "checkout_session"],
23797
+ // Node, Python StripeClient
23798
+ [/\bcheckout\s*\.\s*Session\s*\.\s*create\s*\(/g, "checkout_session"],
23799
+ // Python stripe.checkout.Session
23800
+ [/Checkout::Session\s*\.\s*create\s*\(/g, "checkout_session"],
23801
+ // Ruby
23802
+ [/checkout\s*->\s*sessions\s*->\s*create\s*\(/g, "checkout_session"],
23803
+ // PHP
23804
+ [/\bpaymentLinks\s*\.\s*create\s*\(/g, "payment_link"],
23805
+ // Node
23806
+ [/\bpayment_links\s*\.\s*create\s*\(/g, "payment_link"],
23807
+ // Python StripeClient
23808
+ [/\bPaymentLink\s*\.\s*create\s*\(/g, "payment_link"],
23809
+ // Python, Ruby (Stripe::PaymentLink.create)
23810
+ [/paymentLinks\s*->\s*create\s*\(/g, "payment_link"]
23811
+ // PHP
23812
+ ];
23813
+ glob = (pattern) => {
23814
+ const p = pattern.replace(/^\.\//, "").replace(/\/$/, "/**");
23815
+ const re = p.split("**").map((part) => part.split("*").map((s2) => s2.replace(/[.+^${}()|[\]\\?]/g, "\\$&")).join("[^/]*")).join(".*");
23816
+ return new RegExp(`^${re}$`);
23817
+ };
23818
+ KIND_LABEL2 = { checkout_session: "Stripe Checkout Session", payment_link: "Stripe Payment Link" };
23819
+ }
23820
+ });
23821
+
23527
23822
  // src/ci/inputs.ts
23528
23823
  import { execFile } from "node:child_process";
23529
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
23530
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3 } from "node:path";
23824
+ import { existsSync as existsSync5, readFileSync as readFileSync8 } from "node:fs";
23825
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
23531
23826
  import { pathToFileURL as pathToFileURL2 } from "node:url";
23532
23827
  function readRel(dir, file) {
23533
- const abs = isAbsolute2(file) ? file : resolve3(dir, file);
23828
+ const abs = isAbsolute2(file) ? file : resolve4(dir, file);
23534
23829
  try {
23535
- return readFileSync6(abs, "utf8");
23830
+ return readFileSync8(abs, "utf8");
23536
23831
  } catch (err) {
23537
23832
  const code2 = err.code;
23538
23833
  throw new InputError(code2 === "ENOENT" ? `${file} does not exist (paths are relative to checkbox.json)` : `cannot read ${file} (${code2 ?? err.message})`);
@@ -23540,7 +23835,7 @@ function readRel(dir, file) {
23540
23835
  }
23541
23836
  function hasTsx(dir) {
23542
23837
  for (let d = dir, i = 0; i < 8; i++) {
23543
- if (existsSync5(join5(d, "node_modules", "tsx", "package.json"))) return true;
23838
+ if (existsSync5(join7(d, "node_modules", "tsx", "package.json"))) return true;
23544
23839
  const up = dirname3(d);
23545
23840
  if (up === d) break;
23546
23841
  d = up;
@@ -23560,7 +23855,7 @@ function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
23560
23855
  const hash = ref.lastIndexOf("#");
23561
23856
  const file = hash > 0 ? ref.slice(0, hash) : ref;
23562
23857
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
23563
- const abs = resolve3(dir, file);
23858
+ const abs = resolve4(dir, file);
23564
23859
  if (!existsSync5(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
23565
23860
  const flags = loaderFlags(file, dir);
23566
23861
  if (typeof flags === "string") return Promise.reject(new InputError(flags));
@@ -23602,7 +23897,7 @@ function projectEnv(dir) {
23602
23897
  const out = {};
23603
23898
  for (const f of [".env.example", ".env", ".env.local", ".env.test"]) {
23604
23899
  try {
23605
- Object.assign(out, parseDotenv(readFileSync6(join5(dir, f), "utf8")));
23900
+ Object.assign(out, parseDotenv(readFileSync8(join7(dir, f), "utf8")));
23606
23901
  } catch {
23607
23902
  }
23608
23903
  }
@@ -23628,7 +23923,7 @@ function planTerms(config, plan2) {
23628
23923
  return t;
23629
23924
  }
23630
23925
  function rel(loaded, root, file) {
23631
- return relative2(root, resolve3(loaded.dir, file)).split("\\").join("/") || file;
23926
+ return relative4(root, resolve4(loaded.dir, file)).split("\\").join("/") || file;
23632
23927
  }
23633
23928
  function emailFromFile(content, file, subject) {
23634
23929
  let body = content;
@@ -23857,7 +24152,8 @@ function annotations(run2) {
23857
24152
  const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict ? "error" : "warning" : void 0;
23858
24153
  if (!level) continue;
23859
24154
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
23860
- const body = x.status === "error" ? `Could not run: ${x.message ?? ""}` : [x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
24155
+ const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
24156
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
23861
24157
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
23862
24158
  }
23863
24159
  return out;
@@ -23869,7 +24165,7 @@ function stepSummary(run2) {
23869
24165
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
23870
24166
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
23871
24167
  for (const x of run2.checks) {
23872
- const fix = x.status === "error" || x.status === "skipped" ? x.message ?? "" : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : "";
24168
+ const fix = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : "";
23873
24169
  lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
23874
24170
  }
23875
24171
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
@@ -24441,7 +24737,7 @@ function exitCodeOf(checks, strict) {
24441
24737
  return { status: "pass", exit: EXIT.ok };
24442
24738
  }
24443
24739
  async function runCi(jobs, engine, opts) {
24444
- const checks = [];
24740
+ const checks = [...opts.extra ?? []];
24445
24741
  for (const j of jobs) checks.push(await runJob(j, engine, opts.config));
24446
24742
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0, skipped: 0, error: 0 };
24447
24743
  for (const c of checks) summary[c.status]++;
@@ -24498,168 +24794,6 @@ var init_runner = __esm({
24498
24794
  }
24499
24795
  });
24500
24796
 
24501
- // src/ci/scaffold.ts
24502
- import { readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
24503
- import { basename, join as join6, relative as relative3 } from "node:path";
24504
- function walk3(root, visit) {
24505
- let n = 0;
24506
- const go = (dir, depth) => {
24507
- if (depth > 8 || n > MAX_FILES2) return;
24508
- let names = [];
24509
- try {
24510
- names = readdirSync3(dir).sort();
24511
- } catch {
24512
- return;
24513
- }
24514
- for (const name of names) {
24515
- if (IGNORE.has(name) || name.startsWith(".") && name !== ".well-known") continue;
24516
- const abs = join6(dir, name);
24517
- let st;
24518
- try {
24519
- st = statSync2(abs);
24520
- } catch {
24521
- continue;
24522
- }
24523
- if (st.isDirectory()) go(abs, depth + 1);
24524
- else if (st.isFile() && st.size < 4e5) {
24525
- n++;
24526
- visit(relative3(root, abs).split("\\").join("/"));
24527
- }
24528
- }
24529
- };
24530
- go(root, 0);
24531
- }
24532
- function emailKindOf(file) {
24533
- const name = basename(file).replace(/\.[^.]+$/, "");
24534
- return EMAIL_NAMES.find(([re]) => re.test(name))?.[1];
24535
- }
24536
- function scaffoldConfig(root, env2 = {}) {
24537
- const det = detectProject(root, env2);
24538
- const todos = [];
24539
- const found = [];
24540
- const names = /* @__PURE__ */ new Set();
24541
- const merchant = {};
24542
- merchant.name = det.brand?.name ?? "TODO: the brand subscribers see";
24543
- if (!det.brand) todos.push("merchant.name: the brand subscribers see");
24544
- const cancelPath = det.policyPaths.cancellation;
24545
- if (det.siteUrl && cancelPath) merchant.cancel_url = `${originOf(det.siteUrl)}${cancelPath}`;
24546
- else {
24547
- merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
24548
- todos.push("merchant.cancel_url: the page where subscribers cancel online");
24549
- }
24550
- const plans = det.plans.map((p, i) => ({
24551
- name: p.name ?? `Plan ${i + 1}`,
24552
- amount: p.amount,
24553
- currency: p.currency,
24554
- interval: p.interval,
24555
- ...p.interval_count ? { interval_count: p.interval_count } : {},
24556
- ...p.trial_days ? { trial_days: p.trial_days } : {}
24557
- }));
24558
- for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
24559
- if (!plans.length) {
24560
- plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
24561
- todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
24562
- }
24563
- const planNames = plans.map((p) => String(p.name));
24564
- const planFor = () => plans.length > 1 ? { plan: `TODO: one of ${planNames.map((n) => JSON.stringify(n)).join(", ")}` } : {};
24565
- if (plans.length > 1) todos.push("plan: pick the plan of each disclosure, page and email entry");
24566
- const stripe = [];
24567
- const perFile = /* @__PURE__ */ new Map();
24568
- for (const s2 of det.checkoutSessions) {
24569
- const call = (perFile.get(s2.file) ?? 0) + 1;
24570
- perFile.set(s2.file, call);
24571
- const entry = { name: unique(names, `Stripe Checkout (${basename(s2.file)}${call > 1 ? ` #${call}` : ""})`), source: s2.file, ...call > 1 ? { call } : {} };
24572
- if (!s2.params) {
24573
- todos.push(`${s2.file}:${s2.line}: the params are not an object literal; use "module" (an exported function returning the params) instead of "source"`);
24574
- delete entry.source;
24575
- delete entry.call;
24576
- entry.module = `TODO: ${s2.file.replace(/\.[^.]+$/, "")}.ts#buildCheckoutParams`;
24577
- } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
24578
- else found.push(`${s2.file}:${s2.line}: Checkout Session`);
24579
- stripe.push(entry);
24580
- }
24581
- const emails = [];
24582
- const pages = [];
24583
- const disclosures = [];
24584
- walk3(root, (rel2) => {
24585
- if (IN_EMAIL_DIR.test(rel2)) {
24586
- const kind = emailKindOf(rel2);
24587
- if (/\.(?:html?|txt|md)$/i.test(rel2) && kind) {
24588
- emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...planFor() });
24589
- found.push(`${rel2}: ${kind} email`);
24590
- return;
24591
- }
24592
- if (/\.[jt]sx$|\.mjml$/i.test(rel2) && kind) {
24593
- todos.push(`${rel2}: a ${kind} email component; add it with "module": "<file>#<export>" returning the rendered { subject, html } (React Email: render() with sample props)`);
24594
- return;
24595
- }
24596
- }
24597
- if (PAGE_FILE.test(rel2)) {
24598
- const kind = /checkout|subscribe/i.test(rel2) ? "checkout" : "pricing";
24599
- pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor() });
24600
- found.push(`${rel2}: ${kind} page`);
24601
- return;
24602
- }
24603
- if (/\.(?:txt|md)$/i.test(rel2) && !/(?:^|\/)(?:README|CHANGELOG|LICENSE|CONTRIBUTING|AGENTS|CLAUDE)[^/]*$/i.test(rel2) && !rel2.startsWith("docs/")) {
24604
- let text = "";
24605
- try {
24606
- text = readFileSync7(join6(root, rel2), "utf8");
24607
- } catch {
24608
- return;
24609
- }
24610
- if (text.length < 1500 && DISCLOSURE_TEXT.test(text)) {
24611
- disclosures.push({ name: unique(names, `Disclosure: ${basename(rel2)}`), file: rel2, ...planFor() });
24612
- found.push(`${rel2}: disclosure text`);
24613
- }
24614
- }
24615
- });
24616
- if (!disclosures.length && det.payButtons.length) {
24617
- todos.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
24618
- }
24619
- const checks = {};
24620
- if (stripe.length) checks.stripe_checkout = stripe;
24621
- if (disclosures.length) checks.disclosures = disclosures;
24622
- if (pages.length) checks.pages = pages;
24623
- if (emails.length) checks.emails = emails;
24624
- checks.notice_schedule = true;
24625
- if (!stripe.length && !disclosures.length && !pages.length && !emails.length) {
24626
- todos.push('No checkout code, email templates or pages were found: add entries under "checks" (https://checkbox.site/docs/guides/ci#config)');
24627
- }
24628
- return {
24629
- config: { $schema: SCHEMA_URL, merchant, jurisdictions: "all", plans, checks },
24630
- todos,
24631
- found
24632
- };
24633
- }
24634
- var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique;
24635
- var init_scaffold = __esm({
24636
- "src/ci/scaffold.ts"() {
24637
- "use strict";
24638
- init_detect();
24639
- init_config3();
24640
- IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "coverage", ".astro", "vendor", ".venv", "__pycache__", ".github"]);
24641
- MAX_FILES2 = 5e3;
24642
- EMAIL_NAMES = [
24643
- [/trial/i, "trial_reminder"],
24644
- [/price[-_ ]?(?:change|increase|update)/i, "price_change"],
24645
- [/annual|yearly[-_ ]?terms/i, "annual_terms"],
24646
- [/renew/i, "renewal_reminder"],
24647
- [/cancel/i, "cancellation_confirmation"],
24648
- [/receipt|charge|invoice|payment[-_ ]?(?:succeeded|receipt)/i, "charge_notice"],
24649
- [/welcome|confirm|acknowledg|subscribed|signup|sign[-_]up|order/i, "acknowledgment"]
24650
- ];
24651
- IN_EMAIL_DIR = /(?:^|\/)(?:e-?mails?|mail(?:ers?)?|notifications?)(?:\/|$)|(?:^|\/)templates\/(?:e-?mails?|mail)\//i;
24652
- PAGE_FILE = /(?:^|\/)(?:public|dist|out|build|static|_site|www)\/(?:.*\/)?(pricing|checkout|subscribe|plans)(?:\/index)?\.html?$/i;
24653
- DISCLOSURE_TEXT = /renews? automatically|automatically renews?|auto-?renew|until (?:you )?cancel/i;
24654
- unique = (names, base) => {
24655
- let n = base;
24656
- for (let i = 2; names.has(n); i++) n = `${base} ${i}`;
24657
- names.add(n);
24658
- return n;
24659
- };
24660
- }
24661
- });
24662
-
24663
24797
  // src/ci/workflow.ts
24664
24798
  var WORKFLOW_PATH, WORKFLOW_YAML;
24665
24799
  var init_workflow = __esm({
@@ -24706,12 +24840,12 @@ __export(ci_exports, {
24706
24840
  import { execFileSync } from "node:child_process";
24707
24841
  import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
24708
24842
  import { createInterface } from "node:readline";
24709
- import { dirname as dirname4, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
24843
+ import { dirname as dirname4, join as join8, relative as relative5, resolve as resolve5 } from "node:path";
24710
24844
  function gitChanged(cwd, base) {
24711
24845
  try {
24712
24846
  const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
24713
24847
  const out = execFileSync("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
24714
- return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve4(root, f)) };
24848
+ return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve5(root, f)) };
24715
24849
  } catch (err) {
24716
24850
  const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
24717
24851
  return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
@@ -24722,7 +24856,7 @@ function touchesConfig(loaded, jobs, changed) {
24722
24856
  if (set.has(loaded.path)) return true;
24723
24857
  const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
24724
24858
  for (const j of jobs) {
24725
- for (const f of j.inputs) if (set.has(resolve4(loaded.dir, f))) return true;
24859
+ for (const f of j.inputs) if (set.has(resolve5(loaded.dir, f))) return true;
24726
24860
  }
24727
24861
  const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
24728
24862
  return hasModules && codeChanged;
@@ -24747,8 +24881,8 @@ async function runInit(ctx, argv) {
24747
24881
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
24748
24882
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
24749
24883
  const explicit = str(p.flags, "config");
24750
- const configPath = explicit ? resolve4(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join7(ctx.cwd, f)).find((f) => existsSync6(f)) ?? join7(ctx.cwd, CONFIG_FILES[0]);
24751
- const workflowPath = join7(ctx.cwd, WORKFLOW_PATH);
24884
+ const configPath = explicit ? resolve5(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join8(ctx.cwd, f)).find((f) => existsSync6(f)) ?? join8(ctx.cwd, CONFIG_FILES[0]);
24885
+ const workflowPath = join8(ctx.cwd, WORKFLOW_PATH);
24752
24886
  const force = bool(p.flags, "force") === true;
24753
24887
  const withWorkflow = bool(p.flags, "workflow") !== false;
24754
24888
  const s2 = scaffoldConfig(ctx.cwd, ctx.env);
@@ -24761,7 +24895,7 @@ async function runInit(ctx, argv) {
24761
24895
  let write = !dry && toWrite.length > 0;
24762
24896
  if (write && bool(p.flags, "yes") !== true) {
24763
24897
  if (process.stdin.isTTY && !ctx.flags.json) {
24764
- for (const f of toWrite) ctx.out.info(`${f.action === "create" ? "create" : "overwrite"} ${relative4(ctx.cwd, f.path)}`);
24898
+ for (const f of toWrite) ctx.out.info(`${f.action === "create" ? "create" : "overwrite"} ${relative5(ctx.cwd, f.path)}`);
24765
24899
  write = await confirm("Write these files? [Y/n] ");
24766
24900
  } else write = false;
24767
24901
  }
@@ -24771,7 +24905,7 @@ async function runInit(ctx, argv) {
24771
24905
  writeFileSync2(f.path, f.content);
24772
24906
  }
24773
24907
  }
24774
- const relName = (f) => relative4(ctx.cwd, f) || f;
24908
+ const relName = (f) => relative5(ctx.cwd, f) || f;
24775
24909
  const result = {
24776
24910
  object: "ci_init",
24777
24911
  written: write ? toWrite.map((f) => relName(f.path)) : [],
@@ -24800,6 +24934,7 @@ var init_ci = __esm({
24800
24934
  "use strict";
24801
24935
  init_args();
24802
24936
  init_config3();
24937
+ init_coverage();
24803
24938
  init_inputs();
24804
24939
  init_report();
24805
24940
  init_runner();
@@ -24858,21 +24993,26 @@ var init_ci = __esm({
24858
24993
  loaded = loadConfig(path);
24859
24994
  } catch (err) {
24860
24995
  if (!(err instanceof ConfigError)) throw err;
24861
- const file = relative4(ctx.env.GITHUB_WORKSPACE ?? ctx.cwd, path) || path;
24996
+ const file = relative5(ctx.env.GITHUB_WORKSPACE ?? ctx.cwd, path) || path;
24862
24997
  if (gha) for (const i of err.issues) emit(`::error file=${file},title=Checkbox config::${i.replace(/%/g, "%25").replace(/\n/g, "%0A")}`);
24863
- ctx.out.error({ message: `${relative4(ctx.cwd, path) || path} is not valid:
24998
+ ctx.out.error({ message: `${relative5(ctx.cwd, path) || path} is not valid:
24864
24999
  - ${err.issues.join("\n - ")}`, code: "config_invalid", type: "usage_error", issues: err.issues, next_action: { type: "edit_config", message: "Fix the keys listed above (reference: https://checkbox.site/docs/guides/ci#config)." } });
24865
25000
  return EXIT.usage;
24866
25001
  }
24867
- const configName = relative4(ctx.cwd, path) || path;
25002
+ const configName = relative5(ctx.cwd, path) || path;
24868
25003
  const jobs = await resolveJobs(loaded, { online, env: ctx.env, root: ctx.env.GITHUB_WORKSPACE ?? ctx.cwd });
25004
+ const workspace = ctx.env.GITHUB_WORKSPACE ?? ctx.cwd;
25005
+ const extra = (loaded.config.coverage === false ? [] : coverageResults(coverageOf(loaded, loaded.dir))).map((r) => ({
25006
+ ...r,
25007
+ target: { ...r.target, file: relative5(workspace, join8(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
25008
+ }));
24869
25009
  if (bool(p.flags, "changed-only")) {
24870
25010
  const base = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
24871
25011
  if (!base) ctx.out.info("--changed-only: no --base and not a pull request (GITHUB_BASE_REF); running every check.");
24872
25012
  else {
24873
25013
  const changed = gitChanged(ctx.cwd, base);
24874
25014
  if (typeof changed === "string") ctx.out.warn(`${changed}; running every check.`);
24875
- else if (!touchesConfig(loaded, jobs, changed.files)) {
25015
+ else if (!extra.length && !touchesConfig(loaded, jobs, changed.files)) {
24876
25016
  const message = `No file checkbox.json reads changed since ${base}: ${jobs.length} check(s) skipped.`;
24877
25017
  if (gha) {
24878
25018
  writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, `## Checkbox compliance checks
@@ -24887,7 +25027,7 @@ ${message}
24887
25027
  }
24888
25028
  }
24889
25029
  const engine = online ? onlineEngine(ctx.client()) : offlineEngine();
24890
- const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict });
25030
+ const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra });
24891
25031
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
24892
25032
  if (gha) {
24893
25033
  for (const a of annotations(run2)) emit(a);
@@ -25119,8 +25259,8 @@ var init_patch = __esm({
25119
25259
  });
25120
25260
 
25121
25261
  // src/commands/plan/fix.ts
25122
- import { existsSync as existsSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "node:fs";
25123
- import { relative as relative5, resolve as resolve5 } from "node:path";
25262
+ import { existsSync as existsSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
25263
+ import { relative as relative6, resolve as resolve6 } from "node:path";
25124
25264
  function fixForTask(task, cwd, file) {
25125
25265
  const base = {
25126
25266
  object: "task_fix",
@@ -25132,17 +25272,17 @@ function fixForTask(task, cwd, file) {
25132
25272
  verify: task.verify
25133
25273
  };
25134
25274
  const verifyCmd = `checkbox verify ${task.id} --wait`;
25135
- const candidate = file ?? (task.where.file_hint && existsSync7(resolve5(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
25275
+ const candidate = file ?? (task.where.file_hint && existsSync7(resolve6(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
25136
25276
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
25137
- const path = resolve5(cwd, candidate);
25277
+ const path = resolve6(cwd, candidate);
25138
25278
  let src;
25139
25279
  try {
25140
- src = readFileSync8(path, "utf8");
25280
+ src = readFileSync9(path, "utf8");
25141
25281
  } catch (err) {
25142
25282
  throw new UsageError(`Cannot read ${candidate}: ${err.code ?? err.message}`);
25143
25283
  }
25144
25284
  const r = applyEdits(src, task.change.edits, { jsx: isJsxFile(candidate) });
25145
- const rel2 = relative5(cwd, path).split("\\").join("/");
25285
+ const rel2 = relative6(cwd, path).split("\\").join("/");
25146
25286
  const diff2 = unifiedDiff(src, r.patched, rel2);
25147
25287
  const complete = r.unmatched.length === 0;
25148
25288
  return {
@@ -25197,7 +25337,7 @@ var init_fix = __esm({
25197
25337
  const task = await ctx.client().sdk.tasks.retrieve(id);
25198
25338
  const result = fixForTask(task, ctx.cwd, str(flags, "file"));
25199
25339
  const out = str(flags, "out");
25200
- if (out && result.diff) writeFileSync3(resolve5(ctx.cwd, out), result.diff);
25340
+ if (out && result.diff) writeFileSync3(resolve6(ctx.cwd, out), result.diff);
25201
25341
  ctx.out.result({ ...result, task_object: task }, () => {
25202
25342
  const c = ctx.out.c;
25203
25343
  const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
@@ -25299,11 +25439,11 @@ var init_device = __esm({
25299
25439
  });
25300
25440
 
25301
25441
  // src/commands/init/patch.ts
25302
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
25303
- import { dirname as dirname5, join as join8 } from "node:path";
25442
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
25443
+ import { dirname as dirname5, join as join9 } from "node:path";
25304
25444
  function read(root, rel2) {
25305
25445
  try {
25306
- return readFileSync9(join8(root, rel2), "utf8");
25446
+ return readFileSync10(join9(root, rel2), "utf8");
25307
25447
  } catch {
25308
25448
  return null;
25309
25449
  }
@@ -25466,7 +25606,7 @@ function planGitignore(d) {
25466
25606
  const covered = lines.some((l) => l === env2 || l === `/${env2}` || l === ".env*" || l === "*.env" || env2 === ".env.local" && (l === ".env*.local" || l === "*.local" || l === ".env.local*") || env2 === ".env" && l === ".env*");
25467
25607
  if (covered) return null;
25468
25608
  }
25469
- if (existing === null && !existsSync8(join8(d.root, ".git"))) return null;
25609
+ if (existing === null && !existsSync8(join9(d.root, ".git"))) return null;
25470
25610
  const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
25471
25611
  ${env2}
25472
25612
  `;
@@ -25482,7 +25622,7 @@ function planWellKnown(d, token) {
25482
25622
  }
25483
25623
  function applyChange(root, c) {
25484
25624
  if (c.action === "unchanged" || c.contents === void 0) return;
25485
- const abs = join8(root, c.file);
25625
+ const abs = join9(root, c.file);
25486
25626
  mkdirSync3(dirname5(abs), { recursive: true });
25487
25627
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
25488
25628
  }
@@ -25545,7 +25685,7 @@ function saveState(ctx, root, patch) {
25545
25685
  async function ask2(question) {
25546
25686
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
25547
25687
  try {
25548
- return await new Promise((resolve8) => rl.question(question, resolve8));
25688
+ return await new Promise((resolve9) => rl.question(question, resolve9));
25549
25689
  } finally {
25550
25690
  rl.close();
25551
25691
  }
@@ -26092,7 +26232,7 @@ import { createInterface as createInterface3 } from "node:readline";
26092
26232
  async function prompt(question) {
26093
26233
  const rl = createInterface3({ input: process.stdin, output: process.stderr, terminal: true });
26094
26234
  try {
26095
- return await new Promise((resolve8) => rl.question(question, resolve8));
26235
+ return await new Promise((resolve9) => rl.question(question, resolve9));
26096
26236
  } finally {
26097
26237
  rl.close();
26098
26238
  }
@@ -26187,7 +26327,7 @@ var init_logout = __esm({
26187
26327
  });
26188
26328
 
26189
26329
  // src/commands/plan/lib.ts
26190
- import { dirname as dirname6, resolve as resolve6 } from "node:path";
26330
+ import { dirname as dirname6, resolve as resolve7 } from "node:path";
26191
26331
  async function resolveSite(ctx, flag, client) {
26192
26332
  const explicit = flag ?? ctx.env.CHECKBOX_SITE;
26193
26333
  if (explicit) {
@@ -26195,7 +26335,7 @@ async function resolveSite(ctx, flag, client) {
26195
26335
  return explicit;
26196
26336
  }
26197
26337
  const projects = ctx.config.load().init?.projects ?? {};
26198
- for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
26338
+ for (let dir = resolve7(ctx.cwd); ; dir = dirname6(dir)) {
26199
26339
  const site = projects[dir]?.site;
26200
26340
  if (site) return site;
26201
26341
  if (dirname6(dir) === dir) break;
@@ -26294,11 +26434,11 @@ var init_plan = __esm({
26294
26434
  });
26295
26435
 
26296
26436
  // src/commands/plan/verify.ts
26297
- import { readFileSync as readFileSync10 } from "node:fs";
26298
- import { resolve as resolve7 } from "node:path";
26437
+ import { readFileSync as readFileSync11 } from "node:fs";
26438
+ import { resolve as resolve8 } from "node:path";
26299
26439
  function readJson2(ctx, file, what) {
26300
26440
  try {
26301
- const v = JSON.parse(readFileSync10(resolve7(ctx.cwd, file), "utf8"));
26441
+ const v = JSON.parse(readFileSync11(resolve8(ctx.cwd, file), "utf8"));
26302
26442
  if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
26303
26443
  return v;
26304
26444
  } catch (err) {
@@ -26344,7 +26484,7 @@ var init_verify2 = __esm({
26344
26484
  const htmlFile = str(flags, "html-file");
26345
26485
  if (htmlFile) {
26346
26486
  try {
26347
- body.html = readFileSync10(resolve7(ctx.cwd, htmlFile), "utf8");
26487
+ body.html = readFileSync11(resolve8(ctx.cwd, htmlFile), "utf8");
26348
26488
  } catch (err) {
26349
26489
  throw new UsageError(`Cannot read ${htmlFile}: ${err.code ?? err.message}`);
26350
26490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkbox-site/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The checkbox CLI: automatic-renewal law checks (ROSCA, California ARL, New York and other states) for subscription checkouts, disclosures and emails, from a terminal, CI or an AI coding agent. Runs offline.",
5
5
  "keywords": [
6
6
  "subscription",