@checkbox-site/cli 0.4.3 → 0.4.4

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 +520 -166
  2. package/package.json +2 -1
package/dist/checkbox.js CHANGED
@@ -2444,7 +2444,7 @@ var init_operations = __esm({
2444
2444
  /**
2445
2445
  * Connect a billing platform.
2446
2446
  *
2447
- * Connects the site's billing platform with the one restricted key (a Stripe restricted key, a Recharge token, a Shopify Admin API token, a Chargebee read-only API key, a Recurly private API key or a Paddle API key with read permissions), so Checkbox reads the customer-portal and reminder settings. Stripe: One Stripe restricted key per site: Read permissions for the checks, and Write permissions that Checkbox uses only after you click Approve on a fix, or in test mode (the one exception: Webhook Endpoints, used once to add Checkbox's webhook endpoint when you connect the key) (checklist: https://checkbox.site/docs/guides/stripe-key). A full secret key (sk_test_ / sk_live_) is refused on every path with test_key_not_restricted: use a restricted key. API keys and AI agents may connect TEST-mode keys (rk_test_…) only; a live key (rk_live_ / sk_live_) is refused with live_key_requires_dashboard, because only a person signed in to the Checkbox dashboard can connect one. The credentials are validated by reading the settings once and stored encrypted. When the key's Stripe account looks like another site's (the site's prices or Payment Links are not in it, or it is already connected in another workspace), nothing is connected and the error billing_account_mismatch asks the human to confirm; retry with confirm_account_mismatch: true only after they said yes. The account owner is emailed about every key connected.
2447
+ * Connects the site's billing platform with the one restricted key (a Stripe restricted key, a Recharge token, a Shopify Admin API token, a Chargebee read-only API key, a Recurly private API key or a Paddle API key with read permissions), so Checkbox reads the customer-portal and reminder settings. Stripe: One Stripe restricted key per site: Read permissions for the checks, and Write permissions that Checkbox uses only after you click Approve on a fix, or in test mode (the one exception: Webhook Endpoints, used once to add Checkbox's webhook endpoint when you connect the key) (checklist: https://checkbox.site/docs/guides/stripe-key). A full secret key (sk_test_ / sk_live_) is refused on every path with test_key_not_restricted: use a restricted key. API keys and AI agents may connect TEST-mode keys (rk_test_…) only; a live key (rk_live_ / sk_live_) is refused with live_key_requires_dashboard, because only a person signed in to the Checkbox dashboard can connect one. The credentials are validated by reading the settings once and stored encrypted. When the key's Stripe account looks like another site's (the site's prices or Payment Links are not in it, or it is already connected in another workspace), nothing is connected and the error billing_account_mismatch asks the human to confirm; retry with confirm_account_mismatch: true only after they said yes; the key is then held until a person confirms the account in the dashboard (an agent cannot confirm it). The account owner is emailed about every key connected.
2448
2448
  *
2449
2449
  * @param site Unique identifier of the site (site_…).
2450
2450
  * @see POST /v1/sites/{site}/integrations (operation `connectBillingPlatform`, scope `sites:write`)
@@ -2874,7 +2874,7 @@ var init_operations = __esm({
2874
2874
  /**
2875
2875
  * List due notices.
2876
2876
  *
2877
- * The legally required emails you owe subscribers (sites in "You send" mode, the default): kind, the subscriber (billing-platform ids), the legal window for the subscriber's state, the recommended send date, the copy-ready email and the BCC address. Default: everything not yet sent (upcoming, due, overdue), soonest first. Live keys list live subscribers' notices, test keys test-mode ones. Send each one on its send_date (inside its window), then call markNoticeSent (or BCC the notice's bcc address). Subscribe to the notice.due webhook to get each one on its send date.
2877
+ * The legally required emails you owe subscribers (sites in "You send" mode, the default): kind, the subscriber (billing-platform ids), the legal window for the subscriber's state, the recommended send date, the email (ready to send once email.missing_fields is empty) and the BCC address. Default: everything not yet sent (upcoming, due, overdue), soonest first. Live keys list live subscribers' notices, test keys test-mode ones. Send each one on its send_date (inside its window), then call markNoticeSent (or BCC the notice's bcc address). Subscribe to the notice.due webhook to get each one on its send date.
2878
2878
  *
2879
2879
  * @see GET /v1/notices/due (operation `listDueNotices`, scope `notices:read`)
2880
2880
  */
@@ -2904,7 +2904,7 @@ var init_operations = __esm({
2904
2904
  /**
2905
2905
  * Retrieve a due notice.
2906
2906
  *
2907
- * One notice you owe a subscriber, with its window, recommended send date, copy-ready email, BCC address and send status.
2907
+ * One notice you owe a subscriber, with its window, recommended send date, email (ready to send once email.missing_fields is empty), BCC address and send status.
2908
2908
  *
2909
2909
  * @param notice Unique identifier of the due notice (ntd_…).
2910
2910
  * @see GET /v1/notices/{notice} (operation `retrieveDueNotice`, scope `notices:read`)
@@ -3257,7 +3257,7 @@ var init_operations = __esm({
3257
3257
  /**
3258
3258
  * Store a billing test-mode key.
3259
3259
  *
3260
- * Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); a key whose Stripe account looks like another site's is refused with billing_account_mismatch until the human confirms (confirm_account_mismatch: true); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
3260
+ * Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked "Let your agent store a Stripe test-mode key" on the approval page of the agent's sign-in or connection (else test_key_declined); a key whose Stripe account looks like another site's is refused with billing_account_mismatch (with confirm_account_mismatch: true the key is connected on hold until a person confirms the account in the dashboard; only a person can confirm it); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
3261
3261
  *
3262
3262
  * @param site Unique identifier of the site (site_…).
3263
3263
  * @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
@@ -3579,7 +3579,7 @@ var SDK_VERSION;
3579
3579
  var init_version = __esm({
3580
3580
  "../sdk-node/src/version.ts"() {
3581
3581
  "use strict";
3582
- SDK_VERSION = "0.2.1";
3582
+ SDK_VERSION = "0.2.2";
3583
3583
  }
3584
3584
  });
3585
3585
 
@@ -13103,6 +13103,14 @@ function offerSlice(c, g) {
13103
13103
  links: keep(c.links)
13104
13104
  };
13105
13105
  }
13106
+ function offerCardName(text, cta, index) {
13107
+ const t = normalizeText(text).replace(/\s+/g, " ").trim();
13108
+ const money3 = /(?:[$€£]\s?\d[\d,]*(?:\.\d{1,2})?|\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP))(?:\s*(?:\/|per|a|an|every)\s*(?:month|mo|year|yr|week|day|quarter)\b)?/i.exec(t);
13109
+ const before = (money3 ? t.slice(0, money3.index) : "").replace(new RegExp(`^${cta.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "").replace(/[\s:·|–—-]+$/, "").trim();
13110
+ const heading = before && before.length <= 40 ? before : before ? before.split(" ").slice(0, 4).join(" ") : "";
13111
+ const price3 = money3?.[0].trim();
13112
+ return heading && price3 ? `${heading}, ${price3}` : heading || price3 || `card ${index + 1}`;
13113
+ }
13106
13114
  function buildSignupView(capture, offer) {
13107
13115
  const c = offer === void 0 ? capture : offerSlice(capture, offer);
13108
13116
  const device = c.device === "mobile" ? "mobile" : "desktop";
@@ -13127,9 +13135,11 @@ function buildSignupView(capture, offer) {
13127
13135
  [...c.disclosures ?? [], ...checkboxes, ...allLinks].filter(isVisible).map((e) => e.style?.fontSizePx ?? NaN)
13128
13136
  );
13129
13137
  const offerCta = offer === void 0 ? void 0 : capture.offers?.[offer]?.cta ?? primaryCta2?.text;
13138
+ const sameCta = offer !== void 0 && offerCta ? (capture.offers ?? []).filter((o) => normalizeText(o.cta) === normalizeText(offerCta)).length > 1 : false;
13139
+ const card = sameCta ? offerCardName(capture.offers[offer].text, offerCta, offer) : void 0;
13130
13140
  return {
13131
13141
  capture,
13132
- label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D` : captureLabel(c),
13142
+ label: offerCta ? `${captureLabel(c)} \u2014 plan button \u201C${normalizeText(offerCta).slice(0, 60)}\u201D${card ? ` in the card \u201C${card}\u201D` : ""}` : captureLabel(c),
13133
13143
  ...offer !== void 0 ? { offer: { index: offer, cta: normalizeText(offerCta ?? "").slice(0, 120) } } : {},
13134
13144
  device,
13135
13145
  viewport,
@@ -15056,6 +15066,11 @@ function offerQuestionStop(flow) {
15056
15066
  const last = [...flow.steps ?? []].reverse().find((s2) => s2.pageKind !== "account");
15057
15067
  return last && OFFER_PERMISSION_RE.test(normalizeText(last.text)) ? last : void 0;
15058
15068
  }
15069
+ function stuckHint(flow) {
15070
+ const blockers = flow.blockers ?? [];
15071
+ if (flow.completed || blockers.some((b) => /answered the cancel request with an error/i.test(b))) return "";
15072
+ return blockers.some((b) => /no further cancel option|not completed within \d+ steps/i.test(b)) ? `. ${CANCEL_STEPS_HINT}` : "";
15073
+ }
15059
15074
  function offerQuestionStopResult(id, stop, refs2, data) {
15060
15075
  return makeResult(
15061
15076
  id,
@@ -15345,7 +15360,7 @@ function notRefreshedUnits(stale, fresh) {
15345
15360
  };
15346
15361
  });
15347
15362
  }
15348
- var CANCEL_MAX_STEPS, CANCEL_MAX_RETENTION_OFFERS, CANCEL_LINK_MIN_FONT_PX, CANCEL_LINK_MIN_CONTRAST, CANCEL_FLOW_MAX_AGE_DAYS, CLICK_TO_CANCEL_JURISDICTIONS, OFFER_PERMISSION_JURISDICTIONS, RETENTION_PAGE_RE, OFFER_PERMISSION_RE, ONLINE_ALTERNATIVE_RE, NO_CANCEL_ATTEMPT_MESSAGE, OBSTACLE_BLOCKER_RE, CONTACT_BLOCKER_RE, NOT_FOUND_BLOCKER_RE, RETENTION_BLOCKER_RE, TECHNICAL_BLOCKER_RE, CONTACT_TO_CANCEL_RE, CANCEL_CONTROL_RE, ORDER_ONLY_RE, MANAGE_RE, HOSTED_FLOW_NOTE, onlineAvailable, SUBSCRIPTION_CANCEL_LINK_RE, BILLING_ITEM_RE, NO_ACTIVE_SUBSCRIPTION_RE, FIND_RANK, mechanismFindable, flowFriction, CANCEL_CONFIRM_EMAIL_RE, confirmation, CANCELLATION_CHECKS;
15363
+ var CANCEL_MAX_STEPS, CANCEL_MAX_RETENTION_OFFERS, CANCEL_LINK_MIN_FONT_PX, CANCEL_LINK_MIN_CONTRAST, CANCEL_FLOW_MAX_AGE_DAYS, CLICK_TO_CANCEL_JURISDICTIONS, OFFER_PERMISSION_JURISDICTIONS, RETENTION_PAGE_RE, OFFER_PERMISSION_RE, ONLINE_ALTERNATIVE_RE, NO_CANCEL_ATTEMPT_MESSAGE, OBSTACLE_BLOCKER_RE, CONTACT_BLOCKER_RE, NOT_FOUND_BLOCKER_RE, RETENTION_BLOCKER_RE, TECHNICAL_BLOCKER_RE, CONTACT_TO_CANCEL_RE, CANCEL_CONTROL_RE, ORDER_ONLY_RE, MANAGE_RE, HOSTED_FLOW_NOTE, CANCEL_STEPS_HINT, onlineAvailable, SUBSCRIPTION_CANCEL_LINK_RE, BILLING_ITEM_RE, NO_ACTIVE_SUBSCRIPTION_RE, FIND_RANK, mechanismFindable, flowFriction, CANCEL_CONFIRM_EMAIL_RE, confirmation, CANCELLATION_CHECKS;
15349
15364
  var init_cancellation = __esm({
15350
15365
  "../rules/src/checks/cancellation.ts"() {
15351
15366
  "use strict";
@@ -15379,6 +15394,7 @@ var init_cancellation = __esm({
15379
15394
  ORDER_ONLY_RE = /\bcancel\s+(?:this\s+|my\s+)?order\b/i;
15380
15395
  MANAGE_RE = /\b(?:manage\s+(?:my\s+|your\s+)?(?:subscriptions?|membership|plan|billing)|billing\s+portal|customer\s+portal|subscription\s+settings|my\s+subscriptions?|membership\s+settings|billing\s+(?:&|and)\s+plans?)\b/i;
15381
15396
  HOSTED_FLOW_NOTE = "Checkbox-hosted cancellation page: subscribers confirm their email through a one-time link, then cancel in two clicks (choose the subscription, confirm), with at most one save offer shown behind a continuously displayed cancel button and an on-screen and emailed confirmation";
15397
+ CANCEL_STEPS_HINT = `If Checkbox took a wrong turn on your pages, list the clicks in the site's scan settings as cancel_steps (for example: click "Cancel membership", then click "No thanks, cancel") and re-check`;
15382
15398
  onlineAvailable = {
15383
15399
  id: "cancel.online_available",
15384
15400
  usesText: false,
@@ -15485,10 +15501,10 @@ var init_cancellation = __esm({
15485
15501
  return makeResult(
15486
15502
  id,
15487
15503
  "needs_review",
15488
- `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed`,
15504
+ `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}, so online cancellation could not be confirmed${stuckHint(flow)}`,
15489
15505
  refs2,
15490
15506
  0.4,
15491
- data
15507
+ stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data
15492
15508
  );
15493
15509
  }
15494
15510
  const offline = offlineOnlyCancellation(input);
@@ -15734,13 +15750,13 @@ var init_cancellation = __esm({
15734
15750
  const offerStop = offerQuestionStop(flow);
15735
15751
  if (offerStop) return offerQuestionStopResult(id, offerStop, refs2, data);
15736
15752
  if (b.other.length) {
15737
- return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}`, refs2, 0.6, data);
15753
+ return makeResult(id, "needs_review", `The cancellation flow reported: ${b.other.join("; ")}${stuckHint(flow)}`, refs2, 0.6, stuckHint(flow) ? { ...data, suggestCancelSteps: true } : data);
15738
15754
  }
15739
15755
  if (!flow.completed) {
15740
15756
  return makeResult(
15741
15757
  id,
15742
15758
  "needs_review",
15743
- `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed`,
15759
+ `The test cancellation did not complete${flow.blockers?.length ? ` (${flow.blockers.join("; ")})` : ""}; friction could not be fully assessed${stuckHint(flow)}`,
15744
15760
  refs2,
15745
15761
  0.4,
15746
15762
  data
@@ -15999,6 +16015,19 @@ var init_config2 = __esm({
15999
16015
  }
16000
16016
  return makeResult(id, "fail", `Self-serve cancellation is turned off in ${name}'s customer portal`, refs(input), 0.9, { ...data, cancelPath: "portal_only" });
16001
16017
  }
16018
+ if (model.otherOnlineWorks) {
16019
+ const flow = input.ctx.cancellation;
16020
+ const works = inApp?.state === "works" && !!flow;
16021
+ const via = works ? "subscribers cancel online through your own page (a test cancellation completed; see \u201Ccancel online\u201D)" : model.hosted?.state === "offered" ? `subscribers cancel through the Checkbox hosted cancellation page (${model.hosted.url}); see \u201Ccancel online\u201D` : `your own page offers a cancel option (${inApp?.url}); online cancellation is judged on that page (see \u201Ccancel online\u201D), so the portal setting is not required`;
16022
+ return makeResult(
16023
+ id,
16024
+ "not_applicable",
16025
+ `Checkbox could not read whether self-serve cancellation is on in ${name}'s portal, but ${via}`,
16026
+ [...refs(input), ...works ? [flow.id] : []],
16027
+ works ? 0.75 : 0.6,
16028
+ { ...data, verifiedBy: "cancel.online_available", ...works ? { cancellationId: flow.id } : { ownCancelPage: true }, ...inApp ? { cancelsInApp: true } : {} }
16029
+ );
16030
+ }
16002
16031
  return makeResult(id, "needs_review", `Could not read whether self-serve cancellation is enabled in ${name}`, refs(input), 0.4, data);
16003
16032
  }
16004
16033
  };
@@ -18529,7 +18558,12 @@ async function overViews(checkId, input, headlines, evaluateView) {
18529
18558
  for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
18530
18559
  const failing = outcomes.filter((o) => o.unit.status === "fail");
18531
18560
  const introOnly = checkId === "signup.disclosure.price" && failing.length > 0 && failing.every((o) => typeof o.unit.meta?.introFix === "string");
18532
- return aggregateViews(checkId, outcomes, introOnly ? { ...headlines, fail: "The introductory offer next to the purchase button is missing or misstated" } : headlines);
18561
+ const mismatchOnly = checkId === "signup.disclosure.price" && !introOnly && failing.length > 0 && failing.every((o) => o.unit.meta?.priceMismatch === true);
18562
+ return aggregateViews(
18563
+ checkId,
18564
+ outcomes,
18565
+ introOnly ? { ...headlines, fail: "The introductory offer next to the purchase button is missing or misstated" } : mismatchOnly ? { ...headlines, fail: "The recurring charge stated next to the purchase button is not what the plan charges" } : headlines
18566
+ );
18533
18567
  }
18534
18568
  function pageAnalysis(view) {
18535
18569
  const entry = heuristicCache.get(view) ?? {};
@@ -18821,6 +18855,12 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
18821
18855
  { quote: said, meta: { ...u.meta ?? {}, priceMismatch: true, introPriceMismatch: true, introFix: introFixLine(p.plan) } }
18822
18856
  );
18823
18857
  }
18858
+ function setOffAdvice(sizePx, contrast, weight, ref) {
18859
+ const refWeight = ref.fontWeight ?? 400;
18860
+ const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
18861
+ const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or shaded box (confirm by eye: borders and symbols cannot be measured from a capture)"];
18862
+ return `the renewal terms are legible (${sizePx}px, weight ${weight}${contrast !== null ? `, ${contrast}:1 contrast` : ""}) but look like the surrounding text (about ${ref.fontSizePx}px, weight ${refWeight}${refWeight >= 600 ? ": already bold, so a bolder weight cannot set them apart" : ""}, similar colour) \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border. Any one of these would: ${ways.join("; ")}`;
18863
+ }
18824
18864
  function trialObservedOutsideCheckout(input) {
18825
18865
  const ctx = input.ctx;
18826
18866
  return [...ctx.config?.plans ?? [], ...ctx.referencePlans ?? []].some((p) => (p.trialDays ?? 0) > 0) || (ctx.events ?? []).some((e) => e.type === "trial_will_end" || (e.trialDays ?? 0) > 0) || input.data.emails.some((e) => e.kind === "trial_ending");
@@ -19767,7 +19807,7 @@ ${view.pageText}`;
19767
19807
  if (setOff && !setOff.differentiators.length) {
19768
19808
  return unit(
19769
19809
  "needs_review",
19770
- setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast) but look like the surrounding text (about ${setOff.reference.fontSizePx}px, same weight and color) \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border (borders and symbols cannot be measured from a capture: confirm by eye)`,
19810
+ setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference),
19771
19811
  0.55,
19772
19812
  { meta: { ...meta, ...setOffMeta }, quote: q2 }
19773
19813
  );
@@ -20472,7 +20512,7 @@ var NOTICES_TEST_MODE_NOTE, NOTICE_KIND_CHECKS, NOT_SEEN_RE;
20472
20512
  var init_notices_on = __esm({
20473
20513
  "../rules/src/checks/notices-on.ts"() {
20474
20514
  "use strict";
20475
- NOTICES_TEST_MODE_NOTE = "Test mode: Checkbox Notices does not email test customers. Use 'Send me a test' or a test run to see it work.";
20515
+ NOTICES_TEST_MODE_NOTE = "Test mode: emails for a Stripe test customer go to you as a marked \u201CTest mode copy\u201D (and to the test-run inbox during a test run), never to the test customer. Use 'Send me a test' or a test run to see it work.";
20476
20516
  NOTICE_KIND_CHECKS = {
20477
20517
  acknowledgment: ["confirmation.email.sent", "confirmation.email.terms", "confirmation.email.cancellation_instructions", "confirmation.email.trial_terms"],
20478
20518
  trial_ending: ["reminder.trial_ending", "config.trial_reminder_enabled"],
@@ -22016,6 +22056,9 @@ function planFrequencyFix(c) {
22016
22056
  const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
22017
22057
  return every ? `State how often it is charged ("${every}").` : void 0;
22018
22058
  }
22059
+ function billingUnread(findings) {
22060
+ return findings.some((f) => String(f.checkId).startsWith("config.") && /^No billing platform (?:connected|settings to read)/i.test(f.detail ?? ""));
22061
+ }
22019
22062
  function rollup(items, opts = {}) {
22020
22063
  const counts = { needs_fix: 0, your_call: 0, pending_live_check: 0, verified_live: 0, compliant: 0, not_applicable: 0, not_set_up: 0, not_checked: 0 };
22021
22064
  const pending = [];
@@ -22059,7 +22102,8 @@ function rollup(items, opts = {}) {
22059
22102
  label = `${counts.not_checked} not checked yet${counts.pending_live_check ? ` \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : ""}`;
22060
22103
  } else {
22061
22104
  status = "compliant";
22062
- label = counts.pending_live_check ? `Checks pass \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : counts.verified_live ? "Checks pass \xB7 verified live" : "Checks pass";
22105
+ const pass = opts.billingUnread ? CHECKS_PASS_UNREAD_LABEL : "Checks pass";
22106
+ label = counts.pending_live_check ? `${pass} \xB7 ${plural2(counts.pending_live_check, "live check", "live checks")} pending` : counts.verified_live ? `${pass} \xB7 verified live` : pass;
22063
22107
  }
22064
22108
  return { status, label, counts, pending, suggestions, ...opts.groupOf ? { byTask: Object.fromEntries(groups) } : {} };
22065
22109
  }
@@ -22092,7 +22136,7 @@ function classifyFindings(findings) {
22092
22136
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22093
22137
  }));
22094
22138
  }
22095
- var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, STATUS_RANK;
22139
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22096
22140
  var init_status = __esm({
22097
22141
  "../rules/src/status.ts"() {
22098
22142
  "use strict";
@@ -22781,6 +22825,7 @@ var init_status = __esm({
22781
22825
  "terms.refund_policy_consistent": "Make the refund policy match the money-back promise on your site.",
22782
22826
  "terms.renewal_terms_present": "Add a short automatic-renewal section to your Terms."
22783
22827
  };
22828
+ CHECKS_PASS_UNREAD_LABEL = "Checks pass on what we can see";
22784
22829
  STATUS_RANK = { needs_fix: 0, not_set_up: 1, your_call: 2, not_checked: 3, pending_live_check: 4, verified_live: 5, compliant: 6, not_applicable: 7 };
22785
22830
  }
22786
22831
  });
@@ -23140,7 +23185,7 @@ async function runEvaluationInScope(input, opts) {
23140
23185
  }
23141
23186
  const classified = classifyFindings(findings);
23142
23187
  for (const { finding, info } of classified) finding.compliance = info;
23143
- const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf });
23188
+ const complianceRollup = rollup(classified.map(({ finding, info }) => ({ checkId: finding.checkId, info })), { groupOf: taskGroupOf, billingUnread: billingUnread(findings) });
23144
23189
  const { score, insurable } = scoreFindings(findings);
23145
23190
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0 };
23146
23191
  for (const f of findings) summary[f.status]++;
@@ -24488,16 +24533,24 @@ function readParams(params, merchant, kind, asOf) {
24488
24533
  throw new PreflightInputError("The billing interval is unknown: pass line_items[].price_data.recurring.interval or merchant.interval", "merchant.interval");
24489
24534
  }
24490
24535
  const plan2 = resolvePlan(merged, "merchant.");
24491
- if (coupon && merchantIntro && coupon.duration !== "forever" && plan2.introAmount !== void 0) {
24536
+ if (coupon && merchantIntro && plan2.introAmount !== void 0) {
24492
24537
  const pct = typeof coupon.percent_off === "number" ? coupon.percent_off : void 0;
24493
24538
  const off = typeof coupon.amount_off === "number" ? coupon.amount_off : void 0;
24494
24539
  const fromCoupon = pct !== void 0 ? Math.round(plan2.amount * (1 - pct / 100)) : off !== void 0 ? plan2.amount - off : void 0;
24495
- if (fromCoupon !== void 0 && Math.abs(fromCoupon - plan2.introAmount) >= 1) {
24540
+ const months = plan2.interval === "year" ? 12 * plan2.intervalCount : plan2.interval === "month" ? plan2.intervalCount : void 0;
24541
+ const couponPeriods = coupon.duration === "forever" ? Infinity : coupon.duration === "once" ? 1 : coupon.duration === "repeating" && typeof coupon.duration_in_months === "number" && months ? Math.max(1, Math.ceil(coupon.duration_in_months / months)) : void 0;
24542
+ const declaredPeriods = plan2.introPeriods ?? 1;
24543
+ const amountDiffers = fromCoupon !== void 0 && Math.abs(fromCoupon - plan2.introAmount) >= 1;
24544
+ const durationDiffers = couponPeriods !== void 0 && couponPeriods !== declaredPeriods;
24545
+ if (amountDiffers || durationDiffers) {
24496
24546
  const id = typeof coupon.id === "string" ? `coupon ${coupon.id}` : "the coupon";
24547
+ const per = (n) => n === Infinity ? "every billing period (forever)" : n === 1 ? "the first billing period" : `the first ${n} billing periods`;
24548
+ const couponSays = `${pct !== void 0 ? `${pct}% off` : off !== void 0 ? `${formatMoney(off, plan2.currency)} off` : "a discount"}${fromCoupon !== void 0 ? ` (${formatMoney(fromCoupon, plan2.currency)})` : ""} for ${couponPeriods !== void 0 ? per(couponPeriods) : `an unknown duration (${String(coupon.duration ?? "not given")})`}`;
24549
+ const declaredSays = `${formatMoney(plan2.introAmount, plan2.currency)} for ${per(declaredPeriods)}`;
24497
24550
  caveats.push({
24498
24551
  checks: ["signup.disclosure.price"],
24499
24552
  status: "needs_review",
24500
- reason: `discounts apply ${id} (${pct !== void 0 ? `${pct}% off` : `${off} off in minor units`}), which makes the first charge ${fromCoupon} in minor units, but the plan's declared intro price is ${plan2.introAmount}: the coupon or the declared intro is wrong \u2014 make them the same, then state that promo`,
24553
+ reason: `discounts apply ${id}, which gives ${couponSays}, but the plan's declared intro is ${declaredSays}: the coupon or the declared intro is wrong \u2014 make them the same, then state that promo`,
24501
24554
  param: "params.discounts"
24502
24555
  });
24503
24556
  }
@@ -26460,7 +26513,7 @@ function parseObjectLiteral(source, env2 = {}) {
26460
26513
  const p = new Parser(source, env2);
26461
26514
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26462
26515
  const v = p.object("");
26463
- return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)] };
26516
+ return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
26464
26517
  }
26465
26518
  var UNKNOWN, Parser;
26466
26519
  var init_literal = __esm({
@@ -26474,6 +26527,7 @@ var init_literal = __esm({
26474
26527
  }
26475
26528
  i = 0;
26476
26529
  unresolved = [];
26530
+ fromEnv = {};
26477
26531
  ws() {
26478
26532
  for (; ; ) {
26479
26533
  while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
@@ -26560,7 +26614,10 @@ var init_literal = __esm({
26560
26614
  if (name === "null") return null;
26561
26615
  if (name === "undefined") return void 0;
26562
26616
  const env2 = /^(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
26563
- if (env2) return this.env[env2[1]] ?? UNKNOWN;
26617
+ if (env2) {
26618
+ if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
26619
+ return this.env[env2[1]] ?? UNKNOWN;
26620
+ }
26564
26621
  return UNKNOWN;
26565
26622
  }
26566
26623
  this.skipExpr();
@@ -27001,8 +27058,8 @@ var init_detect = __esm({
27001
27058
  });
27002
27059
 
27003
27060
  // src/ci/scaffold.ts
27004
- import { readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27005
- import { basename, join as join5, relative as relative2 } from "node:path";
27061
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27062
+ import { basename, dirname as dirname3, join as join5, relative as relative2 } from "node:path";
27006
27063
  function walk3(root, visit) {
27007
27064
  let n = 0;
27008
27065
  const go = (dir, depth) => {
@@ -27035,20 +27092,70 @@ function emailKindOf(file) {
27035
27092
  const name = basename(file).replace(/\.[^.]+$/, "");
27036
27093
  return EMAIL_NAMES.find(([re]) => re.test(name))?.[1];
27037
27094
  }
27038
- function scaffoldConfig(root, env2 = {}) {
27095
+ function plansFromLiterals(text, file) {
27096
+ const out = [];
27097
+ for (const m of text.matchAll(/(?:(?:['"]?)([A-Za-z_$][\w$ -]*)['"]?\s*:\s*)?\{([^{}]{0,600})\}/g)) {
27098
+ const body = m[2];
27099
+ const interval = /\binterval\s*:\s*['"](day|week|month|year)['"]/.exec(body)?.[1];
27100
+ if (!interval) continue;
27101
+ const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
27102
+ const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
27103
+ const major = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
27104
+ if (major === void 0 || !(major > 0)) continue;
27105
+ const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
27106
+ if (!name) continue;
27107
+ const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
27108
+ const count = /\binterval_?count\s*:\s*(\d+)/i.exec(body)?.[1];
27109
+ const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
27110
+ if (out.some((p) => p.name === name)) continue;
27111
+ const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27112
+ out.push({ name, ...key ? { key } : {}, price: major, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, source: file });
27113
+ }
27114
+ return out;
27115
+ }
27116
+ function paramsBuilder(root, file, line2) {
27117
+ let text;
27118
+ try {
27119
+ text = readFileSync6(join5(root, file), "utf8");
27120
+ } catch {
27121
+ return void 0;
27122
+ }
27123
+ const from = text.split("\n").slice(line2 - 1).join("\n");
27124
+ const call = /checkout\.sessions\.create\s*\(\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/.exec(from);
27125
+ if (!call) return void 0;
27126
+ const fn = call[1];
27127
+ const takesArgs = call[2].trim().length > 0;
27128
+ const imp = new RegExp(`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from\\s*['"](\\.{1,2}/[^'"]+)['"]`).exec(text);
27129
+ let target;
27130
+ if (imp) {
27131
+ const base = join5(dirname3(file), imp[1]).split("\\").join("/");
27132
+ target = [base, ...[".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.js"].map((e) => `${base.replace(/\.(?:[cm]?js)$/, "")}${e}`)].find((f) => /\.[cm]?[jt]sx?$/.test(f) && existsSync5(join5(root, f)));
27133
+ } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27134
+ return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
27135
+ }
27136
+ function scaffoldConfig(root, env2 = {}, facts = {}) {
27039
27137
  const det = detectProject(root, env2);
27040
27138
  const todos = [];
27041
27139
  const found = [];
27042
27140
  const names = /* @__PURE__ */ new Set();
27043
27141
  const merchant = {};
27044
- merchant.name = det.brand?.name ?? "TODO: the brand subscribers see";
27045
- if (!det.brand) todos.push("merchant.name: the brand subscribers see");
27142
+ const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
27143
+ const brand = fact("merchant_name") ?? det.brand?.name;
27144
+ merchant.name = brand ?? "TODO: the brand subscribers see";
27145
+ if (!brand) todos.push("merchant.name: the brand subscribers see");
27146
+ if (fact("merchant_name")) found.push(`merchant name "${fact("merchant_name")}" (from checkbox init)`);
27046
27147
  const cancelPath = det.policyPaths.cancellation;
27047
- if (det.siteUrl && cancelPath) merchant.cancel_url = `${originOf(det.siteUrl)}${cancelPath}`;
27148
+ const siteUrl = fact("site_url") ?? det.siteUrl;
27149
+ const cancelFact = fact("cancellation_url") ?? fact("account_url");
27150
+ if (cancelFact) {
27151
+ merchant.cancel_url = cancelFact;
27152
+ found.push(`cancel URL ${cancelFact} (from checkbox init)`);
27153
+ } else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
27048
27154
  else {
27049
27155
  merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
27050
27156
  todos.push("merchant.cancel_url: the page where subscribers cancel online");
27051
27157
  }
27158
+ if (fact("support_email")) merchant.support_contact = fact("support_email");
27052
27159
  const plans = det.plans.map((p, i) => ({
27053
27160
  name: p.name ?? `Plan ${i + 1}`,
27054
27161
  amount: p.amount,
@@ -27058,6 +27165,32 @@ function scaffoldConfig(root, env2 = {}) {
27058
27165
  ...p.trial_days ? { trial_days: p.trial_days } : {}
27059
27166
  }));
27060
27167
  for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
27168
+ const planKeys = /* @__PURE__ */ new Map();
27169
+ if (!plans.length && Array.isArray(facts.plans)) {
27170
+ for (const [i, p] of facts.plans.entries()) {
27171
+ if (typeof p.amount !== "number" || typeof p.interval !== "string") continue;
27172
+ plans.push({ name: typeof p.name === "string" ? p.name : `Plan ${i + 1}`, amount: p.amount, currency: typeof p.currency === "string" ? p.currency : "usd", interval: p.interval, ...typeof p.interval_count === "number" ? { interval_count: p.interval_count } : {}, ...typeof p.trial_days === "number" ? { trial_days: p.trial_days } : {} });
27173
+ found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
27174
+ }
27175
+ }
27176
+ if (!plans.length) {
27177
+ walk3(root, (rel2) => {
27178
+ if (!PLAN_FILE.test(rel2) || plans.length >= 8) return;
27179
+ let text = "";
27180
+ try {
27181
+ text = readFileSync6(join5(root, rel2), "utf8");
27182
+ } catch {
27183
+ return;
27184
+ }
27185
+ for (const p of plansFromLiterals(text, rel2)) {
27186
+ if (plans.some((x) => x.name === p.name)) continue;
27187
+ const { source, key, ...plan2 } = p;
27188
+ plans.push(plan2);
27189
+ if (key) planKeys.set(p.name, key);
27190
+ found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
27191
+ }
27192
+ });
27193
+ }
27061
27194
  if (!plans.length) {
27062
27195
  plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
27063
27196
  todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
@@ -27071,6 +27204,22 @@ function scaffoldConfig(root, env2 = {}) {
27071
27204
  const call = (perFile.get(s2.file) ?? 0) + 1;
27072
27205
  perFile.set(s2.file, call);
27073
27206
  const entry = { name: unique(names, `Stripe Checkout (${basename(s2.file)}${call > 1 ? ` #${call}` : ""})`), source: s2.file, ...call > 1 ? { call } : {} };
27207
+ const builder = !s2.params ? paramsBuilder(root, s2.file, s2.line) : void 0;
27208
+ if (builder) {
27209
+ found.push(`${s2.file}:${s2.line}: Checkout Session built by ${builder.module}`);
27210
+ const keyed = plans.length > 1 && plans.every((p) => planKeys.has(String(p.name)));
27211
+ if (builder.takesArgs && plans.length > 1) {
27212
+ for (const p of plans) {
27213
+ const n = String(p.name);
27214
+ stripe.push({ name: unique(names, `Checkout (${n})`), module: builder.module, args: [keyed ? planKeys.get(n) : `TODO: the argument for plan ${n}`], plan: n, strict: true });
27215
+ }
27216
+ if (!keyed) todos.push(`${builder.module}: the argument that builds each plan's params ("args")`);
27217
+ } else {
27218
+ stripe.push({ name: entry.name, module: builder.module, ...builder.takesArgs ? { args: [plans.length === 1 && planKeys.size ? [...planKeys.values()][0] : "TODO: the function argument"] } : {}, ...plans.length === 1 ? { plan: String(plans[0].name) } : {}, strict: true });
27219
+ if (builder.takesArgs && !(plans.length === 1 && planKeys.size)) todos.push(`${builder.module}: the argument that builds the params ("args")`);
27220
+ }
27221
+ continue;
27222
+ }
27074
27223
  if (!s2.params) {
27075
27224
  todos.push(`${s2.file}:${s2.line}: the params are not an object literal; use "module" (an exported function returning the params) instead of "source"`);
27076
27225
  delete entry.source;
@@ -27134,7 +27283,7 @@ function scaffoldConfig(root, env2 = {}) {
27134
27283
  found
27135
27284
  };
27136
27285
  }
27137
- var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique;
27286
+ var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
27138
27287
  var init_scaffold = __esm({
27139
27288
  "src/ci/scaffold.ts"() {
27140
27289
  "use strict";
@@ -27160,6 +27309,7 @@ var init_scaffold = __esm({
27160
27309
  names.add(n);
27161
27310
  return n;
27162
27311
  };
27312
+ PLAN_FILE = /(?:^|\/)(?:plans?|pricing|prices|tiers|subscriptions?|billing|products?)(?:\.config)?\.(?:[cm]?[jt]sx?|json)$/i;
27163
27313
  }
27164
27314
  });
27165
27315
 
@@ -27287,8 +27437,8 @@ var init_coverage = __esm({
27287
27437
 
27288
27438
  // src/ci/inputs.ts
27289
27439
  import { execFile } from "node:child_process";
27290
- import { existsSync as existsSync5, readFileSync as readFileSync8 } from "node:fs";
27291
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
27440
+ import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
27441
+ import { dirname as dirname4, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
27292
27442
  import { pathToFileURL as pathToFileURL2 } from "node:url";
27293
27443
  function readRel(dir, file) {
27294
27444
  const abs = isAbsolute2(file) ? file : resolve4(dir, file);
@@ -27301,8 +27451,8 @@ function readRel(dir, file) {
27301
27451
  }
27302
27452
  function hasTsx(dir) {
27303
27453
  for (let d = dir, i = 0; i < 8; i++) {
27304
- if (existsSync5(join7(d, "node_modules", "tsx", "package.json"))) return true;
27305
- const up = dirname3(d);
27454
+ if (existsSync6(join7(d, "node_modules", "tsx", "package.json"))) return true;
27455
+ const up = dirname4(d);
27306
27456
  if (up === d) break;
27307
27457
  d = up;
27308
27458
  }
@@ -27322,7 +27472,7 @@ function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27322
27472
  const file = hash > 0 ? ref.slice(0, hash) : ref;
27323
27473
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27324
27474
  const abs = resolve4(dir, file);
27325
- if (!existsSync5(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27475
+ if (!existsSync6(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27326
27476
  const flags = loaderFlags(file, dir);
27327
27477
  if (typeof flags === "string") return Promise.reject(new InputError(flags));
27328
27478
  return new Promise((resolvePromise, reject) => {
@@ -27341,12 +27491,39 @@ function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27341
27491
  return;
27342
27492
  }
27343
27493
  const own = /__CHECKBOX_ERR__ (.+)/.exec(stderr)?.[1];
27344
- const tail = stderr.trim().split("\n").filter((l) => !/^\s+at /.test(l)).slice(-4).join(" / ");
27345
- reject(new InputError(`${ref}: ${own ?? (err && err.killed ? `did not finish within ${timeoutMs / 1e3}s` : tail || err?.message || "no output")}`));
27494
+ reject(new InputError(`${ref}: ${own ?? (err && err.killed ? `did not finish within ${timeoutMs / 1e3}s` : moduleErrorMessage(stderr, dir, flags) || err?.message || "no output")}`));
27346
27495
  }
27347
27496
  );
27348
27497
  });
27349
27498
  }
27499
+ function moduleErrorMessage(stderr, dir, flags = []) {
27500
+ const text = stderr.trim();
27501
+ if (!text) return "";
27502
+ const rel2 = (p) => {
27503
+ const r = relative4(dir, p.replace(/^file:\/\//, ""));
27504
+ return r && !r.startsWith("..") ? r.split("\\").join("/") : p;
27505
+ };
27506
+ const tsx = flags.includes("tsx");
27507
+ const missing = /Cannot find (module|package) '([^']+)' imported from (\S+)/.exec(text);
27508
+ if (missing) {
27509
+ const [, what, target, from] = missing;
27510
+ if (what === "package" || !/^[./]|^[A-Za-z]:[\\/]/.test(target) && !target.startsWith(dir)) {
27511
+ return `${rel2(from)} imports the package "${target}", which is not installed here. Install the project's dependencies before checkbox ci (e.g. npm ci in the workflow).`;
27512
+ }
27513
+ const noExt = !/\.[cm]?[jt]sx?$|\.json$/.test(target);
27514
+ if (noExt && !tsx) {
27515
+ return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. Add tsx to the project (npm install -D tsx; checkbox ci then imports modules the way your bundler does), or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
27516
+ }
27517
+ return `${rel2(from)} imports ${rel2(target)}, which does not exist${noExt ? " (tsx found no .ts / .js file of that name either)" : ""}.`;
27518
+ }
27519
+ if (/ERR_UNKNOWN_FILE_EXTENSION/.test(text)) {
27520
+ const file = /Unknown file extension "([^"]+)" for (\S+)/.exec(text);
27521
+ return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. Add tsx to the project (npm install -D tsx), or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
27522
+ }
27523
+ const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v\d/.test(l));
27524
+ const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
27525
+ return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
27526
+ }
27350
27527
  function paramsFromSource(text, file, call = 1, env2 = {}) {
27351
27528
  const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
27352
27529
  if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
@@ -27357,7 +27534,32 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
27357
27534
  if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
27358
27535
  const parsed = parseObjectLiteral(lit, env2);
27359
27536
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27360
- return { params: parsed.value, line: line2, unresolved: parsed.unresolved };
27537
+ return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
27538
+ }
27539
+ function lineOfAny(text, needles) {
27540
+ const lower2 = text.toLowerCase();
27541
+ for (const n of needles) {
27542
+ const k = n.trim().toLowerCase();
27543
+ if (k.length < 2) continue;
27544
+ const i = lower2.indexOf(k);
27545
+ if (i >= 0) return text.slice(0, i).split("\n").length;
27546
+ }
27547
+ return void 0;
27548
+ }
27549
+ function exportLine(dir, ref) {
27550
+ const hash = ref.lastIndexOf("#");
27551
+ const file = hash > 0 ? ref.slice(0, hash) : ref;
27552
+ const name = hash > 0 ? ref.slice(hash + 1) : "default";
27553
+ let text;
27554
+ try {
27555
+ text = readFileSync8(resolve4(dir, file), "utf8");
27556
+ } catch {
27557
+ return 1;
27558
+ }
27559
+ const esc3 = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27560
+ const re = name === "default" ? /^\s*export\s+default\b/m : new RegExp(`^\\s*export\\s+(?:async\\s+)?(?:function\\*?|const|let|var|class)\\s+${esc3}\\b|^\\s*export\\s*\\{[^}]*\\b${esc3}\\b`, "m");
27561
+ const m = re.exec(text);
27562
+ return m ? text.slice(0, m.index).split("\n").length + (m[0].startsWith("\n") ? 1 : 0) : 1;
27361
27563
  }
27362
27564
  function projectEnv(dir) {
27363
27565
  const out = {};
@@ -27423,15 +27625,22 @@ async function stripeJob(loaded, e, o, root) {
27423
27625
  }
27424
27626
  } else if (e.module) {
27425
27627
  const file = e.module.replace(/#.*$/, "");
27426
- job.target = { file: rel(loaded, root, file), line: 1 };
27628
+ job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
27427
27629
  job.inputs.push(file);
27428
27630
  params = await runModule(dir, e.module, e.args ?? [], o.env);
27631
+ const unset = (Array.isArray(params?.line_items) ? params.line_items : []).flatMap((li, i) => li && typeof li === "object" && !Array.isArray(li) && li.price === void 0 && li.price_data === void 0 ? [`line_items[${i}].price`] : []);
27632
+ if (unset.length) {
27633
+ job.placeholders = Object.fromEntries(unset.map((p) => [p, "{{your Stripe Price id: unset when checkbox ci ran the module}}"]));
27634
+ job.warnings.push(`${unset.join(", ")} ${unset.length === 1 ? "was" : "were"} not set when Checkbox ran ${e.module} (an environment variable this job does not set?). The checks use "plan" in checkbox.json for the price, and corrected params keep a placeholder there for your own value.`);
27635
+ }
27429
27636
  } else {
27430
27637
  const src = e.source;
27431
27638
  job.inputs.push(src);
27432
27639
  const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
27433
27640
  job.target = { file: rel(loaded, root, src), line: r.line };
27641
+ job.fromSource = true;
27434
27642
  params = r.params;
27643
+ if (r.fromEnv) job.placeholders = Object.fromEntries(Object.entries(r.fromEnv).map(([p, expr]) => [p, `{{${expr}}}`]));
27435
27644
  if (r.unresolved.length) {
27436
27645
  job.partial = true;
27437
27646
  job.unresolved = r.unresolved;
@@ -27507,6 +27716,14 @@ function pageJob(loaded, e, o, root) {
27507
27716
  if (e.source) {
27508
27717
  job.target = { file: rel(loaded, root, e.source), line: 1 };
27509
27718
  job.inputs.push(e.source);
27719
+ const src = e.source;
27720
+ job.locate = (needles) => {
27721
+ try {
27722
+ return lineOfAny(readRel(dir, src), needles);
27723
+ } catch {
27724
+ return void 0;
27725
+ }
27726
+ };
27510
27727
  }
27511
27728
  if (e.url) {
27512
27729
  if (!o.online) job.skip = "URL pages are fetched by the API: run with --online (and CHECKBOX_SECRET_KEY)";
@@ -27516,7 +27733,9 @@ function pageJob(loaded, e, o, root) {
27516
27733
  try {
27517
27734
  if (!e.source) job.target = { file: rel(loaded, root, e.file), line: 1 };
27518
27735
  job.inputs.push(e.file);
27519
- job.body = { html: readRel(dir, e.file), ...common3 };
27736
+ const html = readRel(dir, e.file);
27737
+ if (!e.source) job.locate = (needles) => lineOfAny(html, needles);
27738
+ job.body = { html, ...common3 };
27520
27739
  } catch (err) {
27521
27740
  job.error = err.message;
27522
27741
  }
@@ -27533,7 +27752,7 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27533
27752
  msg2 = emailFromFile(readRel(dir, e.file), e.file, e.subject);
27534
27753
  } else if (e.module) {
27535
27754
  const file = e.module.replace(/#.*$/, "");
27536
- job.target = { file: rel(loaded, root, file), line: 1 };
27755
+ job.target = { file: rel(loaded, root, file), line: exportLine(dir, e.module) };
27537
27756
  job.inputs.push(file);
27538
27757
  const v = await runModule(dir, e.module, e.args ?? [], o.env);
27539
27758
  if (typeof v === "string") msg2 = emailFromFile(v, /^\s*</.test(v) ? "x.html" : "x.txt", e.subject);
@@ -27641,7 +27860,9 @@ function renderHuman(run2, c, opts = {}) {
27641
27860
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
27642
27861
  if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
27643
27862
  if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
27644
- for (const w of x.warnings.slice(0, opts.verbose ? 10 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
27863
+ const alerts = x.alerts ?? [];
27864
+ for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
27865
+ for (const w of x.warnings.filter((w2) => !alerts.includes(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
27645
27866
  if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
27646
27867
  }
27647
27868
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
@@ -27653,11 +27874,12 @@ function renderHuman(run2, c, opts = {}) {
27653
27874
  function annotations(run2) {
27654
27875
  const out = [];
27655
27876
  for (const x of run2.checks) {
27656
- const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : void 0;
27877
+ const alerts = x.alerts ?? [];
27878
+ const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
27657
27879
  if (!level) continue;
27658
27880
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
27659
27881
  const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
27660
- ${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");
27882
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
27661
27883
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
27662
27884
  }
27663
27885
  return out;
@@ -27669,7 +27891,8 @@ function stepSummary(run2) {
27669
27891
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
27670
27892
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
27671
27893
  for (const x of run2.checks) {
27672
- 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)` : ""}` : "";
27894
+ const fix0 = 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)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
27895
+ const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
27673
27896
  lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
27674
27897
  }
27675
27898
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
@@ -27874,6 +28097,10 @@ function pickDaysBefore(kind, range) {
27874
28097
  if (open2) return lo + OPEN_WINDOW_MARGIN_DAYS;
27875
28098
  return lo + Math.floor((range.maxDays - lo) / 2);
27876
28099
  }
28100
+ function sendLeadDays(range, days) {
28101
+ if (range.maxDays >= OPEN_ENDED_DAYS2 || !Number.isInteger(days) || days <= 0) return days;
28102
+ return days <= range.maxDays ? days + BOUNDED_SEND_MARGIN_DAYS : days;
28103
+ }
27877
28104
  function stabWindows(windows) {
27878
28105
  const sorted = [...windows].sort((a, b) => a.window.maxDays - b.window.maxDays || a.window.minDays - b.window.minDays);
27879
28106
  const groups = [];
@@ -27891,11 +28118,15 @@ function stabWindows(windows) {
27891
28118
  function slotOf(kind, anchorAt, range, members, daysBefore) {
27892
28119
  const days = daysBefore ?? pickDaysBefore(kind, range);
27893
28120
  const maxDays = Math.min(range.maxDays, OPEN_ENDED_DAYS2);
28121
+ const lead = sendLeadDays(range, days);
28122
+ const sendAt = new Date(anchorAt.getTime() - lead * DAY_MS5);
28123
+ const opensAt = new Date(anchorAt.getTime() - maxDays * DAY_MS5);
27894
28124
  return {
27895
28125
  slot: `d${days}`,
27896
28126
  daysBefore: days,
27897
- sendAt: new Date(anchorAt.getTime() - days * DAY_MS5),
27898
- opensAt: new Date(anchorAt.getTime() - maxDays * DAY_MS5),
28127
+ sendAt,
28128
+ // The send time is inside [opensAt, closesAt] (a bounded window's send sits inside its maximum's rounding).
28129
+ opensAt: sendAt.getTime() < opensAt.getTime() ? sendAt : opensAt,
27899
28130
  closesAt: new Date(anchorAt.getTime() - range.minDays * DAY_MS5),
27900
28131
  range: { minDays: range.minDays, maxDays },
27901
28132
  jurisdictions: [...new Set(members.map((m) => m.jurisdiction))].sort(),
@@ -27976,7 +28207,7 @@ function renewalReminderApplies(termMonths2, jurisdictions, asOf, requirements)
27976
28207
  (r) => r.appliesWhen?.minTermMonths !== void 0 && r.appliesWhen.minTermMonths <= termMonths2
27977
28208
  );
27978
28209
  }
27979
- var DAY_MS5, HOUR_MS2, OPEN_ENDED_DAYS2, OPEN_WINDOW_MARGIN_DAYS, TRIAL_MIN_MARGIN_DAYS, TRIAL_SIGNUP_GAP_DAYS, PRICE_CHANGE_MAX_LEAD_DAYS, ANNUAL_TERMS_DEFAULT_WINDOW, NOTICE_CHECKS2, isImmediateKind, KIND_ANCHORS, FALLBACK_WINDOWS, BUSINESS_DAY_MINIMUMS;
28210
+ var DAY_MS5, HOUR_MS2, OPEN_ENDED_DAYS2, OPEN_WINDOW_MARGIN_DAYS, TRIAL_MIN_MARGIN_DAYS, TRIAL_SIGNUP_GAP_DAYS, BOUNDED_SEND_MARGIN_DAYS, PRICE_CHANGE_MAX_LEAD_DAYS, ANNUAL_TERMS_DEFAULT_WINDOW, NOTICE_CHECKS2, isImmediateKind, KIND_ANCHORS, FALLBACK_WINDOWS, BUSINESS_DAY_MINIMUMS;
27980
28211
  var init_timing = __esm({
27981
28212
  "../../apps/api/src/notices/timing.ts"() {
27982
28213
  "use strict";
@@ -27987,6 +28218,7 @@ var init_timing = __esm({
27987
28218
  OPEN_WINDOW_MARGIN_DAYS = 2;
27988
28219
  TRIAL_MIN_MARGIN_DAYS = 0.5;
27989
28220
  TRIAL_SIGNUP_GAP_DAYS = 1 / 24;
28221
+ BOUNDED_SEND_MARGIN_DAYS = 0.25;
27990
28222
  PRICE_CHANGE_MAX_LEAD_DAYS = 60;
27991
28223
  ANNUAL_TERMS_DEFAULT_WINDOW = { minDays: 15, maxDays: 45 };
27992
28224
  NOTICE_CHECKS2 = {
@@ -28039,7 +28271,7 @@ function singleDayNote(requirementIds, days) {
28039
28271
  const lower2 = [...new Set(windows.filter((w) => w.minDays === days).map((w) => jur(w.id)))];
28040
28272
  const upper = [...new Set(windows.filter((w) => w.maxDays === days).map((w) => jur(w.id)))];
28041
28273
  const why = lower2.length && upper.length ? `at least ${days} days (${lower2.slice(0, 4).join(", ")}) and at most ${days} days (${upper.slice(0, 4).join(", ")}) leave a single day` : `the states' windows meet on a single day`;
28042
- return `Exactly ${days} days before: ${why}. Send at send_at (the anchor's time of day, ${days} days earlier). Checkbox counts whole days, so a send within a few hours of send_at still counts as ${days} days; half a day or more never does.`;
28274
+ return `Exactly ${days} days before: ${why}. Send at send_at, ${days} days and 6 hours before the anchor. The one rule: a minimum counts only full days, less a 2-hour allowance for delivery; a maximum is rounded to the nearest day. So a send counts as ${days} days when it goes out less than ${days + 0.5} days and at least ${days} days less 2 hours before the anchor; anything earlier or later does not.`;
28043
28275
  }
28044
28276
  function addInterval(d, interval, n) {
28045
28277
  if (interval === "day") return new Date(d.getTime() + n * DAY_MS5);
@@ -28202,6 +28434,15 @@ function operationFor2(kind) {
28202
28434
  function onlineEngine(client) {
28203
28435
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28204
28436
  }
28437
+ function ciVocabulary(text) {
28438
+ return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored");
28439
+ }
28440
+ function stripeConsentFix(text, params) {
28441
+ if (!/^Add one unchecked checkbox/.test(text)) return text;
28442
+ const cc = params?.consent_collection;
28443
+ if (cc?.terms_of_service === "required") return text;
28444
+ return 'Set consent_collection: { terms_of_service: "required" } in the params: Stripe then shows an unchecked consent checkbox, labelled with custom_text.terms_of_service_acceptance.message.';
28445
+ }
28205
28446
  function ciStatusOf(r) {
28206
28447
  const cs = r.compliance_status;
28207
28448
  if (r.verdict === "not_applicable") return "not_applicable";
@@ -28210,9 +28451,9 @@ function ciStatusOf(r) {
28210
28451
  if (cs && OK_STATUSES.has(cs)) return "pass";
28211
28452
  return "needs_review";
28212
28453
  }
28213
- function fixesOf(r) {
28454
+ function fixesOf(r, onlyNeedsFix = false) {
28214
28455
  const out = [];
28215
- const failing = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status)));
28456
+ const failing = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status)) && (!onlyNeedsFix || c.compliance_status === "needs_fix"));
28216
28457
  failing.sort((a, b) => (a.compliance_status === "needs_fix" ? 0 : 1) - (b.compliance_status === "needs_fix" ? 0 : 1) || (RANK2[a.status] ?? 2) - (RANK2[b.status] ?? 2));
28217
28458
  for (const c of failing) {
28218
28459
  const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
@@ -28225,14 +28466,25 @@ function fixesOf(r) {
28225
28466
  function failingCards(explanation) {
28226
28467
  if (!explanation) return [];
28227
28468
  if (/\(\d+ checkout page views? checked\) — e\.g\./.test(explanation)) return [];
28228
- return [...new Set([...explanation.matchAll(/plan button “([^”]{1,80})”/g)].map((m) => m[1]))];
28469
+ return [...new Set([...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)].map((m) => m[2] ?? m[1]))];
28229
28470
  }
28230
- function correctedOf(type, r, partial = false) {
28471
+ function withPlaceholders(params, placeholders) {
28472
+ if (!placeholders || !params || typeof params !== "object") return params;
28473
+ const copy = JSON.parse(JSON.stringify(params));
28474
+ for (const [path, value] of Object.entries(placeholders)) {
28475
+ const keys = path.split(/\.|\[(\d+)\]/).filter((k) => k !== void 0 && k !== "");
28476
+ let node = copy;
28477
+ for (const k of keys.slice(0, -1)) node = node && typeof node === "object" ? node[k] : void 0;
28478
+ if (node && typeof node === "object") node[keys[keys.length - 1]] = value;
28479
+ }
28480
+ return copy;
28481
+ }
28482
+ function correctedOf(type, r, partial = false, placeholders) {
28231
28483
  if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
28232
28484
  return { format: "json", title: `Changes to make to the params (JSON Patch; keep your other values)${r.correction.verification?.passes ? ", verified by the rules engine" : ""}`, body: JSON.stringify(r.correction.patch, null, 2) };
28233
28485
  }
28234
28486
  if (type === "stripe_checkout" && r.correction?.patch?.length) {
28235
- return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}`, body: JSON.stringify(r.correction.corrected_params, null, 2) };
28487
+ return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}${placeholders ? "; keep your own values where it says {{\u2026}}" : ""}`, body: JSON.stringify(withPlaceholders(r.correction.corrected_params, placeholders), null, 2) };
28236
28488
  }
28237
28489
  const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
28238
28490
  if (type === "page" && perPlan.length >= 2 && ciStatusOf(r) !== "pass") {
@@ -28264,11 +28516,38 @@ ${r.template.text}` };
28264
28516
  }
28265
28517
  return void 0;
28266
28518
  }
28267
- function noticeCoverage(config, planName, schedule) {
28519
+ function declaredByCheckbox(config) {
28520
+ const sb = config.notices?.sent_by_checkbox;
28521
+ return sb === true ? Object.values(NOTICE_TO_EMAIL) : Array.isArray(sb) ? sb : [];
28522
+ }
28523
+ function sentByNotices(seen) {
28524
+ if ("error" in seen || seen.mode !== "checkbox" || seen.real_sends === "off") return /* @__PURE__ */ new Set();
28525
+ return new Set(seen.enabled);
28526
+ }
28527
+ function noticesCrossCheck(config, seen, target) {
28528
+ const declared = declaredByCheckbox(config);
28529
+ if (!declared.length) return void 0;
28530
+ const base = { name: "Checkbox Notices (site settings)", type: "notice_schedule", target, warnings: [] };
28531
+ if ("error" in seen) {
28532
+ return { ...base, status: "needs_review", label: "Not checked: could not read the site's Notice settings", message: seen.error, fixes: [] };
28533
+ }
28534
+ const real = sentByNotices(seen);
28535
+ const all = config.notices?.sent_by_checkbox === true;
28536
+ const missing = all ? real.size ? [] : declared : declared.filter((k) => !real.has(k));
28537
+ if (!missing.length) {
28538
+ const note = seen.real_sends === "needs_confirmation" ? " (a person still has to confirm sending to real subscribers once in the dashboard: Emails tab)" : seen.real_sends === "test_mode" ? " (test mode: they go to test-run inboxes only)" : "";
28539
+ return { ...base, status: "pass", label: `Checks pass \xB7 Checkbox Notices is on for ${seen.site}${all ? ` (${real.size} kind${real.size === 1 ? "" : "s"} turned on)` : ` and sends the ${declared.length} email${declared.length === 1 ? "" : "s"} checkbox.json declares`}${note}`, fixes: [] };
28540
+ }
28541
+ const why = seen.mode !== "checkbox" ? `${seen.site} is in "You send" mode (Checkbox Notices sends nothing)` : seen.real_sends === "off" || !real.size ? `Checkbox Notices is off for ${seen.site}` : `${seen.site} does not have ${missing.map((k) => `"${k}"`).join(", ")} turned on in Checkbox Notices`;
28542
+ const fix = `checkbox.json says Checkbox Notices sends ${all ? "the required emails" : missing.map((k) => `"${k}"`).join(", ")}, but ${why}. Turn ${missing.length === 1 ? "it" : "them"} on in the dashboard (Emails tab) or with updateNoticeSettings, or remove ${missing.length === 1 ? "it" : "them"} from "notices.sent_by_checkbox" and add the templates to checks.emails.`;
28543
+ return { ...base, status: "fail", label: all ? "Checkbox Notices sends nothing for this site" : `${missing.length} declared email${missing.length === 1 ? " is" : "s are"} not sent by Checkbox Notices`, fix, fixes: [fix] };
28544
+ }
28545
+ function noticeCoverage(config, planName, schedule, opts = {}) {
28268
28546
  const plan2 = (config.plans ?? []).find((p) => p.name === planName);
28269
28547
  const have = new Set((config.checks.emails ?? []).filter((e) => !e.plan || e.plan === planName || planList(config, e.plan)?.includes(planName)).map((e) => e.kind));
28270
- const sb = config.notices?.sent_by_checkbox;
28271
- const byCheckbox = new Set(sb === true ? Object.values(NOTICE_TO_EMAIL) : Array.isArray(sb) ? sb : []);
28548
+ const declared = declaredByCheckbox(config);
28549
+ const real = opts.notices && !("error" in opts.notices) ? sentByNotices(opts.notices) : void 0;
28550
+ const byCheckbox = new Set(real ? declared.filter((k) => real.has(k)) : declared);
28272
28551
  for (const k of byCheckbox) have.add(k);
28273
28552
  const yearly = plan2 ? (plan2.interval === "year" ? 12 * (plan2.interval_count ?? 1) : plan2.interval === "month" ? plan2.interval_count ?? 1 : 0) >= 12 : false;
28274
28553
  if (yearly && have.has("renewal_reminder")) have.add("annual_terms");
@@ -28280,8 +28559,8 @@ function noticeCoverage(config, planName, schedule) {
28280
28559
  const missing = [...required].filter(([k]) => !have.has(k));
28281
28560
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
28282
28561
  const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
28283
- if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered${sent2 ? ` (${sent2} sent by Checkbox Notices, as checkbox.json declares)` : ""}`, fixes: [] };
28284
- const fixes = missing.map(([k, w]) => `Add a ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it.`);
28562
+ if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered${sent2 ? ` (${sent2} sent by Checkbox Notices, ${real ? "as the site's Notice settings confirm" : "as checkbox.json declares; --online confirms it against the site"})` : ""}`, fixes: [] };
28563
+ const fixes = missing.map(([k, w]) => `Add ${/^[aeiou]/i.test(EMAIL_LABEL[k]) ? "an" : "a"} ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it${declared.includes(k) ? " (checkbox.json says it does, but the site's Notice settings do not send it)" : ""}.`);
28285
28564
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
28286
28565
  }
28287
28566
  function unverifiedOffer(r) {
@@ -28295,8 +28574,8 @@ function unverifiedOffer(r) {
28295
28574
  if (coupon && open2.length) return coupon.message;
28296
28575
  return void 0;
28297
28576
  }
28298
- async function runJob(job, engine, config) {
28299
- const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: job.target, warnings: [...job.warnings] };
28577
+ async function runJob(job, engine, config, opts = {}) {
28578
+ const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
28300
28579
  if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
28301
28580
  if (job.skip || !job.body) return { ...base, status: "skipped", label: "Skipped", message: job.skip ?? "nothing to check", fixes: [] };
28302
28581
  let report;
@@ -28305,37 +28584,68 @@ async function runJob(job, engine, config) {
28305
28584
  } catch (err) {
28306
28585
  const e = err;
28307
28586
  const failure = isAuthError(err) ? "auth" : err instanceof CheckboxError && (!e.statusCode || e.statusCode >= 500 || e.statusCode === 429) ? "api" : void 0;
28308
- return { ...base, status: "error", label: "Could not run", message: `${e.message}${e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
28587
+ return { ...base, status: "error", label: "Could not run", message: `${ciVocabulary(e.message)}${e.param && !failure && /^merchant\./.test(e.param) ? "" : e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
28309
28588
  }
28310
- if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report), report };
28311
- for (const w of report.warnings ?? []) base.warnings.push(w.message);
28589
+ if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report, opts), report };
28590
+ const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
28591
+ for (const w of report.warnings ?? []) {
28592
+ const text = ciVocabulary(w.message);
28593
+ base.warnings.push(text);
28594
+ if (ALERT_CODES.has(w.code)) alerts.push(text);
28595
+ }
28596
+ if (alerts.length) Object.assign(base, { alerts });
28312
28597
  const status = ciStatusOf(report);
28313
- const fixes = status === "pass" || status === "not_applicable" ? [] : fixesOf(report);
28598
+ const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28599
+ const fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
28314
28600
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
28315
28601
  const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data\.trial)/.test(p)));
28316
28602
  if (unread) {
28317
28603
  fixes.splice(0, fixes.length, `${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary or PLANS.monthly.*), so the checks that depend on ${unreadText.length === 1 ? "it were" : "them were"} not checked (no fix is given for a value Checkbox did not read). Use "module" (a function returning the exact params for each plan) or "file" instead of "source".`);
28318
28604
  }
28605
+ const literal = [];
28606
+ if (unread) {
28607
+ const consentRead = !(job.unresolved ?? []).some((p) => /^(?:consent_collection|\.\.\.)/.test(p));
28608
+ const consentFails = (report.checks ?? []).some((c) => c.check_id === "signup.consent.affirmative" && (c.status === "fail" || c.compliance_status === "needs_fix"));
28609
+ if (consentRead && consentFails && params?.consent_collection?.terms_of_service !== "required") literal.push(stripeConsentFix("Add one unchecked checkbox", params));
28610
+ fixes.unshift(...literal);
28611
+ }
28319
28612
  const offer = !unread && status === "needs_review" && job.type === "stripe_checkout" ? unverifiedOffer(report) : void 0;
28320
28613
  if (offer) {
28321
28614
  fixes.unshift(
28322
28615
  `What the shopper is charged first is not verified (${offer.replace(/\.$/, "")}). Declare the discount on the plan in checkbox.json (intro_percent_off, intro_amount_off or intro_price, with intro_periods) and state it in custom_text.submit, or remove the discount. This fails the run (exit 2) until it is resolved.`
28323
28616
  );
28324
28617
  }
28325
- const finalStatus = unread ? "needs_review" : status;
28326
- const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial);
28618
+ if (job.locate && (status === "fail" || status === "needs_review")) {
28619
+ const named = (report.checks ?? []).filter((c) => c.status === "fail" || c.status === "needs_review").flatMap((c) => failingCards(c.explanation));
28620
+ const needles = named.flatMap((n) => [n.split(", ")[0], ...n.split(", ").slice(1)]);
28621
+ const line2 = needles.length ? job.locate(needles) : void 0;
28622
+ if (line2) base.target = { ...base.target, line: line2 };
28623
+ }
28624
+ const finalStatus = unread ? literal.length ? "fail" : "needs_review" : status;
28625
+ const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
28626
+ if (corrected?.format === "json" && job.placeholders) {
28627
+ const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
28628
+ if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
28629
+ }
28327
28630
  const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || status === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
28328
28631
  return {
28329
28632
  ...base,
28330
28633
  status: finalStatus,
28331
28634
  ...blocking ? { blocking: true } : {},
28332
- label: unread ? `Not checked: ${unreadText[0]} is an expression` : report.compliance?.label || DEFAULT_LABEL[status],
28635
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report),
28333
28636
  ...fixes[0] ? { fix: fixes[0] } : {},
28334
28637
  fixes,
28335
28638
  ...corrected ? { corrected } : {},
28336
28639
  report
28337
28640
  };
28338
28641
  }
28642
+ function countedLabel(label, r) {
28643
+ const m = /^(\d+) fix(?:es)? needed/.exec(label);
28644
+ if (!m) return label;
28645
+ const n = fixesOf(r, true).length;
28646
+ if (!n || n === Number(m[1])) return label;
28647
+ return label.replace(m[0], `${n} fix${n === 1 ? "" : "es"} needed`);
28648
+ }
28339
28649
  function exitCodeOf(checks, strict) {
28340
28650
  if (checks.some((c) => c.failure === "auth")) return { status: "error", exit: EXIT.auth };
28341
28651
  if (checks.some((c) => c.failure === "api")) return { status: "error", exit: EXIT.error };
@@ -28347,7 +28657,7 @@ function exitCodeOf(checks, strict) {
28347
28657
  function mergeSourcePatches(checks) {
28348
28658
  const groups = /* @__PURE__ */ new Map();
28349
28659
  for (const c of checks) {
28350
- if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line) continue;
28660
+ if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line || !c.fromSource) continue;
28351
28661
  const key = `${c.target.file}:${c.target.line}`;
28352
28662
  groups.set(key, [...groups.get(key) ?? [], c]);
28353
28663
  }
@@ -28365,7 +28675,9 @@ function mergeSourcePatches(checks) {
28365
28675
  }
28366
28676
  async function runCi(jobs, engine, opts) {
28367
28677
  const checks = [...opts.extra ?? []];
28368
- for (const j of jobs) checks.push(await runJob(j, engine, opts.config));
28678
+ for (const j of jobs) checks.push(await runJob(j, engine, opts.config, { ...opts.notices ? { notices: opts.notices } : {} }));
28679
+ const cross = opts.notices ? noticesCrossCheck(opts.config, opts.notices, opts.noticesTarget ?? { file: opts.configPath }) : void 0;
28680
+ if (cross) checks.push(cross);
28369
28681
  mergeSourcePatches(checks);
28370
28682
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0, skipped: 0, error: 0 };
28371
28683
  for (const c of checks) summary[c.status]++;
@@ -28385,7 +28697,7 @@ async function runCi(jobs, engine, opts) {
28385
28697
  disclaimer: CI_DISCLAIMER
28386
28698
  };
28387
28699
  }
28388
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL;
28700
+ var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
28389
28701
  var init_runner = __esm({
28390
28702
  "src/ci/runner.ts"() {
28391
28703
  "use strict";
@@ -28422,6 +28734,75 @@ var init_runner = __esm({
28422
28734
  cancellation_confirmation: "cancellation confirmation"
28423
28735
  };
28424
28736
  DEFAULT_LABEL = { pass: "Checks pass", fail: "Fixes needed", needs_review: "Needs review", not_applicable: "Not required for this plan" };
28737
+ ALERT_CODES = /* @__PURE__ */ new Set(["coupon_unverified", "coupon_read", "merchant_trial_ignored", "merchant_price_ignored", "price_not_in_site_plans", "price_from_setup_plan", "plan_terms_conflict", "trial_mismatch", "intro_mismatch"]);
28738
+ }
28739
+ });
28740
+
28741
+ // src/commands/plan/lib.ts
28742
+ import { dirname as dirname5, resolve as resolve5 } from "node:path";
28743
+ async function resolveSite(ctx, flag, client) {
28744
+ const explicit = flag ?? ctx.env.CHECKBOX_SITE;
28745
+ if (explicit) {
28746
+ if (!SITE_RE.test(explicit) && !/^[0-9a-f-]{36}$/i.test(explicit)) throw new UsageError(`--site must be a site id (site_\u2026), got "${explicit}"`);
28747
+ return explicit;
28748
+ }
28749
+ const projects = ctx.config.load().init?.projects ?? {};
28750
+ for (let dir = resolve5(ctx.cwd); ; dir = dirname5(dir)) {
28751
+ const site = projects[dir]?.site;
28752
+ if (site) return site;
28753
+ if (dirname5(dir) === dir) break;
28754
+ }
28755
+ const list2 = await client.sdk.sites.list({ limit: 3 });
28756
+ const sites = list2?.data ?? [];
28757
+ if (sites.length === 1) return sites[0].id;
28758
+ throw new UsageError(
28759
+ sites.length ? `This account has several sites; pass --site <id> (e.g. ${sites.map((s2) => s2.id).join(", ")}) or set CHECKBOX_SITE.` : "No site yet: run `checkbox init` in your project first (it creates the site and remembers it here)."
28760
+ );
28761
+ }
28762
+ function nextLine(n) {
28763
+ const text = n.description ?? n.message ?? n.type;
28764
+ return `${text}${n.command ? `
28765
+ $ ${n.command}` : n.endpoint ? `
28766
+ ${n.endpoint}` : ""}`;
28767
+ }
28768
+ function renderPlan(p, c, opts = {}) {
28769
+ const head = `${c.bold(`Compliance plan ${p.site}`)} \u2014 ${p.compliance?.label ? p.status === "compliant" ? c.green(p.compliance.label) : p.status === "action_required" ? c.red(p.compliance.label) : c.yellow(p.compliance.label) : statusColor(p.status, c)}`;
28770
+ const tasks = opts.all ? p.tasks : p.tasks.filter((t) => t.status !== "done" && t.status !== "wont_fix");
28771
+ const lines = [head, p.summary, ""];
28772
+ for (const t of tasks) {
28773
+ lines.push(`${STATUS_MARK[t.status] ?? "[ ]"} ${String(t.order).padStart(2)}. ${t.title} ${c.dim(`(${t.priority}, ${t.id})`)}`);
28774
+ if (t.status === "manual" && t.next_action?.type === "ask_human_to_confirm") lines.push(` ${c.yellow("waiting for the human to confirm:")} ${t.next_action.description}`);
28775
+ else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
28776
+ else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
28777
+ else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
28778
+ else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
28779
+ }
28780
+ const hidden = p.tasks.length - tasks.length;
28781
+ if (hidden) lines.push(c.dim(` \u2026 ${hidden} done / declined (--all to show)`));
28782
+ lines.push("", `${c.cyan("next")} ${nextLine(p.next_action)}`);
28783
+ return lines;
28784
+ }
28785
+ function statusColor(s2, c) {
28786
+ return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
28787
+ }
28788
+ function renderVerification(v, c) {
28789
+ const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
28790
+ const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
28791
+ const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
28792
+ if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
28793
+ if (v.scan) lines.push(c.dim(` confirming scan ${v.scan}`));
28794
+ for (const d of v.diff.slice(0, 12)) lines.push(` ${d.observed === "fail" ? c.red("\u2717") : c.yellow("?")} ${d.check_id}: expected ${d.expected}, observed ${d.observed} \u2014 ${d.detail}`);
28795
+ if (v.diff.length > 12) lines.push(c.dim(` \u2026 ${v.diff.length - 12} more`));
28796
+ lines.push(`${c.cyan("next")} ${nextLine(v.next_action)}`);
28797
+ return lines;
28798
+ }
28799
+ var SITE_RE, STATUS_MARK;
28800
+ var init_lib = __esm({
28801
+ "src/commands/plan/lib.ts"() {
28802
+ "use strict";
28803
+ init_args();
28804
+ SITE_RE = /^site_[0-9a-f]{32}$/;
28805
+ STATUS_MARK = { open: "[ ]", in_progress: "[~]", verifying: "[?]", done: "[x]", wont_fix: "[-]", manual: "[!]" };
28425
28806
  }
28426
28807
  });
28427
28808
 
@@ -28470,14 +28851,14 @@ __export(ci_exports, {
28470
28851
  undiffableInputs: () => undiffableInputs
28471
28852
  });
28472
28853
  import { execFileSync } from "node:child_process";
28473
- import { appendFileSync, existsSync as existsSync6, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
28854
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
28474
28855
  import { createInterface } from "node:readline";
28475
- import { dirname as dirname4, join as join8, relative as relative5, resolve as resolve5 } from "node:path";
28856
+ import { dirname as dirname6, join as join8, relative as relative5, resolve as resolve6 } from "node:path";
28476
28857
  function gitChanged(cwd, base) {
28477
28858
  try {
28478
28859
  const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
28479
28860
  const out = execFileSync("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28480
- return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve5(root, f)) };
28861
+ return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve6(root, f)) };
28481
28862
  } catch (err) {
28482
28863
  const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
28483
28864
  return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
@@ -28487,13 +28868,13 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28487
28868
  const need = [];
28488
28869
  const out = [];
28489
28870
  for (const e of loaded.config.checks.pages ?? []) {
28490
- if (e.source) need.push({ path: resolve5(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
28871
+ if (e.source) need.push({ path: resolve6(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
28491
28872
  else if (e.url) out.push(`pages "${e.name}" is a URL with no "source" file to diff`);
28492
- else if (e.file) need.push({ path: resolve5(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
28873
+ else if (e.file) need.push({ path: resolve6(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
28493
28874
  }
28494
28875
  for (const j of jobs) {
28495
28876
  if (j.type === "page") continue;
28496
- for (const f of j.inputs) need.push({ path: resolve5(loaded.dir, f), label: `"${j.name}" reads ${f}` });
28877
+ for (const f of j.inputs) need.push({ path: resolve6(loaded.dir, f), label: `"${j.name}" reads ${f}` });
28497
28878
  }
28498
28879
  if (!need.length) return out;
28499
28880
  const real = (p) => {
@@ -28509,7 +28890,7 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28509
28890
  try {
28510
28891
  const rels = [...new Set(need.map((n) => relative5(gitRoot, n.path)))];
28511
28892
  const listed = execFileSync("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28512
- tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve5(gitRoot, f)));
28893
+ tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve6(gitRoot, f)));
28513
28894
  } catch (err) {
28514
28895
  return `git ls-files failed (${String(err.stderr || err.message).trim().split("\n")[0]})`;
28515
28896
  }
@@ -28521,7 +28902,7 @@ function touchesConfig(loaded, jobs, changed) {
28521
28902
  if (set.has(loaded.path)) return true;
28522
28903
  const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
28523
28904
  for (const j of jobs) {
28524
- for (const f of j.inputs) if (set.has(resolve5(loaded.dir, f))) return true;
28905
+ for (const f of j.inputs) if (set.has(resolve6(loaded.dir, f))) return true;
28525
28906
  }
28526
28907
  const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
28527
28908
  return hasModules && codeChanged;
@@ -28546,12 +28927,21 @@ async function runInit(ctx, argv) {
28546
28927
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
28547
28928
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
28548
28929
  const explicit = str(p.flags, "config");
28549
- 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]);
28930
+ const configPath = explicit ? resolve6(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join8(ctx.cwd, f)).find((f) => existsSync7(f)) ?? join8(ctx.cwd, CONFIG_FILES[0]);
28550
28931
  const workflowPath = join8(ctx.cwd, WORKFLOW_PATH);
28551
28932
  const force = bool(p.flags, "force") === true;
28552
28933
  const withWorkflow = bool(p.flags, "workflow") !== false;
28553
- const s2 = scaffoldConfig(ctx.cwd, ctx.env);
28554
- const actionFor = (f) => existsSync6(f) ? force ? "overwrite" : "keep" : "create";
28934
+ const projects = ctx.config.load().init?.projects ?? {};
28935
+ let facts = {};
28936
+ for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
28937
+ if (projects[dir]?.facts) {
28938
+ facts = projects[dir].facts;
28939
+ break;
28940
+ }
28941
+ if (dirname6(dir) === dir) break;
28942
+ }
28943
+ const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
28944
+ const actionFor = (f) => existsSync7(f) ? force ? "overwrite" : "keep" : "create";
28555
28945
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
28556
28946
  `, action: actionFor(configPath) }];
28557
28947
  if (withWorkflow) files.push({ path: workflowPath, content: WORKFLOW_YAML, action: actionFor(workflowPath) });
@@ -28566,7 +28956,7 @@ async function runInit(ctx, argv) {
28566
28956
  }
28567
28957
  if (write) {
28568
28958
  for (const f of toWrite) {
28569
- mkdirSync2(dirname4(f.path), { recursive: true });
28959
+ mkdirSync2(dirname6(f.path), { recursive: true });
28570
28960
  writeFileSync2(f.path, f.content);
28571
28961
  }
28572
28962
  }
@@ -28593,7 +28983,21 @@ async function runInit(ctx, argv) {
28593
28983
  });
28594
28984
  return EXIT.ok;
28595
28985
  }
28596
- var HELP2, ci_default;
28986
+ async function readNotices(ctx, client, siteFlag) {
28987
+ let site;
28988
+ try {
28989
+ site = await resolveSite(ctx, siteFlag, client);
28990
+ } catch (err) {
28991
+ return { error: `${err.message} checkbox.json says Checkbox Notices sends emails; --online checks that against the site's Notice settings once it knows the site (--site or CHECKBOX_SITE).` };
28992
+ }
28993
+ try {
28994
+ const s2 = await client.sdk.noticeSettings.retrieve(site);
28995
+ return { site, mode: s2.mode, enabled: (s2.enabled_kinds ?? []).map((k) => NOTICE_KIND_TO_EMAIL[k] ?? k), real_sends: s2.real_sends };
28996
+ } catch (err) {
28997
+ return { error: `Could not read the Notice settings of ${site} (${err.message}).` };
28998
+ }
28999
+ }
29000
+ var HELP2, NOTICE_KIND_TO_EMAIL, ci_default;
28597
29001
  var init_ci = __esm({
28598
29002
  "src/commands/ci/index.ts"() {
28599
29003
  "use strict";
@@ -28603,6 +29007,8 @@ var init_ci = __esm({
28603
29007
  init_inputs();
28604
29008
  init_report();
28605
29009
  init_runner();
29010
+ init_run();
29011
+ init_lib();
28606
29012
  init_scaffold();
28607
29013
  init_workflow();
28608
29014
  init_types2();
@@ -28611,7 +29017,9 @@ var init_ci = __esm({
28611
29017
  "",
28612
29018
  "Flags:",
28613
29019
  " --config <path> Config file (default: checkbox.json, then .checkbox.json)",
28614
- " --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages and site-aware checks",
29020
+ " --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages and site-aware checks;",
29021
+ ` with "notices" in checkbox.json, also checks that the site's Checkbox Notices really send them`,
29022
+ " --site <site_\u2026> The site for --online (default: $CHECKBOX_SITE, the site `checkbox init` saved, or the only one)",
28615
29023
  ' --strict "Needs review" fails too (exit 2)',
28616
29024
  " --changed-only Skip when no file the config reads changed (git diff against --base, or",
28617
29025
  " origin/$GITHUB_BASE_REF in a pull request). Never skips while a page or file it reads",
@@ -28634,6 +29042,7 @@ var init_ci = __esm({
28634
29042
  "",
28635
29043
  "Docs: https://checkbox.site/docs/guides/ci"
28636
29044
  ].join("\n");
29045
+ NOTICE_KIND_TO_EMAIL = { trial_ending: "trial_reminder" };
28637
29046
  ci_default = defineCommand({
28638
29047
  name: "ci",
28639
29048
  summary: "Run the checks in checkbox.json (CI: annotations + job summary); `ci init` scaffolds the config and workflow",
@@ -28641,7 +29050,7 @@ var init_ci = __esm({
28641
29050
  help: HELP2,
28642
29051
  async run(ctx) {
28643
29052
  if (ctx.argv[0] === "init") return runInit(ctx, ctx.argv.slice(1));
28644
- const p = parseArgs(ctx.argv, { boolean: ["online", "offline", "strict", "verbose", "changed-only"], string: ["config", "base"] });
29053
+ const p = parseArgs(ctx.argv, { boolean: ["online", "offline", "strict", "verbose", "changed-only"], string: ["config", "base", "site"] });
28645
29054
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}" (did you mean \`checkbox ci init\`?)`);
28646
29055
  const gha = ctx.env.GITHUB_ACTIONS === "true";
28647
29056
  const strict = bool(p.flags, "strict") === true;
@@ -28698,8 +29107,21 @@ ${message}
28698
29107
  }
28699
29108
  }
28700
29109
  }
28701
- const engine = online ? onlineEngine(ctx.client()) : offlineEngine();
28702
- const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra });
29110
+ let client;
29111
+ if (online) {
29112
+ try {
29113
+ client = ctx.client();
29114
+ } catch (err) {
29115
+ if (!(err instanceof NotLoggedInError)) throw err;
29116
+ if (gha) emit("::error title=Checkbox::--online needs CHECKBOX_SECRET_KEY. Add it as a repository secret, or drop --online (checkbox ci runs offline without a key).");
29117
+ ctx.out.error({ message: `${err.message}`, code: "not_logged_in", type: "authentication_error", next_action: { type: "set_key", message: "--online needs an API key: set CHECKBOX_SECRET_KEY (a repository secret in GitHub Actions), or drop --online: checkbox ci runs offline with no key." } });
29118
+ return EXIT.auth;
29119
+ }
29120
+ }
29121
+ const engine = client ? onlineEngine(client) : offlineEngine();
29122
+ const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29123
+ const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
29124
+ const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
28703
29125
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
28704
29126
  if (gha) {
28705
29127
  for (const a of annotations(run2)) emit(a);
@@ -28931,8 +29353,8 @@ var init_patch = __esm({
28931
29353
  });
28932
29354
 
28933
29355
  // src/commands/plan/fix.ts
28934
- import { existsSync as existsSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
28935
- import { relative as relative6, resolve as resolve6 } from "node:path";
29356
+ import { existsSync as existsSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
29357
+ import { relative as relative6, resolve as resolve7 } from "node:path";
28936
29358
  function fixForTask(task, cwd, file) {
28937
29359
  const base = {
28938
29360
  object: "task_fix",
@@ -28944,9 +29366,9 @@ function fixForTask(task, cwd, file) {
28944
29366
  verify: task.verify
28945
29367
  };
28946
29368
  const verifyCmd = `checkbox verify ${task.id} --wait`;
28947
- const candidate = file ?? (task.where.file_hint && existsSync7(resolve6(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
29369
+ const candidate = file ?? (task.where.file_hint && existsSync8(resolve7(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
28948
29370
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
28949
- const path = resolve6(cwd, candidate);
29371
+ const path = resolve7(cwd, candidate);
28950
29372
  let src;
28951
29373
  try {
28952
29374
  src = readFileSync9(path, "utf8");
@@ -29009,7 +29431,7 @@ var init_fix = __esm({
29009
29431
  const task = await ctx.client().sdk.tasks.retrieve(id);
29010
29432
  const result = fixForTask(task, ctx.cwd, str(flags, "file"));
29011
29433
  const out = str(flags, "out");
29012
- if (out && result.diff) writeFileSync3(resolve6(ctx.cwd, out), result.diff);
29434
+ if (out && result.diff) writeFileSync3(resolve7(ctx.cwd, out), result.diff);
29013
29435
  ctx.out.result({ ...result, task_object: task }, () => {
29014
29436
  const c = ctx.out.c;
29015
29437
  const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
@@ -29112,8 +29534,8 @@ var init_device = __esm({
29112
29534
 
29113
29535
  // src/commands/init/patch.ts
29114
29536
  import { execFileSync as execFileSync2 } from "node:child_process";
29115
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
29116
- import { dirname as dirname5, join as join9 } from "node:path";
29537
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
29538
+ import { dirname as dirname7, join as join9 } from "node:path";
29117
29539
  function read(root, rel2) {
29118
29540
  try {
29119
29541
  return readFileSync10(join9(root, rel2), "utf8");
@@ -29311,7 +29733,7 @@ function planGitignore(d) {
29311
29733
  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*");
29312
29734
  if (covered) return null;
29313
29735
  }
29314
- if (existing === null && !existsSync8(join9(d.root, ".git"))) return null;
29736
+ if (existing === null && !existsSync9(join9(d.root, ".git"))) return null;
29315
29737
  const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
29316
29738
  ${env2}
29317
29739
  `;
@@ -29328,7 +29750,7 @@ function planWellKnown(d, token) {
29328
29750
  function applyChange(root, c) {
29329
29751
  if (c.action === "unchanged" || c.contents === void 0) return;
29330
29752
  const abs = join9(root, c.file);
29331
- mkdirSync3(dirname5(abs), { recursive: true });
29753
+ mkdirSync3(dirname7(abs), { recursive: true });
29332
29754
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
29333
29755
  }
29334
29756
  var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
@@ -29375,7 +29797,7 @@ __export(init_exports, {
29375
29797
  parsePlanFlag: () => parsePlanFlag,
29376
29798
  syncCheckoutConfigs: () => syncCheckoutConfigs
29377
29799
  });
29378
- import { existsSync as existsSync9 } from "node:fs";
29800
+ import { existsSync as existsSync10 } from "node:fs";
29379
29801
  import { join as join10 } from "node:path";
29380
29802
  import { createInterface as createInterface2 } from "node:readline";
29381
29803
  function loadState(ctx, root) {
@@ -29474,7 +29896,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
29474
29896
  const from = configFileOf(c.name);
29475
29897
  if (!from) continue;
29476
29898
  const sameFile = perFile.has(from.file);
29477
- const gone = !sameFile && !existsSync9(join10(root, from.file));
29899
+ const gone = !sameFile && !existsSync10(join10(root, from.file));
29478
29900
  const current = sameFile && from.call !== void 0 && from.call <= perFile.get(from.file) && c.name === configNameOf(from.file, from.call);
29479
29901
  if (!sameFile && !gone || current) continue;
29480
29902
  if (await client.sdk.stripeCheckoutConfigs.del(c.id).then(() => true, () => false)) {
@@ -30093,74 +30515,6 @@ var init_logout = __esm({
30093
30515
  }
30094
30516
  });
30095
30517
 
30096
- // src/commands/plan/lib.ts
30097
- import { dirname as dirname6, resolve as resolve7 } from "node:path";
30098
- async function resolveSite(ctx, flag, client) {
30099
- const explicit = flag ?? ctx.env.CHECKBOX_SITE;
30100
- if (explicit) {
30101
- if (!SITE_RE.test(explicit) && !/^[0-9a-f-]{36}$/i.test(explicit)) throw new UsageError(`--site must be a site id (site_\u2026), got "${explicit}"`);
30102
- return explicit;
30103
- }
30104
- const projects = ctx.config.load().init?.projects ?? {};
30105
- for (let dir = resolve7(ctx.cwd); ; dir = dirname6(dir)) {
30106
- const site = projects[dir]?.site;
30107
- if (site) return site;
30108
- if (dirname6(dir) === dir) break;
30109
- }
30110
- const list2 = await client.sdk.sites.list({ limit: 3 });
30111
- const sites = list2?.data ?? [];
30112
- if (sites.length === 1) return sites[0].id;
30113
- throw new UsageError(
30114
- sites.length ? `This account has several sites; pass --site <id> (e.g. ${sites.map((s2) => s2.id).join(", ")}) or set CHECKBOX_SITE.` : "No site yet: run `checkbox init` in your project first (it creates the site and remembers it here)."
30115
- );
30116
- }
30117
- function nextLine(n) {
30118
- const text = n.description ?? n.message ?? n.type;
30119
- return `${text}${n.command ? `
30120
- $ ${n.command}` : n.endpoint ? `
30121
- ${n.endpoint}` : ""}`;
30122
- }
30123
- function renderPlan(p, c, opts = {}) {
30124
- const head = `${c.bold(`Compliance plan ${p.site}`)} \u2014 ${p.compliance?.label ? p.status === "compliant" ? c.green(p.compliance.label) : p.status === "action_required" ? c.red(p.compliance.label) : c.yellow(p.compliance.label) : statusColor(p.status, c)}`;
30125
- const tasks = opts.all ? p.tasks : p.tasks.filter((t) => t.status !== "done" && t.status !== "wont_fix");
30126
- const lines = [head, p.summary, ""];
30127
- for (const t of tasks) {
30128
- lines.push(`${STATUS_MARK[t.status] ?? "[ ]"} ${String(t.order).padStart(2)}. ${t.title} ${c.dim(`(${t.priority}, ${t.id})`)}`);
30129
- if (t.status === "manual" && t.next_action?.type === "ask_human_to_confirm") lines.push(` ${c.yellow("waiting for the human to confirm:")} ${t.next_action.description}`);
30130
- else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
30131
- else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
30132
- else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
30133
- else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
30134
- }
30135
- const hidden = p.tasks.length - tasks.length;
30136
- if (hidden) lines.push(c.dim(` \u2026 ${hidden} done / declined (--all to show)`));
30137
- lines.push("", `${c.cyan("next")} ${nextLine(p.next_action)}`);
30138
- return lines;
30139
- }
30140
- function statusColor(s2, c) {
30141
- return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
30142
- }
30143
- function renderVerification(v, c) {
30144
- const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
30145
- const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
30146
- const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
30147
- if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
30148
- if (v.scan) lines.push(c.dim(` confirming scan ${v.scan}`));
30149
- for (const d of v.diff.slice(0, 12)) lines.push(` ${d.observed === "fail" ? c.red("\u2717") : c.yellow("?")} ${d.check_id}: expected ${d.expected}, observed ${d.observed} \u2014 ${d.detail}`);
30150
- if (v.diff.length > 12) lines.push(c.dim(` \u2026 ${v.diff.length - 12} more`));
30151
- lines.push(`${c.cyan("next")} ${nextLine(v.next_action)}`);
30152
- return lines;
30153
- }
30154
- var SITE_RE, STATUS_MARK;
30155
- var init_lib = __esm({
30156
- "src/commands/plan/lib.ts"() {
30157
- "use strict";
30158
- init_args();
30159
- SITE_RE = /^site_[0-9a-f]{32}$/;
30160
- STATUS_MARK = { open: "[ ]", in_progress: "[~]", verifying: "[?]", done: "[x]", wont_fix: "[-]", manual: "[!]" };
30161
- }
30162
- });
30163
-
30164
30518
  // src/commands/plan/index.ts
30165
30519
  var plan_exports = {};
30166
30520
  __export(plan_exports, {